Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
274 changes: 157 additions & 117 deletions .github/workflows/_release_bump.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -239,20 +247,121 @@ 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"
echo
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:
# - `<pr-num>/merge` or `<pr-num>/head` on `pull_request` triggers,
# - `pull-request/<pr-num>` 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: |
Expand All @@ -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
9 changes: 9 additions & 0 deletions .github/workflows/_release_library.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 }}
Expand Down
Loading