ci: deploy abcdev.app from the release chain, preview from main - #421
Merged
Conversation
Add .github/workflows/site.yml: one reusable workflow with the three entry points adr-48 fixes. The trigger is the RELEASE CHAIN, not a `release:` event. Releases here are created by release.yml's `gh release create` under the workflow's own GITHUB_TOKEN, and GitHub Actions fires no event trigger for anything done with that token — auto-release.yml's header already documents the same semantic — so a `release: published` trigger would never fire for this repository's releases at all. release.yml therefore calls this workflow (next commit). Production renders the site with the RELEASED binary, checksum-verified byte-for-byte the way the README install one-liner verifies it, so abcdev.app is one statement anyone can check: this is abcd at that tag. It packs site.tar.gz, attests it, attaches it to the release with --clobber so a re-run is idempotent, and deploys with a pinned wrangler from a protected GitHub Environment. The emergency path (workflow_dispatch) always resolves a tag — the latest published release when the input is empty — so the released-bytes invariant survives the emergency. Pre-releases never deploy production, by SemVer suffix and by the Release's own flag, because `gh release create` does not mark an rc tag as a pre-release on its own. The preview path (push to main) is built from SOURCE, because main is ahead of every release and there are no released bytes to verify; the "unreleased" build stamp is exactly the disclosure of that difference, and `wrangler versions upload` never takes the abcdev.app route. Its Cloudflare credential lives in a separate, unprotected environment, so a push to main cannot read the production one. `abcd site build` and `abcd site check` land in a sibling slice; the two steps that call them carry a DEPENDENCY comment naming it. Assisted-by: Claude:claude-fable-5
Add one `site` job to release.yml. It needs `release`, so a site failure can neither block nor taint a release that already published — the isolation adr-48 asks for is this job dependency, not workflow-file separation. Running after `gh release create` has returned also removes the race against the asset uploads site.yml reads. `verify` is untouched: its step list is byte-identical to main, so the record-lint gate_lockstep rule, which parses those step names against the runbook, is unaffected. The job sits before `rehearsal` rather than at the end of the file because the AC6 property test slices the rehearsal job from its key to EOF to prove it holds no publish verb; `rehearsal` has to stay last. release.yml is rendered from an embedded template that a parity test holds byte-identical, so the job is added there too, guarded by `.Abcd` — a managed repo scaffolded from this template has no website and receives no site.yml. The bare rendering is unchanged. Assisted-by: Claude:claude-fable-5
auto-release.yml's `release` job already grants exactly the three scopes site.yml needs (contents/id-token/attestations: write), so the called- workflow cap needs no widening — the nesting is auto-release -> release -> site and the cap is transitive. Only the comments change, so the record says why each grant is held rather than leaving a future reader to rediscover that a second called workflow now rides on them. The comment fork is guarded by `.Abcd` in the embedded template: a bare managed repo has no site.yml and keeps its own (contents: write) wording. Assisted-by: Claude:claude-fable-5
The host project's automatic production and branch builds are turned off (adr-48 decision 5) — a dashboard setting, and the tree is the only durable record of it, which is why this file already carries the build commands as comments. Say what the dashboard is set to, keep the old commands as the record of what it ran and the shape a by-hand deploy has to reproduce, and name what Actions runs instead on each of the two paths. Comments only; no JSON value changes. Assisted-by: Claude:claude-fable-5
Adversarial review of the new workflow found four defects, none of which could ship a wrong release but each of which could deploy the wrong site or misdescribe what happened. Serialise every production deploy on one group. The workflow-level group keys on the tag, but a dispatch with an empty tag has no tag to key on yet — it resolves one later — so it landed in the branch group and could run concurrently with a release chain deploying a NEWER tag. Two wrangler deploys then race for one route and the last writer wins, which can leave abcdev.app on the older release. There is one production route, so every production deploy now shares one group and they queue. Let a caller's stated mode outrank the event name. Inside a called workflow the event name is the TOP-LEVEL caller's, so testing it first let an inherited `workflow_dispatch` overrule a caller that explicitly asked for preview. The comment already said the inputs were the discriminator; now the code agrees. Match the tag shape against the whole string. `grep` is line-oriented, so a tag carrying an embedded newline satisfied a ^…$ pattern on its first line and the remainder was then written into $GITHUB_OUTPUT as a forged second line. A bash [[ =~ ]] match sees the newline and refuses. Stop reading build metadata as a pre-release. `*-*` matched the hyphens inside a v1.2.3+2026-08-22 build-metadata suffix and silently skipped production for a real release. A SemVer pre-release is a '-' immediately after the version core, and that is now what is tested. Also correct the release.yml comment: "non-gating" means a red site cannot block the release or change a byte of what it published, but a called workflow's failure IS the caller's failure, so the run does go red. Saying it "cannot taint" the release overstated it. Assisted-by: Claude:claude-fable-5
The header still claimed a site failure "can neither block nor taint a release" and still described the workflow-level concurrency group as sufficient on its own. The first overstates the isolation the same way release.yml's did — a called workflow's failure IS the caller's failure, so the run goes red even though the Release is untouched. The second described the behaviour before the production job gained its own target-keyed group, and would have sent a reader looking for a guarantee that lives somewhere else in the file. These files are the record; a stale comment on a guard is worse than no comment, because it is believed. Assisted-by: Claude:claude-fable-5
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
abcd | 39d732d | Aug 22 2026, 02:29 PM |
A dispatch can name any tag, and every tag released before the site slice carries a binary with no `site` verb. Without a probe the job checks out, downloads, checksum-verifies and then dies on an unknown-command error several steps into a render, reporting a cobra usage dump rather than the actual cause. The preview path has the same hole while main itself predates the verbs. Probe once at the top of each step that calls `./abcd site …` and refuse with a message naming the real reason. This is also the one exception to "re-running is always the safe response to a failure", which the header claimed without qualification: for a tag whose binary has no verbs, re-running cannot help and the fix is to dispatch a tag that has them. Say so where the claim is made. Assisted-by: Claude:claude-fable-5
…erves
An independent security review returned BLOCK on the production path.
This is the restructure that answers it. The pieces are one change: the
job split is what makes the credential isolation and the attested-bytes
guarantee true at the same time, and neither holds without it.
Split `production` into `render` and `deploy`. Rendering runs third-party
build code — `pip install` resolves and executes whatever
docs/requirements.txt names, and mkdocs runs its own plugins — and that
code shared a job with the Cloudflare token. Now `render` holds no
credential and `deploy` runs no build code: it checks out the tagged
commit for wrangler.jsonc alone, unpacks the artefact `render` attested,
and calls wrangler. The bytes that reach abcdev.app are handed over
in-run as a workflow artifact rather than re-fetched, so they are the
same bytes that were attested — which is what makes the attestation mean
anything to a visitor.
Verify the released binary's build-provenance attestation before
executing it, exactly as release.yml does on the producing side, guarded
by the same repo-visibility condition. The job downloads an asset and
RUNS it; a checksums.txt sitting beside the file it describes proves the
download is intact, not who built it. The checksum stays as the floor for
the private-repo case where no attestation exists.
Resolve the tag to a commit sha once, in `resolve`, and check out that
sha everywhere. Checkout re-resolves a named ref at each job's start, so
a tag re-pointed mid-run could have swapped unverified code between the
render and the deploy. Annotated tags need peeling — auto-release makes
them with `git tag -a`, so refs/tags/<tag> names a tag object and its
.object.sha is not the commit; the API equivalent of `$tag^{commit}` is a
second lookup through git/tags/<object>.
Re-check the release's pre-release and draft flags immediately before
anything is published. `resolve` read that state minutes earlier and the
render is slow; a maintainer who flips it mid-run has said the release
must not be published, in the only way GitHub offers.
Name the preview credentials apart from the production ones
(CLOUDFLARE_PREVIEW_*). A secret reference that resolves to nothing is
empty, not an error, so identically named secrets let a missing
`site-preview` environment fall through to a repo-level secret — the
preview would run holding the production token, silently.
Record the environment contract in the tree, in both site.yml's header
and wrangler.jsonc: `site` protected by a `v*` deployment-tag restriction
and deliberately NO required reviewer, so the emergency dispatch is never
blocked on a human while a branch-dispatched run still cannot obtain the
secrets. This replaces the header's older claim that the dispatch ref
"selects the file only", which was misleading: the ref is exactly what
the tag restriction is evaluated against. The comment is also honest that
Cloudflare cannot scope a token to a verb, so the preview token could
technically call `deploy` — the controls that hold are the distinct
secret names and the tag restriction, not the token's own scope.
Age the wrangler pin. 4.125.0 was one day old. Unlike ci.yml's
GITLEAKS_VERSION, which is fetched and checksum-verified against a
published manifest, wrangler-action resolves this from npm with nothing
to verify it against, and a version in a `with:` value is invisible to
dependabot — so no cooldown applies automatically. Aging is the whole
defence: 4.123.0, published 2026-08-13, is the newest at least 7 days
old, mirroring dependabot's cooldown.
Give `deploy` a timeout sized for a slow Cloudflare API rather than for
its work, and say why: a deploy killed mid-call leaves assets uploaded
and the route unswitched, which is the half-moved state the concurrency
group exists to prevent.
The preview stays a single job and the comment says so, with the reason
and the condition that would change it.
Assisted-by: Claude:claude-fable-5
release.yml's `site` job hands the deploy to site.yml as a reusable workflow, and that call is resolved when the file is PARSED. A `with:` block naming an input site.yml does not declare, or omitting one it requires, fails the whole release run before any job starts. Every other coupling between these two files is a runtime one that `needs: release` contains — the Release is already published and a red site job cannot touch it. This one is different in kind: a parse failure means no job runs at all, so there is nothing for a job dependency to isolate, and the release chain itself would be the thing that breaks. The only day anyone would find out is release day. Hold the contract with a test instead: the caller's supplied keys must equal site.yml's declared workflow_call inputs, and every required input must be supplied. A second test anchors the first by asserting the caller still targets site.yml at all — otherwise the contract test would pass while checking nothing. Hand-parsed, no YAML dependency: internal/core/lint's gate_lockstep already hand-parses these same workflows for the same reason, and both blocks read here are fixed-indentation keys and comments. It lives beside TestInstallSurfacesAgree, which holds committed surfaces to one another in exactly this way. Watched fail four ways before passing: an omitted required input, an undeclared extra key, a renamed input on the site.yml side, and a caller that no longer targets site.yml. release.yml's comment now states the boundary honestly rather than implying `needs:` covers everything. Assisted-by: Claude:claude-fable-5
The probe was a no-op. `./abcd site --help` exits 0 on a binary with no site verb at all: cobra's root command consumes --help before subcommand resolution fails, so it prints the ROOT help and succeeds. The guard committed in 2b60318 could never have fired, and the commit message claiming it would was wrong. `./abcd site build --help` and `./abcd help site` fail the same way, for the same reason — measured, all three exit 0 against a binary built from this tree, which has no site verb. A bare `./abcd site` does exit 2, but it would EXECUTE the verb once the verb exists, so it is not a probe. Grep the root command listing instead. It is the public surface, has no side effects, and names `site` exactly when the verb is registered. Verified in three directions: exit 1 on the real verb-less binary, exit 0 against a listing that registers `site`, and exit 1 against a listing that merely mentions the word in another command's description. Assisted-by: Claude:claude-fable-5
Four fixes from the second security review, plus two claims corrected.
The `v*`-only deployment policy refused the PRIMARY release path. The
release chain is auto-release -> release.yml -> here, and its run ref is
refs/heads/main, so a tags-only restriction would have refused every
ordinary release and left the emergency dispatch as the only working
path — a control that breaks the thing it protects. The policy is now
{branch main, tags v*}. What it refuses is unreviewed WORKFLOW CONTENT:
a dispatch runs this file as it exists on the ref it came from, so an
arbitrary branch is refused, while main is the reviewed state and a v*
tag is immutable. The residual is recorded where the policy is: whoever
can merge to main can change this file and have it run with these
secrets, which is the repository's ordinary review boundary and not a new
surface.
Gate `render` behind a third environment, `site-render`, holding NO
secrets and the same {main, v*} policy. render carries contents: write,
id-token and attestations: write — it signs artefacts and clobbers a
release asset. Ungated, a run dispatched from an arbitrary branch reached
it and could attach arbitrary bytes to a real release; the gate on
`deploy` refuses too late, because the asset is already replaced. render
needs no credential and references none, so gating it costs nothing.
Audit site.tar.gz before extracting it, and extract outside the
workspace. tar writes what the archive tells it to, and the file most
worth overwriting here is wrangler.jsonc, which names the routes the next
step asserts. Absolute paths, `..` components and any non-regular member
are refused outright rather than left to tar's stripping, which differs
between GNU and BSD and warns where it should refuse. Extraction now
lands in RUNNER_TEMP and the verified tree is copied into ./site, so no
archive member is ever written near the config. Verified against
absolute-path, traversal, symlink and hardlink archives, driving the real
step scripts: all four refused, the benign one accepted with
wrangler.jsonc intact.
Peel tag objects in a loop and refuse a non-commit. git permits a tag of
a tag, which one hop would leave pointing at the inner tag object, and a
tag may target a tree or a blob — in which case the old message claimed
"did not resolve to a commit sha" while actually having resolved
something. The loop is bounded so a cyclic tag cannot spin.
Two claims softened to name the visibility gate: attestation applies once
the repository is public, and what holds today is the checksum floor on
the input binary plus the in-run handover between the two jobs.
The deploy concurrency comment claimed more than the mechanism gives.
`concurrency` provides mutual exclusion, not ordering: a job enters the
group when it starts, after its own render, so an older tag with a slow
render can deploy after a newer tag with a fast one and leave the site a
release behind. Nothing is corrupted — each deploy serves one coherent
verified tree. Tag-aware serialisation would mean reading the deployed
version back from Cloudflare and comparing SemVer, a live API read on the
release path that this job deliberately avoids. The residual is accepted
in writing, and recovery is named: re-run the newest tag's site job,
which is idempotent per tag.
Assisted-by: Claude:claude-fable-5
The site slice has merged, so the placeholders can go. The real surface is `site build --out --version --commit --date` and `site check --out`; there is no `--tag`. All four call sites are corrected and the four DEPENDENCY comments removed — the dependency is met. --version takes the tag WITHOUT its leading v, and that is not cosmetic. The page renders the stamp as "v" prepended to the value, so v0.6.1 would appear as "vv0.6.1", and `site check`'s provenance gate admits only a token matching ^v?\d+(\.\d+)*$. Measured against a binary built from origin/main: bare 0.6.1 renders "v0.6.1" and every gate passes; v-prefixed renders "vv0.6.1" and fails two. --date is deliberately not passed. Its default is the newest dated CHANGELOG heading read from the tag the render job checked out, which is this release's own date, derived once by the binary. Injecting a second copy — from the Release's publishedAt — would let the site's stamp disagree with the CHANGELOG the release was cut from, and adr-37 makes that heading the release date. One source, no tie to break. The preview keeps an UNMET adr-48 requirement, now recorded rather than misdescribed. adr-48 3 wants an "unreleased — main@<sha>" label; the merged generator cannot express one, because every non-numeric version fails the same provenance gate. Measured: `unreleased`, `0.6.1-unreleased`, `0.6.1+main` and `0.6.1-main.<sha>` each fail with 2 findings. So the stamp carries the commit — the half that does work, and never a release commit — while the version half still reads as the last release. The comment that claimed the generator renders the label from its own interface strings was wrong and is replaced by what is true, plus where the fix belongs (internal/core/site, not a workflow that can only pass strings the gate refuses). Not worked around by dropping the check step: a preview that skipped the gates would stop being a preview. The verb probe is re-verified against real binaries in both directions: a main-built binary passes it, the pre-slice binary refuses, and the three `--help` forms still pass on the verb-less binary, which is why the probe greps the root listing instead. Assisted-by: Claude:claude-fable-5
`abcd site build --preview` (#438) closes the gap the previous commit recorded: the generator stamps `unreleased · <commit>` and writes `preview: true` into record.json, so a preview built from main claims no version at all. adr-48 decision 3 is implementable and now implemented. The preview job passes --preview alongside --commit, and no version anywhere on that path — the two flags are mutually exclusive and the binary refuses the pair, which is the right shape: a preview with a pinned version is a contradiction, and it should fail loudly rather than have one silently win. The comments that described the gap are replaced by what the build now does. The preview probe's note is corrected too: main carries the site verb, so it is a standing guard against a rename rather than a window being held open. Verified against a binary built from origin/fix/site-build-purge (134252c), since #438 is not yet on main: --preview builds clean and the page reads "2026-08-20 · unreleased · 134252c…"; --preview with --version is refused; record.json carries preview true; and `site check` passes every gate on the preview render. The production path was re-measured against the same binary, because #438 touches the stamp renderer — bare 0.6.1 still renders v0.6.1 and passes, v-prefixed still renders vv0.6.1 and fails two gates, so ${TAG#v} stands. Assisted-by: Claude:claude-fable-5
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Slice 5 of the website build (adr-48 plumbing): the reusable
site.ymldeploy workflow, invoked from the release chain as a separate non-gating job after the release job; a source-built labelled preview on every push to main;workflow_dispatch(tag)emergencies from the latest tag, never from main; wrangler deploys from protected environments; Cloudflare dashboard state recorded inwrangler.jsonccomments. The scaffold templates move in lockstep (parity-tested), andrelease.yml'sverifyjob is byte-identical to main's.DRAFT — do not merge until the
abcd site buildandabcd site checkslices land: the workflow invokes both verbs; the four dependency-marked call sites are commented insite.yml. Environment setup (site,site-preview, Cloudflare token/account secrets) and the dashboard auto-build toggle are manual maintainer actions, listed in the workflow's header comment.Assisted-by: Claude:claude-fable-5