From 9df8722b24f0528fa1cb90155af2744bc6c39fb0 Mon Sep 17 00:00:00 2001 From: Santiago Medina Rolong Date: Wed, 15 Jul 2026 17:35:08 -0700 Subject: [PATCH] ci: cut release without opening a PR GITHUB_TOKEN cannot create PRs unless a separate repo setting is enabled, and the PR path still needs review under the main ruleset. Commit the changelog on main, tag, and publish directly instead. Document the need for a GitHub Actions ruleset bypass (or RELEASE_GITHUB_TOKEN). --- .github/workflows/cut-release.yml | 164 ++++++++++-------------------- CHANGELOG.md | 2 +- 2 files changed, 55 insertions(+), 111 deletions(-) diff --git a/.github/workflows/cut-release.yml b/.github/workflows/cut-release.yml index 2101ce0..489b4c0 100644 --- a/.github/workflows/cut-release.yml +++ b/.github/workflows/cut-release.yml @@ -4,9 +4,18 @@ # 1. Resolve the next version (patch/minor/major, or an explicit version) # 2. Promote CHANGELOG.md "## Unreleased" → "## vX.Y.Z - " # 3. Run tests -# 4. Open a release PR (main is PR-protected), merge it when allowed -# 5. Create and push an annotated tag -# 6. Publish via GoReleaser (GitHub release + Homebrew) and npm +# 4. Commit CHANGELOG on main, create annotated tag, push both +# 5. Publish via GoReleaser (GitHub release + Homebrew) and npm +# +# No pull request is opened. GITHUB_TOKEN cannot create PRs unless the repo +# setting "Allow GitHub Actions to create and approve pull requests" is on, +# and the PR path is unnecessary for a release cut. +# +# Requirements: +# - Ruleset/branch protection must allow GitHub Actions to push to main +# (add "GitHub Actions" as a bypass actor on the main ruleset), OR use a +# fine-grained PAT in the RELEASE_GITHUB_TOKEN secret with contents:write +# and ruleset bypass. # # Why publish here instead of relying on the tag-triggered Release workflow? # Tag pushes authenticated with GITHUB_TOKEN do not start other workflows, so @@ -20,10 +29,6 @@ # gh workflow run "Cut Release" -f version=1.2.3 # gh workflow run "Cut Release" -f bump=patch -f dry_run=true # gh workflow run "Cut Release" -f version=1.2.3 -f skip_changelog=true -# -# If merge is blocked by required reviews, the workflow opens the PR and stops. -# After the PR is approved+merged, re-run with the same version and -# skip_changelog=true to tag and publish. name: Cut Release @@ -48,7 +53,7 @@ on: type: boolean default: false dry_run: - description: Resolve version / validate only; no PR, tag, or publish + description: Resolve version / validate only; no commit, tag, or publish type: boolean default: false @@ -59,7 +64,6 @@ concurrency: permissions: contents: write - pull-requests: write id-token: write # npm OIDC trusted publishing jobs: @@ -72,6 +76,9 @@ jobs: with: ref: main fetch-depth: 0 + # Prefer RELEASE_GITHUB_TOKEN when set so pushes can bypass rulesets + # that block the default GITHUB_TOKEN. Falls back to GITHUB_TOKEN. + token: ${{ secrets.RELEASE_GITHUB_TOKEN || github.token }} - name: Set up Go uses: actions/setup-go@v5 @@ -107,7 +114,7 @@ jobs: echo "- tag: \`${{ steps.meta.outputs.tag }}\`" echo "- changelog_changed: \`${{ steps.meta.outputs.changelog_changed }}\`" echo "" - echo "No PR, tag, or publish was performed." + echo "No commit, tag, or publish was performed." } >> "$GITHUB_STEP_SUMMARY" - name: Configure git identity @@ -116,107 +123,14 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - name: Open release PR (changelog) - id: pr - if: ${{ !inputs.dry_run && !inputs.skip_changelog && steps.meta.outputs.changelog_changed == 'true' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - VERSION="${{ steps.meta.outputs.version }}" - TAG="${{ steps.meta.outputs.tag }}" - BRANCH="release/${TAG}" - - git checkout -B "$BRANCH" - git add CHANGELOG.md - git commit -m "chore(release): ${TAG}" - git push -u origin "$BRANCH" --force - - if gh pr view "$BRANCH" --json number --jq .number >/dev/null 2>&1; then - PR_URL=$(gh pr view "$BRANCH" --json url --jq .url) - PR_NUMBER=$(gh pr view "$BRANCH" --json number --jq .number) - else - PR_URL=$(gh pr create \ - --base main \ - --head "$BRANCH" \ - --title "chore(release): ${TAG}" \ - --body "$(cat <> "$GITHUB_OUTPUT" - echo "number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" - echo "url=${PR_URL}" >> "$GITHUB_OUTPUT" - echo "Opened ${PR_URL}" - - - name: Merge release PR - id: merge - if: ${{ !inputs.dry_run && !inputs.skip_changelog && steps.meta.outputs.changelog_changed == 'true' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - PR_NUMBER="${{ steps.pr.outputs.number }}" - - # Wait for required checks (e.g. build) before attempting merge. - echo "Waiting for PR checks…" - if ! gh pr checks "$PR_NUMBER" --watch --fail-fast; then - echo "::warning::PR checks did not pass; merge will likely fail until they are green" - fi - - # Prefer a regular merge; fall back to admin merge when the token can bypass. - if gh pr merge "$PR_NUMBER" --squash --delete-branch; then - echo "merged=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if gh pr merge "$PR_NUMBER" --squash --delete-branch --admin; then - echo "merged=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - { - echo "## Cut Release blocked on review" - echo "" - echo "Opened ${{ steps.pr.outputs.url }} but could not merge (branch protection requires review and/or checks)." - echo "" - echo "1. Approve and merge the PR" - echo "2. Re-run **Cut Release** with:" - echo " - version: \`${{ steps.meta.outputs.version }}\`" - echo " - skip_changelog: \`true\`" - } >> "$GITHUB_STEP_SUMMARY" - - echo "merged=false" >> "$GITHUB_OUTPUT" - echo "::error::Release PR needs approval before tagging. Merge ${{ steps.pr.outputs.url }} then re-run with version=${{ steps.meta.outputs.version }} and skip_changelog=true" - exit 1 - - # If the merge step fails (needs review), the job stops here. Re-run with - # skip_changelog=true after the PR is merged. - - name: Sync main - if: ${{ !inputs.dry_run }} - run: | - set -euo pipefail - git checkout main - git pull --ff-only origin main - - - name: Create and push tag + - name: Commit changelog, tag, and push if: ${{ !inputs.dry_run }} run: | set -euo pipefail TAG="${{ steps.meta.outputs.tag }}" if git rev-parse "$TAG" >/dev/null 2>&1; then - echo "::error::Tag ${TAG} already exists on main" + echo "::error::Tag ${TAG} already exists locally" exit 1 fi if git ls-remote --tags origin "refs/tags/${TAG}" | grep -q .; then @@ -224,11 +138,41 @@ jobs: exit 1 fi + # We checked out main at the start of the job (concurrency group prevents + # concurrent cuts). cut_release.py may have already rewritten CHANGELOG.md + # in the working tree — commit that directly; do not pull over the edit. + if [ "${{ steps.meta.outputs.changelog_changed }}" = "true" ]; then + if git diff --quiet -- CHANGELOG.md; then + echo "::error::Expected CHANGELOG.md changes but the working tree is clean" + exit 1 + fi + git add CHANGELOG.md + git commit -m "chore(release): ${TAG}" + fi + git tag -a "$TAG" -m "${TAG}" - # GITHUB_TOKEN tag pushes do not trigger other workflows (by design), - # so publish steps below run in this job instead of release.yml. - git push origin "$TAG" - echo "Created and pushed ${TAG} at $(git rev-parse HEAD)" + + # Push main (if we committed) and the tag. GITHUB_TOKEN tag pushes do + # not trigger other workflows, so publish runs in the steps below. + if ! git push origin HEAD:main "refs/tags/${TAG}"; then + { + echo "## Cut Release: push rejected" + echo "" + echo "Could not push to \`main\` / \`${TAG}\`. Branch rules are blocking" + echo "the workflow token. Fix one of:" + echo "" + echo "1. **Ruleset bypass (recommended):** Settings → Rules → Rulesets → main" + echo " → Bypass list → add **GitHub Actions** with bypass mode **Always**" + echo "2. **PAT secret:** create a fine-grained PAT that can bypass the" + echo " ruleset, add it as repo secret \`RELEASE_GITHUB_TOKEN\`, re-run" + } >> "$GITHUB_STEP_SUMMARY" + echo "::error::Push to main/${TAG} rejected by branch protection. Add GitHub Actions as a ruleset bypass actor (Always), or set RELEASE_GITHUB_TOKEN." + # Drop the local tag so a re-run is clean if the remote rejected it. + git tag -d "$TAG" || true + exit 1 + fi + + echo "Pushed main and ${TAG} at $(git rev-parse HEAD)" - name: Run GoReleaser if: ${{ !inputs.dry_run }} @@ -237,7 +181,7 @@ jobs: version: "~> v2" args: release --clean env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} - name: Set up Node.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 38f29a1..b5acaa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All user-visible bugs and enhancements should be recorded here. ### Added -- [2026-07-15] **Cut Release** GitHub Actions workflow (`workflow_dispatch`) to promote `CHANGELOG.md`, open/merge a release PR, tag, and publish (GitHub release, Homebrew, npm) in one run. +- [2026-07-15] **Cut Release** GitHub Actions workflow (`workflow_dispatch`) to promote `CHANGELOG.md`, commit + tag on `main`, and publish (GitHub release, Homebrew, npm) in one run. ### Fixed