Skip to content

Commit b047ebc

Browse files
docs(release): say what the gates actually guarantee
Three claims that were stronger than the code, plus one legibility fix. The PR gate's comment said a `packages/` edit "on the PR or its base" fails the status before merge. The base half is not true: GitHub fires no `pull_request` event when the base branch advances, it only recomputes refs/pull/N/merge, so a change landing on the base after this went green is never re-verified and the PR merges on the stale result. Checking the merge tree does make base drift visible WHEN the job runs, which is worth keeping — but finalize is what actually catches the drift case, after the merge and before any stable name. Say both, in the workflow and in the reviewer checklist, so nobody reads the green as covering more than it does. "Transactional at every consumer-visible name" is a judgement call dressed as an absolute, inherited from the "this dispatch performs NO registry mutation" it replaced. The dispatch now does write to the registry: the `promotion-*` candidate is a real, publicly listable tag. What it creates is no *stable* name — no vX.Y.Z, no :latest, no stable installer. Stated that way the sentence stops needing the reader to already know which writes were meant. The retention paragraph listed two of the conditions the workflow enforces. It now lists all of them, including the ones the workflow gained here: same-repository, bot-authored, `release-X.Y.Z` branch, the `release` label, exactly one valid digest pin, and protection from a base-branch tip as well as an open PR. Also: `flux pull artifact` reads its credentials from ~/.docker/config.json, which is why finalize now runs `docker login` — but its runner class is not one of the docker-using build ones and the setup step installs everything except docker. Check for it there, where the message can name what is missing, instead of failing with "command not found" halfway down the release path. And the verifier's exclusion list is a restatement of flux's `excludeOCI`, not a derivation — the CLI does not expose it — so the coupling and the pinned version are now written down next to the list. Assisted-By: Claude <[email protected]> Signed-off-by: Myasnikov Daniil <[email protected]>
1 parent 88a5063 commit b047ebc

5 files changed

Lines changed: 45 additions & 14 deletions

File tree

‎.github/workflows/promote-rc.yaml‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,23 @@ name: Promote RC
77
# declarative packages artifact is re-serialized after its rc tag strings are
88
# rewritten to stable, then verified before any stable name is created.
99
#
10-
# Promotion is TRANSACTIONAL at every consumer-visible name. This dispatch
11-
# re-serializes the tag-rewritten packages tree under a temporary `promotion-*`
12-
# tag, pins its digest in the release-X.Y.Z staging branch, drafts the stable
13-
# release with restamped assets, and opens the release-X.Y.Z PR. It does not
14-
# create a stable tag, move :latest, or publish the stable installer. The rc's
10+
# Promotion CREATES NO STABLE NAME before merge. This is weaker than the "no
11+
# registry mutation at dispatch" this workflow used to promise, and the
12+
# difference is deliberate rather than a slip: the dispatch does now write to
13+
# the registry, publishing one `promotion-*` packages candidate, and that tag is
14+
# real and publicly listable. What it is not is a name anything resolves — no
15+
# vX.Y.Z, no :latest, no stable installer chart. Say it that way round; the
16+
# older phrasing only reads as true if you already know which writes are meant.
17+
#
18+
# So the dispatch re-serializes the tag-rewritten packages tree under that
19+
# temporary tag, pins its digest in the release-X.Y.Z staging branch, drafts the
20+
# stable release with restamped assets, and opens the release-X.Y.Z PR. The rc's
1521
# mandatory full e2e is verified before staging; the promote PR does not repeat
1622
# it by default. Finalize verifies the pinned candidate against the merged tree
1723
# before performing any stable-named registry or git side effect. An abandoned
1824
# promotion therefore leaves only an overwritable draft + branch and a
19-
# temporary artifact: retention protects it while the PR is open and can prune
20-
# it after that reference disappears.
25+
# temporary artifact, which retention protects until it is retagged stable and
26+
# can prune once nothing references it.
2127
#
2228
# Triggered manually once an rc has gone green. The stable git tag is never
2329
# pushed by a human and never force-moved — it is created only at PR merge,

