From bc2ae32207d7e8d7d0d390ee6dc7fd625fb1afd6 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 20 Sep 2026 16:42:51 -0700 Subject: [PATCH] The weekly release lands its bump through a pull request main is protected as of today -- no force-push, no deletion, and a pull request with a green build to merge -- and GITHUB_TOKEN is not a bypass actor. `git push origin HEAD:main` in the cut job would have been refused from Monday, on a scheduled run nobody watches. GitHub would not take the obvious fix. Adding the Actions integration as a bypass actor is rejected ("must be part of the ruleset source or owner organization") because the organization has no app installations. The other two routes -- an organization-level ruleset, a deploy key with write access -- both amount to handing the release a credential that outranks the rule, which is a worse thing to own than a slower Monday. So the bump lands the way every other change does. It commits to release/v, opens a pull request, waits for the build the ruleset requires, merges, and tags what came out. The waiting is not merely the rule being satisfied: a release cut from a tree that does not compile is the failure this whole arrangement exists to prevent, and until now nothing checked. Three details that would each have produced a wrong tag. The sha comes from GitHub's merge commit, not the tip that was pushed, because a rebase merge rewrites it. The pull request is tracked by number, not by branch, because the branch is deleted on merge and a deleted branch no longer resolves to its pull request. And a failed or slow build leaves the pull request open and cuts nothing, rather than tagging whatever main happened to hold. Quiet weeks are unaffected: the tag still names the bump commit, so `previous..HEAD` is still zero when nothing else has landed. The cost is a Monday run that now takes as long as a full build -- about 25 minutes at the moment, most of it saving the cache. --- .github/workflows/release.yml | 68 ++++++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 287d182..bccb9b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -121,8 +121,9 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + pull-requests: write outputs: - sha: ${{ steps.bump.outputs.sha }} + sha: ${{ steps.land.outputs.sha }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -131,6 +132,7 @@ jobs: - id: bump env: VERSION: ${{ needs.check.outputs.version }} + BRANCH: release/v${{ needs.check.outputs.version }} run: | set -euo pipefail @@ -153,14 +155,72 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add crates/types/src/branding.rs git commit -m "Version ${VERSION}" - git push origin HEAD:main + git push origin "HEAD:refs/heads/${BRANCH}" - echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + # main is protected: it takes a pull request with a green build, and + # GITHUB_TOKEN is not among the bypass actors. So the bump lands the way + # every other change does. The alternative was to hand the release a + # credential that outranks the rule, which is a worse thing to own than + # a slower Monday. + - id: land + env: + VERSION: ${{ needs.check.outputs.version }} + BRANCH: release/v${{ needs.check.outputs.version }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + url="$(gh pr create --base main --head "${BRANCH}" \ + --title "Version ${VERSION}" \ + --body "Weekly release. Bumps \`brand_version!\` to ${VERSION} so the tag names a tree that reports the version the tag claims.")" + # The number, not the branch: the branch is deleted on merge, and a + # deleted branch no longer resolves to its pull request. + pr="${url##*/}" + echo "Opened #${pr}" + + # The build is what the rule actually requires, and it is also the + # thing worth waiting for: a release cut from a tree that does not + # compile is the failure this whole arrangement exists to prevent. + # A full build of this tree is long, so the deadline is generous. + deadline=$(( SECONDS + 3600 )) + while :; do + state="$(gh pr view "${pr}" --json statusCheckRollup \ + --jq '[.statusCheckRollup[]? | .conclusion // "PENDING"] | join(",")')" + case "${state}" in + *FAILURE*|*CANCELLED*|*TIMED_OUT*) + echo "::error::CI failed on ${BRANCH} (${state}); no release cut. PR #${pr} is left open." + exit 1 ;; + *SUCCESS*) break ;; + esac + if [ "${SECONDS}" -ge "${deadline}" ]; then + echo "::error::timed out waiting for CI on ${BRANCH}. PR #${pr} is left open." + exit 1 + fi + sleep 30 + done + + gh pr merge "${pr}" --rebase --delete-branch + + # A rebase merge rewrites the commit, so the sha to tag is the one + # GitHub recorded for the merge, not the tip that was pushed. It can + # take a moment to appear. + sha="" + for _ in $(seq 1 30); do + sha="$(gh pr view "${pr}" --json mergeCommit --jq '.mergeCommit.oid // ""')" + [ -n "${sha}" ] && break + sleep 5 + done + if [ -z "${sha}" ]; then + echo "::error::#${pr} merged but GitHub reported no merge commit; nothing safe to tag." + exit 1 + fi + + echo "sha=${sha}" >> "$GITHUB_OUTPUT" - env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - args=(--target "${{ steps.bump.outputs.sha }}" + args=(--target "${{ steps.land.outputs.sha }}" --title "INBUXA ${{ needs.check.outputs.version }}" --generate-notes) # Bound the notes to what is actually new. Without a start tag the