Files
zpan/.github/workflows/deploy-cloudflare.yml

240 lines
10 KiB
YAML

name: Deploy to Cloudflare Workers
# Triggered by the top-level Deploy dispatcher (.github/workflows/deploy.yml)
# when CF secrets are configured. Manual runs via the Actions UI also
# supported.
on:
workflow_call:
workflow_dispatch:
inputs:
version:
description: 'Release tag to deploy (e.g. v2.1.0). Leave empty for latest.'
required: false
# Prevent overlapping deployments.
concurrency:
group: deploy-cloudflare
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
steps:
- name: Disable upstream-only workflows
env:
GH_TOKEN: ${{ github.token }}
run: |
for workflow in ci.yml release.yml; do
gh api -X PUT "repos/${{ github.repository }}/actions/workflows/$workflow/disable" 2>/dev/null && \
echo "Disabled $workflow" || echo "$workflow already disabled"
done
- name: Check required secrets
env:
HAS_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN != '' }}
HAS_ACCOUNT: ${{ secrets.CLOUDFLARE_ACCOUNT_ID != '' }}
run: |
if [ "$HAS_TOKEN" != "true" ] || [ "$HAS_ACCOUNT" != "true" ]; then
echo "::error::Missing required secrets. Go to Settings → Secrets and variables → Actions and add CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID."
exit 1
fi
- name: Resolve release tag
id: release
env:
GH_TOKEN: ${{ github.token }}
INPUT_VERSION: ${{ inputs.version }}
run: |
if [ -n "$INPUT_VERSION" ]; then
TAG="$INPUT_VERSION"
else
TAG=$(gh api repos/saltbo/zpan/releases/latest --jq '.tag_name')
fi
if [ -z "$TAG" ]; then
echo "::error::No release found in saltbo/zpan"
exit 1
fi
echo "version=$TAG" >> "$GITHUB_OUTPUT"
echo "### 🚀 Deploying $TAG" >> "$GITHUB_STEP_SUMMARY"
- uses: actions/checkout@v6
with:
repository: saltbo/zpan
ref: ${{ steps.release.outputs.version }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Ensure D1 database exists
id: d1
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
DB_ID=$(pnpm exec wrangler d1 list --json | jq -r '.[] | select(.name == "zpan-db") | .uuid')
if [ -z "$DB_ID" ]; then
# wrangler d1 create does not support --json; parse the TOML snippet it prints.
DB_ID=$(pnpm exec wrangler d1 create zpan-db | awk -F'"' '/database_id/{print $2; exit}')
echo "Created D1 database: $DB_ID"
else
echo "Reusing D1 database: $DB_ID"
fi
if [ -z "$DB_ID" ]; then
echo "::error::Failed to obtain D1 database UUID"
exit 1
fi
echo "id=$DB_ID" >> "$GITHUB_OUTPUT"
- name: Ensure Queue exists
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
OUTPUT=$(pnpm exec wrangler queues create zpan-archive-jobs 2>&1) || true
if echo "$OUTPUT" | grep -q "already taken"; then
echo "Queue already exists, skipping"
elif echo "$OUTPUT" | grep -q "ERROR"; then
echo "$OUTPUT"
exit 1
else
echo "Created queue: zpan-archive-jobs"
fi
# Avatars are self-hosted in R2 on Workers (the PUBLIC_IMAGES binding). Provision the
# bucket so the binding resolves, expose its managed public URL, and pin it to
# PUBLIC_IMAGES_URL so prod serves avatars straight from R2 (zero Worker egress);
# without the secret the app falls back to its own /api/avatar-blobs route.
- name: Ensure R2 public-images bucket exists
env:
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
EXISTS=$(curl -sf \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/r2/buckets" \
-H "Authorization: Bearer $CF_API_TOKEN" \
| jq -r '.result.buckets[]? | select(.name == "zpan-public-images") | .name')
if [ -z "$EXISTS" ]; then
curl -sf -X POST \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/r2/buckets" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "zpan-public-images"}' > /dev/null
echo "Created R2 bucket: zpan-public-images"
else
echo "Reusing R2 bucket: zpan-public-images"
fi
- name: Enable R2 managed public URL + capture
id: r2
env:
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
# Idempotent: PUT enabled=true — CF returns the same pub-<hash>.r2.dev
# on every call once enabled.
curl -sf -X PUT \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/r2/buckets/zpan-public-images/domains/managed" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"enabled": true}' > /dev/null
DOMAIN=$(curl -sf \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/r2/buckets/zpan-public-images/domains/managed" \
-H "Authorization: Bearer $CF_API_TOKEN" | jq -r '.result.domain')
if [ -z "$DOMAIN" ] || [ "$DOMAIN" = "null" ]; then
echo "::error::Failed to retrieve R2 managed public domain. Make sure CLOUDFLARE_API_TOKEN has 'R2 Storage: Edit' scope."
exit 1
fi
echo "url=https://$DOMAIN" >> "$GITHUB_OUTPUT"
echo "R2 public URL: https://$DOMAIN"
- name: Set PUBLIC_IMAGES_URL (always upsert — R2 domain is stable)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
echo "${{ steps.r2.outputs.url }}" | pnpm exec wrangler secret put PUBLIC_IMAGES_URL
echo "Set PUBLIC_IMAGES_URL = ${{ steps.r2.outputs.url }}"
- name: Patch wrangler.toml with D1 database ID
run: sed -i "s/database_id = \"[^\"]*\"/database_id = \"${{ steps.d1.outputs.id }}\"/" wrangler.toml
- name: Set BETTER_AUTH_SECRET (first deploy only)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
USER_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
run: |
EXISTS=$(pnpm exec wrangler secret list --format json | jq -r '.[] | select(.name == "BETTER_AUTH_SECRET") | .name')
if [ -n "$USER_SECRET" ]; then
echo "$USER_SECRET" | pnpm exec wrangler secret put BETTER_AUTH_SECRET
echo "Set BETTER_AUTH_SECRET from GitHub secret"
elif [ -z "$EXISTS" ]; then
openssl rand -base64 32 | pnpm exec wrangler secret put BETTER_AUTH_SECRET
echo "Auto-generated BETTER_AUTH_SECRET"
else
echo "BETTER_AUTH_SECRET already set, skipping"
fi
# vite build runs the @cloudflare/vite-plugin, which writes the deploy
# config (dist/zpan/wrangler.json) that the subsequent wrangler deploy
# consumes: it resolves the assets directory to dist/client and inlines
# build-time defines. Must run after the D1 id patch so the generated
# config carries the real database_id. ZPAN_APP_VERSION sidesteps a git
# describe on the shallow tag checkout.
- name: Build
env:
ZPAN_APP_VERSION: ${{ steps.release.outputs.version }}
ZPAN_APP_COMMIT: ${{ github.sha }}
run: pnpm build
- name: Deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: pnpm run deploy
- name: Clear WebDAV domain readiness
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
pnpm exec wrangler d1 execute zpan-db --remote --command \
"INSERT INTO system_options (key, value) VALUES ('webdav_verified_origin', ''), ('webdav_verified_at', ''), ('webdav_verification_error', '') ON CONFLICT(key) DO UPDATE SET value = excluded.value"
- name: Reconcile fixed WebDAV domain
id: webdav
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
OUTPUT=$(node scripts/sync-cloudflare-webdav.mjs)
echo "$OUTPUT"
printf '%s\n' "$OUTPUT" >> "$GITHUB_STEP_SUMMARY"
- name: Record WebDAV domain readiness
if: steps.webdav.outputs.origin != ''
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
WEBDAV_ORIGIN: ${{ steps.webdav.outputs.origin }}
run: |
if [[ ! "$WEBDAV_ORIGIN" =~ ^https://[A-Za-z0-9.-]+$ ]]; then
echo "::error::Invalid WebDAV origin returned by deployment sync"
exit 1
fi
VERIFIED_AT=$(date -u +'%Y-%m-%dT%H:%M:%S.000Z')
pnpm exec wrangler d1 execute zpan-db --remote --command \
"INSERT INTO system_options (key, value) VALUES ('webdav_verified_origin', '$WEBDAV_ORIGIN'), ('webdav_verified_at', '$VERIFIED_AT'), ('webdav_verification_error', '') ON CONFLICT(key) DO UPDATE SET value = excluded.value"
echo "WebDAV readiness recorded for $WEBDAV_ORIGIN" >> "$GITHUB_STEP_SUMMARY"