Skip to content

ci: deploy abcdev.app from the release chain, preview from main - #421

Merged
REPPL merged 13 commits into
mainfrom
ci/site-deploy-workflow
Aug 22, 2026
Merged

ci: deploy abcdev.app from the release chain, preview from main#421
REPPL merged 13 commits into
mainfrom
ci/site-deploy-workflow

Conversation

@REPPL

@REPPL REPPL commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Slice 5 of the website build (adr-48 plumbing): the reusable site.yml deploy 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 in wrangler.jsonc comments. The scaffold templates move in lockstep (parity-tested), and release.yml's verify job is byte-identical to main's.

DRAFT — do not merge until the abcd site build and abcd site check slices land: the workflow invokes both verbs; the four dependency-marked call sites are commented in site.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

REPPL added 6 commits August 22, 2026 09:32
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
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 22, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
abcd 39d732d Aug 22 2026, 02:29 PM

REPPL added 7 commits August 22, 2026 10:10
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
@REPPL
REPPL marked this pull request as ready for review August 22, 2026 17:50
@REPPL
REPPL added this pull request to the merge queue Aug 22, 2026
Merged via the queue into main with commit d072098 Aug 22, 2026
13 checks passed
@REPPL
REPPL deleted the ci/site-deploy-workflow branch August 22, 2026 17:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant