Deploy origins #56
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Deploy origins | |
| # Deploys the three ADR-0039 origins from one workflow so functions always | |
| # land before the frontends that call them, and so each origin only rebuilds | |
| # when its own inputs changed. | |
| # | |
| # functions -> api.ringdrill.app (Netlify, netlify/functions/** only) | |
| # pwa -> web.ringdrill.app (Cloudflare Pages, project ringdrill-pwa) | |
| # site -> ringdrill.app (Cloudflare Workers, project ringdrill-site — a | |
| # Pages project of the same name is retired; the | |
| # @astrojs/cloudflare adapter emits a Workers | |
| # bundle, not a Pages one, as of v13+) | |
| # | |
| # `pwa` and `site` declare `needs: functions` so a functions change is live | |
| # before either frontend redeploys. All three are gated by the `changes` | |
| # job's output so an unrelated push (e.g. only `lib/**`) does not also | |
| # redeploy the site, and vice versa. `changes` diffs the pushed commits | |
| # itself instead of pulling in a marketplace path-filter action, matching | |
| # this repo's preference for first-party tooling over third-party Action | |
| # wrappers (see the Netlify-deploy comment this replaced in the old | |
| # deploy-web.yml, and DEBT-0011). | |
| # | |
| # The apex proxy Worker (workers/apex-proxy/) is not one of the three | |
| # origins and keeps deploying from its own workflow, deploy-proxy.yml. | |
| # | |
| # Dry run: trigger via workflow_dispatch (from any branch) with `dry_run` | |
| # left at its default of true. Every job still runs for real — checkout, | |
| # build, secret checks, the `changes` path gating, the `functions`-before- | |
| # `pwa`/`site` ordering — only the final `netlify deploy` / `wrangler pages | |
| # deploy` command is replaced with an echo. Set `dry_run: false` to publish | |
| # for real from workflow_dispatch; push events always publish for real. | |
| # | |
| # Republishing one origin: workflow_dispatch also takes `origins`, which | |
| # overrides the path gating entirely and deploys exactly what you name. | |
| # | |
| # gh workflow run deploy-origins.yml -f origins=pwa -f dry_run=false | |
| # | |
| # That is the recovery path for an origin whose job did not run — the diff | |
| # is per-push, so `needs: functions` skipping `pwa` (on a failure, or on a | |
| # cancellation from the concurrency groups) strands work that no later | |
| # push's diff will contain. The `report` job fails the run and names the | |
| # command whenever that happens, so the gap is visible rather than silent. | |
| # | |
| # Required secrets, all on the PROD environment | |
| # (Settings -> Environments -> PROD -> Secrets): | |
| # NETLIFY_AUTH_TOKEN - used by the `functions` job. | |
| # NETLIFY_SITE_ID - used by the `functions` job. | |
| # SENTRY_AUTH_TOKEN - used by the `pwa` job. | |
| # CLOUDFLARE_API_TOKEN - used by the `pwa` job (Pages edit) and the | |
| # `site` job (Workers Scripts edit + Workers KV | |
| # Storage edit — site deploys to a Worker, not | |
| # Pages). | |
| # CLOUDFLARE_ACCOUNT_ID - used by the `pwa` and `site` jobs. | |
| on: | |
| push: | |
| branches: [main] | |
| workflow_dispatch: | |
| inputs: | |
| origins: | |
| description: >- | |
| Which origins to deploy, ignoring what changed. Pick one to republish | |
| an origin whose job was skipped in an earlier run without rebuilding | |
| the other two. | |
| type: choice | |
| options: | |
| - all | |
| - functions | |
| - pwa | |
| - site | |
| default: all | |
| dry_run: | |
| description: >- | |
| Run every job for real (build, secrets, ordering, path gating) but | |
| skip the final publish command. Safe way to validate this workflow | |
| from a branch without touching Netlify or Cloudflare. | |
| type: boolean | |
| default: true | |
| # Wrangler is pinned rather than tracking the newest release. These jobs hold a | |
| # Cloudflare API token with edit rights, and an unpinned `npx` invocation | |
| # resolves and executes whatever was published to npm minutes earlier — so an | |
| # upstream compromise would run with that token, on a push, unattended. A | |
| # version bump should be a reviewed commit like any other. | |
| # | |
| # Nothing bumps it automatically: dependabot's github-actions ecosystem tracks | |
| # `uses:` versions, not `npx` inside a `run:` step. Bump it here by hand when | |
| # Cloudflare needs a newer client. | |
| env: | |
| WRANGLER: wrangler@4.118.0 | |
| jobs: | |
| changes: | |
| name: Detect changed paths | |
| runs-on: ubuntu-latest | |
| outputs: | |
| functions: ${{ steps.filter.outputs.functions }} | |
| pwa: ${{ steps.filter.outputs.pwa }} | |
| site: ${{ steps.filter.outputs.site }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Diff against the previous commit | |
| id: filter | |
| env: | |
| # Empty on a push; "all" or one origin name on workflow_dispatch. | |
| FORCE: ${{ inputs.origins }} | |
| run: | | |
| before="${{ github.event.before }}" | |
| if [ -z "$before" ] || ! git cat-file -e "$before" 2>/dev/null; then | |
| files="ALL" | |
| else | |
| files=$(git diff --name-only "$before" "${{ github.sha }}") | |
| fi | |
| # A push deploys what its diff says. A manual run deploys what you | |
| # asked for and nothing else, which is the recovery path when an | |
| # origin's job did not run. | |
| # | |
| # It needs one, because the diff is per-push and a job that does not | |
| # run leaves its origin behind: `needs: functions` skips pwa and site | |
| # when functions fails or is cancelled (the concurrency groups below | |
| # use cancel-in-progress, so two quick pushes do it), and the next | |
| # push's diff no longer contains the stranded work. That happened on | |
| # 2026-08-04 — a lib/views/ fix sat unpublished for two hours after a | |
| # failed functions job, until an unrelated Makefile edit happened to | |
| # match the pwa pattern. The `report` job at the bottom is what tells | |
| # you to come here; this is what you run. | |
| # | |
| # gh workflow run deploy-origins.yml -f origins=pwa -f dry_run=false | |
| match() { | |
| origin="$1" | |
| pattern="$2" | |
| if [ -n "$FORCE" ]; then | |
| if [ "$FORCE" = "all" ] || [ "$FORCE" = "$origin" ]; then | |
| echo "true" | |
| else | |
| echo "false" | |
| fi | |
| return | |
| fi | |
| if [ "$files" = "ALL" ]; then | |
| echo "true" | |
| elif echo "$files" | grep -qE "$pattern"; then | |
| echo "true" | |
| else | |
| echo "false" | |
| fi | |
| } | |
| # Each pattern must cover everything the matching job actually reads, | |
| # or a change ships without the job that would have caught it ever | |
| # running. That is not hypothetical: a Makefile-only fix to | |
| # `strip-source-maps-web` passed CI green twice while the pwa job was | |
| # skipped, because Makefile was not listed here. | |
| # | |
| # functions: `npm ci` at the repo root, then `netlify deploy | |
| # --functions=netlify/functions`. So the root | |
| # package.json/lock are inputs — a dependabot bump to a | |
| # function dependency otherwise deploys nothing. The | |
| # bundler follows imports and netlify.toml's | |
| # included_files, and both leave netlify/functions/: | |
| # mcp.js imports ../../mcp/tools.mjs (the shared tool | |
| # table, the same one the stdio server uses) and ships | |
| # skills/ringdrill-plan-authoring/ as MCP resources | |
| # (ADR-0065). Without those two, an edit to the tool | |
| # descriptions or the authoring guide is live in the repo | |
| # and stale on /mcp with nothing to show for it. | |
| # netlify/tests/ is deliberately absent: it does not ship. | |
| # pwa: `make build-web`, so Makefile is an input, as is | |
| # assets/ (templates and images are bundled into the web | |
| # artifact) and pubspec.lock (a lock-only bump changes | |
| # the build without touching pubspec.yaml). | |
| # site: `npm ci && npm run build` inside site/, with no make at | |
| # all — so ^site/ is complete, and Makefile does NOT | |
| # belong here. | |
| { | |
| echo "functions=$(match functions '^(netlify/functions/|mcp/|skills/ringdrill-plan-authoring/|netlify\.toml|package\.json|package-lock\.json)')" | |
| echo "pwa=$(match pwa '^(lib/|assets/|web/|pubspec\.(yaml|lock)|Makefile)')" | |
| echo "site=$(match site '^site/')" | |
| } >> "$GITHUB_OUTPUT" | |
| functions: | |
| name: Deploy functions | |
| needs: changes | |
| if: needs.changes.outputs.functions == 'true' | |
| runs-on: ubuntu-latest | |
| environment: PROD | |
| concurrency: | |
| group: deploy-origins-functions | |
| cancel-in-progress: true | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - name: Verify required secrets are present | |
| env: | |
| NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} | |
| NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} | |
| run: | | |
| missing=() | |
| [ -z "$NETLIFY_AUTH_TOKEN" ] && missing+=("NETLIFY_AUTH_TOKEN") | |
| [ -z "$NETLIFY_SITE_ID" ] && missing+=("NETLIFY_SITE_ID") | |
| if [ ${#missing[@]} -ne 0 ]; then | |
| echo "::error::Missing or empty repository secrets: ${missing[*]}" | |
| echo "Check Settings -> Environments -> PROD -> Secrets." | |
| exit 1 | |
| fi | |
| echo "NETLIFY_AUTH_TOKEN length: ${#NETLIFY_AUTH_TOKEN}" | |
| echo "NETLIFY_SITE_ID length: ${#NETLIFY_SITE_ID}" | |
| - name: Setup Node | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version-file: package.json | |
| cache: npm | |
| - name: Install function dependencies | |
| # Netlify CLI bundles functions from node_modules/, so the deps | |
| # must be installed before the deploy step picks them up. | |
| run: npm ci | |
| - name: Verify the MCP function packages and runs | |
| # Packages netlify/functions/mcp.js exactly as a deploy does and calls it | |
| # with the package layout, before anything is published. The deployed shape | |
| # is not the checkout — local imports are inlined into one mcp.mjs and | |
| # included_files land at their repo-relative paths — and the endpoint once | |
| # shipped a compiler that could not find its own bundle in that layout while | |
| # every checkout-based test passed. Runs on dry runs too: it publishes | |
| # nothing, and a dry run is exactly when you want to know. | |
| run: node --test netlify/tests/mcp-packaging.test.mjs | |
| - name: Deploy functions to Netlify | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }} | |
| env: | |
| NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} | |
| NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} | |
| run: npx netlify deploy --prod --dir=. --functions=netlify/functions | |
| - name: Smoke test the live MCP endpoint | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }} | |
| # The packaging test above proves the artefact; this proves the origin — | |
| # redirects, blob bindings, and whatever Netlify actually published. Every | |
| # check calls a tool that has to do real work, because the failure mode | |
| # being guarded against is a server that introspects as healthy and errors | |
| # only when asked to compile. | |
| run: npm run smoke:mcp | |
| - name: Dry run - skip Netlify publish | |
| if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} | |
| run: | | |
| echo "DRY RUN: would run 'npx netlify deploy --prod --dir=. --functions=netlify/functions'" | |
| echo "DRY RUN: would run 'npm run smoke:mcp' against https://api.ringdrill.app/mcp" | |
| pwa: | |
| name: Deploy PWA | |
| needs: [changes, functions] | |
| if: | | |
| always() && | |
| needs.changes.outputs.pwa == 'true' && | |
| (needs.functions.result == 'success' || needs.functions.result == 'skipped') | |
| runs-on: ubuntu-latest | |
| environment: PROD | |
| concurrency: | |
| group: deploy-origins-pwa | |
| cancel-in-progress: true | |
| timeout-minutes: 25 | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| with: | |
| # sentry_dart_plugin reads git refs when stamping the release. | |
| # Shallow clones drop those refs and break the upload. | |
| fetch-depth: 0 | |
| - name: Verify required secrets are present | |
| env: | |
| SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} | |
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| run: | | |
| missing=() | |
| [ -z "$SENTRY_AUTH_TOKEN" ] && missing+=("SENTRY_AUTH_TOKEN") | |
| [ -z "$CLOUDFLARE_API_TOKEN" ] && missing+=("CLOUDFLARE_API_TOKEN") | |
| [ -z "$CLOUDFLARE_ACCOUNT_ID" ] && missing+=("CLOUDFLARE_ACCOUNT_ID") | |
| if [ ${#missing[@]} -ne 0 ]; then | |
| echo "::error::Missing or empty repository secrets: ${missing[*]}" | |
| echo "Check Settings -> Environments -> PROD -> Secrets." | |
| exit 1 | |
| fi | |
| echo "SENTRY_AUTH_TOKEN length: ${#SENTRY_AUTH_TOKEN}" | |
| echo "CLOUDFLARE_API_TOKEN length: ${#CLOUDFLARE_API_TOKEN}" | |
| echo "CLOUDFLARE_ACCOUNT_ID length: ${#CLOUDFLARE_ACCOUNT_ID}" | |
| - name: Setup Flutter | |
| uses: subosito/flutter-action@v2.23.0 | |
| with: | |
| channel: stable | |
| cache: true | |
| - name: Cache pub-cache and .dart_tool | |
| uses: actions/cache@v6 | |
| with: | |
| path: | | |
| ~/.pub-cache | |
| .dart_tool | |
| key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }} | |
| restore-keys: | | |
| pub-${{ runner.os }}- | |
| - name: Install Flutter dependencies | |
| run: flutter pub get | |
| - name: Build web | |
| run: make build-web | |
| - name: Upload sources and source maps to Sentry | |
| env: | |
| SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} | |
| run: make upload-symbols-web | |
| - name: Strip source maps from published artifact | |
| run: make strip-source-maps-web | |
| - name: Deploy to Cloudflare Pages | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }} | |
| env: | |
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| run: | | |
| npx "$WRANGLER" pages deploy build/web \ | |
| --project-name=ringdrill-pwa \ | |
| --branch=main | |
| - name: Dry run - skip Cloudflare Pages publish | |
| if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} | |
| run: | | |
| echo "DRY RUN: would run 'wrangler pages deploy build/web --project-name=ringdrill-pwa'" | |
| site: | |
| name: Deploy site | |
| needs: [changes, functions] | |
| if: | | |
| always() && | |
| needs.changes.outputs.site == 'true' && | |
| (needs.functions.result == 'success' || needs.functions.result == 'skipped') | |
| runs-on: ubuntu-latest | |
| environment: PROD | |
| concurrency: | |
| group: deploy-origins-site | |
| cancel-in-progress: true | |
| timeout-minutes: 15 | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - name: Verify required secrets are present | |
| env: | |
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| run: | | |
| missing=() | |
| [ -z "$CLOUDFLARE_API_TOKEN" ] && missing+=("CLOUDFLARE_API_TOKEN") | |
| [ -z "$CLOUDFLARE_ACCOUNT_ID" ] && missing+=("CLOUDFLARE_ACCOUNT_ID") | |
| if [ ${#missing[@]} -ne 0 ]; then | |
| echo "::error::Missing or empty repository secrets: ${missing[*]}" | |
| echo "Check Settings -> Environments -> PROD -> Secrets." | |
| exit 1 | |
| fi | |
| echo "CLOUDFLARE_API_TOKEN length: ${#CLOUDFLARE_API_TOKEN}" | |
| echo "CLOUDFLARE_ACCOUNT_ID length: ${#CLOUDFLARE_ACCOUNT_ID}" | |
| - name: Setup Node | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version-file: site/package.json | |
| cache: npm | |
| cache-dependency-path: site/package-lock.json | |
| - name: Install site dependencies | |
| working-directory: site | |
| run: npm ci | |
| - name: Build site | |
| working-directory: site | |
| run: npm run build | |
| # @astrojs/cloudflare writes `"legacy_env": true` into the config it | |
| # generates, and wrangler removed the field: it now hard-errors with "The | |
| # legacy_env field is no longer supported". Both are pinned, so this is a | |
| # standing incompatibility rather than a version that drifted — and | |
| # upgrading the adapter does not fix it (14.1.7, the latest at time of | |
| # writing, still emits it). Stripping the key here is what Cloudflare's own | |
| # error message asks for, and it is behaviour-neutral by their statement: | |
| # service environments are gone, every environment now deploys as its own | |
| # Worker, and that already matches `legacy_env = true` — which was the | |
| # default. So removing it changes nothing about how this Worker deploys. | |
| # | |
| # Edits the generated file, never site/wrangler.json: the field is not ours | |
| # and does not appear in the committed config. Drop this step once the | |
| # adapter stops emitting it, which is the real fix. | |
| # | |
| # It cost a red main to find, because the `site` job is path-gated on | |
| # `site/**` and the wrangler pin landed a month after the last site change. | |
| # Nothing exercised the two together until the next site commit. | |
| - name: Drop legacy_env from the generated Worker config | |
| working-directory: site | |
| run: | | |
| node -e ' | |
| const fs = require("node:fs"); | |
| const path = "dist/server/wrangler.json"; | |
| const config = JSON.parse(fs.readFileSync(path, "utf8")); | |
| if (!("legacy_env" in config)) { | |
| console.log("legacy_env absent — the adapter stopped emitting it, so this step can go"); | |
| process.exit(0); | |
| } | |
| delete config.legacy_env; | |
| fs.writeFileSync(path, JSON.stringify(config)); | |
| console.log("stripped legacy_env from " + path); | |
| ' | |
| - name: Deploy to Cloudflare Workers | |
| # @astrojs/cloudflare (v13+) emits a Workers-with-assets bundle | |
| # (dist/client + dist/server/{entry.mjs,wrangler.json}), not a | |
| # classic Pages Functions bundle — `wrangler deploy` against the | |
| # generated config, not `wrangler pages deploy`. See the dated | |
| # addendum in docs/adrs/0039-site-pwa-api-origins.md for the | |
| # Pages -> Workers cutover this replaced. | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }} | |
| working-directory: site | |
| env: | |
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| run: | | |
| npx "$WRANGLER" deploy --config dist/server/wrangler.json | |
| - name: Dry run - skip Cloudflare Workers publish | |
| if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} | |
| run: | | |
| echo "DRY RUN: would run 'wrangler deploy --config dist/server/wrangler.json' (from site/)" | |
| report: | |
| name: Report undeployed origins | |
| needs: [changes, functions, pwa, site] | |
| # always(), or this is skipped by the very failures it exists to describe. | |
| if: always() | |
| runs-on: ubuntu-latest | |
| permissions: {} | |
| steps: | |
| - name: Fail if an origin had changes but did not publish | |
| # The gap the manual override exists to close is *noticing*. An origin | |
| # whose job is skipped because `needs: functions` failed or was cancelled | |
| # leaves no trace of its own: the next push is green, its diff no longer | |
| # contains the stranded work, and the origin quietly serves old code. | |
| # Today that ran for two hours. | |
| # | |
| # So the run stays red until every origin that had changes has actually | |
| # published, and it prints the command that fixes it. No permissions, no | |
| # state, no tokens — this job only reads the other jobs' results. | |
| env: | |
| WANT_FUNCTIONS: ${{ needs.changes.outputs.functions }} | |
| WANT_PWA: ${{ needs.changes.outputs.pwa }} | |
| WANT_SITE: ${{ needs.changes.outputs.site }} | |
| GOT_FUNCTIONS: ${{ needs.functions.result }} | |
| GOT_PWA: ${{ needs.pwa.result }} | |
| GOT_SITE: ${{ needs.site.result }} | |
| run: | | |
| stranded="" | |
| check() { | |
| # $1 origin, $2 did it have changes, $3 what its job did | |
| if [ "$2" = "true" ] && [ "$3" != "success" ]; then | |
| echo "::warning title=$1 not deployed::$1 had changes in this run" \ | |
| "but its job ended '$3', so the origin is still on older code." \ | |
| "The next push will not pick this up — its diff will not contain" \ | |
| "these commits. Republish with:" \ | |
| "gh workflow run deploy-origins.yml -f origins=$1 -f dry_run=false" | |
| stranded="$stranded $1" | |
| fi | |
| } | |
| check functions "$WANT_FUNCTIONS" "$GOT_FUNCTIONS" | |
| check pwa "$WANT_PWA" "$GOT_PWA" | |
| check site "$WANT_SITE" "$GOT_SITE" | |
| if [ -z "$stranded" ]; then | |
| echo "Every origin with changes published." >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| { | |
| echo "### Origins still to publish" | |
| echo | |
| for origin in $stranded; do | |
| echo "- \`$origin\` — \`gh workflow run deploy-origins.yml" \ | |
| "-f origins=$origin -f dry_run=false\`" | |
| done | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| exit 1 |