Skip to content

Commit aebef09

Browse files
committed
ci(release): refuse a Promote RC dispatch from any ref but main
A workflow_dispatch runs the copy of promote-rc.yaml at the ref it was dispatched from. On 2026-10-16 a dispatch from release-1.6 ran that branch's older copy, one job instead of five, and had to be re-run from main. Since the release assets are signed, the same dispatch would now fail at the upload step, after the staging branch, the promotion-* candidate and the draft release already exist, and docs/release.md pins the signature identity to this workflow on main. Make the first step of parse compare github.ref with refs/heads/main and fail with an error that names main, so a dispatch from a release branch, a tag or a SHA stops before the first write. The bats test runs the step's shell under dash for main, a release branch, a tag and an empty ref, and pins the step to be the first one in parse. Assisted-by: LLM Signed-off-by: Marian Koreniuk <[email protected]>
1 parent d32092a commit aebef09

3 files changed

Lines changed: 57 additions & 1 deletion

File tree

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,22 @@ jobs:
8383
line: ${{ steps.parse.outputs.line }}
8484
e2e_verification: ${{ steps.e2e-gate.outputs.verification || steps.e2e-bypass.outputs.verification }}
8585
steps:
86+
# A dispatch runs the copy of this workflow at the ref it was dispatched
87+
# from, so a dispatch from release-X.Y runs that branch's older copy (on
88+
# 2026-10-16 one job instead of five) and, now that release assets are
89+
# signed, would fail at the upload step after the staging branch, the
90+
# promotion-* candidate and the draft already exist. docs/release.md also
91+
# pins the signature identity to this workflow on main. Refuse anything
92+
# else here, before the first write.
93+
- name: Refuse a dispatch from any ref but main
94+
env:
95+
REF: ${{ github.ref }}
96+
run: |
97+
if [ "$REF" != "refs/heads/main" ]; then
98+
echo "::error::Promote RC must be dispatched from 'main' (got '$REF'); pick 'main' as the branch and let rc_tag choose the line."
99+
exit 1
100+
fi
101+
86102
# Validate the dispatch input against a strict rc pattern before it ever
87103
# reaches a shell or an API call. Derives the stable version, the rc
88104
# staging branch, and the maintenance line.

‎docs/release.md‎

Lines changed: 1 addition & 1 deletion
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 **from `main`**, the only branch it is dispatched from, whichever line the rc belongs to) 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:
76+
3. Once the checks are green, run the [`promote-rc.yaml`](../.github/workflows/promote-rc.yaml) workflow (manual dispatch **from `main`**, the only branch it is dispatched from, whichever line the rc belongs to; its `parse` job refuses any other ref before anything is written) 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.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#!/usr/bin/env bats
2+
# promote-rc.yaml is dispatched from main only: a dispatch from another ref
3+
# runs that ref's copy of the workflow, and the release signature names main
4+
# as the identity. The refuse step has to be the first thing parse does, so a
5+
# wrong dispatch fails before the staging branch, the promotion-* candidate or
6+
# the draft release exist.
7+
#
8+
# Run with: bats hack/promote-rc-dispatch-ref_test.bats
9+
10+
load test_helper
11+
12+
REPO_ROOT="$(cd "$(dirname "${BATS_TEST_FILENAME:-$0}")/.." && pwd)"
13+
WF="$REPO_ROOT/.github/workflows/promote-rc.yaml"
14+
STEP_NAME="Refuse a dispatch from any ref but main"
15+
16+
_step_run() {
17+
yq ".jobs.parse.steps[] | select(.name == \"$STEP_NAME\").run" "$WF"
18+
}
19+
20+
@test "the refuse step is the first step of parse and reads github.ref" {
21+
[ "$(yq '.jobs.parse.steps[0].name' "$WF")" = "$STEP_NAME" ]
22+
[ "$(yq '.jobs.parse.steps[0].env.REF' "$WF")" = '${{ github.ref }}' ]
23+
}
24+
25+
@test "a dispatch from main passes" {
26+
step="$(_step_run)"
27+
[ -n "$step" ]
28+
run env REF=refs/heads/main dash -c "$step"
29+
[ "$status" -eq 0 ]
30+
}
31+
32+
@test "a dispatch from a release branch or a tag fails and names main" {
33+
step="$(_step_run)"
34+
for ref in refs/heads/release-1.7 refs/tags/v1.7.0-rc.3 ""; do
35+
run env REF="$ref" dash -c "$step"
36+
[ "$status" -ne 0 ]
37+
printf '%s\n' "$output" | grep -qF "::error::"
38+
printf '%s\n' "$output" | grep -qF "from 'main'"
39+
done
40+
}

0 commit comments

Comments
 (0)