diff --git a/.github/scripts/extract-changelog-section.mjs b/.github/scripts/extract-changelog-section.mjs new file mode 100644 index 0000000..1a7e4da --- /dev/null +++ b/.github/scripts/extract-changelog-section.mjs @@ -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 '); + 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'); diff --git a/.github/scripts/generate-changelog-entry.mjs b/.github/scripts/generate-changelog-entry.mjs new file mode 100644 index 0000000..a13e6c3 --- /dev/null +++ b/.github/scripts/generate-changelog-entry.mjs @@ -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 '); + 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'); diff --git a/.github/scripts/insert-changelog-entry.mjs b/.github/scripts/insert-changelog-entry.mjs new file mode 100644 index 0000000..1ec6ada --- /dev/null +++ b/.github/scripts/insert-changelog-entry.mjs @@ -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 ', + ); + 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'); diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml new file mode 100644 index 0000000..915cd0e --- /dev/null +++ b/.github/workflows/release-prepare.yml @@ -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 diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 0000000..262c9ed --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -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