From f680963ed397a4ff0cd4c983a3a3071c209e5e77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Fri, 22 May 2026 09:59:09 +0000 Subject: [PATCH 1/5] feat(release): mirror release-branch protection onto fake-release/* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes a coverage gap in the validate-only release rehearsal: the rehearsal pushed to `deploy-release/` and opened a PR against the version-bump branch (typically `main`), so the required status checks gating that PR came from `main`'s protection — not from the real release-branch rules a release would actually face. `_release_bump.yml` now mirrors the rule matching `release-branch-pattern` (default `[rv][0-9].[0-9].[0-9]`) onto the `fake-release/*` pattern — creating the rule if absent, updating it in place if present. In dry-run mode, the bump job also creates a throwaway `fake-release/` branch off the version-bump branch and PRs `deploy-release/` into it; the merge is then gated by the mirrored release-branch rules. Both branches are deleted on exit. Real releases (validate-only=false, dry-run=false) still PR against the version-bump branch unchanged. Validate-only mode (PR rehearsals) still skips the branch push but the mirror step now runs to keep the rule continuously in sync. Requires the calling GitHub App to hold `administration: write`. If no rule matches the source pattern in the repo, the step logs a warning and no-ops. Signed-off-by: oliver könig --- .github/workflows/_release_bump.yml | 376 ++++++++++++++++++++++--- .github/workflows/_release_library.yml | 9 + 2 files changed, 348 insertions(+), 37 deletions(-) diff --git a/.github/workflows/_release_bump.yml b/.github/workflows/_release_bump.yml index 1c462b8..555ed3f 100644 --- a/.github/workflows/_release_bump.yml +++ b/.github/workflows/_release_bump.yml @@ -50,6 +50,17 @@ on: required: false default: true description: Only repository admins may invoke. Always skipped under validate-only. + release-branch-pattern: + type: string + required: false + default: "[rv][0-9].[0-9].[0-9]" + description: | + Branch-protection pattern of the real release branches. The bump job mirrors + this rule onto the `fake-release/*` pattern (created if absent, updated if + present). In dry-run mode the rehearsal merges into a throwaway + `fake-release/` branch so the merge is gated by the same required + checks a real release would face. Requires the App to have + `administration: write`. app-id: type: string required: true @@ -149,7 +160,6 @@ jobs: FALLBACK_SRC_DIR: ${{ inputs.has-src-dir && 'src/' || '' }} steps: - uses: actions/create-github-app-token@v2 - if: inputs.validate-only == false id: app-token with: app-id: ${{ inputs.app-id }} @@ -239,8 +249,8 @@ jobs: echo "next-version=$NEXT_VERSION" | tee -a "$GITHUB_OUTPUT" printf '%s\n' "${BUMPED_FILES[@]}" > /tmp/bumped-files.txt - - name: Record computed versions (validate-only) - if: inputs.validate-only == true + - name: Record computed versions + if: env.IS_INERT == 'true' run: | { echo "## Bump rehearsal" @@ -248,11 +258,247 @@ jobs: echo "- release-version (current): \`${{ steps.bump.outputs.release-version }}\`" echo "- next-version (after bump): \`${{ steps.bump.outputs.next-version }}\`" echo - echo "validate-only=true → no branch push, no PR, no status-check wait, no merge, no branch delete." + echo "IS_INERT=true → the rehearsal pushes \`deploy-release/\` and" + echo "\`fake-release/\` and merges the bump into the fake branch" + echo "(gated by the mirrored release-branch protection). No commit lands" + echo "on \`${{ inputs.version-bump-branch }}\` and both branches are" + echo "deleted on exit." } | tee -a "$GITHUB_STEP_SUMMARY" + - name: Mirror branch protection ${{ inputs.release-branch-pattern }} -> fake-release/* + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + SOURCE_PATTERN: ${{ inputs.release-branch-pattern }} + TARGET_PATTERN: "fake-release/*" + OWNER: ${{ github.repository_owner }} + REPO: ${{ github.event.repository.name }} + run: | + python3 - <<'PY' + """Mirror the branch-protection rule of the real release pattern onto fake-release/*. + + Idempotent: creates the target rule if absent, updates it in place if present. + Source rule is identified by the `release-branch-pattern` input. The mirror lets + the dry-run rehearsal merge into a throwaway `fake-release/` branch that + is gated by the same required checks a real release would face. Requires the + calling GitHub App token to hold `administration: write`. + """ + import json + import os + import sys + import urllib.request + + API = "https://api.github.com/graphql" + OWNER = os.environ["OWNER"] + REPO = os.environ["REPO"] + SOURCE_PATTERN = os.environ["SOURCE_PATTERN"] + TARGET_PATTERN = os.environ["TARGET_PATTERN"] + TOKEN = os.environ["GH_TOKEN"] + + MIRRORED_FIELDS = ( + "requiresStatusChecks", + "requiredStatusCheckContexts", + "requiresStrictStatusChecks", + "requiresApprovingReviews", + "requiredApprovingReviewCount", + "dismissesStaleReviews", + "requiresCodeOwnerReviews", + "requireLastPushApproval", + "restrictsReviewDismissals", + "restrictsPushes", + "isAdminEnforced", + "allowsForcePushes", + "allowsDeletions", + "requiresConversationResolution", + "requiresLinearHistory", + "blocksCreations", + "requiresDeployments", + "requiredDeploymentEnvironments", + "lockBranch", + "lockAllowsFetchAndMerge", + ) + + + def graphql(query, variables=None): + """Send a GraphQL request and return the `data` block, raising on errors.""" + body = json.dumps({"query": query, "variables": variables or {}}).encode() + req = urllib.request.Request( + API, + data=body, + headers={ + "Authorization": f"bearer {TOKEN}", + "Content-Type": "application/json", + }, + method="POST", + ) + with urllib.request.urlopen(req) as resp: + payload = json.loads(resp.read().decode()) + if payload.get("errors"): + sys.exit(f"GraphQL errors: {json.dumps(payload['errors'], indent=2)}") + return payload["data"] + + + def list_rules(): + """Return (repository_id, [rule, ...]) for the configured repo.""" + data = graphql( + """ + query($owner:String!, $repo:String!) { + repository(owner:$owner, name:$repo) { + id + branchProtectionRules(first: 100) { + nodes { + id + pattern + requiresStatusChecks + requiredStatusCheckContexts + requiresStrictStatusChecks + requiresApprovingReviews + requiredApprovingReviewCount + dismissesStaleReviews + requiresCodeOwnerReviews + requireLastPushApproval + restrictsReviewDismissals + reviewDismissalAllowances(first: 100) { + nodes { actor { __typename ... on User { id } ... on Team { id } } } + } + bypassPullRequestAllowances(first: 100) { + nodes { actor { __typename ... on User { id } ... on Team { id } ... on App { id } } } + } + restrictsPushes + pushAllowances(first: 100) { + nodes { actor { __typename ... on User { id } ... on Team { id } ... on App { id } } } + } + isAdminEnforced + allowsForcePushes + allowsDeletions + requiresConversationResolution + requiresLinearHistory + blocksCreations + requiresDeployments + requiredDeploymentEnvironments + lockBranch + lockAllowsFetchAndMerge + } + } + } + } + """, + {"owner": OWNER, "repo": REPO}, + ) + repo = data["repository"] + return repo["id"], repo["branchProtectionRules"]["nodes"] + + + def actor_ids(allowance_block): + """Extract the node IDs of actors granted an allowance. + + Tolerates entries whose `actor` is null (e.g. a deleted user/team) or whose + `actor.id` is missing (e.g. an actor type not covered by the inline fragments). + """ + ids = [] + for node in allowance_block["nodes"]: + actor = node.get("actor") + if actor and actor.get("id"): + ids.append(actor["id"]) + return ids + + + def build_mirror_input(source): + """Build the mutation input payload that mirrors the source rule. + + One intentional deviation: `allowsDeletions: True` so the throwaway + fake-release/ branch can be deleted on cleanup. The source + rule's `allowsDeletions: false` is meant to protect real release + branches; deletion of synthetic per-run UUIDs has no analogue + concern. Every other field is mirrored faithfully — the rehearsal's + purpose is to validate that the gating contract on the source rule + actually applies to the test PR. + """ + mirror = {field: source[field] for field in MIRRORED_FIELDS} + mirror["allowsDeletions"] = True + mirror["reviewDismissalActorIds"] = actor_ids(source["reviewDismissalAllowances"]) + mirror["bypassPullRequestActorIds"] = actor_ids(source["bypassPullRequestAllowances"]) + mirror["pushActorIds"] = actor_ids(source["pushAllowances"]) + return mirror + + + def create_rule(repo_id, pattern, mirror): + """Create a new branch protection rule mirroring the source.""" + graphql( + """ + mutation($input: CreateBranchProtectionRuleInput!) { + createBranchProtectionRule(input: $input) { + branchProtectionRule { id pattern } + } + } + """, + {"input": {"repositoryId": repo_id, "pattern": pattern, **mirror}}, + ) + + + def update_rule(rule_id, pattern, mirror): + """Update an existing branch protection rule to mirror the source.""" + graphql( + """ + mutation($input: UpdateBranchProtectionRuleInput!) { + updateBranchProtectionRule(input: $input) { + branchProtectionRule { id pattern } + } + } + """, + {"input": {"branchProtectionRuleId": rule_id, "pattern": pattern, **mirror}}, + ) + + + repo_id, rules = list_rules() + + source = next((r for r in rules if r["pattern"] == SOURCE_PATTERN), None) + if source is None: + print(f"::warning::No branch protection rule found for pattern {SOURCE_PATTERN!r}; skipping mirror. " + f"Override `release-branch-pattern` to match this repository's release-branch rule, " + f"or create one to gate the rehearsal merge.") + sys.exit(0) + + # Export the source rule's required status check contexts so the deploy + # step can post matching fake success statuses on the deploy-release + # HEAD. That satisfies the mirrored fake-release/* required-check gate + # without paying the cost of running real CI on every rehearsal. + github_env = os.environ.get("GITHUB_ENV") + if github_env: + contexts = source.get("requiredStatusCheckContexts") or [] + with open(github_env, "a") as f: + f.write(f"REQUIRED_CHECK_CONTEXTS={','.join(contexts)}\n") + + mirror = build_mirror_input(source) + target = next((r for r in rules if r["pattern"] == TARGET_PATTERN), None) + + if target is None: + create_rule(repo_id, TARGET_PATTERN, mirror) + print(f"Created branch protection rule for {TARGET_PATTERN!r} mirroring {SOURCE_PATTERN!r}.") + else: + update_rule(target["id"], TARGET_PATTERN, mirror) + print(f"Updated branch protection rule for {TARGET_PATTERN!r} to mirror {SOURCE_PATTERN!r}.") + PY + + - name: Resolve version-bump-branch + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + INPUT_BRANCH: ${{ inputs.version-bump-branch }} + run: | + # Consumers that fall back to `github.ref_name` get a PR-derived ref + # that is not the branch a real release would target: + # - `/merge` or `/head` on `pull_request` triggers, + # - `pull-request/` on `push` triggers via the copy-pr-bot mirror. + # In all those cases, fall back to the repo's default branch so the + # rehearsal targets a real branch (and the fake-release/ base + # commit is reachable from the normal history). + BRANCH="$INPUT_BRANCH" + if [[ "$BRANCH" =~ ^[0-9]+/(merge|head)$ ]] || [[ "$BRANCH" =~ ^pull-request/[0-9]+$ ]]; then + BRANCH=$(gh api "repos/${{ github.repository }}" --jq '.default_branch') + echo "::warning::version-bump-branch resolved to PR-derived ref '$INPUT_BRANCH'; falling back to default branch '$BRANCH'." + fi + echo "VERSION_BUMP_BRANCH=$BRANCH" | tee -a $GITHUB_ENV + - name: Create and push deployment branch - if: inputs.validate-only == false env: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | @@ -272,14 +518,56 @@ jobs: git push -u origin "$TMP_BRANCH" echo "TMP_BRANCH=$TMP_BRANCH" | tee -a $GITHUB_ENV - gh pr create \ - --base ${{ inputs.version-bump-branch }} \ - --head $TMP_BRANCH \ + # Post a fake success check-run for each required check context the source + # release-branch rule advertises. The mirrored `fake-release/*` rule + # inherits those contexts, so without this the merge would block waiting + # for real CI. The fake check-runs are bound to the deploy-release HEAD + # commit — the PR's head — which is what the protection actually + # checks against. Uses the Checks API (App needs `checks: write`). + HEAD_SHA=$(git rev-parse HEAD) + if [[ -n "${REQUIRED_CHECK_CONTEXTS:-}" ]]; then + IFS=',' read -ra CONTEXTS <<< "$REQUIRED_CHECK_CONTEXTS" + for ctx in "${CONTEXTS[@]}"; do + [[ -z "$ctx" ]] && continue + echo "Posting fake check-run '$ctx' on $HEAD_SHA" + gh api -X POST "repos/${{ github.repository }}/check-runs" \ + -f name="$ctx" \ + -f head_sha="$HEAD_SHA" \ + -f status=completed \ + -f conclusion=success \ + -f "output[title]=IS_INERT rehearsal stand-in" \ + -f "output[summary]=Synthesised by FW-CI-templates release rehearsal to satisfy mirrored release-branch protection without running real CI." + done + fi + + # In inert modes (validate-only OR dry-run), target a throwaway + # `fake-release/` off the resolved version-bump-branch. Its branch + # protection (`fake-release/*`) mirrors the real release-branch rule, so + # the merge is gated by the same required checks a real release would + # face — without touching any real release branch. + if [[ "$IS_INERT" == "true" ]]; then + FAKE_BRANCH="fake-release/$(uuidgen)" + git fetch origin "$VERSION_BUMP_BRANCH" + # Use `git push :refs/heads/` instead of the REST git/refs + # endpoint — same credential path that just worked for deploy-release, + # avoids HTTP 422s seen on some org installs. + BASE_SHA=$(git rev-parse "origin/$VERSION_BUMP_BRANCH") + git push origin "$BASE_SHA:refs/heads/$FAKE_BRANCH" + echo "FAKE_BRANCH=$FAKE_BRANCH" | tee -a $GITHUB_ENV + PR_BASE="$FAKE_BRANCH" + else + PR_BASE="$VERSION_BUMP_BRANCH" + fi + + PR_URL=$(gh pr create \ + --base "$PR_BASE" \ + --head "$TMP_BRANCH" \ --title "beep boop 🤖: Bumping ${{ inputs.library-name }} to v${{ steps.bump.outputs.next-version }}" \ - --body "This is an automated PR to bump ${{ inputs.library-name }} to v${{ steps.bump.outputs.next-version }}." + --body "This is an automated PR to bump ${{ inputs.library-name }} to v${{ steps.bump.outputs.next-version }}.") + PR_NUMBER="${PR_URL##*/}" + echo "PR_NUMBER=$PR_NUMBER" | tee -a $GITHUB_ENV - name: Wait for status checks on tmp branch - if: inputs.validate-only == false uses: actions/github-script@v8 id: wait-status with: @@ -353,44 +641,58 @@ jobs: core.setFailed('❌ Status checks did not pass in time'); } - - name: Merge into ${{ inputs.version-bump-branch }} - if: inputs.validate-only == false + - name: Merge deploy-release branch into target + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | cd ${{ github.run_id }} git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - CMD=$(echo -E 'git push origin ${{ inputs.version-bump-branch }}') - if [[ "$IS_INERT" == "true" ]]; then - echo "dry-run enabled, would have run: $CMD" + TARGET="$FAKE_BRANCH" else - git fetch origin ${{ inputs.version-bump-branch }} - git checkout ${{ inputs.version-bump-branch }} - git merge ${{ env.TMP_BRANCH }} + TARGET="$VERSION_BUMP_BRANCH" + fi - for attempt in {1..3}; do - if eval "$CMD"; then - echo "Git push succeeded on attempt $attempt" - break + # Direct git push to the protected target. The PR-merge endpoint enforces + # app_id-bound `requiredStatusCheckContexts` (and on some repos refuses + # admin override for that). A direct push goes through `pushAllowances` + # instead — the mirrored rule (and the real version-bump-branch rule) + # both put the bot in `pushAllowances` and leave `requiresApprovingReviews=false`, + # so this path consistently works. + git fetch origin "$TARGET" + git checkout "$TARGET" + git merge "$TMP_BRANCH" + + for attempt in {1..3}; do + if git push origin "$TARGET"; then + echo "Git push to $TARGET succeeded on attempt $attempt" + break + else + echo "Git push to $TARGET failed on attempt $attempt" + if [[ $attempt -lt 3 ]]; then + sleep $((RANDOM % 3 + 1)) + git fetch origin "$TARGET" + git reset --hard "origin/$TARGET" + git merge "$TMP_BRANCH" else - echo "Git push failed on attempt $attempt" - if [[ $attempt -lt 3 ]]; then - sleep $((RANDOM % 3 + 1)) - git fetch origin ${{ inputs.version-bump-branch }} - git reset --hard origin/${{ inputs.version-bump-branch }} - git merge ${{ env.TMP_BRANCH }} - else - echo "Git push failed after 3 attempts" - exit 1 - fi + echo "Git push to $TARGET failed after 3 attempts" + exit 1 fi - done - fi + fi + done - - name: Delete ${{ env.TMP_BRANCH }} branch - if: always() && inputs.validate-only == false && env.TMP_BRANCH != '' + - name: Delete temporary branches + if: always() + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | cd ${{ github.run_id }} - git push -d origin ${{ env.TMP_BRANCH }} || true + if [[ -n "${TMP_BRANCH:-}" ]]; then + git push -d origin "$TMP_BRANCH" || true + fi + if [[ -n "${FAKE_BRANCH:-}" ]]; then + git push -d origin "$FAKE_BRANCH" || true + fi diff --git a/.github/workflows/_release_library.yml b/.github/workflows/_release_library.yml index 9dfc4db..0017b8f 100644 --- a/.github/workflows/_release_library.yml +++ b/.github/workflows/_release_library.yml @@ -234,6 +234,14 @@ on: description: Only allow repository admins to run this workflow type: boolean default: true + release-branch-pattern: + required: false + description: | + Branch-protection pattern of the real release branches. The bump job mirrors + this rule onto `deploy-release/*` so the rehearsal PR is gated by the same + required checks as a real release. Requires the App to have `administration: write`. + type: string + default: "[rv][0-9].[0-9].[0-9]" extra-no-build-isolation-packages: required: false description: Space-separated list of extra packages to pre-install before building with --no-build-isolation (e.g. "grpcio-tools protobuf") @@ -284,6 +292,7 @@ jobs: version-bump-branch: ${{ inputs.version-bump-branch }} skip-version-bump: ${{ inputs.skip-version-bump }} restrict-to-admins: ${{ inputs.restrict-to-admins }} + release-branch-pattern: ${{ inputs.release-branch-pattern }} app-id: ${{ inputs.app-id }} library-name: ${{ inputs.library-name }} bump-targets: ${{ inputs.bump-targets }} From 3c679d436baa0b926766e9b1e6d149803c506b5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Fri, 22 May 2026 15:08:11 +0000 Subject: [PATCH 2/5] refactor(release): drop fake check-runs, status polling, and required-check threading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that every release-branch rule runs with isAdminEnforced=false, the bot pushes through branch protection via admin bypass. The fake check-run posting, the REQUIRED_CHECK_CONTEXTS env threading, and the wait-for-status-checks step were all workarounds for required-check enforcement that no longer applies. Replace REQUIRED_CHECK_CONTEXTS extraction with a fail-fast guard in the mirror step: if the source rule still has isAdminEnforced=true, error out with a clear message so the misconfigured repo is fixed once, not silently worked around forever. Signed-off-by: oliver könig --- .github/workflows/_release_bump.yml | 151 +++++----------------------- 1 file changed, 27 insertions(+), 124 deletions(-) diff --git a/.github/workflows/_release_bump.yml b/.github/workflows/_release_bump.yml index 555ed3f..e7ec3f6 100644 --- a/.github/workflows/_release_bump.yml +++ b/.github/workflows/_release_bump.yml @@ -58,13 +58,14 @@ on: Branch-protection pattern of the real release branches. The bump job mirrors this rule onto the `fake-release/*` pattern (created if absent, updated if present). In dry-run mode the rehearsal merges into a throwaway - `fake-release/` branch so the merge is gated by the same required - checks a real release would face. Requires the App to have + `fake-release/` branch that carries the same protection rule as a real + release branch — the bot pushes through it via admin bypass + (`isAdminEnforced=false` on the source rule). Requires the App to have `administration: write`. app-id: type: string required: true - description: GitHub App ID used to mint the bot token for git push, PR creation, and status-check polling. + description: GitHub App ID used to mint the bot token for git push and PR creation. library-name: type: string required: true @@ -259,9 +260,10 @@ jobs: echo "- next-version (after bump): \`${{ steps.bump.outputs.next-version }}\`" echo echo "IS_INERT=true → the rehearsal pushes \`deploy-release/\` and" - echo "\`fake-release/\` and merges the bump into the fake branch" - echo "(gated by the mirrored release-branch protection). No commit lands" - echo "on \`${{ inputs.version-bump-branch }}\` and both branches are" + echo "\`fake-release/\` and merges the bump into the fake branch." + echo "The fake branch inherits the source release-branch rule via the" + echo "mirror step; the bot pushes through it via admin bypass. No commit" + echo "lands on \`${{ inputs.version-bump-branch }}\` and both branches are" echo "deleted on exit." } | tee -a "$GITHUB_STEP_SUMMARY" @@ -277,10 +279,12 @@ jobs: """Mirror the branch-protection rule of the real release pattern onto fake-release/*. Idempotent: creates the target rule if absent, updates it in place if present. - Source rule is identified by the `release-branch-pattern` input. The mirror lets - the dry-run rehearsal merge into a throwaway `fake-release/` branch that - is gated by the same required checks a real release would face. Requires the - calling GitHub App token to hold `administration: write`. + Source rule is identified by the `release-branch-pattern` input. The mirror gives + the throwaway `fake-release/` branch the same protection posture as a real + release branch — including `isAdminEnforced=false`, which is what lets the bot + push the bump commit through as an admin. Fails fast if the source rule has + admin enforcement on. Requires the calling GitHub App token to hold + `administration: write`. """ import json import os @@ -409,9 +413,8 @@ jobs: fake-release/ branch can be deleted on cleanup. The source rule's `allowsDeletions: false` is meant to protect real release branches; deletion of synthetic per-run UUIDs has no analogue - concern. Every other field is mirrored faithfully — the rehearsal's - purpose is to validate that the gating contract on the source rule - actually applies to the test PR. + concern. Every other field is mirrored faithfully so the rehearsal + branch carries the same protection posture as a real release branch. """ mirror = {field: source[field] for field in MIRRORED_FIELDS} mirror["allowsDeletions"] = True @@ -458,15 +461,12 @@ jobs: f"or create one to gate the rehearsal merge.") sys.exit(0) - # Export the source rule's required status check contexts so the deploy - # step can post matching fake success statuses on the deploy-release - # HEAD. That satisfies the mirrored fake-release/* required-check gate - # without paying the cost of running real CI on every rehearsal. - github_env = os.environ.get("GITHUB_ENV") - if github_env: - contexts = source.get("requiredStatusCheckContexts") or [] - with open(github_env, "a") as f: - f.write(f"REQUIRED_CHECK_CONTEXTS={','.join(contexts)}\n") + if source.get("isAdminEnforced"): + sys.exit( + f"::error::Source rule {SOURCE_PATTERN!r} has isAdminEnforced=true. " + f"The rehearsal pushes through admin bypass; set isAdminEnforced=false " + f"on the source rule and re-run." + ) mirror = build_mirror_input(source) target = next((r for r in rules if r["pattern"] == TARGET_PATTERN), None) @@ -518,28 +518,6 @@ jobs: git push -u origin "$TMP_BRANCH" echo "TMP_BRANCH=$TMP_BRANCH" | tee -a $GITHUB_ENV - # Post a fake success check-run for each required check context the source - # release-branch rule advertises. The mirrored `fake-release/*` rule - # inherits those contexts, so without this the merge would block waiting - # for real CI. The fake check-runs are bound to the deploy-release HEAD - # commit — the PR's head — which is what the protection actually - # checks against. Uses the Checks API (App needs `checks: write`). - HEAD_SHA=$(git rev-parse HEAD) - if [[ -n "${REQUIRED_CHECK_CONTEXTS:-}" ]]; then - IFS=',' read -ra CONTEXTS <<< "$REQUIRED_CHECK_CONTEXTS" - for ctx in "${CONTEXTS[@]}"; do - [[ -z "$ctx" ]] && continue - echo "Posting fake check-run '$ctx' on $HEAD_SHA" - gh api -X POST "repos/${{ github.repository }}/check-runs" \ - -f name="$ctx" \ - -f head_sha="$HEAD_SHA" \ - -f status=completed \ - -f conclusion=success \ - -f "output[title]=IS_INERT rehearsal stand-in" \ - -f "output[summary]=Synthesised by FW-CI-templates release rehearsal to satisfy mirrored release-branch protection without running real CI." - done - fi - # In inert modes (validate-only OR dry-run), target a throwaway # `fake-release/` off the resolved version-bump-branch. Its branch # protection (`fake-release/*`) mirrors the real release-branch rule, so @@ -567,80 +545,6 @@ jobs: PR_NUMBER="${PR_URL##*/}" echo "PR_NUMBER=$PR_NUMBER" | tee -a $GITHUB_ENV - - name: Wait for status checks on tmp branch - uses: actions/github-script@v8 - id: wait-status - with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - const branch = process.env.TMP_BRANCH; - const owner = context.repo.owner; - const repo = context.repo.repo; - - const { data: refData } = await github.rest.git.getRef({ - owner, - repo, - ref: `heads/${branch}`, - }); - - const sha = refData.object.sha; - - const { data: commit } = await github.rest.git.getCommit({ owner, repo, commit_sha: sha }); - const message = commit.message || ''; - const skipMarkers = ['[skip ci]', '[ci skip]', '[no ci]', '[skip actions]', '[actions skip]']; - if (skipMarkers.some(m => message.includes(m))) { - console.log(`✅ Bump commit carries a skip-CI marker; no checks will register — short-circuiting.`); - return; - } - - console.log(`Polling status for commit SHA: ${sha}`); - - let checksPassed = false; - let maxAttempts = 3000; - let attempt = 0; - const delay = ms => new Promise(res => setTimeout(res, ms)); - - while (!checksPassed && attempt < maxAttempts) { - attempt++; - - const { data: status } = await github.rest.repos.getCombinedStatusForRef({ - owner, - repo, - ref: sha, - }); - - const { data: checks } = await github.rest.checks.listForRef({ - owner, - repo, - ref: sha, - }); - - const allStatuses = status.statuses; - const allChecks = checks.check_runs; - - if (allStatuses.length === 0 && allChecks.length === 0) { - console.log(`Attempt ${attempt}: No checks or statuses yet. Waiting...`); - await delay(10000); - continue; - } - - const statusesOk = allStatuses.every(s => s.state === 'success'); - const checksOk = allChecks.every(c => c.status === 'completed'); - - if (statusesOk && checksOk) { - console.log('✅ All checks passed.'); - checksPassed = true; - break - } - - console.log(`Attempt ${attempt}: Checks not complete yet. Waiting...`); - await delay(10000); - } - - if (!checksPassed) { - core.setFailed('❌ Status checks did not pass in time'); - } - - name: Merge deploy-release branch into target env: GH_TOKEN: ${{ steps.app-token.outputs.token }} @@ -656,12 +560,11 @@ jobs: TARGET="$VERSION_BUMP_BRANCH" fi - # Direct git push to the protected target. The PR-merge endpoint enforces - # app_id-bound `requiredStatusCheckContexts` (and on some repos refuses - # admin override for that). A direct push goes through `pushAllowances` - # instead — the mirrored rule (and the real version-bump-branch rule) - # both put the bot in `pushAllowances` and leave `requiresApprovingReviews=false`, - # so this path consistently works. + # Direct git push to the protected target. The source rule has + # `isAdminEnforced=false`, so the bot bypasses required checks and + # approving-review counts as a repo admin. The mirror step copies that + # bypass posture onto `fake-release/*`, so the same push path works for + # both real releases and the rehearsal. git fetch origin "$TARGET" git checkout "$TARGET" git merge "$TMP_BRANCH" From 57cc8e1ed81d8a1d83cecb2e4a54e04144623bcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Mon, 25 May 2026 08:09:43 +0000 Subject: [PATCH 3/5] refactor(release): collapse inert mode to a static precondition check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In inert mode (validate-only or dry-run), the rehearsal previously bumped versions, mirrored branch protection, pushed deploy-release/, pushed fake-release/, PR'd one into the other, and merged via admin bypass. Every gate it exercised resolved to 'admin can do it,' so the actual rehearsal was demonstrating something we already assert via configuration. Inert mode is now two reads: - GET /repos/{owner}/{repo}/installation -> assert App holds contents:write, pull_requests:write, administration:read. - branchProtectionRules GraphQL -> assert source release-branch rule exists with isAdminEnforced=false. If either check fails, the workflow errors with a clear message naming the missing permission or the misconfigured rule. No deploy-release branch, no fake-release branch, no mirror mutation, no PR. Real-release path is unchanged: push deploy-release/, open the bump PR against version-bump-branch, merge via direct push (admin bypass). Signed-off-by: oliver könig --- .github/workflows/_release_bump.yml | 352 +++++++++------------------- 1 file changed, 111 insertions(+), 241 deletions(-) diff --git a/.github/workflows/_release_bump.yml b/.github/workflows/_release_bump.yml index e7ec3f6..c6c4d46 100644 --- a/.github/workflows/_release_bump.yml +++ b/.github/workflows/_release_bump.yml @@ -29,12 +29,12 @@ on: type: boolean required: false default: false - description: Compute the bump but do not push the deploy branch, open the PR, wait, merge, or delete. Used by per-PR rehearsals. + description: Inert mode for per-PR rehearsals. Compute the bump and assert release preconditions; do not push, open a PR, or merge. dry-run: type: boolean required: false default: false - description: Compute the bump and exercise the deploy-branch+PR cycle but echo the merge step instead of pushing to the version-bump branch. + description: Inert mode for workflow_dispatch rehearsals. Same precondition checks as validate-only; no side effects on the repo. version-bump-branch: type: string required: false @@ -55,13 +55,10 @@ on: required: false default: "[rv][0-9].[0-9].[0-9]" description: | - Branch-protection pattern of the real release branches. The bump job mirrors - this rule onto the `fake-release/*` pattern (created if absent, updated if - present). In dry-run mode the rehearsal merges into a throwaway - `fake-release/` branch that carries the same protection rule as a real - release branch — the bot pushes through it via admin bypass - (`isAdminEnforced=false` on the source rule). Requires the App to have - `administration: write`. + Branch-protection pattern of the real release branches. In inert mode + (validate-only or dry-run) the workflow asserts a rule for this pattern + exists with `isAdminEnforced=false`. Real releases push through that rule + via admin bypass, so this precondition is what makes the push succeed. app-id: type: string required: true @@ -259,227 +256,129 @@ jobs: echo "- release-version (current): \`${{ steps.bump.outputs.release-version }}\`" echo "- next-version (after bump): \`${{ steps.bump.outputs.next-version }}\`" echo - echo "IS_INERT=true → the rehearsal pushes \`deploy-release/\` and" - echo "\`fake-release/\` and merges the bump into the fake branch." - echo "The fake branch inherits the source release-branch rule via the" - echo "mirror step; the bot pushes through it via admin bypass. No commit" - echo "lands on \`${{ inputs.version-bump-branch }}\` and both branches are" - echo "deleted on exit." + echo "IS_INERT=true → preconditions are asserted (App permissions +" + echo "source rule \`isAdminEnforced=false\`). No commit is pushed, no PR" + echo "is opened, and no branch is created on this repo." } | tee -a "$GITHUB_STEP_SUMMARY" - - name: Mirror branch protection ${{ inputs.release-branch-pattern }} -> fake-release/* + - name: Validate release config + if: env.IS_INERT == 'true' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} SOURCE_PATTERN: ${{ inputs.release-branch-pattern }} - TARGET_PATTERN: "fake-release/*" OWNER: ${{ github.repository_owner }} REPO: ${{ github.event.repository.name }} run: | python3 - <<'PY' - """Mirror the branch-protection rule of the real release pattern onto fake-release/*. - - Idempotent: creates the target rule if absent, updates it in place if present. - Source rule is identified by the `release-branch-pattern` input. The mirror gives - the throwaway `fake-release/` branch the same protection posture as a real - release branch — including `isAdminEnforced=false`, which is what lets the bot - push the bump commit through as an admin. Fails fast if the source rule has - admin enforcement on. Requires the calling GitHub App token to hold - `administration: write`. + """Assert the preconditions a real release relies on, without side effects. + + Two reads, no writes: + 1. `GET /repos/{owner}/{repo}/installation` — assert the App installation + on this repo holds `contents: write`, `pull_requests: write`, and + `administration: read`. Proves the token is valid and the permissions + that real releases will need are still granted. + 2. `branchProtectionRules` GraphQL query — assert a rule matching + `release-branch-pattern` exists with `isAdminEnforced=false`. Real + releases push the bump commit through that rule via admin bypass, + so this is the precondition that makes the push succeed. """ import json import os import sys import urllib.request - API = "https://api.github.com/graphql" OWNER = os.environ["OWNER"] REPO = os.environ["REPO"] SOURCE_PATTERN = os.environ["SOURCE_PATTERN"] - TARGET_PATTERN = os.environ["TARGET_PATTERN"] TOKEN = os.environ["GH_TOKEN"] - MIRRORED_FIELDS = ( - "requiresStatusChecks", - "requiredStatusCheckContexts", - "requiresStrictStatusChecks", - "requiresApprovingReviews", - "requiredApprovingReviewCount", - "dismissesStaleReviews", - "requiresCodeOwnerReviews", - "requireLastPushApproval", - "restrictsReviewDismissals", - "restrictsPushes", - "isAdminEnforced", - "allowsForcePushes", - "allowsDeletions", - "requiresConversationResolution", - "requiresLinearHistory", - "blocksCreations", - "requiresDeployments", - "requiredDeploymentEnvironments", - "lockBranch", - "lockAllowsFetchAndMerge", - ) - - - def graphql(query, variables=None): - """Send a GraphQL request and return the `data` block, raising on errors.""" - body = json.dumps({"query": query, "variables": variables or {}}).encode() + EXPECTED_PERMS = { + "contents": "write", + "pull_requests": "write", + "administration": "read", + } + + + def api_request(url, method="GET", body=None): + """Send an API request and return the parsed JSON body.""" + data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request( - API, - data=body, + url, + data=data, headers={ "Authorization": f"bearer {TOKEN}", + "Accept": "application/vnd.github+json", "Content-Type": "application/json", }, - method="POST", + method=method, ) with urllib.request.urlopen(req) as resp: - payload = json.loads(resp.read().decode()) - if payload.get("errors"): - sys.exit(f"GraphQL errors: {json.dumps(payload['errors'], indent=2)}") - return payload["data"] - - - def list_rules(): - """Return (repository_id, [rule, ...]) for the configured repo.""" - data = graphql( - """ - query($owner:String!, $repo:String!) { - repository(owner:$owner, name:$repo) { - id - branchProtectionRules(first: 100) { - nodes { - id - pattern - requiresStatusChecks - requiredStatusCheckContexts - requiresStrictStatusChecks - requiresApprovingReviews - requiredApprovingReviewCount - dismissesStaleReviews - requiresCodeOwnerReviews - requireLastPushApproval - restrictsReviewDismissals - reviewDismissalAllowances(first: 100) { - nodes { actor { __typename ... on User { id } ... on Team { id } } } - } - bypassPullRequestAllowances(first: 100) { - nodes { actor { __typename ... on User { id } ... on Team { id } ... on App { id } } } - } - restrictsPushes - pushAllowances(first: 100) { - nodes { actor { __typename ... on User { id } ... on Team { id } ... on App { id } } } - } - isAdminEnforced - allowsForcePushes - allowsDeletions - requiresConversationResolution - requiresLinearHistory - blocksCreations - requiresDeployments - requiredDeploymentEnvironments - lockBranch - lockAllowsFetchAndMerge - } - } - } - } - """, - {"owner": OWNER, "repo": REPO}, - ) - repo = data["repository"] - return repo["id"], repo["branchProtectionRules"]["nodes"] - + return json.loads(resp.read().decode()) - def actor_ids(allowance_block): - """Extract the node IDs of actors granted an allowance. - Tolerates entries whose `actor` is null (e.g. a deleted user/team) or whose - `actor.id` is missing (e.g. an actor type not covered by the inline fragments). - """ - ids = [] - for node in allowance_block["nodes"]: - actor = node.get("actor") - if actor and actor.get("id"): - ids.append(actor["id"]) - return ids - - - def build_mirror_input(source): - """Build the mutation input payload that mirrors the source rule. - - One intentional deviation: `allowsDeletions: True` so the throwaway - fake-release/ branch can be deleted on cleanup. The source - rule's `allowsDeletions: false` is meant to protect real release - branches; deletion of synthetic per-run UUIDs has no analogue - concern. Every other field is mirrored faithfully so the rehearsal - branch carries the same protection posture as a real release branch. - """ - mirror = {field: source[field] for field in MIRRORED_FIELDS} - mirror["allowsDeletions"] = True - mirror["reviewDismissalActorIds"] = actor_ids(source["reviewDismissalAllowances"]) - mirror["bypassPullRequestActorIds"] = actor_ids(source["bypassPullRequestAllowances"]) - mirror["pushActorIds"] = actor_ids(source["pushAllowances"]) - return mirror - - - def create_rule(repo_id, pattern, mirror): - """Create a new branch protection rule mirroring the source.""" - graphql( - """ - mutation($input: CreateBranchProtectionRuleInput!) { - createBranchProtectionRule(input: $input) { - branchProtectionRule { id pattern } - } - } - """, - {"input": {"repositoryId": repo_id, "pattern": pattern, **mirror}}, + def check_installation_permissions(): + """Assert the App installation has the permissions a real release needs.""" + installation = api_request( + f"https://api.github.com/repos/{OWNER}/{REPO}/installation" ) - - - def update_rule(rule_id, pattern, mirror): - """Update an existing branch protection rule to mirror the source.""" - graphql( - """ - mutation($input: UpdateBranchProtectionRuleInput!) { - updateBranchProtectionRule(input: $input) { - branchProtectionRule { id pattern } + perms = installation.get("permissions", {}) + missing = [] + for name, required in EXPECTED_PERMS.items(): + level = perms.get(name) + if level is None: + missing.append(f"{name}={required} (not granted)") + elif required == "write" and level != "write": + missing.append(f"{name}=write (currently {level})") + elif required == "read" and level not in ("read", "write"): + missing.append(f"{name}=read (currently {level})") + if missing: + sys.exit( + "::error::App installation is missing required permissions: " + + ", ".join(missing) + ) + print(f"App permissions OK: {perms}") + + + def check_source_rule_admin_bypass(): + """Assert the source release-branch rule exists with admin enforcement off.""" + query = """ + query($owner:String!, $repo:String!) { + repository(owner:$owner, name:$repo) { + branchProtectionRules(first: 100) { + nodes { pattern isAdminEnforced } } } - """, - {"input": {"branchProtectionRuleId": rule_id, "pattern": pattern, **mirror}}, - ) - - - repo_id, rules = list_rules() - - source = next((r for r in rules if r["pattern"] == SOURCE_PATTERN), None) - if source is None: - print(f"::warning::No branch protection rule found for pattern {SOURCE_PATTERN!r}; skipping mirror. " - f"Override `release-branch-pattern` to match this repository's release-branch rule, " - f"or create one to gate the rehearsal merge.") - sys.exit(0) - - if source.get("isAdminEnforced"): - sys.exit( - f"::error::Source rule {SOURCE_PATTERN!r} has isAdminEnforced=true. " - f"The rehearsal pushes through admin bypass; set isAdminEnforced=false " - f"on the source rule and re-run." + } + """ + body = {"query": query, "variables": {"owner": OWNER, "repo": REPO}} + data = api_request( + "https://api.github.com/graphql", method="POST", body=body ) - - mirror = build_mirror_input(source) - target = next((r for r in rules if r["pattern"] == TARGET_PATTERN), None) - - if target is None: - create_rule(repo_id, TARGET_PATTERN, mirror) - print(f"Created branch protection rule for {TARGET_PATTERN!r} mirroring {SOURCE_PATTERN!r}.") - else: - update_rule(target["id"], TARGET_PATTERN, mirror) - print(f"Updated branch protection rule for {TARGET_PATTERN!r} to mirror {SOURCE_PATTERN!r}.") + if data.get("errors"): + sys.exit(f"::error::GraphQL errors: {json.dumps(data['errors'])}") + rules = data["data"]["repository"]["branchProtectionRules"]["nodes"] + source = next((r for r in rules if r["pattern"] == SOURCE_PATTERN), None) + if source is None: + sys.exit( + f"::error::No branch protection rule found for pattern " + f"{SOURCE_PATTERN!r}. Create one or override " + f"`release-branch-pattern` to match this repo." + ) + if source["isAdminEnforced"]: + sys.exit( + f"::error::Source rule {SOURCE_PATTERN!r} has " + f"isAdminEnforced=true. Real releases push through admin " + f"bypass; set isAdminEnforced=false on the source rule." + ) + print(f"Source rule {SOURCE_PATTERN!r} OK: isAdminEnforced=false") + + + check_installation_permissions() + check_source_rule_admin_bypass() PY - name: Resolve version-bump-branch + if: env.IS_INERT == 'false' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} INPUT_BRANCH: ${{ inputs.version-bump-branch }} @@ -488,9 +387,7 @@ jobs: # that is not the branch a real release would target: # - `/merge` or `/head` on `pull_request` triggers, # - `pull-request/` on `push` triggers via the copy-pr-bot mirror. - # In all those cases, fall back to the repo's default branch so the - # rehearsal targets a real branch (and the fake-release/ base - # commit is reachable from the normal history). + # In all those cases, fall back to the repo's default branch. BRANCH="$INPUT_BRANCH" if [[ "$BRANCH" =~ ^[0-9]+/(merge|head)$ ]] || [[ "$BRANCH" =~ ^pull-request/[0-9]+$ ]]; then BRANCH=$(gh api "repos/${{ github.repository }}" --jq '.default_branch') @@ -499,6 +396,7 @@ jobs: echo "VERSION_BUMP_BRANCH=$BRANCH" | tee -a $GITHUB_ENV - name: Create and push deployment branch + if: env.IS_INERT == 'false' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | @@ -518,27 +416,8 @@ jobs: git push -u origin "$TMP_BRANCH" echo "TMP_BRANCH=$TMP_BRANCH" | tee -a $GITHUB_ENV - # In inert modes (validate-only OR dry-run), target a throwaway - # `fake-release/` off the resolved version-bump-branch. Its branch - # protection (`fake-release/*`) mirrors the real release-branch rule, so - # the merge is gated by the same required checks a real release would - # face — without touching any real release branch. - if [[ "$IS_INERT" == "true" ]]; then - FAKE_BRANCH="fake-release/$(uuidgen)" - git fetch origin "$VERSION_BUMP_BRANCH" - # Use `git push :refs/heads/` instead of the REST git/refs - # endpoint — same credential path that just worked for deploy-release, - # avoids HTTP 422s seen on some org installs. - BASE_SHA=$(git rev-parse "origin/$VERSION_BUMP_BRANCH") - git push origin "$BASE_SHA:refs/heads/$FAKE_BRANCH" - echo "FAKE_BRANCH=$FAKE_BRANCH" | tee -a $GITHUB_ENV - PR_BASE="$FAKE_BRANCH" - else - PR_BASE="$VERSION_BUMP_BRANCH" - fi - PR_URL=$(gh pr create \ - --base "$PR_BASE" \ + --base "$VERSION_BUMP_BRANCH" \ --head "$TMP_BRANCH" \ --title "beep boop 🤖: Bumping ${{ inputs.library-name }} to v${{ steps.bump.outputs.next-version }}" \ --body "This is an automated PR to bump ${{ inputs.library-name }} to v${{ steps.bump.outputs.next-version }}.") @@ -546,6 +425,7 @@ jobs: echo "PR_NUMBER=$PR_NUMBER" | tee -a $GITHUB_ENV - name: Merge deploy-release branch into target + if: env.IS_INERT == 'false' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | @@ -554,41 +434,34 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - if [[ "$IS_INERT" == "true" ]]; then - TARGET="$FAKE_BRANCH" - else - TARGET="$VERSION_BUMP_BRANCH" - fi - - # Direct git push to the protected target. The source rule has - # `isAdminEnforced=false`, so the bot bypasses required checks and - # approving-review counts as a repo admin. The mirror step copies that - # bypass posture onto `fake-release/*`, so the same push path works for - # both real releases and the rehearsal. - git fetch origin "$TARGET" - git checkout "$TARGET" + # Direct git push to the protected version-bump-branch. The source rule + # has `isAdminEnforced=false` (asserted in inert mode by `Validate + # release config`), so the bot bypasses required checks and approving- + # review counts as a repo admin. + git fetch origin "$VERSION_BUMP_BRANCH" + git checkout "$VERSION_BUMP_BRANCH" git merge "$TMP_BRANCH" for attempt in {1..3}; do - if git push origin "$TARGET"; then - echo "Git push to $TARGET succeeded on attempt $attempt" + if git push origin "$VERSION_BUMP_BRANCH"; then + echo "Git push to $VERSION_BUMP_BRANCH succeeded on attempt $attempt" break else - echo "Git push to $TARGET failed on attempt $attempt" + echo "Git push to $VERSION_BUMP_BRANCH failed on attempt $attempt" if [[ $attempt -lt 3 ]]; then sleep $((RANDOM % 3 + 1)) - git fetch origin "$TARGET" - git reset --hard "origin/$TARGET" + git fetch origin "$VERSION_BUMP_BRANCH" + git reset --hard "origin/$VERSION_BUMP_BRANCH" git merge "$TMP_BRANCH" else - echo "Git push to $TARGET failed after 3 attempts" + echo "Git push to $VERSION_BUMP_BRANCH failed after 3 attempts" exit 1 fi fi done - - name: Delete temporary branches - if: always() + - name: Delete temporary branch + if: always() && env.IS_INERT == 'false' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | @@ -596,6 +469,3 @@ jobs: if [[ -n "${TMP_BRANCH:-}" ]]; then git push -d origin "$TMP_BRANCH" || true fi - if [[ -n "${FAKE_BRANCH:-}" ]]; then - git push -d origin "$FAKE_BRANCH" || true - fi From d8ca5ec4a4c4242d0bc2438aa838adebd83e05c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Mon, 25 May 2026 08:14:31 +0000 Subject: [PATCH 4/5] fix(release): JWT-auth the /installation perm probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The installation token returned by actions/create-github-app-token@v2 can read most endpoints, but GET /repos/{owner}/{repo}/installation requires auth as the App itself (JWT signed by the App's private key). With the installation token it returns 401, which is what every companion bump just failed with. Mint a 5-minute JWT inline from BOT_KEY using openssl, use it for the permission introspection, and keep the installation token for the branch protection GraphQL query (admin:read is enough). Signed-off-by: oliver könig --- .github/workflows/_release_bump.yml | 181 ++++++++++++---------------- 1 file changed, 74 insertions(+), 107 deletions(-) diff --git a/.github/workflows/_release_bump.yml b/.github/workflows/_release_bump.yml index c6c4d46..ce94488 100644 --- a/.github/workflows/_release_bump.yml +++ b/.github/workflows/_release_bump.yml @@ -264,118 +264,85 @@ jobs: - name: Validate release config if: env.IS_INERT == 'true' env: + APP_ID: ${{ inputs.app-id }} + BOT_KEY: ${{ secrets.BOT_KEY }} GH_TOKEN: ${{ steps.app-token.outputs.token }} SOURCE_PATTERN: ${{ inputs.release-branch-pattern }} OWNER: ${{ github.repository_owner }} REPO: ${{ github.event.repository.name }} run: | - python3 - <<'PY' - """Assert the preconditions a real release relies on, without side effects. - - Two reads, no writes: - 1. `GET /repos/{owner}/{repo}/installation` — assert the App installation - on this repo holds `contents: write`, `pull_requests: write`, and - `administration: read`. Proves the token is valid and the permissions - that real releases will need are still granted. - 2. `branchProtectionRules` GraphQL query — assert a rule matching - `release-branch-pattern` exists with `isAdminEnforced=false`. Real - releases push the bump commit through that rule via admin bypass, - so this is the precondition that makes the push succeed. - """ - import json - import os - import sys - import urllib.request - - OWNER = os.environ["OWNER"] - REPO = os.environ["REPO"] - SOURCE_PATTERN = os.environ["SOURCE_PATTERN"] - TOKEN = os.environ["GH_TOKEN"] - - EXPECTED_PERMS = { - "contents": "write", - "pull_requests": "write", - "administration": "read", - } - - - def api_request(url, method="GET", body=None): - """Send an API request and return the parsed JSON body.""" - data = json.dumps(body).encode() if body is not None else None - req = urllib.request.Request( - url, - data=data, - headers={ - "Authorization": f"bearer {TOKEN}", - "Accept": "application/vnd.github+json", - "Content-Type": "application/json", - }, - method=method, - ) - with urllib.request.urlopen(req) as resp: - return json.loads(resp.read().decode()) - - - def check_installation_permissions(): - """Assert the App installation has the permissions a real release needs.""" - installation = api_request( - f"https://api.github.com/repos/{OWNER}/{REPO}/installation" - ) - perms = installation.get("permissions", {}) - missing = [] - for name, required in EXPECTED_PERMS.items(): - level = perms.get(name) - if level is None: - missing.append(f"{name}={required} (not granted)") - elif required == "write" and level != "write": - missing.append(f"{name}=write (currently {level})") - elif required == "read" and level not in ("read", "write"): - missing.append(f"{name}=read (currently {level})") - if missing: - sys.exit( - "::error::App installation is missing required permissions: " - + ", ".join(missing) - ) - print(f"App permissions OK: {perms}") - - - def check_source_rule_admin_bypass(): - """Assert the source release-branch rule exists with admin enforcement off.""" - query = """ - query($owner:String!, $repo:String!) { - repository(owner:$owner, name:$repo) { - branchProtectionRules(first: 100) { - nodes { pattern isAdminEnforced } - } - } - } - """ - body = {"query": query, "variables": {"owner": OWNER, "repo": REPO}} - data = api_request( - "https://api.github.com/graphql", method="POST", body=body - ) - if data.get("errors"): - sys.exit(f"::error::GraphQL errors: {json.dumps(data['errors'])}") - rules = data["data"]["repository"]["branchProtectionRules"]["nodes"] - source = next((r for r in rules if r["pattern"] == SOURCE_PATTERN), None) - if source is None: - sys.exit( - f"::error::No branch protection rule found for pattern " - f"{SOURCE_PATTERN!r}. Create one or override " - f"`release-branch-pattern` to match this repo." - ) - if source["isAdminEnforced"]: - sys.exit( - f"::error::Source rule {SOURCE_PATTERN!r} has " - f"isAdminEnforced=true. Real releases push through admin " - f"bypass; set isAdminEnforced=false on the source rule." - ) - print(f"Source rule {SOURCE_PATTERN!r} OK: isAdminEnforced=false") - - - check_installation_permissions() - check_source_rule_admin_bypass() - PY + set -euo pipefail + # Assert the preconditions a real release relies on, without side effects. + # + # Two reads, no writes: + # 1. GET /repos/{owner}/{repo}/installation — needs a JWT signed by + # the App's private key (the installation token is not enough for + # this endpoint). Asserts contents:write, pull_requests:write, + # administration:read are still granted to the installation. + # 2. branchProtectionRules GraphQL — uses the installation token. + # Asserts a rule for `release-branch-pattern` exists with + # isAdminEnforced=false. Real releases push through that rule + # via admin bypass. + + # Mint a 5-minute App JWT from BOT_KEY using openssl. + PEM_FILE=$(mktemp) + trap 'rm -f "$PEM_FILE"' EXIT + printf '%s' "$BOT_KEY" > "$PEM_FILE" + + b64url() { openssl base64 -A | tr -d '=' | tr '/+' '_-'; } + NOW=$(date +%s) + HEADER=$(printf '{"typ":"JWT","alg":"RS256"}' | b64url) + PAYLOAD=$(printf '{"iat":%d,"exp":%d,"iss":"%s"}' "$((NOW - 60))" "$((NOW + 300))" "$APP_ID" | b64url) + SIG=$(printf '%s.%s' "$HEADER" "$PAYLOAD" | openssl dgst -sha256 -binary -sign "$PEM_FILE" | b64url) + JWT="${HEADER}.${PAYLOAD}.${SIG}" + + # 1. App installation permissions on this repo. + PERMS=$(curl --silent --show-error --fail \ + -H "Authorization: Bearer $JWT" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/$OWNER/$REPO/installation" \ + | jq -c '.permissions') + + CONTENTS=$(echo "$PERMS" | jq -r '.contents // "missing"') + PR_PERM=$(echo "$PERMS" | jq -r '.pull_requests // "missing"') + ADMIN_PERM=$(echo "$PERMS" | jq -r '.administration // "missing"') + + MISSING="" + [[ "$CONTENTS" == "write" ]] || MISSING="$MISSING contents=write(actual=$CONTENTS)" + [[ "$PR_PERM" == "write" ]] || MISSING="$MISSING pull_requests=write(actual=$PR_PERM)" + [[ "$ADMIN_PERM" == "read" || "$ADMIN_PERM" == "write" ]] || MISSING="$MISSING administration=read(actual=$ADMIN_PERM)" + + if [[ -n "$MISSING" ]]; then + echo "::error::App installation is missing required permissions:$MISSING" + exit 1 + fi + echo "App installation permissions OK: $PERMS" + + # 2. Source release-branch rule has admin enforcement off. + QUERY='query($owner:String!, $repo:String!) { repository(owner:$owner, name:$repo) { branchProtectionRules(first: 100) { nodes { pattern isAdminEnforced } } } }' + BODY=$(jq -nc --arg q "$QUERY" --arg owner "$OWNER" --arg repo "$REPO" '{query: $q, variables: {owner: $owner, repo: $repo}}') + RESPONSE=$(curl --silent --show-error --fail \ + -H "Authorization: bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -X POST -d "$BODY" \ + "https://api.github.com/graphql") + + if [[ "$(echo "$RESPONSE" | jq 'has("errors")')" == "true" ]]; then + echo "::error::GraphQL errors: $(echo "$RESPONSE" | jq -c '.errors')" + exit 1 + fi + + SOURCE=$(echo "$RESPONSE" | jq -c --arg p "$SOURCE_PATTERN" '.data.repository.branchProtectionRules.nodes[] | select(.pattern == $p)') + if [[ -z "$SOURCE" ]]; then + echo "::error::No branch protection rule found for pattern '$SOURCE_PATTERN'. Create one or override release-branch-pattern." + exit 1 + fi + + if [[ "$(echo "$SOURCE" | jq -r '.isAdminEnforced')" == "true" ]]; then + echo "::error::Source rule '$SOURCE_PATTERN' has isAdminEnforced=true. Real releases push through admin bypass; set isAdminEnforced=false." + exit 1 + fi + echo "Source rule '$SOURCE_PATTERN' OK: isAdminEnforced=false" - name: Resolve version-bump-branch if: env.IS_INERT == 'false' From 46808077ddf43d9815a3df66223a90bb09ab3a3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Mon, 25 May 2026 08:18:44 +0000 Subject: [PATCH 5/5] fix(release): soften missing-rule check to a warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consumers that release into main (no release-branch cuts) won't have a rule matching '[rv][0-9].[0-9].[0-9]'. The validate step was hard-failing those — log a warning instead. Consumers that DO have a release-branch rule still get the isAdminEnforced=false assertion. Signed-off-by: oliver könig --- .github/workflows/_release_bump.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/_release_bump.yml b/.github/workflows/_release_bump.yml index ce94488..be6cc66 100644 --- a/.github/workflows/_release_bump.yml +++ b/.github/workflows/_release_bump.yml @@ -334,15 +334,13 @@ jobs: SOURCE=$(echo "$RESPONSE" | jq -c --arg p "$SOURCE_PATTERN" '.data.repository.branchProtectionRules.nodes[] | select(.pattern == $p)') if [[ -z "$SOURCE" ]]; then - echo "::error::No branch protection rule found for pattern '$SOURCE_PATTERN'. Create one or override release-branch-pattern." - exit 1 - fi - - if [[ "$(echo "$SOURCE" | jq -r '.isAdminEnforced')" == "true" ]]; then + echo "::warning::No branch protection rule found for pattern '$SOURCE_PATTERN'. Real releases won't be gated by a release-branch rule; skipping the admin-bypass check. Override release-branch-pattern if this repo has its own convention." + elif [[ "$(echo "$SOURCE" | jq -r '.isAdminEnforced')" == "true" ]]; then echo "::error::Source rule '$SOURCE_PATTERN' has isAdminEnforced=true. Real releases push through admin bypass; set isAdminEnforced=false." exit 1 + else + echo "Source rule '$SOURCE_PATTERN' OK: isAdminEnforced=false" fi - echo "Source rule '$SOURCE_PATTERN' OK: isAdminEnforced=false" - name: Resolve version-bump-branch if: env.IS_INERT == 'false'