Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/scripts/extract-changelog-section.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env node
// Prints the body (heading stripped) of the "## [VERSION] ..." section from
// CHANGELOG.md, for use as GitHub Release notes. Used by
// .github/workflows/release-publish.yml.
import { readFileSync } from 'node:fs';

const [, , changelogPath, version] = process.argv;
if (!changelogPath || !version) {
console.error('Usage: extract-changelog-section.mjs <CHANGELOG.md> <version>');
process.exit(1);
}

const lines = readFileSync(changelogPath, 'utf8').split('\n');
const startPattern = new RegExp(`^## \\[${version.replace(/\./g, '\\.')}\\]`);

const start = lines.findIndex((l) => startPattern.test(l));
if (start === -1) {
console.error(`Could not find a "## [${version}]" heading in ${changelogPath}.`);
process.exit(1);
}

let end = lines.findIndex((l, i) => i > start && (l.startsWith('## [') || /^\[\d/.test(l)));
if (end === -1) end = lines.length;

const body = lines
.slice(start + 1, end)
.join('\n')
.trim();

process.stdout.write(body + '\n');
75 changes: 75 additions & 0 deletions .github/scripts/generate-changelog-entry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/usr/bin/env node
// Builds a Keep a Changelog section from conventional-commit subjects since the
// previous tag. Used by .github/workflows/release.yml — not part of the app.
import { execFileSync } from 'node:child_process';

const [, , prevTag, version] = process.argv;
if (!version) {
console.error('Usage: generate-changelog-entry.mjs <prevTag|""> <version>');
process.exit(1);
}

const RS = '\x1e';
const FS = '\x1f';
const range = prevTag ? `${prevTag}..HEAD` : 'HEAD';

const raw = execFileSync(
'git',
['log', range, '--no-merges', `--pretty=%H${FS}%s${FS}%b${RS}`],
{ encoding: 'utf8', maxBuffer: 1024 * 1024 * 32 },
);

const commits = raw
.split(RS)
.map((r) => r.trim())
.filter(Boolean)
.map((r) => {
const [hash, subject, body] = r.split(FS);
return { hash: hash.slice(0, 7), subject, body: body ?? '' };
});

const TYPE_TO_SECTION = {
feat: 'Added',
fix: 'Fixed',
perf: 'Changed',
refactor: 'Changed',
revert: 'Removed',
};
const INTERNAL_TYPES = new Set(['docs', 'style', 'test', 'build', 'ci', 'chore']);
const CONVENTIONAL_RE = /^(\w+)(\([^)]*\))?(!)?:\s*(.+)$/;

const sections = { Added: [], Changed: [], Fixed: [], Removed: [] };
const breaking = [];

for (const { hash, subject, body } of commits) {
const match = subject.match(CONVENTIONAL_RE);
const isBreaking = Boolean(match?.[3]) || /BREAKING CHANGE:/.test(body);
const description = match ? match[4] : subject;

if (isBreaking) {
breaking.push(`- ${description} (${hash})`);
continue;
}

if (match && INTERNAL_TYPES.has(match[1])) continue;

const section = (match && TYPE_TO_SECTION[match[1]]) || 'Changed';
sections[section].push(`- ${description} (${hash})`);
}

const date = new Date().toISOString().slice(0, 10);
const lines = [`## [${version}] — ${date}`, ''];

if (breaking.length) {
lines.push('### ⚠️ Breaking changes', '', ...breaking, '');
}
for (const name of ['Added', 'Changed', 'Fixed', 'Removed']) {
if (sections[name].length) {
lines.push(`### ${name}`, '', ...sections[name], '');
}
}
if (!breaking.length && Object.values(sections).every((s) => s.length === 0)) {
lines.push('_No user-facing changes recorded since the previous release._', '');
}

process.stdout.write(lines.join('\n').trimEnd() + '\n');
42 changes: 42 additions & 0 deletions .github/scripts/insert-changelog-entry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env node
// Splices a generated section (see generate-changelog-entry.mjs) into
// CHANGELOG.md above the most recent existing entry, and adds its link
// reference above the previous one. Used by .github/workflows/release.yml.
import { readFileSync, writeFileSync } from 'node:fs';

const [, , changelogPath, sectionPath, version, repo] = process.argv;
if (!changelogPath || !sectionPath || !version || !repo) {
console.error(
'Usage: insert-changelog-entry.mjs <CHANGELOG.md> <section.md> <version> <owner/repo>',
);
process.exit(1);
}

const changelog = readFileSync(changelogPath, 'utf8');
const section = readFileSync(sectionPath, 'utf8').trimEnd();
const lines = changelog.split('\n');

