Skip to content
Merged
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
164 changes: 54 additions & 110 deletions .github/workflows/cut-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,18 @@
# 1. Resolve the next version (patch/minor/major, or an explicit version)
# 2. Promote CHANGELOG.md "## Unreleased" → "## vX.Y.Z - <date>"
# 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
Expand All @@ -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

Expand All @@ -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

Expand All @@ -59,7 +64,6 @@ concurrency:

permissions:
contents: write
pull-requests: write
id-token: write # npm OIDC trusted publishing

jobs:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -116,119 +123,56 @@ 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 <<EOF
## Summary
Promote \`CHANGELOG.md\` Unreleased notes to **${TAG}** and prepare the patch/minor/major release.

Created by the **Cut Release** workflow. After this lands on \`main\`, the same workflow tags \`${TAG}\` and publishes (GitHub release, Homebrew, npm).

## Test plan
- [x] \`go test ./...\` in Cut Release workflow
- [ ] Confirm CHANGELOG section \`${TAG}\` looks right
EOF
)")
PR_NUMBER=$(gh pr view "$BRANCH" --json number --jq .number)
fi

echo "branch=${BRANCH}" >> "$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
echo "::error::Tag ${TAG} already exists on origin"
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 }}
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading