Release: dev to main (v1.0.0) #12
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Branch Lifecycle Automation | |
| # Automates: branch creation from issues, rebase on dev before merge, | |
| # delete merged branch, close linked issue. | |
| on: | |
| issues: | |
| types: [labeled] | |
| pull_request: | |
| types: [synchronize, closed] | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| issues: write | |
| jobs: | |
| auto-create-branch: | |
| name: Auto-Create Branch from Issue | |
| if: github.event_name == 'issues' && github.event.label.name == 'ready' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Create branch from issue | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const issue = context.payload.issue; | |
| const issueNumber = issue.number; | |
| const issueTitle = issue.title || ''; | |
| const labelNames = (issue.labels || []).map(l => l.name); | |
| // Choose branch prefix from labels | |
| let prefix = 'feature'; | |
| if (labelNames.includes('bug') || labelNames.includes('fix')) prefix = 'fix'; | |
| else if (labelNames.includes('documentation')) prefix = 'docs'; | |
| else if (labelNames.includes('refactoring')) prefix = 'refactor'; | |
| else if (labelNames.includes('chore')) prefix = 'chore'; | |
| else if (labelNames.includes('enhancement') || labelNames.includes('feature')) prefix = 'feature'; | |
| const slug = issueTitle | |
| .toLowerCase() | |
| .replace(/[^a-z0-9]+/g, '-') | |
| .replace(/^-|-$/g, '') | |
| .substring(0, 40); | |
| const branchName = `${prefix}-${issueNumber}-${slug}`; | |
| // If branch already exists, just nudge the user | |
| try { | |
| await github.rest.repos.getBranch({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| branch: branchName, | |
| }); | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issueNumber, | |
| body: [ | |
| `Branch \`${branchName}\` already exists. Start working on it:`, | |
| '', | |
| '```bash', | |
| `git fetch origin`, | |
| `git checkout ${branchName}`, | |
| '```', | |
| ].join('\n'), | |
| }); | |
| return; | |
| } catch (e) { | |
| if (e.status !== 404) throw e; | |
| } | |
| // Branch must be created from dev (enforces branching strategy) | |
| const { data: devBranch } = await github.rest.repos.getBranch({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| branch: 'dev', | |
| }); | |
| await github.rest.git.createRef({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| ref: `refs/heads/${branchName}`, | |
| sha: devBranch.commit.sha, | |
| }); | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issueNumber, | |
| body: [ | |
| `Branch created from \`dev\`: \`${branchName}\``, | |
| '', | |
| '```bash', | |
| `git fetch origin`, | |
| `git checkout ${branchName}`, | |
| '```', | |
| '', | |
| 'When you push commits, a PR to `dev` will be auto-created.', | |
| ].join('\n'), | |
| }); | |
| await github.rest.issues.addLabels({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issueNumber, | |
| labels: ['in-progress'], | |
| }); | |
| auto-rebase-on-dev: | |
| name: Auto-Rebase PR on dev | |
| if: | | |
| github.event_name == 'pull_request' && | |
| github.event.action == 'synchronize' && | |
| github.event.pull_request.base.ref == 'dev' && | |
| github.event.pull_request.head.repo.full_name == github.repository | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout PR branch | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| ref: ${{ github.event.pull_request.head.ref }} | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Configure git | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| - name: Rebase on dev | |
| id: rebase | |
| continue-on-error: true | |
| run: | | |
| git fetch origin dev | |
| if git merge-base --is-ancestor origin/dev HEAD; then | |
| echo "Branch already up to date with dev." | |
| echo "rebase_needed=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo "Rebasing on origin/dev..." | |
| if git rebase origin/dev; then | |
| echo "rebase_needed=true" >> "$GITHUB_OUTPUT" | |
| echo "rebase_success=true" >> "$GITHUB_OUTPUT" | |
| git push --force-with-lease | |
| else | |
| echo "rebase_needed=true" >> "$GITHUB_OUTPUT" | |
| echo "rebase_success=false" >> "$GITHUB_OUTPUT" | |
| git rebase --abort || true | |
| fi | |
| - name: Comment on conflict | |
| if: steps.rebase.outputs.rebase_success == 'false' | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.payload.pull_request.number, | |
| body: [ | |
| '**Merge conflict detected**', | |
| '', | |
| 'This branch has conflicts with `dev`. Resolve them locally:', | |
| '', | |
| '```bash', | |
| 'git fetch origin', | |
| 'git rebase origin/dev', | |
| '# Resolve conflicts, then:', | |
| 'git rebase --continue', | |
| 'git push --force-with-lease', | |
| '```', | |
| ].join('\n'), | |
| }); | |
| auto-delete-merged-branch: | |
| name: Auto-Delete Merged Branch | |
| if: | | |
| github.event_name == 'pull_request' && | |
| github.event.action == 'closed' && | |
| github.event.pull_request.merged == true | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Delete merged branch | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const pr = context.payload.pull_request; | |
| const branchName = pr.head.ref; | |
| const protectedBranches = new Set(['main', 'dev', 'develop', 'master']); | |
| if (protectedBranches.has(branchName)) { | |
| core.info(`Skipping deletion of protected branch: ${branchName}`); | |
| return; | |
| } | |
| if (pr.head.repo.full_name !== context.repo.owner + '/' + context.repo.repo) { | |
| core.info(`Skipping deletion of branch on fork: ${pr.head.repo.full_name}`); | |
| return; | |
| } | |
| try { | |
| await github.rest.git.deleteRef({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| ref: `heads/${branchName}`, | |
| }); | |
| core.info(`Deleted branch: ${branchName}`); | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pr.number, | |
| body: `Branch \`${branchName}\` was automatically deleted after merge.`, | |
| }); | |
| } catch (e) { | |
| core.warning(`Could not delete branch ${branchName}: ${e.message}`); | |
| } | |
| auto-close-linked-issue: | |
| name: Auto-Close Linked Issue | |
| if: | | |
| github.event_name == 'pull_request' && | |
| github.event.action == 'closed' && | |
| github.event.pull_request.merged == true | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Close linked issue | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const pr = context.payload.pull_request; | |
| const branchName = pr.head.ref; | |
| const match = branchName.match(/^(?:feature|fix|docs|refactor|chore)-(\d+)-/); | |
| if (!match) { | |
| core.info(`No issue number in branch name: ${branchName}`); | |
| return; | |
| } | |
| const issueNumber = parseInt(match[1], 10); | |
| try { | |
| const { data: issue } = await github.rest.issues.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issueNumber, | |
| }); | |
| if (issue.state !== 'open') return; | |
| await github.rest.issues.update({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issueNumber, | |
| state: 'closed', | |
| }); | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issueNumber, | |
| body: `Closed automatically by merging PR #${pr.number}.`, | |
| }); | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issueNumber, | |
| name: 'in-progress', | |
| }); | |
| } catch (_) { /* label may not exist */ } | |
| await github.rest.issues.addLabels({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issueNumber, | |
| labels: ['completed'], | |
| }); | |
| core.info(`Closed issue #${issueNumber}`); | |
| } catch (e) { | |
| core.warning(`Could not close issue #${issueNumber}: ${e.message}`); | |
| } |