From ea47804f9f29d549bf677830607b315a7dd99555 Mon Sep 17 00:00:00 2001 From: Danathar Date: Tue, 1 Sep 2026 07:26:33 -0400 Subject: [PATCH] fix(release): mirror gate as a commit status The release workflow verifies gate through a workflow_dispatch check-run on its scratch branch, but those check-runs have no pull-request association. The protected release PR therefore omits gate from its required-context rollup and rejects every merge with HTTP 405, even when a newer gate check-run is green. After the exact-SHA gate wait succeeds, publish gate:success as a SHA-scoped commit status before opening the PR. Fail closed if that POST fails, remove the ineffective post-PR re-dispatch race, preserve gh pr create diagnostics, and document the corrected protection flow. Extend the release harness to prove status/PR/merge ordering and both new failure paths. Fixes #5356 Signed-off-by: Danathar --- .github/workflows/docker.yml | 18 +- .github/workflows/tagged-release.yml | 268 ++++++------------------- src/docs/releases.md | 72 ++++--- src/scripts/test-release-push-retry.sh | 77 +++++-- 4 files changed, 173 insertions(+), 262 deletions(-) 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