‎.github/workflows/pull-requests-release.yaml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,14 @@ jobs:
135135
|| { sudo apt-get update -qq && sudo apt-get install -y -qq skopeo; }
136136
command -v helm >/dev/null \
137137
|| curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
138+
# Not installed here, only required: `flux pull artifact` reads its
139+
# registry credentials from ~/.docker/config.json, which is why the
140+
# login step below runs `docker login` as well as skopeo's and helm's.
141+
# This job's runner class is not one of the docker-using build ones, so
142+
# say what is missing here rather than letting the login step fail with
143+
# a bare "command not found" in the middle of the release path.
144+
command -v docker >/dev/null \
145+
|| { echo "::error::docker is required on this runner: flux reads registry credentials from ~/.docker/config.json"; exit 1; }
138146
139147
- name: Login to registry (GHCR)
140148
if: ${{ !contains(steps.get_tag.outputs.tag, '-') }}

‎.github/workflows/pull-requests.yaml‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,9 +197,17 @@ jobs:
197197
fi
198198
199199
# Promotion publishes the rewritten packages artifact before opening its PR.
200-
# Verify the exact prospective merge tree here, so a packages edit on the PR
201-
# or its base fails the required E2E Tests status before merge. Finalize repeats
202-
# the same check before creating the write-once stable tag.
200+
# Verify GitHub's prospective merge tree here, so a `packages/` edit pushed to
201+
# this PR fails the required E2E Tests status before it can merge.
202+
#
203+
# Checking the MERGE tree rather than the head is what makes base drift
204+
# visible when this job runs — but nothing re-runs it when the base moves:
205+
# GitHub does not fire `pull_request` on the base branch advancing, it only
206+
# recomputes refs/pull/N/merge. So a `packages/` change landing on the base
207+
# after this went green is never re-verified here, and the PR stays mergeable
208+
# on the stale result. Finalize repeats the check against the real merge
209+
# commit before creating the write-once stable tag, which is where that case
210+
# is actually caught — loudly, and before any stable name exists.
203211
verify-release-candidate:
204212
name: Verify release packages candidate
205213
runs-on: ubuntu-latest

