Skip to content

Release: dev to main (v1.0.0) #12

Release: dev to main (v1.0.0)

Release: dev to main (v1.0.0) #12

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}`);
}