Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions .github/workflows/cf-fallback.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions build.sh
Original file line number Diff line number Diff line change
@@ -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}"
7 changes: 7 additions & 0 deletions public/_headers
Original file line number Diff line number Diff line change
@@ -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
15 changes: 13 additions & 2 deletions wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Loading