From c8be73b426c241cef3f8db1755cab4c30095fb63 Mon Sep 17 00:00:00 2001 From: Ryan Anderson Date: Tue, 23 Dec 2025 12:41:11 -0600 Subject: [PATCH 1/2] refactor: use PR-based versioning to respect branch protection - Creates a PR for version bump instead of pushing directly - Auto-merges version PR after checks pass - Creates tag and release when version PR is merged - Respects branch protection rules - Follows GitHub Actions best practices --- .github/workflows/release.yml | 175 +++++++++++++++++++++------------- 1 file changed, 110 insertions(+), 65 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 12ec88c..af03efe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,7 @@ on: push: branches: [ main ] pull_request: - types: [opened, synchronize, labeled] + types: [opened, synchronize, labeled, closed] permissions: contents: write @@ -12,21 +12,15 @@ permissions: id-token: write jobs: - auto-version-and-publish: + create-version-pr: runs-on: ubuntu-latest - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: | + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + !contains(github.event.head_commit.message, '[skip ci]') && + !contains(github.event.head_commit.message, 'chore: bump version') steps: - - name: Check if should skip - id: skip - run: | - if [[ "${{ github.event.head_commit.message }}" == *"[skip ci]"* ]] || [[ "${{ github.event.head_commit.message }}" == *"chore: bump version"* ]]; then - echo "should_skip=true" >> $GITHUB_OUTPUT - else - echo "should_skip=false" >> $GITHUB_OUTPUT - fi - - uses: actions/checkout@v4 - if: steps.skip.outputs.should_skip != 'true' with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -35,74 +29,39 @@ jobs: with: node-version: '24' cache: 'npm' - registry-url: 'https://registry.npmjs.org' - name: Install dependencies run: npm ci - - name: Auto-version patch + - name: Bump version + id: version run: | npm version patch --no-git-tag-version VERSION=$(node -p "require('./package.json').version") - echo "NEW_VERSION=$VERSION" >> $GITHUB_ENV - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add package.json package-lock.json - git commit -m "chore: bump version to $VERSION [skip ci]" - git push - - - name: Create and push tag - if: steps.skip.outputs.should_skip != 'true' - id: tag - run: | - VERSION=$(node -p "require('./package.json').version") - git tag -a "v$VERSION" -m "Release v$VERSION" - git push origin "v$VERSION" echo "version=$VERSION" >> $GITHUB_OUTPUT - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Create GitHub Release - if: steps.skip.outputs.should_skip != 'true' - uses: actions/github-script@v7 + - name: Create version PR + uses: peter-evans/create-pull-request@v6 with: - script: | - const version = '${{ steps.tag.outputs.version }}'; - const tagName = `v${version}`; - - // Get the latest commits for release notes - const { data: commits } = await github.rest.repos.listCommits({ - owner: context.repo.owner, - repo: context.repo.repo, - per_page: 10 - }); - - // Generate release notes from recent commits (excluding version bumps) - const releaseNotes = commits - .filter(commit => !commit.commit.message.includes('chore: bump version')) - .slice(0, 5) - .map(commit => `- ${commit.commit.message.split('\n')[0]}`) - .join('\n'); - - const body = `## Changes\n\n${releaseNotes || 'See commit history for details.'}`; + token: ${{ secrets.GITHUB_TOKEN }} + branch: chore/version-bump-${{ steps.version.outputs.version }} + title: "chore: bump version to ${{ steps.version.outputs.version }}" + body: | + Automated version bump to **${{ steps.version.outputs.version }}** - await github.rest.repos.createRelease({ - owner: context.repo.owner, - repo: context.repo.repo, - tag_name: tagName, - name: `Release ${tagName}`, - body: body, - draft: false, - prerelease: false - }); + This PR will be automatically merged to trigger the release process. + commit-message: "chore: bump version to ${{ steps.version.outputs.version }}" + labels: | + automated + version-bump - auto-merge-version: + auto-merge-version-pr: runs-on: ubuntu-latest if: | github.event_name == 'pull_request' && - github.event.pull_request.title == 'chore: version packages' && + startsWith(github.event.pull_request.title, 'chore: bump version to') && github.event.pull_request.head.repo.full_name == github.repository && - (github.event.action == 'opened' || github.event.action == 'synchronize' || (github.event.action == 'labeled' && github.event.label.name == 'auto-merge')) + (github.event.action == 'opened' || github.event.action == 'synchronize') steps: - name: Wait for status checks uses: actions/github-script@v7 @@ -143,3 +102,89 @@ jobs: merge_method: 'squash' }); + create-tag-and-release: + runs-on: ubuntu-latest + if: | + github.event_name == 'pull_request' && + startsWith(github.event.pull_request.title, 'chore: bump version to') && + github.event.pull_request.merged == true + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + + - name: Extract version from PR title + id: version + run: | + VERSION=$(echo "${{ github.event.pull_request.title }}" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Extracted version: $VERSION" + + - name: Create and push tag + run: | + VERSION="${{ steps.version.outputs.version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "v$VERSION" -m "Release v$VERSION" + git push origin "v$VERSION" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Create GitHub Release + uses: actions/github-script@v7 + with: + script: | + const version = '${{ steps.version.outputs.version }}'; + const tagName = `v${version}`; + + // Get commits since last tag + const { data: tags } = await github.rest.repos.listTags({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 2 + }); + + let since = null; + if (tags.length > 1) { + since = tags[1].commit.sha; + } + + // Get commits for release notes + const { data: commits } = await github.rest.repos.listCommits({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 20, + sha: 'main' + }); + + // Filter out version bump commits + const releaseNotes = commits + .filter(commit => + !commit.commit.message.includes('chore: bump version') && + !commit.commit.message.includes('[skip ci]') + ) + .slice(0, 10) + .map(commit => { + const message = commit.commit.message.split('\n')[0]; + const author = commit.commit.author.name; + return `- ${message} (${author})`; + }) + .join('\n'); + + const body = `## What's Changed\n\n${releaseNotes || 'See commit history for details.'}\n\n**Full Changelog**: ${tags[0] ? `https://github.com/${context.repo.owner}/${context.repo.repo}/compare/${tags[1] ? tags[1].name : 'HEAD'}...${tagName}` : `https://github.com/${context.repo.owner}/${context.repo.repo}/commits/${tagName}`}`; + + await github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: tagName, + name: `Release ${tagName}`, + body: body, + draft: false, + prerelease: false + }); From a8cc617b03addd911dcd74d2892caa8eeb21c080 Mon Sep 17 00:00:00 2001 From: Ryan Anderson Date: Tue, 23 Dec 2025 12:43:44 -0600 Subject: [PATCH 2/2] refactor: use marketplace actions to simplify workflow - Use softprops/action-gh-release for creating releases (replaces custom script) - Simplify auto-merge logic - Use GitHub API for tag creation (no checkout needed) - Much cleaner and more maintainable code --- .github/workflows/release.yml | 112 +++++++++++++--------------------- 1 file changed, 42 insertions(+), 70 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af03efe..8a8787b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,7 @@ on: push: branches: [ main ] pull_request: - types: [opened, synchronize, labeled, closed] + types: [opened, synchronize, closed] permissions: contents: write @@ -63,13 +63,12 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository && (github.event.action == 'opened' || github.event.action == 'synchronize') steps: - - name: Wait for status checks + - name: Wait for checks and merge uses: actions/github-script@v7 - id: wait with: script: | - const maxWait = 300; // 5 minutes - const checkInterval = 10; // 10 seconds + const maxWait = 300; + const checkInterval = 10; let waited = 0; while (waited < maxWait) { @@ -80,28 +79,21 @@ jobs: }); if (pr.mergeable === true && pr.mergeable_state === 'clean') { - console.log('PR is ready to merge'); + await github.rest.pulls.merge({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + merge_method: 'squash' + }); return; } - console.log(`Waiting for checks... (${waited}s)`); await new Promise(resolve => setTimeout(resolve, checkInterval * 1000)); waited += checkInterval; } throw new Error('Timeout waiting for PR to be mergeable'); - - name: Auto-merge version PR - uses: actions/github-script@v7 - with: - script: | - await github.rest.pulls.merge({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.payload.pull_request.number, - merge_method: 'squash' - }); - create-tag-and-release: runs-on: ubuntu-latest if: | @@ -109,53 +101,32 @@ jobs: startsWith(github.event.pull_request.title, 'chore: bump version to') && github.event.pull_request.merged == true steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - uses: actions/setup-node@v4 - with: - node-version: '24' - cache: 'npm' - - name: Extract version from PR title id: version run: | VERSION=$(echo "${{ github.event.pull_request.title }}" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Extracted version: $VERSION" - - name: Create and push tag - run: | - VERSION="${{ steps.version.outputs.version }}" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag -a "v$VERSION" -m "Release v$VERSION" - git push origin "v$VERSION" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Create GitHub Release + - name: Create tag uses: actions/github-script@v7 with: script: | const version = '${{ steps.version.outputs.version }}'; const tagName = `v${version}`; - // Get commits since last tag - const { data: tags } = await github.rest.repos.listTags({ + // Create tag via API (doesn't require checkout) + await github.rest.git.createRef({ owner: context.repo.owner, repo: context.repo.repo, - per_page: 2 + ref: `refs/tags/${tagName}`, + sha: context.payload.pull_request.merge_commit_sha }); - - let since = null; - if (tags.length > 1) { - since = tags[1].commit.sha; - } - - // Get commits for release notes + + - name: Generate release notes + id: release-notes + uses: actions/github-script@v7 + with: + script: | const { data: commits } = await github.rest.repos.listCommits({ owner: context.repo.owner, repo: context.repo.repo, @@ -163,28 +134,29 @@ jobs: sha: 'main' }); - // Filter out version bump commits - const releaseNotes = commits - .filter(commit => - !commit.commit.message.includes('chore: bump version') && - !commit.commit.message.includes('[skip ci]') + const notes = commits + .filter(c => + !c.commit.message.includes('chore: bump version') && + !c.commit.message.includes('[skip ci]') ) .slice(0, 10) - .map(commit => { - const message = commit.commit.message.split('\n')[0]; - const author = commit.commit.author.name; - return `- ${message} (${author})`; - }) + .map(c => `- ${c.commit.message.split('\n')[0]}`) .join('\n'); - const body = `## What's Changed\n\n${releaseNotes || 'See commit history for details.'}\n\n**Full Changelog**: ${tags[0] ? `https://github.com/${context.repo.owner}/${context.repo.repo}/compare/${tags[1] ? tags[1].name : 'HEAD'}...${tagName}` : `https://github.com/${context.repo.owner}/${context.repo.repo}/commits/${tagName}`}`; + core.setOutput('body', notes || 'See commit history for details.'); + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.version.outputs.version }} + name: Release v${{ steps.version.outputs.version }} + body: | + ## What's Changed - await github.rest.repos.createRelease({ - owner: context.repo.owner, - repo: context.repo.repo, - tag_name: tagName, - name: `Release ${tagName}`, - body: body, - draft: false, - prerelease: false - }); + ${{ steps.release-notes.outputs.body }} + + **Full Changelog**: https://github.com/${{ github.repository }}/compare/v${{ steps.version.outputs.version }}^...v${{ steps.version.outputs.version }} + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}