diff --git a/.github/workflows/_release_bump.yml b/.github/workflows/_release_bump.yml index 1c462b8..be6cc66 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 @@ -50,10 +50,19 @@ 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. 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 - 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 @@ -149,7 +158,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 +247,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 +256,112 @@ 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 → 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: 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: | + 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 "::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 + + - name: Resolve version-bump-branch + if: env.IS_INERT == 'false' + 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. + 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 + if: env.IS_INERT == 'false' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | @@ -272,125 +381,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 \ + PR_URL=$(gh pr create \ + --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 }}." - - - name: Wait for status checks on tmp branch - if: inputs.validate-only == false - 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)); + --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 - 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 into ${{ inputs.version-bump-branch }} - if: inputs.validate-only == false + - name: Merge deploy-release branch into target + if: env.IS_INERT == 'false' + 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" - else - git fetch origin ${{ inputs.version-bump-branch }} - git checkout ${{ inputs.version-bump-branch }} - git merge ${{ env.TMP_BRANCH }} - - for attempt in {1..3}; do - if eval "$CMD"; then - echo "Git push succeeded on attempt $attempt" - break + # 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 "$VERSION_BUMP_BRANCH"; then + echo "Git push to $VERSION_BUMP_BRANCH succeeded on attempt $attempt" + break + else + echo "Git push to $VERSION_BUMP_BRANCH failed on attempt $attempt" + if [[ $attempt -lt 3 ]]; then + sleep $((RANDOM % 3 + 1)) + git fetch origin "$VERSION_BUMP_BRANCH" + git reset --hard "origin/$VERSION_BUMP_BRANCH" + 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 $VERSION_BUMP_BRANCH 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 branch + if: always() && env.IS_INERT == 'false' + 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 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 }}