diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index be37fe186..0e2463e36 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -92,17 +92,13 @@ jobs:
# release-line guard asserts this list against .github/release-lines.yml —
# see the `env_lists` entry there and src/docs/release-line-guard.md.
#
- # NOTE (#5339): an earlier fix made this job skip `release-gate/*` on
- # `pull_request`, on the theory that the release PR's own docker run
- # superseded the green `gate` earned by the dispatched run. That theory was
- # WRONG and the skip is reverted here. The release PR's `pull_request` runs
- # are opened with GITHUB_TOKEN, so GitHub's recursion guard never starts
- # their jobs — those suites contain ZERO check-runs, so they never produced
- # a competing `gate` to supersede anything. Verified: the pre-fix release
- # PR #5355 and the post-fix #5364 have byte-identical rollups (`tide`,
- # `dco`, no `gate`) and both are BLOCKED. Skipping the job changed nothing
- # except removing the one path that could ever attach a PR-associated
- # `gate`, so it is a latent trap rather than a fix.
+ # NOTE (#5339/#5356): do not special-case `release-gate/*` pull requests in
+ # this job. GITHUB_TOKEN-opened release PR runs may be recursion-blocked
+ # before any job starts, and workflow_dispatch check-runs are not associated
+ # with a PR even when dispatched after it exists. tagged-release.yml handles
+ # that release-only gap by mirroring its verified check-run as a SHA-scoped
+ # commit status. Ordinary pull requests still need this job unchanged so
+ # their head SHA receives the required `gate` check-run (#4965).
gate:
runs-on: ubuntu-latest
outputs:
diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml
index b3d80d979..252213585 100644
--- a/.github/workflows/tagged-release.yml
+++ b/.github/workflows/tagged-release.yml
@@ -39,19 +39,14 @@ name: Tagged Release
# - MERGEABILITY SETTLING IS RETRIED. A direct `git push` to v4 (the
# pre-#5222 approach) was retried on GH006 under the theory that branch
# protection just needed a few seconds to ingest the gate check-run's
-# conclusion — but that check-run's check suite ran against the scratch
-# branch, not v4, and a direct push's required-status evaluation never
-# counts a check earned on a different ref for the same SHA, so no
+# conclusion — but that check-run belongs to the scratch branch's
+# workflow_dispatch suite, not to v4's protected-ref evaluation, so no
# amount of retrying could ever have worked (confirmed live: 15 retries
-# over 120s, gate green throughout, GH006 every time — run 33330740324).
-# The final step now opens a PR from the scratch branch into v4 and
-# merges it instead, which evaluates required status checks by SHA alone
-# — the same lookup this workflow's own wait-for-gate step already uses,
-# and the same path every ordinary contributor PR into v4 merges
-# through. That merge call is still retried for a bounded window in case
-# GitHub's required-status evaluation needs a moment to settle after the
-# gate check lands, which is a real (if short) async step on GitHub's
-# side independent of the #5222 root cause.
+# over 120s, gate green throughout — run 33330740324). The final step now
+# opens a PR from the scratch branch into v4 and merges it after publishing
+# the SHA-scoped `gate` status described below. That merge call is still
+# retried for a bounded window in case GitHub needs a moment to ingest the
+# status and recompute mergeability.
# - THE MERGE IS SHA-KEYED, NOT AGGREGATE-KEYED (#5318/#5324). That merge
# is performed by `gh api -X PUT .../pulls/{n}/merge -f sha=
`, NOT
# by `gh pr merge`. `gh pr merge` refuses any PR whose AGGREGATE
@@ -67,60 +62,25 @@ name: Tagged Release
# consulting a status the repo does not require. This is the same
# "trust the SHA lookup, not the aggregate or branch-scoped view"
# reasoning as the wait-for-gate step, extended one step further.
-# - KNOWN UNRESOLVED: THE PR ROLLUP HAS NO `gate` (#5339). The merge below
-# evaluates required contexts against the PULL REQUEST's status rollup,
-# not against the head commit's check-runs. A PR opened with GITHUB_TOKEN
-# never gets its own workflow runs started (recursion guard), so nothing
-# ever attaches a PR-associated `gate` — the rollup for a release PR is
-# exactly `tide`, `dco` and nothing else, while `GET /commits/{sha}/
-# check-runs` shows `gate` present and green. The two views disagree and
-# only the PR-scoped one governs the merge, which is why this step fails
-# with `Required status check "gate" is expected` on a commit whose gate
-# is demonstrably satisfied. Resolving it needs a decision this workflow
-# cannot make for itself: open the release PR with an identity whose PRs
-# run workflows (a GitHub App installation token or PAT), or make the
-# merge not depend on a PR-scoped evaluation. Do NOT "fix" it by skipping
-# docker.yml's gate job on release-gate/* — that was tried and reverted;
-# it removes the only path that could ever supply the missing context.
-# - THE RELEASE PR MUST NOT RE-TRIGGER docker.yml (#5339, 7th). Opening the
-# PR below fires docker.yml again on the SAME SHA under `pull_request`.
-# That run is created by GITHUB_TOKEN, so GitHub's recursion guard never
-# starts its jobs and it fails with zero jobs in ~2s — and because
-# protection resolves a required context to the MOST RECENT check-run of
-# that name, it superseded the green `gate` earned seconds earlier and the
-# merge was refused (405) for the whole retry window with no way to
-# recover. docker.yml's `gate` job now skips `release-gate/*` pull_request
-# events so no competing check-run is ever created. If a future change
-# makes the release PR trigger a required check again, this failure comes
-# back looking exactly like a green gate that protection refuses to see.
-# - THE GATE SUITE MUST FINISH, NOT JUST THE GATE JOB (#5339, 8th). Branch
-# protection evaluates the whole check SUITE, not just the `gate`
-# check-run. docker.yml's heavyweight build jobs used to keep the
-# dispatched run's suite in_progress for ~10 minutes after `gate` itself
-# went green in seconds, and the merge below waits only 120s — so it lost
-# that race every time while the API reported a perfectly green `gate`.
-# Those builds publish nothing on a release-gate/* branch (push=false), so
-# they now skip and the suite completes when `gate` does. If a job is ever
-# added to docker.yml that runs on the scratch branch, it will re-block
-# this merge in exactly the same invisible way.
-# - OPENING THE RELEASE PR HIDES ITS OWN GREEN GATE (#5339 7th, #5356 8th).
-# Opening the PR below fires every `pull_request`-triggered workflow on the
-# SAME SHA. Those runs are created by GITHUB_TOKEN, so GitHub's recursion
-# guard never starts their jobs: each fails in ~2s having scheduled ZERO
-# jobs, leaving a check-SUITE with no check-RUNS in it. Protection resolves
-# a required context against the most recent suite, so with an empty suite
-# newest, `gate` reads as MISSING — `Required status check "gate" is
-# expected` (405) for the whole retry window.
+# - THE PR ROLLUP NEEDS A SHA-SCOPED `gate` STATUS (#5356). The scratch
+# workflow_dispatch run proves the release commit by producing a green
+# `gate` check-run, but that check-run has `pull_requests: []`. Opening a
+# PR later does not retroactively associate it, and even re-dispatching
+# docker.yml after the PR exists leaves the new check-run unassociated.
+# Release PR #5402 proved recency was not the discriminator: its second
+# green suite was newer than every empty PR-open suite by 35 seconds, yet
+# protection still returned `Required status check "gate" is expected`
+# for the full merge window. The PR rollup contained only the SHA-scoped
+# `dco` and `tide` commit statuses; it never contained the green check-run.
#
-# Note the green `gate` is NOT overwritten: a zero-job run publishes no
-# check-runs, and the one `gate` check-run on the SHA stays `success`. It
-# is simply not in the suite protection consults. #5358's job-level `if:`
-# in docker.yml therefore fixed nothing (the runner never reaches a job),
-# and no docker.yml-only change could — nine other workflows leave an
-# identical empty suite in the same second. The step below instead
-# RE-DISPATCHES docker.yml once the PR exists, making a green suite newest
-# again. If a future change stops that re-dispatch, this failure returns
-# looking exactly like a green gate that protection refuses to see.
+# Once the exact-SHA wait below verifies docker.yml's real `gate`, the
+# merge step mirrors that verdict as a `gate: success` commit status before
+# opening the PR. Commit statuses have no check-suite or PR-association
+# requirement, so the release PR rollup can see it. The mirror does not
+# manufacture a verdict: a red or missing check-run stops the preceding
+# step, and a failed status POST stops the merge step. The SHA-keyed merge
+# remains server-side protection-enforced; the change only makes the
+# already-earned verdict visible in the representation protection reads.
#
# IDEMPOTENCY: this workflow's own release commit (moving Unreleased into a
# dated section) is what empties Unreleased, which re-triggers docker.yml on
@@ -175,11 +135,8 @@ permissions:
# to v4 (#5222, 4th GH006 recurrence): a direct `git push` to v4 is
# evaluated against required status checks by the push event, and never
# sees the `gate` check-run earned on the scratch branch even though it is
- # on the identical SHA (see the "Merge release commit to v4" step for the
- # full mechanism). Merging a PR instead evaluates required status checks by
- # SHA lookup against ALL check-runs for that commit, independent of which
- # ref originally produced them — the same lookup every ordinary
- # contributor PR into v4 already relies on.
+ # on the identical SHA. The release therefore enters through a protected PR
+ # merge, using the mirrored status described immediately below.
pull-requests: write
jobs:
@@ -303,6 +260,15 @@ jobs:
needs: [decide, precheck]
if: needs.decide.outputs.release == 'true' && needs.precheck.outputs.proceed == 'true'
runs-on: ubuntu-latest
+ # Job-level permissions replace the workflow defaults. Repeat the release
+ # job's existing scopes and add statuses:write only here, so decide and
+ # precheck cannot manufacture a required context (#5356).
+ permissions:
+ contents: write
+ packages: write
+ actions: write
+ pull-requests: write
+ statuses: write
env:
VERSION: ${{ needs.decide.outputs.version }}
SHA: ${{ needs.decide.outputs.sha }}
@@ -574,11 +540,11 @@ jobs:
while [ "$SECONDS" -lt "$deadline" ]; do
conclusion="$(gh api "repos/${{ github.repository }}/commits/${commit_sha}/check-runs" \
--jq '[.check_runs[] | select(.name == "gate")] | sort_by(.started_at) | last | .conclusion // "pending"')"
- # GitHub treats success/skipped/neutral as satisfying a required
- # status check; only those three should let this proceed.
+ # GitHub treats success/skipped/neutral as acceptable check-run
+ # conclusions; only those three may be mirrored as success.
case "$conclusion" in
success|skipped|neutral)
- echo "gate concluded '${conclusion}' on ${commit_sha} — satisfies the required check."
+ echo "gate concluded '${conclusion}' on ${commit_sha} — eligible for the required-status mirror."
break
;;
pending)
@@ -598,33 +564,14 @@ jobs:
;;
esac
- # #5222 (4th GH006 recurrence): a raw `git push origin
- # HEAD:refs/heads/v4` was retried here for up to 120s and rejected
- # EVERY time, even though `gate` had already concluded 'success' on
- # this exact SHA before the very first attempt (run 33330740324 — see
- # the issue for the full timeline). That ruled out propagation lag:
- # the check-runs API reported success from 19:28:18Z onward, and every
- # push from 19:28:19Z to 19:30:26Z was still rejected.
- #
- # Root cause, confirmed via the Checks API on that run's commit: the
- # `gate` check-run's check suite carries `head_branch:
- # "release-gate/v4.0.1"` — the scratch branch — never `v4`, because
- # that is genuinely the ref docker.yml ran against. A direct push to a
- # protected branch evaluates required status checks against reports
- # tied to THAT push/ref event; a check-run earned on a different
- # branch's push, even for the byte-identical commit SHA, never
- # registers as satisfying protection on v4 — there is no propagation
- # window to wait out, because that check-run was never going to count.
- #
- # A pull-request MERGE evaluates required status checks differently:
- # GitHub looks up check-runs by commit SHA alone (`GET
- # /commits/{sha}/check-runs`) against the PR's head SHA, independent of
- # which ref originally produced them — this is the exact lookup this
- # workflow's own wait-for-gate step above already uses, and the same
- # path every ordinary contributor PR into v4 merges through. So: open a
- # PR from the scratch branch (whose head SHA already carries the
- # successful `gate` check-run) into v4, then merge it via the API
- # instead of pushing the ref directly.
+ # A raw push cannot use the scratch branch's check-run to satisfy v4
+ # protection (#5222), so the release enters v4 through a PR. A
+ # workflow_dispatch check-run is not associated with a PR, however, and
+ # the release PR's required-context rollup therefore omits it even when
+ # it is green on the exact head SHA (#5356). Before opening the PR,
+ # mirror the gate already verified by the preceding step as a commit
+ # status. The status is SHA-scoped and appears in the PR rollup without
+ # depending on a check-suite association.
- name: Open and merge a PR from the scratch branch into v4
id: push_v4
env:
@@ -648,115 +595,24 @@ jobs:
}
trap cleanup EXIT
+ echo "Publishing the verified gate as a SHA-scoped commit status (#5356)..."
+ gh api --method POST "repos/${GITHUB_REPOSITORY}/statuses/${commit_sha}" \
+ -f state=success \
+ -f context=gate \
+ -f description="docker.yml gate verified for automated release"
+
echo "Opening a PR from ${scratch} into v4..."
- pr_url="$(gh pr create \
- --base v4 \
- --head "${scratch}" \
- --title "release: v${VERSION}" \
- --body "Automated release PR (#5222). Merges the release commit for v${VERSION}, already carrying a successful gate check on ${commit_sha}." 2>&1)" \
- || { echo "::error::gh pr create failed: ${pr_url}" >&2; exit 1; }
+ if ! pr_url="$(gh pr create \
+ --base v4 \
+ --head "${scratch}" \
+ --title "release: v${VERSION}" \
+ --body "Automated release PR (#5222/#5356). Merges the release commit for v${VERSION}, carrying a successful gate check and mirrored status on ${commit_sha}." 2>&1)"; then
+ echo "::error::gh pr create failed: ${pr_url:-}" >&2
+ exit 1
+ fi
pr_number="$(grep -oE '[0-9]+$' <<<"$pr_url")"
echo "Opened PR #${pr_number}: ${pr_url}"
- # #5356 (8th recurrence): RE-EARN `gate` AFTER the PR exists.
- #
- # Opening the PR above fires every `pull_request`-triggered workflow
- # in this repo on the SAME SHA. Those runs are created by this job's
- # GITHUB_TOKEN, so GitHub's recursive-workflow guard never starts
- # their jobs: each one completes as `failure` within ~2s having
- # scheduled ZERO jobs, and therefore produces a check-SUITE that
- # contains no check-RUNS at all. Measured on release PR #5361, head
- # 4d6510fc: one green suite from the dispatch at 20:22:25Z with 10
- # runs in it, then TEN empty failed github-actions suites at
- # 20:22:42Z — docker.yml, changelog-reminder, docs-index-reminder,
- # greetings, copilot-dco, copilot-automation, quadlet-gate and the
- # three podman lanes.
- #
- # WHY THE EARLIER READING OF THIS BUG WAS WRONG, and why #5358 could
- # not have fixed it. The failure was diagnosed as the empty run
- # publishing a FAILED `gate` check-run that superseded the green one.
- # It does not: a run with zero jobs publishes zero check-runs, and on
- # 4d6510fc there is exactly ONE `gate` check-run and it is `success`.
- # What protection actually reports is `Required status check "gate"
- # is expected` — "expected", i.e. MISSING, not failed — because it
- # resolves the required context against the most recent
- # github-actions check-suite on the SHA, and the newest suite is an
- # empty one that contains no `gate`. So the green `gate` is never
- # overwritten; it is merely no longer the suite protection looks at.
- #
- # That is also why suppressing docker.yml alone cannot work, whether
- # at the job layer or the trigger layer: NINE other workflows create
- # an identical empty suite in the same second, and any one of them
- # left newest reproduces the 405. #5358 added a job-level `if:` to
- # docker.yml's `gate` job; a job-level condition is evaluated by the
- # runner after the run exists, and these runs never schedule a job,
- # so it is unreachable by construction. There is no trigger-layer fix
- # either: `pull_request`'s `branches`/`branches-ignore` filter matches
- # the PR's BASE ref (`v4`), never its head, and GitHub has no
- # head-branch trigger filter — so `branches-ignore: [release-gate/**]`
- # would exclude PRs INTO the scratch branch, not the release PR, while
- # breaking the fork-PR contract (#4965) it is meant to preserve.
- #
- # The fix that matches the actual mechanism is to make a GREEN suite
- # the newest one again, by dispatching docker.yml a second time now
- # that the PR (and its burst of empty suites) exists. `gate` is a ~5s
- # shell job and `release-gate/*` never pushes an image, so this is
- # cheap and cannot publish anything. This does not weaken the gate:
- # the merge below is still SHA-keyed and still rejected with 405 by
- # the server if `gate` is genuinely unsatisfied.
- #
- # Settle first. The empty suites all land within ~2s of `gh pr
- # create` returning; dispatching before they exist would put the
- # green suite BACK underneath them and rebuild the same failure.
- #
- # RELEASE_REGATE_SETTLE / RELEASE_REGATE_WINDOW are test seams in the
- # same spirit as RELEASE_PUSH_GH006_WINDOW (test-release-push-retry.sh
- # sets them to 0 so the extracted step does not idle); production uses
- # the defaults.
- settle="${RELEASE_REGATE_SETTLE:-30}"
- echo "Waiting ${settle}s for the PR-open workflow suites to land before re-earning gate (#5356)..."
- [ "$settle" -gt 0 ] && sleep "$settle"
-
- echo "Re-dispatching docker.yml on ${scratch} so a green 'gate' suite is the most recent on ${commit_sha} (#5356)..."
- gh workflow run docker.yml --ref "${scratch}" \
- || echo "::warning::re-dispatch of docker.yml failed; the merge retry below may exhaust its window (#5356)."
-
- echo "Waiting for the re-dispatched 'gate' to conclude on ${commit_sha}..."
- regate_deadline=$((SECONDS + ${RELEASE_REGATE_WINDOW:-600}))
- regate=""
- while [ "$SECONDS" -lt "$regate_deadline" ]; do
- # Newest `gate` check-run by started_at — the one protection will
- # resolve to once its suite is the most recent.
- regate="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${commit_sha}/check-runs" \
- --jq '[.check_runs[] | select(.name == "gate")] | sort_by(.started_at) | last | (.conclusion // "pending")' 2>/dev/null || true)"
- case "$regate" in
- success|skipped|neutral)
- echo "Re-earned gate concluded '${regate}' on ${commit_sha}."
- break
- ;;
- failure|cancelled|timed_out|action_required|stale)
- # A REAL red conclusion. Everything else — including an empty
- # or unparseable response — is treated as "not known yet" and
- # retried, never as a verdict: this step must not turn a
- # transient API hiccup into a failed release.
- echo "::error::re-dispatched gate concluded '${regate}' on ${commit_sha} — refusing to merge." >&2
- exit 1
- ;;
- *) ;;
- esac
- [ "${RELEASE_REGATE_WINDOW:-600}" -gt 0 ] && sleep 10
- done
- case "$regate" in
- success|skipped|neutral) ;;
- *)
- # NOT fatal. The merge below is SHA-keyed and the server enforces
- # protection on it regardless, so an unconfirmed re-gate costs at
- # worst the existing retry window — whereas failing here would
- # abandon a release that may well be mergeable.
- echo "::warning::could not confirm the re-dispatched gate on ${commit_sha} within the window; proceeding to the merge retry below, which enforces protection server-side regardless (#5356)."
- ;;
- esac
-
# #5318/#5324 (5th recurrence): this loop called `gh pr merge`, which
# refuses to act on a PR whose aggregate `mergeStateStatus` is
# BLOCKED, and reports `the base branch policy prohibits the merge`.
@@ -850,7 +706,7 @@ jobs:
sleep 8
continue
fi
- echo "::error::PR #${pr_number} still not mergeable ${RELEASE_PUSH_GH006_WINDOW:-120}s after gate succeeded on ${commit_sha} — this is no longer a settling-time artifact. Note this merge is SHA-keyed (#5318/#5324), so a pending non-required status such as 'tide' is no longer the cause; check the REQUIRED contexts on v4 against the check-runs for ${commit_sha}." >&2
+ echo "::error::PR #${pr_number} still not mergeable ${RELEASE_PUSH_GH006_WINDOW:-120}s after gate succeeded on ${commit_sha} — this is no longer a settling-time artifact. Note this merge is SHA-keyed (#5318/#5324), so a pending non-required status such as 'tide' is no longer the cause; compare v4's REQUIRED contexts with the combined status for ${commit_sha}, including the mirrored gate status (#5356)." >&2
exit 1
fi
echo "::error::merging PR #${pr_number} failed for an unrecognized reason (see the API response above) — not retrying." >&2
diff --git a/src/docs/releases.md b/src/docs/releases.md
index bb682bad8..1a1191ba2 100644
--- a/src/docs/releases.md
+++ b/src/docs/releases.md
@@ -143,36 +143,43 @@ Concretely, per release:
(see "Software bill of materials (SBOM)" below) — this happens before the
changelog commit, using the version tag written in step 3.
5. That change is committed (`git commit -s`, signed off by the release bot).
-5a. Before it can reach `v4`, the commit has to earn the `gate` status check
- that branch protection requires (see "Satisfying branch protection"
- below) — the commit is pushed to a throwaway `release-gate/v`
- branch first, the workflow waits for `gate` to succeed on that exact SHA,
- then deletes the scratch branch.
-6. The workflow pushes the same commit to `v4` (protection now finds a
- successful `gate` check already on it and allows the fast-forward), then
- creates and pushes the `v` git tag on that commit.
+5a. Before it can reach `v4`, the commit has to earn the `gate` check that
+ branch protection requires (see "Satisfying branch protection" below).
+ The commit is pushed to a throwaway `release-gate/v` branch,
+ `docker.yml` is dispatched, and the workflow waits for `gate` to succeed
+ on that exact SHA. It then mirrors the verified result as a SHA-scoped
+ `gate: success` commit status so a release PR can see it.
+6. The workflow opens a PR from the scratch branch into `v4` and merges it
+ through the SHA-keyed merge API, leaving branch protection fully enforced.
+ It deletes the scratch branch, then creates and pushes the `v` tag
+ on the commit that landed on `v4`.
7. A GitHub Release is created from the tag, with GitHub's auto-generated
notes plus an SBOM callout, and the three SBOM files from step 4a attached
as release assets.
## Satisfying branch protection
-`v4`'s only required status check is `gate` (`docker.yml`). `gate` only ever
-attaches to a commit through `docker.yml`'s own `push` / `pull_request`
-triggers — nothing manufactures it out of band — so the release commit this
-workflow creates in-job has no `gate` check on it the moment it exists, and a
-direct push straight to `v4` is rejected (`GH006: Required status check
-"gate" is expected`, [#5026](https://github.com/kubestellar/hive/issues/5026)).
-This is not intermittent: every retry recreates the same ungated commit and
-fails identically, so the workflow cannot simply retry its way past it.
-
-GitHub evaluates a required status check against the commit **SHA**, not the
-ref the check happened to run on, and it accepts a check that already
-succeeded on that SHA before the push — pushing to a side branch first, then
-to the protected branch, is GitHub's own documented pattern for this. The
-release commit is pushed to a scratch branch (`release-gate/v`)
-first, `gate` runs and succeeds on that exact SHA, and the workflow then
-pushes the *same* commit to `v4` — which protection now accepts.
+`v4`'s only required context is `gate` (`docker.yml`). The release commit is
+created inside `tagged-release.yml`, so it has no check when it first exists;
+a direct push to `v4` is rejected (`GH006: Required status check "gate" is
+expected`, [#5026](https://github.com/kubestellar/hive/issues/5026)). Retrying
+does not create the missing evidence, so every attempt fails identically.
+
+The workflow first pushes the commit to `release-gate/v`, dispatches
+`docker.yml`, and waits for its `gate` check-run on the exact release SHA.
+That verifies the same code path as an ordinary PR gate, but a
+`workflow_dispatch` check-run has no pull-request association: its
+`pull_requests` list remains empty even if it is dispatched after the release
+PR exists. Consequently GitHub's protected-PR rollup omits it and the merge
+API still reports `gate` as expected ([#5356](https://github.com/kubestellar/hive/issues/5356)).
+
+After the check-run succeeds, the workflow posts a `gate: success` commit
+status on the same SHA using its `GITHUB_TOKEN` and `statuses: write`
+permission. A commit status is SHA-scoped rather than check-suite/PR-scoped,
+so it appears in the release PR's required-context rollup. This is a mirror,
+not a second source of truth: a missing or red docker gate prevents the status
+from being posted, a failed status POST prevents the PR from opening, and the
+SHA-keyed merge API still asks GitHub to enforce `v4` protection server-side.
**Getting `docker.yml` to actually run on the scratch branch (#5072):**
`docker.yml`'s `push` trigger is `branches: ["**"]` (minus bot branches — see
@@ -199,14 +206,15 @@ branch name is deliberately not in `docker.yml`'s `LONG_LIVED` set (`v2 v4 mk
dd`) and the exception forces `push=false` for it unconditionally, so this
detour never pushes a GHCR image or moves a channel tag; `gate` runs
regardless of push policy, which is all this needs. The scratch branch is
-deleted immediately after (`trap ... EXIT`), whether the wait succeeds or
-fails, so a failed release run never leaves a stray branch behind.
-
-This preserves the branch protection exactly as configured — no bypass, no
-weakened check, no `enforce_admins` change, no force push. The workflow earns
-the same check a human contributor's PR would, just via a scratch branch
-instead of a PR, because the release commit has no PR of its own to attach a
-check to.
+deleted by the merge step's `trap ... EXIT` once that step starts, whether the
+PR merges or fails. A failure during the preceding gate-earning step leaves the
+branch in place for diagnosis.
+
+This preserves branch protection exactly as configured — no bypass, no
+weakened check, no `enforce_admins` change, and no force push. The workflow
+earns the real docker gate on the scratch branch, mirrors that exact-SHA
+verdict into the representation the release PR can consume, and lets the
+protected merge endpoint make the final decision.
## Software bill of materials (SBOM)
diff --git a/src/scripts/test-release-push-retry.sh b/src/scripts/test-release-push-retry.sh
index d448f7862..6cf1bd2b5 100755
--- a/src/scripts/test-release-push-retry.sh
+++ b/src/scripts/test-release-push-retry.sh
@@ -25,6 +25,12 @@
# different tree.
# anything else — hard-fail immediately, no retry.
#
+# Since #5356 the step first mirrors the already-verified docker.yml check-run
+# as a SHA-scoped `gate` commit status. workflow_dispatch check-runs have no PR
+# association and never enter the release PR's required-context rollup; commit
+# statuses do. The status POST must succeed before the PR is opened, and the
+# tests below pin that fail-closed ordering.
+#
# Usage: src/scripts/test-release-push-retry.sh
set -uo pipefail
@@ -100,7 +106,16 @@ state="$RPR_STATE"
# so dispatch on the argv containing a /merge endpoint rather than on $1 $2.
args="$*"
case "$args" in
+ *"/statuses/"*)
+ echo status >> "$state/timeline"
+ if [ "$RPR_SCENARIO" = status_fails ]; then
+ echo "gh: Resource not accessible by integration (HTTP 403)" >&2
+ exit 1
+ fi
+ echo '{"context":"gate","state":"success"}'
+ exit 0 ;;
*"/merge"*)
+ echo merge >> "$state/timeline"
n=$(( $(cat "$state/merge" 2>/dev/null || echo 0) + 1 ))
echo "$n" > "$state/merge"
# A missing `-f sha=` would silently reintroduce the head-moved race the
@@ -133,6 +148,11 @@ case "$args" in
esac
case "$1 $2" in
"pr create")
+ echo pr >> "$state/timeline"
+ if [ "$RPR_SCENARIO" = pr_create_fails ]; then
+ echo "gh: a pull request for branch release-gate/v4.0.1 already exists"
+ exit 1
+ fi
echo "https://github.com/kubestellar/hive/pull/9999"
exit 0 ;;
"pr close")
@@ -155,7 +175,6 @@ run_step() {
: > "$st/out"
RPR_SCENARIO="$1" RPR_TAG_SCENARIO="${2:-ok}" RPR_STATE="$st" \
RELEASE_PUSH_GH006_WINDOW="${3:-120}" \
- RELEASE_REGATE_SETTLE=0 RELEASE_REGATE_WINDOW=0 \
VERSION="4.0.1" SHA="deadbeefcafe" GITHUB_OUTPUT="$st/gh_output" \
GITHUB_REPOSITORY="kubestellar/hive" \
PATH="$tmp/bin:$PATH" bash "$tmp/push_v4.sh" > "$st/out" 2>&1
@@ -169,6 +188,29 @@ run_step ok
[ "$rc" -eq 0 ] && note_ok "exit 0" || note_fail "exit $rc, want 0: $output"
grep -q '^pushed=true$' <<<"$ghout" && note_ok "pushed=true" || note_fail "GITHUB_OUTPUT lacks pushed=true: $ghout"
[ "$(cat "$st/tag" 2>/dev/null)" = 1 ] && note_ok "tag pushed once" || note_fail "tag not pushed exactly once"
+[ "$(tr '\n' ' ' < "$st/timeline")" = "status pr merge " ] \
+ && note_ok "gate status published before PR creation and merge" \
+ || note_fail "unexpected status/PR/merge order: $(tr '\n' ' ' < "$st/timeline")"
+
+echo "case: gate status publication failure stops before opening the PR"
+run_step status_fails
+[ "$rc" -ne 0 ] && note_ok "non-zero exit" || note_fail "status failure must fail closed, got exit 0"
+grep -q 'Resource not accessible' <<<"$output" && note_ok "status API error preserved" || note_fail "status API error masked: $output"
+[ "$(tr '\n' ' ' < "$st/timeline")" = "status " ] \
+ && note_ok "no PR or merge attempted" \
+ || note_fail "workflow continued after status failure: $(tr '\n' ' ' < "$st/timeline")"
+[ -f "$st/tag" ] && note_fail "tag was pushed despite status failure" || note_ok "no tag pushed"
+
+echo "case: PR creation failure preserves the API response"
+run_step pr_create_fails
+[ "$rc" -ne 0 ] && note_ok "non-zero exit" || note_fail "PR creation failure must fail, got exit 0"
+grep -q 'a pull request for branch release-gate/v4.0.1 already exists' <<<"$output" \
+ && note_ok "gh pr create response preserved" \
+ || note_fail "gh pr create response masked: $output"
+[ "$(tr '\n' ' ' < "$st/timeline")" = "status pr " ] \
+ && note_ok "merge not attempted" \
+ || note_fail "workflow continued after PR creation failure: $(tr '\n' ' ' < "$st/timeline")"
+[ -f "$st/tag" ] && note_fail "tag was pushed despite PR creation failure" || note_ok "no tag pushed"
echo "case: mergeable_state settling twice, then success"
run_step settle_then_ok
@@ -255,6 +297,9 @@ elif "steps.push_v4.outputs.pushed == 'true'" not in (gh_release.get("if") or ""
perms = w.get("permissions", {})
if perms.get("pull-requests") != "write":
bad("permissions no longer grant pull-requests: write — the #5222 PR-merge path needs it")
+release_perms = rel.get("permissions", {})
+if release_perms.get("statuses") != "write":
+ bad("the release job no longer grants statuses: write — the #5356 gate mirror cannot be published")
# #5318: the deferral chain has no terminating condition of its own — a
# superseded run correctly stands down, and a cancelled docker.yml run never
# fires workflow_run at all. The schedule trigger is what eventually comes
@@ -333,18 +378,24 @@ else:
bad("push_v4 regressed to `gh pr merge`, which refuses any PR whose AGGREGATE "
"mergeStateStatus is BLOCKED — a pending non-required `tide` status alone is "
"enough to block every release forever (#5318/#5324)")
- # #5356: the re-dispatch must happen AFTER `gh pr create`, or the green
- # suite goes back underneath the empty ones the PR-open burst creates and
- # the 405 returns. Order is the whole point, so assert it, not mere
- # presence.
- if "gh workflow run docker.yml" not in code:
- bad("push_v4 no longer re-dispatches docker.yml after opening the release PR — "
- "the PR-open burst leaves an EMPTY check-suite newest on the head SHA and "
- "protection reports `gate` as missing, blocking every release (#5356)")
- elif code.index("gh workflow run docker.yml") < code.index("gh pr create"):
- bad("push_v4 re-dispatches docker.yml BEFORE opening the release PR — the "
- "PR-open burst then leaves an empty check-suite newest again and the "
- "merge is refused exactly as in #5356. Re-dispatch after `gh pr create`.")
+ # #5356: workflow_dispatch check-runs are not PR-associated, even when
+ # dispatched after the PR exists. The verified gate must be mirrored as a
+ # SHA-scoped commit status BEFORE opening the PR so its rollup can see it.
+ status_endpoint = 'repos/${GITHUB_REPOSITORY}/statuses/${commit_sha}'
+ if status_endpoint not in code:
+ bad("push_v4 no longer publishes the SHA-scoped gate status — the release PR "
+ "rollup cannot see workflow_dispatch check-runs and protection 405s (#5356)")
+ else:
+ status_at = code.index(status_endpoint)
+ pr_at = code.index("gh pr create")
+ merge_at = code.index("gh api -X PUT")
+ if not status_at < pr_at < merge_at:
+ bad("push_v4 must publish gate status, then open the PR, then merge it (#5356)")
+ if "-f state=success" not in code or "-f context=gate" not in code:
+ bad("push_v4's commit status is not the required gate:success verdict (#5356)")
+ if "gh workflow run docker.yml" in code:
+ bad("push_v4 still re-dispatches docker.yml after PR creation — dispatched "
+ "check-runs remain unassociated and cannot satisfy the PR rollup (#5356)")
sys.exit(0 if ok else 1)
PY