From 43b7c3eb3067d81826c4acd62d18d9bb7e4cb964 Mon Sep 17 00:00:00 2001 From: Jonathan Roemer Date: Sun, 27 Sep 2026 21:03:32 -0500 Subject: [PATCH] Deploy main via Workers Builds, with a GitHub Actions fallback Pushes to main never reached production: the Worker had no Workers Builds trigger and CI deliberately does not deploy. This adds the pieces for the same two-path setup rx and pid1.github.io use. - build.sh stages public/ into dist/ and writes the checked-out commit to dist/.build-id. wrangler.toml's [build] runs it before `wrangler dev` and `wrangler deploy`, and the assets directory moves to ./dist. - public/_headers serves /.build-id with Cache-Control: no-store. Static assets are served before the Worker runs, so the header has to come from _headers rather than from a handler in the router. - .github/workflows/cf-fallback.yml waits 600s, reads ${SITE}/.build-id, and only if the live commit is not github.sha runs typecheck, tests and `wrangler deploy`. It skips docs-only pushes. The Workers Builds connection itself is made in the dashboard. Neither path touches runtime secrets, which deploys keep, or D1 migrations. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ANZvTxc4uANFhddTwYq4TH --- .github/workflows/cf-fallback.yml | 112 ++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 6 +- README.md | 19 ++++- build.sh | 26 +++++++ public/_headers | 7 ++ wrangler.toml | 15 +++- 6 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/cf-fallback.yml create mode 100755 build.sh create mode 100644 public/_headers diff --git a/.github/workflows/cf-fallback.yml b/.github/workflows/cf-fallback.yml new file mode 100644 index 0000000..6ac52b4 --- /dev/null +++ b/.github/workflows/cf-fallback.yml @@ -0,0 +1,112 @@ +--- +name: cloudflare fallback deploy + +# Cloudflare Workers Builds is the primary deploy path: it builds and deploys +# this Worker on every push to main, with GitHub acting only as the git +# server. This workflow exists for the pushes that path misses entirely -- a +# Cloudflare incident, a revoked GitHub App, a dropped webhook. +# +# It is deduplicated, not merely delayed: it waits for Cloudflare to have its +# turn, then asks the live site which commit it is serving. If that is already +# this commit, the job stops. Only a site still serving an older tree gets a +# deploy from here, so the two paths cannot both publish the same push. +# +# CLOUDFLARE_API_TOKEN needs Workers Scripts: Edit on the account, which is +# enough to replace any Worker in it. It is only exposed to pushes to main and +# manual runs, never to pull requests. + +on: # yamllint disable-line rule:truthy + push: + branches: [main] + # Pushes that cannot change the deployed Worker. Workers Builds still + # deploys them, but if it misses one there is nothing worth redeploying. + paths-ignore: + - "docs/**" + - "README.md" + - "PLAN.md" + - "LICENSE" + - ".github/workflows/ci.yml" + - ".github/workflows/semgrep.yml" + workflow_dispatch: + +permissions: {} + +concurrency: + group: cf-fallback + cancel-in-progress: false + +env: + SITE: https://scram.pid1.space + +jobs: + fallback: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Give Cloudflare first crack + # Workers Builds finishes this project in a minute or two. Waiting is + # what makes this a fallback instead of a second deploy racing the + # first. + run: sleep 600 + + - name: Ask the live site which commit it is serving + id: check + run: | + set -euo pipefail + + # /.build-id is served with Cache-Control: no-store (public/_headers), + # so this reads the deployed Worker rather than an edge copy of an + # older one. + if ! live=$(curl -fsS --max-time 20 --retry 3 --retry-delay 5 "${SITE}/.build-id"); then + # Unreachable is not the same as stale, but it is the case where + # a deploy is most likely to be the thing that fixes it. + echo "could not read ${SITE}/.build-id; deploying" | tee -a "$GITHUB_STEP_SUMMARY" + echo "deploy=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + live=$(echo "$live" | tr -d '[:space:]') + + if [ "$live" = "$GITHUB_SHA" ]; then + echo "Cloudflare already published ${GITHUB_SHA:0:7}; nothing to do." \ + | tee -a "$GITHUB_STEP_SUMMARY" + echo "deploy=false" >> "$GITHUB_OUTPUT" + else + echo "Site is serving ${live:0:7}, this commit is ${GITHUB_SHA:0:7}; deploying." \ + | tee -a "$GITHUB_STEP_SUMMARY" + echo "deploy=true" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: steps.check.outputs.deploy == 'true' + with: + # This job only reads the tree; no need to persist the token. + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + if: steps.check.outputs.deploy == 'true' + with: + node-version: 22 + cache: npm + + - name: Install + if: steps.check.outputs.deploy == 'true' + run: npm ci + + # The same gate Workers Builds runs as its build command: a kill switch + # that fails its own tests does not get published by either path. + - name: Typecheck and test + if: steps.check.outputs.deploy == 'true' + run: npm run typecheck && npm test + + # wrangler.toml's [build] runs ./build.sh first, which stages dist/ and + # stamps dist/.build-id with this commit. Runtime secrets (CF_API_TOKEN, + # ADMIN_TOKEN, ARMED, NOTIFY_WEBHOOK) are kept across deploys and are not + # needed here. + - name: Deploy to Cloudflare + if: steps.check.outputs.deploy == 'true' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: npx wrangler deploy diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97218b5..c6b5b99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,8 +25,8 @@ jobs: run: npm run typecheck # The suite runs inside workerd via @cloudflare/vitest-pool-workers, so - # it needs no Cloudflare account and no secrets. Deploy is deliberately - # not wired up: it would need a token that can disable every Worker in - # the account, and this repo is public. + # it needs no Cloudflare account and no secrets. Deploys are not done + # here: Cloudflare Workers Builds publishes main, and cf-fallback.yml + # covers the pushes it misses. - name: Test run: npm test diff --git a/README.md b/README.md index 4282229..4536727 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,9 @@ scram needs a Cloudflare API token, created at | Zone ยท Zone | Read (all zones) | Enumerate the zones to look in | **This token can disable every Worker in the account.** That is the entire -point of it, and it is also the reason it lives only as a Worker secret, is -never committed, and why CI here does not deploy. +point of it, and it is also the reason it lives only as a Worker secret and is +never committed. Nothing that deploys scram ever sees it: deploys keep the +Worker's existing secrets. ### Arming it @@ -210,6 +211,20 @@ npm test # vitest, inside workerd npm run deploy # wrangler deploy ``` +`wrangler dev` and `wrangler deploy` run `./build.sh` first (via `[build]` in +`wrangler.toml`), which stages `public/` into `dist/` and writes the commit +being deployed to `dist/.build-id`, served at `/.build-id`. + +### Deploying + +Pushes to `main` are deployed by Cloudflare Workers Builds (build command +`npm run typecheck && npm test`, deploy command `npx wrangler deploy`). +`.github/workflows/cf-fallback.yml` waits ten minutes, reads `/.build-id` from +the live site, and deploys with `wrangler` only if Cloudflare has not already +published that commit. It needs the `CLOUDFLARE_API_TOKEN` and +`CLOUDFLARE_ACCOUNT_ID` repository secrets. Neither path applies D1 migrations: +run `npm run db:migrate` before merging a change that adds one. + Tests run in workerd via `@cloudflare/vitest-pool-workers` and need no Cloudflare account. The pricing tests check against Cloudflare's own worked billing examples, so if the rate table drifts they fail. diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..4de6d95 --- /dev/null +++ b/build.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Stage the static status page into dist/ and stamp it with the commit being +# deployed. wrangler.toml's [build] runs this before `wrangler dev` and +# `wrangler deploy`, so there is no separate step to forget. + +set -euo pipefail + +cd "$(dirname "$0")" + +rm -rf dist +mkdir -p dist +cp -R public/. dist/ + +# Identifies the deployed commit so .github/workflows/cf-fallback.yml can tell +# whether Cloudflare Workers Builds already published this tree. public/_headers +# serves it with Cache-Control: no-store. +# +# Read it from the checkout rather than the environment. Workers Builds sets +# WORKERS_CI_COMMIT_SHA to the *branch name* for a manually started build, and +# the fallback compares this value against github.sha -- so trusting the +# variable would leave the site looking permanently stale and make the +# fallback redeploy on every push, which is precisely what it exists to avoid. +sha=$(git rev-parse HEAD 2>/dev/null || echo "${WORKERS_CI_COMMIT_SHA:-${GITHUB_SHA:-local}}") +printf '%s\n' "$sha" > dist/.build-id + +echo "staged dist/ at ${sha}" diff --git a/public/_headers b/public/_headers new file mode 100644 index 0000000..177bf57 --- /dev/null +++ b/public/_headers @@ -0,0 +1,7 @@ +# The deploy fallback in .github/workflows/cf-fallback.yml reads /.build-id to +# decide whether Cloudflare already published the current commit. It must +# never be served from a cache, or the fallback would redeploy on top of a +# good build (or skip one it should have made). +/.build-id + Cache-Control: no-store + Content-Type: text/plain; charset=utf-8 diff --git a/wrangler.toml b/wrangler.toml index 4695983..3ae7268 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -2,10 +2,21 @@ name = "scram" main = "src/index.ts" compatibility_date = "2026-08-22" +# Cloudflare Workers Builds deploys this on push to main. The workflow in +# .github/workflows/cf-fallback.yml is the fallback for when that does not +# happen, and no-ops when Cloudflare already published the commit. + +# Copies public/ into dist/ and writes dist/.build-id (the deployed commit). +# Runs automatically before `wrangler dev` and `wrangler deploy`. +[build] +command = "./build.sh" +watch_dir = ["src", "public"] + # The status page is a static asset. It reads everything it shows from -# /api/status, so the Worker is not invoked to serve the shell. +# /api/status, so the Worker is not invoked to serve the shell. The source is +# public/; build.sh stages it into dist/. [assets] -directory = "./public" +directory = "./dist" binding = "ASSETS" not_found_handling = "404-page"