const headingIndex = lines.findIndex((l) => l.startsWith('## ['));
const linkIndex = lines.findIndex((l) => /^\[\d/.test(l));

if (headingIndex === -1 || linkIndex === -1) {
console.error('Could not locate an existing "## [x.y.z]" heading or "[x.y.z]:" link line.');
process.exit(1);
}

const before = lines.slice(0, headingIndex);
const middle = lines.slice(headingIndex, linkIndex);
const linksAndAfter = lines.slice(linkIndex);

const newLink = `[${version}]: https://github.com/${repo}/releases/tag/v${version}`;

const output = [
...before,
...section.split('\n'),
'',
...middle,
newLink,
...linksAndAfter,
].join('\n');

writeFileSync(changelogPath, output.endsWith('\n') ? output : output + '\n');
106 changes: 106 additions & 0 deletions .github/workflows/release-prepare.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
name: Prepare release

# Manually-triggered: bumps every version-bearing file and writes the
# CHANGELOG.md entry (from conventional-commit subjects since the previous
# tag) on a release/vX.Y.Z branch, then opens a PR against main. main is
# ruleset-protected, so this cannot push or tag directly — merging the PR is
# the approval gate. Once merged, .github/workflows/release-publish.yml tags
# the merge commit and creates the GitHub Release automatically.
on:
workflow_dispatch:
inputs:
version:
description: 'New version, semver without a leading "v" (e.g. 0.3.0)'
required: true
type: string

permissions:
contents: write
pull-requests: write

concurrency:
group: release
cancel-in-progress: false

jobs:
prepare:
runs-on: ubuntu-latest
steps:
- name: Ensure running from main
run: |
if [ "${{ github.ref }}" != "refs/heads/main" ]; then
echo "::error::Run this workflow from the main branch (got ${{ github.ref }})."
exit 1
fi

- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
fetch-tags: true

- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'

- name: Validate version and compute previous tag
run: |
VERSION="${{ inputs.version }}"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::version must be semver X.Y.Z with no leading 'v' (got '$VERSION')."
exit 1
fi
if git rev-parse "v$VERSION" >/dev/null 2>&1; then
echo "::error::tag v$VERSION already exists."
exit 1
fi
if git ls-remote --exit-code --heads origin "release/v$VERSION" >/dev/null 2>&1; then
echo "::error::branch release/v$VERSION already exists on origin."
exit 1
fi
PREV_TAG="$(git describe --tags --abbrev=0 2>/dev/null || true)"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "PREV_TAG=$PREV_TAG" >> "$GITHUB_ENV"
echo "Preparing v$VERSION (previous tag: ${PREV_TAG:-none})"

- name: Generate changelog section
run: |
node .github/scripts/generate-changelog-entry.mjs "$PREV_TAG" "$VERSION" > /tmp/section.md
cat /tmp/section.md

- name: Insert changelog section
run: |
node .github/scripts/insert-changelog-entry.mjs CHANGELOG.md /tmp/section.md "$VERSION" "${{ github.repository }}"

- name: Bump package versions
run: |
for dir in . dashboard .github/actions/report; do
(cd "$dir" && npm version "$VERSION" --no-git-tag-version --allow-same-version)
done

- name: Update README action-usage pins
run: |
sed -i -E "s#(\.github/actions/report@v)[0-9]+\.[0-9]+\.[0-9]+#\1${VERSION}#g" \
README.md .github/actions/report/README.md

- name: Commit release changes
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "release/v$VERSION"
git add \
package.json package-lock.json \
dashboard/package.json dashboard/package-lock.json \
.github/actions/report/package.json .github/actions/report/package-lock.json \
CHANGELOG.md README.md .github/actions/report/README.md
git commit -m "chore(release): v$VERSION"
git push origin "release/v$VERSION"

- name: Open release PR
env:
GH_TOKEN: ${{ github.token }}
run: |
gh pr create \
--base main \
--head "release/v$VERSION" \
--title "chore(release): v$VERSION" \
--body-file /tmp/section.md
63 changes: 63 additions & 0 deletions .github/workflows/release-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: Publish release

# Fires when a release/vX.Y.Z branch (opened by release-prepare.yml) merges
# into main. Tags the merge commit and creates the GitHub Release, using the
# CHANGELOG.md section the PR just merged as the release notes. Merging the
# PR is the only human action required — this completes the release.
on:
pull_request:
types: [closed]
branches: [main]

permissions:
contents: write

jobs:
publish:
if: >
github.event.pull_request.merged == true &&
startsWith(github.event.pull_request.head.ref, 'release/v')
runs-on: ubuntu-latest
steps:
- name: Extract version from branch name
run: |
HEAD_REF="${{ github.event.pull_request.head.ref }}"
VERSION="${HEAD_REF#release/v}"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::could not parse a semver version from branch '$HEAD_REF'."
exit 1
fi
echo "VERSION=$VERSION" >> "$GITHUB_ENV"

- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 0
fetch-tags: true

- name: Guard against re-runs
run: |
if git rev-parse "v$VERSION" >/dev/null 2>&1; then
echo "::error::tag v$VERSION already exists — refusing to re-publish."
exit 1
fi

- name: Extract release notes from CHANGELOG.md
run: |
node .github/scripts/extract-changelog-section.mjs CHANGELOG.md "$VERSION" > /tmp/release-notes.md
cat /tmp/release-notes.md

- name: Tag release commit
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "v$VERSION" -m "v$VERSION"
git push origin "v$VERSION"

- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "v$VERSION" \
--title "v$VERSION" \
--notes-file /tmp/release-notes.md