‎docs/release.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ A regular release sequence runs as follows:
7373
commit id: "cherry-picked fix" tag: "v1.2.0-rc.2"
7474
```
7575

76-
3. Once the checks are green, run the [`promote-rc.yaml`](../.github/workflows/promote-rc.yaml) workflow (manual dispatch) with that rc tag. Its `parse` job verifies E2E before any staging: it accepts a successful `rc-e2e` job in the latest `tags.yaml` push run for the tag, or any retained successful manual `e2e-tag.yaml` run whose expanded E2E job name targets the tag. The emergency `skip_e2e_gate` input bypasses this check, logs a loud warning, and marks the promote PR with ⚠️. Before its first registry write, promotion also proves that the selected `main` or `release-X.Y` target already contains the candidate-aware PR gate, finalizer and trusted verifier; backport this pipeline to an existing maintenance branch before promoting its next patch. Promotion is **transactional at every consumer-visible name**: dispatch publishes one temporary `promotion-*` packages candidate, but creates no stable tag, moves no `latest`, and publishes no stable installer. It:
76+
3. Once the checks are green, run the [`promote-rc.yaml`](../.github/workflows/promote-rc.yaml) workflow (manual dispatch) with that rc tag. Its `parse` job verifies E2E before any staging: it accepts a successful `rc-e2e` job in the latest `tags.yaml` push run for the tag, or any retained successful manual `e2e-tag.yaml` run whose expanded E2E job name targets the tag. The emergency `skip_e2e_gate` input bypasses this check, logs a loud warning, and marks the promote PR with ⚠️. Before its first registry write, promotion also proves that the selected `main` or `release-X.Y` target already contains the candidate-aware PR gate, finalizer and trusted verifier; backport this pipeline to an existing maintenance branch before promoting its next patch. Promotion **creates no stable name before merge**: dispatch does write to the registry — it publishes one `promotion-*` packages candidate, and that tag is real and publicly listable — but it creates no `vX.Y.Z` tag, moves no `latest`, and publishes no stable installer, so nothing a consumer resolves changes until the PR merges. It:
7777
1. Rewrites the rc version substring to `1.2.0` and commits that exact tree, serializes its `packages/` directory under a run-specific temporary OCI tag, then adds a second commit pinning the candidate's immutable digest in the installer before pushing `release-1.2.0`. Container digests remain exactly the rc's; the two commits give the OCI revision an honest source SHA without pretending the artifact can contain its own digest.
7878
2. Drafts the `v1.2.0` release and uploads the (restamped) assets.
7979
3. Generates `docs/changelogs/v1.2.0.md` (in a parallel job, detached at the rc tag) — or, if you pressed the rc-time `Generate RC Changelog` button, copies the file already banked on the `release-1.2.0-rc.N` staging branch instead of regenerating — and commits it onto the `release-1.2.0` branch, so the changelog is reviewed as part of the promotion and finalize can publish the release with it already in place. Non-blocking: if generation fails the PR still opens, carrying a ⚠️ line that says the changelog is missing.
@@ -178,7 +178,7 @@ gitGraph
178178

179179
2. The maintainer cuts a release candidate (`v1.2.1-rc.N`) via the [`Cut Pre-release Tag`](../.github/workflows/cut-prerelease.yaml) workflow (manual dispatch **from `release-1.2`**), which tags that branch's `HEAD`. CI builds and publishes the rc, pushes the `release-1.2.1-rc.N` staging branch, and runs the mandatory full `rc-e2e` job against the published tag.
180180
3. Watch `rc-e2e` and wait for it to pass, then complete the other release checks. A successful manual `e2e-tag.yaml` run for `v1.2.1-rc.N` is also valid evidence if the latest `tags.yaml` run does not carry a green `rc-e2e`. **Optional:** press the `Generate RC Changelog` button ([`changelog-rc.yaml`](../.github/workflows/changelog-rc.yaml)) to generate `docs/changelogs/v1.2.1.md` at rc time and bank it on the rc staging branch; promotion then reuses it instead of regenerating.
181-
4. Once the checks are green, run [`promote-rc.yaml`](../.github/workflows/promote-rc.yaml) (manual dispatch) with that rc tag. It runs the same five jobs as a regular promotion: the dispatch first gates on the latest tag-push `rc-e2e` or a successful tag-correlated manual E2E run (`skip_e2e_gate` overrides it), verifies that `release-1.2` already carries the candidate-aware pipeline, then commits and publishes the rewritten packages tree under a run-specific temporary `promotion-*` tag, adds a second commit pinning the candidate on the `release-1.2.1` branch, drafts the `v1.2.1` release with the restamped assets, generates or picks up `docs/changelogs/v1.2.1.md` and commits it onto that branch, opens the parked `cozystack/website` docs PR (updating the existing `v1.2/` directory in place, since a patch does not promote `next/`), and opens the promote PR into `release-1.2` with only the `release` label. The promote PR body carries the same ✅/⚠️ E2E, changelog, and website-docs lines and runs no E2E by default; add `full-e2e` manually to force a repeat. As with a regular release every stable name is deferred to merge; retention protects the temporary candidate while this PR is open.
181+
4. Once the checks are green, run [`promote-rc.yaml`](../.github/workflows/promote-rc.yaml) (manual dispatch) with that rc tag. It runs the same five jobs as a regular promotion: the dispatch first gates on the latest tag-push `rc-e2e` or a successful tag-correlated manual E2E run (`skip_e2e_gate` overrides it), verifies that `release-1.2` already carries the candidate-aware pipeline, then commits and publishes the rewritten packages tree under a run-specific temporary `promotion-*` tag, adds a second commit pinning the candidate on the `release-1.2.1` branch, drafts the `v1.2.1` release with the restamped assets, generates or picks up `docs/changelogs/v1.2.1.md` and commits it onto that branch, opens the parked `cozystack/website` docs PR (updating the existing `v1.2/` directory in place, since a patch does not promote `next/`), and opens the promote PR into `release-1.2` with only the `release` label. The promote PR body carries the same ✅/⚠️ E2E, changelog, and website-docs lines and runs no E2E by default; add `full-e2e` manually to force a repeat. As with a regular release every stable name is deferred to merge; retention protects the temporary candidate while this PR is open and, after it merges, while `release-1.2` still pins it — until finalize retags it stable.
182182

183183
```mermaid
184184
gitGraph
@@ -284,7 +284,7 @@ If anyone changes the website Makefile to write somewhere else (e.g. `static/`,
284284
Reviewer checklist for the `chore(release): promote vX.Y.Z-rc.N -> vX.Y.Z` PR:
285285

286286
- [ ] The PR body says `✅ RC full e2e was verified`; if it says the gate was bypassed, obtain explicit maintainer acceptance or add `full-e2e` and wait for that opt-in run.
287-
- [ ] `Verify release packages candidate` is green. It pulls the candidate and original rc artifact by digest, compares the candidate to GitHub's prospective merge tree, and feeds the required `E2E Tests` status even when full E2E is not repeated. Do not edit `packages/` on this PR after generation; if the guard reports drift, re-dispatch promotion to create and pin a new run-specific candidate.
287+
- [ ] `Verify release packages candidate` is green. It pulls the candidate and original rc artifact by digest, compares the candidate to GitHub's prospective merge tree, and feeds the required `E2E Tests` status even when full E2E is not repeated. Do not edit `packages/` on this PR after generation; if the guard reports drift, re-dispatch promotion to create and pin a new run-specific candidate. Note what a green here does *not* cover: GitHub fires no `pull_request` event when the base branch advances, so a `packages/` change merged to the base afterwards is never re-verified on the PR. Finalize repeats the check against the real merge commit and catches that case before any stable name exists — but it catches it after the promote PR has already merged, so a `packages/` change landing on the base while a promote PR is open is worth holding back.
288288
- [ ] The PR body's **website-docs line** is present: if ✅, the `cozystack/website` docs PR is open and **parked** — do NOT merge it until `vX.Y.Z` is published (see [Phase 3](#phase-3--update-website-docs-now-the-backstop)); if ⚠️, the tag-time backstop will backfill it after finalize.
289289
- [ ] Diff is digest pins + image tags only — nothing else.
290290
- [ ] No accidental config drift (a value file you don't recognize).
@@ -325,7 +325,7 @@ A nightly is an **installable copy of `main` on GHCR — not a rebuild and not a
325325
2. **build-disk** — assemble the Talos `nocloud` disk from the upstream siderolabs imager (the profile references only `ghcr.io/siderolabs/*`, so this needs no cozystack image and no rebuild) and publish it as a GHCR OCI artifact (`cozystack-nocloud`) so a nightly is installable on real hardware, not just in e2e.
326326
3. **e2e** — stage the published GHCR closure (rewritten tree + the `cozy-installer` chart pinned to the GHCR packages artifact + the disk) and run the **full** app suite. A nightly has no diff, so Test Impact Analysis does not apply.
327327

328-
No GitHub release and no `api/apps/v1alpha1/*` Go-module tag are created for a nightly. [`retention.yaml`](../.github/workflows/retention.yaml) prunes old GHCR nightly versions (keeps the newest 14 per package; the floating `nightly`, release tags and untagged versions are never touched) and abandoned packages candidates older than 30 days only when every tag on that manifest still matches the strict `promotion-*` form and no open bot-authored release PR pins that digest.
328+
No GitHub release and no `api/apps/v1alpha1/*` Go-module tag are created for a nightly. [`retention.yaml`](../.github/workflows/retention.yaml) prunes old GHCR nightly versions (keeps the newest 14 per package; the floating `nightly`, release tags and untagged versions are never touched) and abandoned packages candidates. A candidate version is eligible only when all of these hold: it carries at least one tag and **every** tag on that manifest matches the strict `promotion-vX.Y.Z-from-vX.Y.Z-rc.N-run-<run-id>-<attempt>` form, so a manifest that has been retagged stable is excluded; it was last updated more than 30 days ago; its digest is not pinned by `packages/core/installer/values.yaml` at the head of an **open** pull request that is same-repository, authored by `cozystack-ci[bot]`, on a `release-X.Y.Z` branch and carrying the `release` label; and its digest is not pinned by that same file at the tip of `main` or of any `release-X.Y` branch — which is what keeps a candidate safe between the promote PR merging and finalize retagging it. Any such PR head whose pin is not exactly one `digest=sha256:<64 hex>` stops the candidate sweep for that run and fails the job, without affecting the nightly sweep.
329329

330330
Install a nightly:
331331

‎hack/verify-promoted-packages.sh‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,15 @@ fi
106106
# an ignored build nonce generated only by the rc image-packages target; it is
107107
# not consumed at runtime. The installer values are compared separately below
108108
# after normalizing the one impossible self-reference.
109+
#
110+
# This list is a RESTATEMENT of flux's built-in `excludeOCI` set, not a
111+
# derivation of it — the CLI does not expose it — so it is coupled to a flux
112+
# version. That version is pinned as FLUX_VERSION (2.8.6) in all three workflow
113+
# steps that install the toolchain, and the promote-packages contract test pins
114+
# that they agree. If flux ever changes the set, the symptom is a "contain
115+
# different files" failure naming the newly included or excluded class, which
116+
# is loud and lands before any stable name exists; the fix is to update this
117+
# list and FLUX_VERSION together, in that order.
109118
artifact_entries() {
110119
(
111120
cd "$1"

0 commit comments

Comments
 (0)