ci(site): prod releases on a VERSION bump (UI-signed merge only); non-release merges build as a check #5

Merged
casey merged 5 commits from ctrl-agent/metronome:ci/test-and-prod-channels into main 2026-10-22 20:27:33 +00:00
Collaborator

The site's CI now publishes to prod only, and only on a release. CI serves no other environment. Store workflow untouched.

What CI deploys (operator decision, 2026-10-22)

  • prod metronome.varasys.io — changes only on a release (below).
  • Nothing else. The lab's *.test.<apex> wildcard is bizon's: the project agent serves local builds there, and this workflow never deploys to it.

What each event does

Event Registry (sites/metronome) Doorbell Forgejo
merge to main, VERSION unchanged (or v<VERSION> already exists) nothing pushed — the merge commit is built through the image step as a buildability check — —
merge to main that bumps VERSION to an untagged X.Y.Z :sha-<12-char short> pushed once; then :v<VERSION> (new, immutable) and :prod (moved) — the byte-identical manifest copied by digest via the OCI distribution API, no rebuild $DEPLOY_COMMAND metronome prod release + tag v<VERSION> via the ambient token, target_commitish = the merge commit SHA (github.sha); no git push; release asset sites-metronome.digest
  • Builds github.sha (the merge commit), not main's tip at run time; concurrency: image serialises runs; a dispatch on a non-main ref does nothing.
  • Signed-merge pre-check: before any tag or registry state exists, the release job reads the merge commit through the API and refuses (FATAL) unless it carries the instance signature.
  • Re-runs converge: a v<VERSION> tag already pointing at this commit is a retry; an existing :v* is never overwritten and its digest is what :prod gets. The tag is read back and must name the merge commit.
  • :latest and :main are not pushed. A release build is stamped with the bare VERSION; a check build keeps the -dev.<ts>.g<sha> stamp.
  • No secrets on argv (/proc/<pid>/cmdline is readable inside the job container): the Forgejo token and the zot basic auth go to curl on stdin (curl --config -), the clone uses GIT_ASKPASS, buildah login keeps --password-stdin, and the deploy key file lives in the trap-cleaned work dir.
  • Release asset sites-<SITE_NAME>.digest (here sites-metronome.digest, name derived from vars.SITE_NAME): after :v<VERSION>/:prod are set and BEFORE the doorbell, the job uploads one line sha256:<64 hex> = the promoted digest (POST …/releases/{id}/assets?name=…, multipart, token on stdin), then re-reads the release (asset listed, size = 72 bytes) and fetches it anonymously from /<repo>/releases/download/v<VERSION>/sites-metronome.digest — the exact URL the origin's gate reads — and byte-compares. Re-run safe: an existing asset with identical bytes is accepted; one with any other content is a FATAL, never overwritten.
  • VERSION ⇔ tag binding: the tag is always v + the merge commit's own root VERSION file. The build job reads VERSION from its checkout of github.sha; the release job independently reads VERSION at that SHA through the API (/raw/VERSION?ref=<sha>) and fails unless v$(cat VERSION) equals the tag, and the tag read-back must name that same commit.
  • The doorbell is exactly $DEPLOY_COMMAND $SITE prod. The origin's refresh-gate accepts exactly [sudo [-n]] [/usr/local/bin/]refresh-site <site> [prod] and refuses anything else (exit 126). Before accepting :prod, the origin verifies that :prod equals some :v* digest, that release v<VERSION> on the public forge publishes sites-metronome.digest naming that digest, and that its tagged commit carries the instance's merge signature (myos #492).

How merges to this repo must be made

Every merge of this repo is made in the Forgejo UI as a merge commit (instance-signed) — never by direct push to main, and never by rebase-merge or squash. Only an instance-signed merge commit can be released: the release job refuses anything else up front, and the origin would refuse it too. A release PR changes only VERSION. Never push v* tags by hand.

Merge conditions — all three before this PR is merged

  1. myos #492 merged and converged on the origin (refresh-gate + refresh-site <site> prod + the release/signature verification).
  2. The cutover retag done: sites/metronome:prod set, and a signed cutover release v<VERSION> on this forge for the digest it names, so the origin's verification has something true to verify.
  3. The myos overlay entry for metronome flipped to the channelled shape and re-sealed. Until (3), a release rings prod and nothing changes on the origin.

Until all three hold, merging is unsafe to rely on even though it is inert for prod today (no VERSION bump exists past v0.0.68, so the release job would not run, and a non-release merge publishes nothing).

Setup

None new: the same vars.* (REGISTRY, IMAGE_REPO, SITE_NAME, DEPLOY_COMMAND, DEPLOY_ORIGIN, REGISTRY_USER) and secrets (REGISTRY_TOKEN, STATIC_DEPLOY_KEY) are reused. Release creation uses the ambient job token, as store-release-on-merge.yml does. No Loki conclusion-line step: neither this workflow nor the store's ships one today.

Docs in this PR

README + CLAUDE.md + the workflow header: CI deploys prod only, on a VERSION-bump merge made in the UI; no test channel; the release contract (tag = v + the commit's VERSION, signed merge, asset sites-metronome.digest). Also fixed the stale store-release.yml → store-release-on-merge.yml name in CLAUDE.md and store/README.md.

🤖 Generated with Claude Code

The site's CI now publishes to **prod only, and only on a release**. CI serves no other environment. Store workflow untouched. ## What CI deploys (operator decision, 2026-10-22) - **prod** `metronome.varasys.io` — changes only on a release (below). - Nothing else. The lab's `*.test.<apex>` wildcard is bizon's: the project agent serves local builds there, and this workflow never deploys to it. ## What each event does | Event | Registry (`sites/metronome`) | Doorbell | Forgejo | |---|---|---|---| | merge to `main`, `VERSION` unchanged (or `v<VERSION>` already exists) | **nothing pushed** — the merge commit is built through the image step as a buildability check | — | — | | merge to `main` that bumps `VERSION` to an untagged X.Y.Z | `:sha-<12-char short>` pushed once; then `:v<VERSION>` (new, immutable) and `:prod` (moved) — **the byte-identical manifest copied by digest via the OCI distribution API, no rebuild** | `$DEPLOY_COMMAND metronome prod` | release + tag `v<VERSION>` via the ambient token, `target_commitish` = the merge commit SHA (`github.sha`); no `git push`; release asset **`sites-metronome.digest`** | - Builds `github.sha` (the merge commit), not `main`'s tip at run time; `concurrency: image` serialises runs; a dispatch on a non-main ref does nothing. - **Signed-merge pre-check:** before any tag or registry state exists, the release job reads the merge commit through the API and refuses (FATAL) unless it carries the instance signature. - Re-runs converge: a `v<VERSION>` tag already pointing at this commit is a retry; an existing `:v*` is never overwritten and its digest is what `:prod` gets. The tag is read back and must name the merge commit. - `:latest` and `:main` are **not pushed**. A release build is stamped with the bare `VERSION`; a check build keeps the `-dev.<ts>.g<sha>` stamp. - **No secrets on argv** (`/proc/<pid>/cmdline` is readable inside the job container): the Forgejo token and the zot basic auth go to curl on stdin (`curl --config -`), the clone uses `GIT_ASKPASS`, `buildah login` keeps `--password-stdin`, and the deploy key file lives in the trap-cleaned work dir. - **Release asset `sites-<SITE_NAME>.digest`** (here `sites-metronome.digest`, name derived from `vars.SITE_NAME`): after `:v<VERSION>`/`:prod` are set and BEFORE the doorbell, the job uploads one line `sha256:<64 hex>` = the promoted digest (`POST …/releases/{id}/assets?name=…`, multipart, token on stdin), then re-reads the release (asset listed, size = 72 bytes) and fetches it anonymously from `/<repo>/releases/download/v<VERSION>/sites-metronome.digest` — the exact URL the origin's gate reads — and byte-compares. Re-run safe: an existing asset with identical bytes is accepted; one with any other content is a **FATAL, never overwritten**. - **VERSION ⇔ tag binding:** the tag is always `v` + the merge commit's own root `VERSION` file. The build job reads `VERSION` from its checkout of `github.sha`; the release job independently reads `VERSION` at that SHA through the API (`/raw/VERSION?ref=<sha>`) and fails unless `v$(cat VERSION)` equals the tag, and the tag read-back must name that same commit. - The doorbell is exactly `$DEPLOY_COMMAND $SITE prod`. The origin's `refresh-gate` accepts exactly `[sudo [-n]] [/usr/local/bin/]refresh-site <site> [prod]` and refuses anything else (exit 126). Before accepting `:prod`, the origin verifies that `:prod` equals some `:v*` digest, that release `v<VERSION>` on the public forge publishes `sites-metronome.digest` naming that digest, and that its tagged commit carries the instance's merge signature (myos #492). ## How merges to this repo must be made **Every merge of this repo is made in the Forgejo UI as a merge commit** (instance-signed) — never by direct push to `main`, and never by rebase-merge or squash. Only an instance-signed merge commit can be released: the release job refuses anything else up front, and the origin would refuse it too. A release PR changes only `VERSION`. Never push `v*` tags by hand. ## Merge conditions — all three before this PR is merged 1. **myos #492 merged and converged on the origin** (refresh-gate + `refresh-site <site> prod` + the release/signature verification). 2. **The cutover retag done:** `sites/metronome:prod` set, and a signed cutover release `v<VERSION>` on this forge for the digest it names, so the origin's verification has something true to verify. 3. **The myos overlay entry for metronome flipped to the channelled shape and re-sealed.** Until (3), a release rings prod and nothing changes on the origin. Until all three hold, merging is unsafe to rely on even though it is inert for prod today (no `VERSION` bump exists past `v0.0.68`, so the release job would not run, and a non-release merge publishes nothing). ## Setup None new: the same `vars.*` (`REGISTRY`, `IMAGE_REPO`, `SITE_NAME`, `DEPLOY_COMMAND`, `DEPLOY_ORIGIN`, `REGISTRY_USER`) and secrets (`REGISTRY_TOKEN`, `STATIC_DEPLOY_KEY`) are reused. Release creation uses the ambient job token, as `store-release-on-merge.yml` does. No Loki conclusion-line step: neither this workflow nor the store's ships one today. ## Docs in this PR README + CLAUDE.md + the workflow header: CI deploys prod only, on a `VERSION`-bump merge made in the UI; no test channel; the release contract (tag = `v` + the commit's `VERSION`, signed merge, asset `sites-metronome.digest`). Also fixed the stale `store-release.yml` → `store-release-on-merge.yml` name in CLAUDE.md and `store/README.md`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Every merge to main builds the content image once, pushes :sha-<short> and :main, and
rings `refresh-site <site> test` (metronome.test.varasys.io, Tier 2). A merge that bumps
the root VERSION to a value with no v<VERSION> tag is a release: the release job creates
the v<VERSION> tag/release via the API from the signed merge (no git push), copies the
already-pushed manifest to :v<VERSION> (immutable) and :prod by digest — no rebuild — and
rings `refresh-site <site> prod`. :latest is no longer pushed. Re-runs converge (a tag that
already points at this commit is a re-run; an existing :v* is never overwritten).

Docs: README + CLAUDE.md deploy paragraphs describe the two environments.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Operator decision (2026-10-22): the test environment is the project agent's own
(`*.test.<apex>` forwarded to bizon); CI never deploys to it. So a merge to main that
does not bump VERSION builds the pushed commit through the image step and publishes
nothing (no :main, no :sha push — nothing would consume it). A VERSION bump is unchanged:
build once, push :sha-<short>, release + tag v<VERSION> via the ambient token, copy the
byte-identical manifest to :v<VERSION> and :prod, ring `$DEPLOY_COMMAND metronome prod`
(the only shape the origin's refresh-gate accepts).

Docs: README + CLAUDE.md describe the two environments; the stale
`store-release.yml` name is corrected to `store-release-on-merge.yml` (CLAUDE.md +
store/README.md).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ctrl-agent changed title from ci(site): test + prod channels — build once on main, promote the same digest on a VERSION bump to ci(site): prod releases on a VERSION bump; test is bizon's wildcard, non-release merges build as a check 2026-10-22 16:10:18 +00:00
- Every credential now reaches its program by stdin (`curl --config -` for the
  Forgejo token and the zot basic auth) or environment (`GIT_ASKPASS` for the clone),
  never argv; `buildah login --password-stdin` was already so.
- The release job first verifies via the API that the merge commit carries the
  instance signature (only a Forgejo-UI merge commit does) and refuses otherwise,
  before any tag or registry state exists. Verified against the live API: a UI merge
  on main reads verified=true, a pushed commit reads not_signed_commit.
- The gate accepts exactly `[sudo [-n]] [/usr/local/bin/]refresh-site <site> [prod]`;
  no test channel exists — comment, README and CLAUDE.md say CI serves prod only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- The prod deploy key is written (umask 077) into $work, so the EXIT trap removes it
  even when ssh fails; before, a failed ssh left it in /tmp until the container died.
- The doorbell comment states what the origin verifies before accepting :prod: :prod
  equals some :v* digest, release v<VERSION> exists on the public forge, and its
  tagged commit carries the instance's merge signature.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ctrl-agent changed title from ci(site): prod releases on a VERSION bump; test is bizon's wildcard, non-release merges build as a check to ci(site): prod releases on a VERSION bump (UI-signed merge only); non-release merges build as a check 2026-10-22 16:31:44 +00:00
The origin's prod gate accepts :prod only when the Forgejo release v<VERSION>
carries the asset sites-<site>.digest (one line, sha256:<64 hex>) naming that
digest. The release job now uploads it after promoting the manifest and before
the doorbell, reads it back anonymously at the URL the gate uses (listed, size,
bytes), accepts an identical asset on a re-run and fails on any other content
(never overwrites). It also reads VERSION at the merge commit via the API and
asserts v<VERSION> equals the tag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
casey merged commit f2530445db into main 2026-10-22 20:27:33 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
varasys/metronome!5
No description provided.