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-.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"