Skip to content

Commit c368b4c

Browse files
committed
feat: add automated release workflows
Adds a manually-triggered "Prepare release" workflow that bumps all version-bearing files (root/dashboard/report-action package.json + lockfiles, README action-usage pins) and writes the CHANGELOG.md entry from conventional-commit history, then opens a PR. Since main is ruleset-protected, a second "Publish release" workflow tags the merge commit and creates the GitHub Release automatically once that PR merges.
1 parent 8d7f9c6 commit c368b4c

5 files changed

Lines changed: 316 additions & 0 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#!/usr/bin/env node
2+
// Prints the body (heading stripped) of the "## [VERSION] ..." section from
3+
// CHANGELOG.md, for use as GitHub Release notes. Used by
4+
// .github/workflows/release-publish.yml.
5+
import { readFileSync } from 'node:fs';
6+
7+
const [, , changelogPath, version] = process.argv;
8+
if (!changelogPath || !version) {
9+
console.error('Usage: extract-changelog-section.mjs <CHANGELOG.md> <version>');
10+
process.exit(1);
11+
}
12+
13+
const lines = readFileSync(changelogPath, 'utf8').split('\n');
14+
const startPattern = new RegExp(`^## \\[${version.replace(/\./g, '\\.')}\\]`);
15+
16+
const start = lines.findIndex((l) => startPattern.test(l));
17+
if (start === -1) {
18+
console.error(`Could not find a "## [${version}]" heading in ${changelogPath}.`);
19+
process.exit(1);
20+
}
21+
22+
let end = lines.findIndex((l, i) => i > start && (l.startsWith('## [') || /^\[\d/.test(l)));
23+
if (end === -1) end = lines.length;
24+
25+
const body = lines
26+
.slice(start + 1, end)
27+
.join('\n')
28+
.trim();
29+
30+
process.stdout.write(body + '\n');
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
#!/usr/bin/env node
2+
// Builds a Keep a Changelog section from conventional-commit subjects since the
3+
// previous tag. Used by .github/workflows/release.yml — not part of the app.
4+
import { execFileSync } from 'node:child_process';
5+
6+
const [, , prevTag, version] = process.argv;
7+
if (!version) {
8+
console.error('Usage: generate-changelog-entry.mjs <prevTag|""> <version>');
9+
process.exit(1);
10+
}
11+
12+
const RS = '\x1e';
13+
const FS = '\x1f';
14+
const range = prevTag ? `${prevTag}..HEAD` : 'HEAD';
15+
16+
const raw = execFileSync(
17+
'git',
18+
['log', range, '--no-merges', `--pretty=%H${FS}%s${FS}%b${RS}`],
19+
{ encoding: 'utf8', maxBuffer: 1024 * 1024 * 32 },
20+
);
21+
22+
const commits = raw
23+
.split(RS)
24+
.map((r) => r.trim())
25+
.filter(Boolean)
26+
.map((r) => {
27+
const [hash, subject, body] = r.split(FS);
28+
return { hash: hash.slice(0, 7), subject, body: body ?? '' };
29+
});
30+
31+
const TYPE_TO_SECTION = {
32+
feat: 'Added',
33+
fix: 'Fixed',
34+
perf: 'Changed',
35+
refactor: 'Changed',
36+
revert: 'Removed',
37+
};
38+
const INTERNAL_TYPES = new Set(['docs', 'style', 'test', 'build', 'ci', 'chore']);
39+
const CONVENTIONAL_RE = /^(\w+)(\([^)]*\))?(!)?:\s*(.+)$/;
40+
41+
const sections = { Added: [], Changed: [], Fixed: [], Removed: [] };
42+
const breaking = [];
43+
44+
for (const { hash, subject, body } of commits) {
45+
const match = subject.match(CONVENTIONAL_RE);
46+
const isBreaking = Boolean(match?.[3]) || /BREAKING CHANGE:/.test(body);
47+
const description = match ? match[4] : subject;
48+
49+
if (isBreaking) {
50+
breaking.push(`- ${description} (${hash})`);
51+
continue;
52+
}
53+
54+
if (match && INTERNAL_TYPES.has(match[1])) continue;
55+
56+
const section = (match && TYPE_TO_SECTION[match[1]]) || 'Changed';
57+
sections[section].push(`- ${description} (${hash})`);
58+
}
59+
60+
const date = new Date().toISOString().slice(0, 10);
61+
const lines = [`## [${version}] — ${date}`, ''];
62+
63+
if (breaking.length) {
64+
lines.push('### ⚠️ Breaking changes', '', ...breaking, '');
65+
}
66+
for (const name of ['Added', 'Changed', 'Fixed', 'Removed']) {
67+
if (sections[name].length) {
68+
lines.push(`### ${name}`, '', ...sections[name], '');
69+
}
70+
}
71+
if (!breaking.length && Object.values(sections).every((s) => s.length === 0)) {
72+
lines.push('_No user-facing changes recorded since the previous release._', '');
73+
}
74+
75+
process.stdout.write(lines.join('\n').trimEnd() + '\n');
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/usr/bin/env node
2+
// Splices a generated section (see generate-changelog-entry.mjs) into
3+
// CHANGELOG.md above the most recent existing entry, and adds its link
4+
// reference above the previous one. Used by .github/workflows/release.yml.
5+
import { readFileSync, writeFileSync } from 'node:fs';
6+
7+
const [, , changelogPath, sectionPath, version, repo] = process.argv;
8+
if (!changelogPath || !sectionPath || !version || !repo) {
9+
console.error(
10+
'Usage: insert-changelog-entry.mjs <CHANGELOG.md> <section.md> <version> <owner/repo>',
11+
);
12+
process.exit(1);
13+
}
14+
15+
const changelog = readFileSync(changelogPath, 'utf8');
16+
const section = readFileSync(sectionPath, 'utf8').trimEnd();
17+
const lines = changelog.split('\n');
18+
19+
const headingIndex = lines.findIndex((l) => l.startsWith('## ['));
20+
const linkIndex = lines.findIndex((l) => /^\[\d/.test(l));
21+
22+
if (headingIndex === -1 || linkIndex === -1) {
23+
console.error('Could not locate an existing "## [x.y.z]" heading or "[x.y.z]:" link line.');
24+
process.exit(1);
25+
}
26+
27+
const before = lines.slice(0, headingIndex);
28+
const middle = lines.slice(headingIndex, linkIndex);
29+
const linksAndAfter = lines.slice(linkIndex);
30+
31+
const newLink = `[${version}]: https://github.com/${repo}/releases/tag/v${version}`;
32+
33+
const output = [
34+
...before,
35+
...section.split('\n'),
36+
'',
37+
...middle,
38+
newLink,
39+
...linksAndAfter,
40+
].join('\n');
41+
42+
writeFileSync(changelogPath, output.endsWith('\n') ? output : output + '\n');
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
name: Prepare release
2+
3+
# Manually-triggered: bumps every version-bearing file and writes the
4+
# CHANGELOG.md entry (from conventional-commit subjects since the previous
5+
# tag) on a release/vX.Y.Z branch, then opens a PR against main. main is
6+
# ruleset-protected, so this cannot push or tag directly — merging the PR is
7+
# the approval gate. Once merged, .github/workflows/release-publish.yml tags
8+
# the merge commit and creates the GitHub Release automatically.
9+
on:
10+
workflow_dispatch:
11+
inputs:
12+
version:
13+
description: 'New version, semver without a leading "v" (e.g. 0.3.0)'
14+
required: true
15+
type: string
16+
17+
permissions:
18+
contents: write
19+
pull-requests: write
20+
21+
concurrency:
22+
group: release
23+
cancel-in-progress: false
24+
25+
jobs:
26+
prepare:
27+
runs-on: ubuntu-latest
28+
steps:
29+
- name: Ensure running from main
30+
run: |
31+
if [ "${{ github.ref }}" != "refs/heads/main" ]; then
32+
echo "::error::Run this workflow from the main branch (got ${{ github.ref }})."
33+
exit 1
34+
fi
35+
36+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
37+
with:
38+
fetch-depth: 0
39+
fetch-tags: true
40+
41+
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
42+
with:
43+
node-version: '22'
44+
45+
- name: Validate version and compute previous tag
46+
run: |
47+
VERSION="${{ inputs.version }}"
48+
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
49+
echo "::error::version must be semver X.Y.Z with no leading 'v' (got '$VERSION')."
50+
exit 1
51+
fi
52+
if git rev-parse "v$VERSION" >/dev/null 2>&1; then
53+
echo "::error::tag v$VERSION already exists."
54+
exit 1
55+
fi
56+
if git ls-remote --exit-code --heads origin "release/v$VERSION" >/dev/null 2>&1; then
57+
echo "::error::branch release/v$VERSION already exists on origin."
58+
exit 1
59+
fi
60+
PREV_TAG="$(git describe --tags --abbrev=0 2>/dev/null || true)"
61+
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
62+
echo "PREV_TAG=$PREV_TAG" >> "$GITHUB_ENV"
63+
echo "Preparing v$VERSION (previous tag: ${PREV_TAG:-none})"
64+
65+
- name: Generate changelog section
66+
run: |
67+
node .github/scripts/generate-changelog-entry.mjs "$PREV_TAG" "$VERSION" > /tmp/section.md
68+
cat /tmp/section.md
69+
70+
- name: Insert changelog section
71+
run: |
72+
node .github/scripts/insert-changelog-entry.mjs CHANGELOG.md /tmp/section.md "$VERSION" "${{ github.repository }}"
73+
74+
- name: Bump package versions
75+
run: |
76+
for dir in . dashboard .github/actions/report; do
77+
(cd "$dir" && npm version "$VERSION" --no-git-tag-version --allow-same-version)
78+
done
79+
80+
- name: Update README action-usage pins
81+
run: |
82+
sed -i -E "s#(\.github/actions/report@v)[0-9]+\.[0-9]+\.[0-9]+#\1${VERSION}#g" \
83+
README.md .github/actions/report/README.md
84+
85+
- name: Commit release changes
86+
run: |
87+
git config user.name "github-actions[bot]"
88+
git config user.email "github-actions[bot]@users.noreply.github.com"
89+
git checkout -b "release/v$VERSION"
90+
git add \
91+
package.json package-lock.json \
92+
dashboard/package.json dashboard/package-lock.json \
93+
.github/actions/report/package.json .github/actions/report/package-lock.json \
94+
CHANGELOG.md README.md .github/actions/report/README.md
95+
git commit -m "chore(release): v$VERSION"
96+
git push origin "release/v$VERSION"
97+
98+
- name: Open release PR
99+
env:
100+
GH_TOKEN: ${{ github.token }}
101+
run: |
102+
gh pr create \
103+
--base main \
104+
--head "release/v$VERSION" \
105+
--title "chore(release): v$VERSION" \
106+
--body-file /tmp/section.md
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
name: Publish release
2+
3+
# Fires when a release/vX.Y.Z branch (opened by release-prepare.yml) merges
4+
# into main. Tags the merge commit and creates the GitHub Release, using the
5+
# CHANGELOG.md section the PR just merged as the release notes. Merging the
6+
# PR is the only human action required — this completes the release.
7+
on:
8+
pull_request:
9+
types: [closed]
10+
branches: [main]
11+
12+
permissions:
13+
contents: write
14+
15+
jobs:
16+
publish:
17+
if: >
18+
github.event.pull_request.merged == true &&
19+
startsWith(github.event.pull_request.head.ref, 'release/v')
20+
runs-on: ubuntu-latest
21+
steps:
22+
- name: Extract version from branch name
23+
run: |
24+
HEAD_REF="${{ github.event.pull_request.head.ref }}"
25+
VERSION="${HEAD_REF#release/v}"
26+
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
27+
echo "::error::could not parse a semver version from branch '$HEAD_REF'."
28+
exit 1
29+
fi
30+
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
31+
32+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
33+
with:
34+
ref: ${{ github.event.pull_request.merge_commit_sha }}
35+
fetch-depth: 0
36+
fetch-tags: true
37+
38+
- name: Guard against re-runs
39+
run: |
40+
if git rev-parse "v$VERSION" >/dev/null 2>&1; then
41+
echo "::error::tag v$VERSION already exists — refusing to re-publish."
42+
exit 1
43+
fi
44+
45+
- name: Extract release notes from CHANGELOG.md
46+
run: |
47+
node .github/scripts/extract-changelog-section.mjs CHANGELOG.md "$VERSION" > /tmp/release-notes.md
48+
cat /tmp/release-notes.md
49+
50+
- name: Tag release commit
51+
run: |
52+
git config user.name "github-actions[bot]"
53+
git config user.email "github-actions[bot]@users.noreply.github.com"
54+
git tag -a "v$VERSION" -m "v$VERSION"
55+
git push origin "v$VERSION"
56+
57+
- name: Create GitHub Release
58+
env:
59+
GH_TOKEN: ${{ github.token }}
60+
run: |
61+
gh release create "v$VERSION" \
62+
--title "v$VERSION" \
63+
--notes-file /tmp/release-notes.md

0 commit comments

Comments
 (0)