name: Release Automation
# Note: This workflow uses dynamic outputs from github-script actions.
# Step outputs (should_release, release_type, changelog, version) are set at runtime
# and may not be statically analyzable by linters - this is expected behavior.
on:
# push:
# branches: [ main ] # Disabled to prevent automatic releases on protected branch
workflow_dispatch:
inputs:
release_type:
description: 'Type of release'
required: true
default: 'auto'
type: choice
options:
- auto
- patch
- minor
- major
- prerelease
dry_run:
description: 'Dry run (no actual release)'
required: false
default: false
type: boolean
create_version_pr:
description: 'Create PR to bump package.json version'
required: false
default: true
type: boolean
permissions:
contents: read
jobs:
check-changes:
name: Check for Release-worthy Changes
runs-on: ubuntu-latest
permissions:
contents: read # Required to read git history and commits
outputs:
# These outputs are dynamically set by the github-script action below
should_release: ${{ steps.check.outputs.should_release }} # yamllint disable-line rule:line-length
release_type: ${{ steps.check.outputs.release_type }} # yamllint disable-line rule:line-length
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Check conventional commits
id: check
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
with:
script: |
const { execSync } = require('child_process');
// Get commits since last release
let commits = [];
try {
const output = execSync('git log $(git describe --tags --abbrev=0)..HEAD --oneline --no-merges', { encoding: 'utf8' });
commits = output.trim().split('\n').filter(line => line.trim());
} catch (e) {
// If no tags exist, get all commits
const output = execSync('git log --oneline --no-merges', { encoding: 'utf8' });
commits = output.trim().split('\n').filter(line => line.trim());
}
if (commits.length === 0 || (commits.length === 1 && !commits[0])) {
console.log('No new commits found');
core.setOutput('should_release', 'false');
return;
}
console.log(`Analyzing ${commits.length} commits:`);
commits.forEach(commit => console.log(` ${commit}`));
let hasBreaking = false;
let hasFeature = false;
let hasFix = false;
let hasDocsOrChore = false;
for (const commit of commits) {
// Strip leading hash and whitespace, analyze only the subject
const subject = commit.replace(/^[a-f0-9]+\s+/, '');
const msg = subject.toLowerCase();
const startsWith = (type) => msg.startsWith(`${type}:`) || msg.startsWith(`${type}(`);
if (msg.includes('breaking change') || msg.includes('!:')) {
hasBreaking = true;
} else if (startsWith('feat') || msg.includes('feature:')) {
hasFeature = true;
} else if (startsWith('fix') || msg.includes('bugfix:')) {
hasFix = true;
} else if (startsWith('docs') || msg.includes('doc:') || startsWith('chore')) {
hasDocsOrChore = true;
}
}
let releaseType = 'none';
if (hasBreaking) {
releaseType = 'major';
} else if (hasFeature) {
releaseType = 'minor';
} else if (hasFix || hasDocsOrChore) {
releaseType = 'patch';
}
// Override with manual input if provided
const manualType = '${{ github.event.inputs.release_type }}';
if (manualType && manualType !== 'auto') {
releaseType = manualType;
}
const shouldRelease = releaseType !== 'none' || '${{ github.event.inputs.dry_run }}' === 'true';
console.log(`Release decision: ${shouldRelease ? 'YES' : 'NO'} (type: ${releaseType})`);
core.setOutput('should_release', shouldRelease.toString());
core.setOutput('release_type', releaseType);
release:
name: Create Release
runs-on: ubuntu-latest
needs: check-changes
if: needs.check-changes.outputs.should_release == 'true'
outputs:
version: ${{ steps.version.outputs.version }}
changelog: ${{ steps.changelog.outputs.changelog }}
permissions:
# CodeQL Token-Permissions: Write permissions required for release operations
# This job needs contents:write for both RELEASE_TOKEN and GITHUB_TOKEN fallback scenarios:
# - git push tags (line 231): requires repository write access
# - actions/create-release (line 237): requires contents write to create GitHub releases
# Without write permissions, repositories lacking RELEASE_TOKEN configuration would fail
# during tag creation and release publishing, breaking automated release workflows.
# This is an intentional security trade-off for release pipeline functionality.
contents: write # Required for fallback GITHUB_TOKEN to push tags and create releases
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903
with:
node-version: '18'
cache: 'npm'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Run linting
run: npm run lint || npx eslint .
- name: Generate changelog
id: changelog # This step dynamically sets the 'changelog' output
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
with:
script: |
const { execSync } = require('child_process');
// Get current version
const package = require('./package.json');
const currentVersion = package.version;
// Get commits since last release
let commits = [];
try {
const output = execSync('git log $(git describe --tags --abbrev=0)..HEAD --pretty=format:"%h %s" --no-merges', { encoding: 'utf8' });
commits = output.trim().split('\n').filter(line => line.trim());
} catch (e) {
const output = execSync('git log --pretty=format:"%h %s" --no-merges', { encoding: 'utf8' });
commits = output.trim().split('\n').filter(line => line.trim());
}
// Categorize commits
const features = [];
const fixes = [];
const breaking = [];
const other = [];
commits.forEach(commit => {
const [hash, ...msgParts] = commit.split(' ');
const msg = msgParts.join(' ');
const lowerMsg = msg.toLowerCase();
if (lowerMsg.includes('breaking change') || lowerMsg.includes('!:')) {
breaking.push(`- ${msg} (${hash})`);
} else if (lowerMsg.startsWith('feat:') || lowerMsg.startsWith('feature:')) {
features.push(`- ${msg.replace(/^feat:\s?/i, '').replace(/^feature:\s?/i, '')} (${hash})`);
} else if (lowerMsg.startsWith('fix:') || lowerMsg.startsWith('bugfix:')) {
fixes.push(`- ${msg.replace(/^fix:\s?/i, '').replace(/^bugfix:\s?/i, '')} (${hash})`);
} else {
other.push(`- ${msg} (${hash})`);
}
});
// Generate changelog
let changelog = `## Changes\n\n`;
if (breaking.length > 0) {
changelog += `### 💥 Breaking Changes\n${breaking.join('\n')}\n\n`;
}
if (features.length > 0) {
changelog += `### ✨ New Features\n${features.join('\n')}\n\n`;
}
if (fixes.length > 0) {
changelog += `### 🐛 Bug Fixes\n${fixes.join('\n')}\n\n`;
}
if (other.length > 0) {
changelog += `### 📝 Other Changes\n${other.join('\n')}\n\n`;
}
changelog += `**Full Changelog**: https://github.com/${{ github.repository }}/compare/v${currentVersion}...HEAD`;
core.setOutput('changelog', changelog);
return changelog;
- name: Bump version
id: version # This step dynamically sets the 'version' output
run: |
RELEASE_TYPE="${{ needs.check-changes.outputs.release_type }}"
echo "Release type: $RELEASE_TYPE"
if [ "$RELEASE_TYPE" = "none" ]; then
echo "No version bump needed"
echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
else
NEW_VERSION=$(npm version $RELEASE_TYPE --no-git-tag-version)
CANDIDATE=${NEW_VERSION#v}
echo "Initial bumped version: v$CANDIDATE"
# If the tag already exists, keep bumping patch until we find a free version
while git rev-parse "v$CANDIDATE" >/dev/null 2>&1; do
echo "Tag v$CANDIDATE already exists. Bumping patch..."
NEW_VERSION=$(npm version patch --no-git-tag-version)
CANDIDATE=${NEW_VERSION#v}
done
echo "version=$CANDIDATE" >> $GITHUB_OUTPUT
echo "Bumped to available version: v$CANDIDATE"
fi
- name: Create Git tag (without committing version bump)
if: ${{ github.event.inputs.dry_run != 'true' }}
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git tag -a "v${{ steps.version.outputs.version }}" -m "Release v${{ steps.version.outputs.version }}"
# Use authenticated git push with token
git push https://$GITHUB_TOKEN@github.com/${{ github.repository }} "v${{ steps.version.outputs.version }}"
echo "✅ Created and pushed tag v${{ steps.version.outputs.version }}"
echo "ℹ️ Note: Version bump not committed to main due to branch protection"
- name: Create GitHub Release
if: ${{ github.event.inputs.dry_run != 'true' }}
uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
with:
tag_name: v${{ steps.version.outputs.version }}
release_name: Release v${{ steps.version.outputs.version }}
body: ${{ steps.changelog.outputs.changelog }} # Dynamic output from changelog step
draft: false
prerelease: ${{ contains(steps.version.outputs.version, '-') }}
- name: Dry run summary
if: ${{ github.event.inputs.dry_run == 'true' }}
run: |
echo "## 🧪 Dry Run Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Would create release:** v${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Changelog preview:**" >> $GITHUB_STEP_SUMMARY
echo '${{ steps.changelog.outputs.changelog }}' >> $GITHUB_STEP_SUMMARY # Dynamic output
notify:
name: Post-release Notifications
runs-on: ubuntu-latest
needs: [check-changes, release]
if: success() && needs.check-changes.outputs.should_release == 'true' && github.event.inputs.dry_run != 'true'
permissions:
issues: write
steps:
- name: Update README badge
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
with:
script: |
console.log('🎉 Release completed successfully!');
console.log('Consider updating documentation or notifying stakeholders.');
- name: Create follow-up issue for documentation
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
with:
script: |
const { owner, repo } = context.repo;
await github.rest.issues.create({
owner,
repo,
title: `📝 Update documentation for release v${{ needs.release.outputs.version }}`,
body: `## Post-release Documentation Tasks
A new release has been created. Please review and update:
- [ ] Update CHANGELOG.md with detailed changes
- [ ] Review README.md for any needed updates
- [ ] Update any version-specific documentation
- [ ] Notify users about breaking changes (if any)
- [ ] Update examples if API changed
**Release**: v${{ needs.release.outputs.version }}
**Auto-created by**: Release automation workflow`,
labels: ['documentation', 'automated', 'post-release']
});
version-pr:
name: Create Version Bump PR
runs-on: ubuntu-latest
needs: [check-changes, release]
if: success() && needs.check-changes.outputs.should_release == 'true' && github.event.inputs.dry_run != 'true' && github.event.inputs.create_version_pr == 'true'
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903
with:
node-version: '18'
- name: Prepare version bump commit
id: bumpfile
env:
PUSH_TOKEN: ${{ secrets.RELEASE_PR_TOKEN || secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ needs.release.outputs.version }}"
BRANCH="chore/release/v$VERSION"
echo "Using version: $VERSION"
echo "Using branch: $BRANCH"
git config user.email "action@github.com"
git config user.name "GitHub Action"
# Ensure we have latest refs
git fetch origin --prune
# Create or update local branch from remote if it exists
if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
echo "Remote branch exists. Checking out tracking branch: $BRANCH"
git checkout -B "$BRANCH" "origin/$BRANCH"
else
echo "Remote branch does not exist. Creating new branch: $BRANCH"
git checkout -b "$BRANCH"
fi
# Update package.json version to match released tag
node -e "const fs=require('fs'); const p=require('./package.json'); p.version='$VERSION'; fs.writeFileSync('package.json', JSON.stringify(p, null, 2)+'\n');"
# Only commit if there is a change
if ! git diff --quiet -- package.json; then
git add package.json
git commit -m "chore(release): bump package.json to v$VERSION"
# Try normal push first using token to ensure CI triggers
if ! git push -u "https://$PUSH_TOKEN@github.com/${{ github.repository }}" "$BRANCH"; then
echo "Non-fast-forward push rejected. Rebasing and retrying with force-with-lease..."
git pull --rebase origin "$BRANCH" || true
git push --force-with-lease "https://$PUSH_TOKEN@github.com/${{ github.repository }}" "$BRANCH"
fi
else
echo "No package.json version change needed"
fi
- name: Open pull request
if: always()
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
with:
github-token: ${{ secrets.RELEASE_PR_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const version = process.env.VERSION || '${{ needs.release.outputs.version }}';
const branch = process.env.BRANCH || `chore/release/v${{ needs.release.outputs.version }}`;
const { owner, repo } = context.repo;
// Check if PR already exists
const prs = await github.paginate(github.rest.pulls.list, { owner, repo, state: 'open', head: `${owner}:${branch}` });
if (prs.length > 0) {
core.info(`PR already exists: #${prs[0].number}`);
return;
}
const body = [
`This automated PR bumps package.json to v${version} to match the released tag.`,
'',
`- Version: v${version}`,
`- Source workflow: Release Automation`,
].join('\n');
const pr = await github.rest.pulls.create({
owner,
repo,
title: `chore(release): bump package.json to v${version}`,
head: branch,
base: 'main',
body
});
core.info(`Opened PR #${pr.data.number}`);
cleanup:
name: Cleanup
runs-on: ubuntu-latest
needs: [check-changes, release]
if: always()
steps:
- name: Workflow summary
run: |
echo "## 🚀 Release Workflow Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Should Release**: ${{ needs.check-changes.outputs.should_release }}" >> $GITHUB_STEP_SUMMARY
echo "**Release Type**: ${{ needs.check-changes.outputs.release_type }}" >> $GITHUB_STEP_SUMMARY
echo "**Dry Run**: ${{ github.event.inputs.dry_run }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${{ needs.release.result }}" = "success" ]; then
echo "✅ **Release Status**: Completed successfully" >> $GITHUB_STEP_SUMMARY
elif [ "${{ needs.check-changes.outputs.should_release }}" = "false" ]; then
echo "⏭️ **Release Status**: Skipped (no release-worthy changes)" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Release Status**: Failed" >> $GITHUB_STEP_SUMMARY
fi