Skip to content

Deploy origins

Deploy origins #56

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