From 071f1d7513390a07e794cf6b86129b29f1bf53c8 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:44:48 -0400 Subject: [PATCH 1/2] Automate the release and gate the changelog Cutting a release took a version bump committed straight onto main, a hand pushed tag, and a release page written by hand. The default branch now requires a pull request, so the bump only lands by bypassing the rule, and the page has been forgotten once. Add a dispatched prep workflow that dates the changelog section, writes the compare link, bumps api/package.json and moves the demo pin, then opens a pull request. Merging it tags the release, and the tag build publishes the release page from the same changelog section. Add scripts/changelog-check.js to the shared checks: structure fails, style warns on [Unreleased] only. Authenticate the prep, the tag and the page as a GitHub App, since GITHUB_TOKEN cannot push to the protected branch and anything it creates starts no workflow run. --- .github/actions/checks/action.yml | 13 ++ .github/workflows/release-prep.yml | 88 ++++++++++ .github/workflows/release-tag.yml | 72 +++++++++ .github/workflows/release.yml | 57 +++++++ CONTRIBUTING.md | 7 + api/test/changelog.test.js | 175 ++++++++++++++++++++ api/test/release-automation.test.js | 80 ++++++++++ docs/releasing.md | 45 ++++++ scripts/changelog-check.js | 239 ++++++++++++++++++++++++++++ scripts/changelog.js | 146 +++++++++++++++++ scripts/release-notes.js | 38 +++++ scripts/release-prep.js | 80 ++++++++++ 12 files changed, 1040 insertions(+) create mode 100644 .github/workflows/release-prep.yml create mode 100644 .github/workflows/release-tag.yml create mode 100644 api/test/changelog.test.js create mode 100644 api/test/release-automation.test.js create mode 100644 docs/releasing.md create mode 100644 scripts/changelog-check.js create mode 100644 scripts/changelog.js create mode 100644 scripts/release-notes.js create mode 100644 scripts/release-prep.js diff --git a/.github/actions/checks/action.yml b/.github/actions/checks/action.yml index d83bab0d..54274093 100644 --- a/.github/actions/checks/action.yml +++ b/.github/actions/checks/action.yml @@ -31,6 +31,19 @@ runs: shell: bash run: npm ci + # Structure fails here; style prints warnings on the pull request. + - name: Check the changelog + shell: bash + run: node scripts/changelog-check.js + + # A release must also agree with itself: the newest dated section, the + # package version and the demo pin all name one version, and nothing is left + # under [Unreleased]. + - name: Check the changelog describes this release + if: inputs.mode == 'release' + shell: bash + run: node scripts/changelog-check.js --release "$(node -p "require('./api/package.json').version")" + - name: Check asset cache-busting shell: bash run: node scripts/bump-cache-busting.js --check diff --git a/.github/workflows/release-prep.yml b/.github/workflows/release-prep.yml new file mode 100644 index 00000000..ba36cac5 --- /dev/null +++ b/.github/workflows/release-prep.yml @@ -0,0 +1,88 @@ +name: Release prep + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release, without the leading v (for example 1.8.0)' + required: true + type: string + +# Every write goes through the app token below. GITHUB_TOKEN needs no scope of +# its own, and a pull request it opened would start no checks. +permissions: {} + +concurrency: + group: release + cancel-in-progress: false + +jobs: + prep: + runs-on: ubuntu-latest + steps: + - name: Mint a token for the release app + id: app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - name: Checkout default branch + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '24' + + # Dates the section, writes the compare link, bumps api/package.json and + # moves the demo pin in render.yaml. + - name: Prepare the release + env: + VERSION: ${{ inputs.version }} + run: node scripts/release-prep.js "$VERSION" + + # The same gate the pull request will run, failing here instead of after + # a branch exists. + - name: Check the changelog + env: + VERSION: ${{ inputs.version }} + run: node scripts/changelog-check.js --release "$VERSION" + + - name: Open the release pull request + env: + VERSION: ${{ inputs.version }} + BASE: ${{ github.event.repository.default_branch }} + REPO: ${{ github.repository }} + GH_TOKEN: ${{ steps.app.outputs.token }} + run: | + set -euo pipefail + BRANCH="release/v${VERSION}" + git config user.name 'stackyard-release[bot]' + git config user.email '${{ steps.app.outputs.app-slug }}[bot]@users.noreply.github.com' + git checkout -b "${BRANCH}" + git add CHANGELOG.md api/package.json render.yaml + git commit -m "Release ${VERSION}" + git push "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "HEAD:${BRANCH}" + gh pr create \ + --base "${BASE}" \ + --head "${BRANCH}" \ + --title "Release: ${VERSION}" \ + --label release \ + --body "$(printf '%s\n' \ + '**Summary**' \ + "- Date the changelog section as ${VERSION} and add its compare link." \ + "- Bump api/package.json to ${VERSION}." \ + "- Pin the demo image to ${VERSION} in render.yaml." \ + '' \ + '**Testing**' \ + 'The changelog gate ran in release mode before this branch was pushed. Merging this tags the release and publishes the image.' \ + '' \ + '**Checklist**' \ + '- [x] Stays within the project constraints: one container, no runtime dependencies, no build step (see CONTRIBUTING.md).' \ + '- [ ] Tests added or updated for new logic, and the full suite, lint, and typechecks pass.' \ + '- [ ] Docs updated if behavior or configuration changed.' \ + '- [x] CHANGELOG.md updated under Unreleased if the change is user-visible.')" diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml new file mode 100644 index 00000000..7bc5391c --- /dev/null +++ b/.github/workflows/release-tag.yml @@ -0,0 +1,72 @@ +name: Release tag + +on: + push: + branches: + - main + paths: + - api/package.json + +# The tag is pushed with the app token. A tag pushed with GITHUB_TOKEN starts no +# workflow run, so the image would never build. +permissions: {} + +concurrency: + group: release + cancel-in-progress: false + +jobs: + tag: + runs-on: ubuntu-latest + steps: + - name: Mint a token for the release app + id: app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '24' + + # api/package.json changes for other reasons too. Only a version that the + # changelog already describes as released is a release. + - name: Decide whether this is a release + id: decide + env: + REPO: ${{ github.repository }} + run: | + set -euo pipefail + VERSION="$(node -p "require('./api/package.json').version")" + if git ls-remote --exit-code --tags "https://github.com/${REPO}.git" "v${VERSION}" >/dev/null 2>&1; then + echo "v${VERSION} is already tagged." + echo "release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if ! node scripts/changelog-check.js --release "${VERSION}"; then + echo "::notice::api/package.json says ${VERSION}, but the tree is not release-ready. No tag." + echo "release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "release=true" >> "$GITHUB_OUTPUT" + + - name: Tag the release + if: steps.decide.outputs.release == 'true' + env: + VERSION: ${{ steps.decide.outputs.version }} + REPO: ${{ github.repository }} + GH_TOKEN: ${{ steps.app.outputs.token }} + run: | + set -euo pipefail + git config user.name 'stackyard-release[bot]' + git config user.email '${{ steps.app.outputs.app-slug }}[bot]@users.noreply.github.com' + git tag -a "v${VERSION}" -m "Release ${VERSION}" + git push "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "refs/tags/v${VERSION}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9174fa15..f2e35b25 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -226,3 +226,60 @@ jobs: repository: ${{ vars.DOCKERHUB_NAMESPACE || vars.DOCKERHUB_USERNAME }}/stackyard readme-filepath: dockerhub-description.md short-description: A self-hosted homelab dashboard you actually want to look at. + + # A tag with an image and no release page has happened before. This runs after + # the image is published, so the page never describes something unpublished. + release-page: + needs: build-and-push + runs-on: ubuntu-latest + # Every write here is the app token's. + permissions: {} + steps: + # The app token, not GITHUB_TOKEN: a release published with the built-in + # token starts no workflow run, and the Community Applications template + # update listens for exactly that event. + - name: Mint a token for the release app + id: app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '24' + + - name: Classify the tag + id: tag + run: node scripts/is-prerelease.js "$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT" + + # The notes are the changelog section, so the page and the file cannot + # describe the release differently. + - name: Publish the release page + env: + TAG: ${{ github.ref_name }} + PRERELEASE: ${{ steps.tag.outputs.prerelease }} + GH_TOKEN: ${{ steps.app.outputs.token }} + run: | + set -euo pipefail + if gh release view "$TAG" >/dev/null 2>&1; then + echo "$TAG already has a release page." + exit 0 + fi + node scripts/release-notes.js "$TAG" > release-notes.md + if [ "$PRERELEASE" = 'true' ]; then + FLAG=--prerelease + else + FLAG=--latest + fi + gh release create "$TAG" \ + --title "${TAG#v}" \ + --notes-file release-notes.md \ + --verify-tag \ + "$FLAG" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8adf80f1..f4931c4e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,7 @@ in `.github/actions/checks/action.yml`, so the list here is the whole of it: ``` npm ci +node scripts/changelog-check.js node scripts/bump-cache-busting.js --check npm run paths:check cd api && npm test @@ -41,6 +42,12 @@ docker build -t stackyard:ci . CodeQL also runs on every pull request. A finding it reports has to be resolved before merge. +- **`changelog-check.js`** verifies the structure of `CHANGELOG.md`: the Keep a + Changelog header, the six section names, a real date on every released + version, descending versions, and a compare link per version. Structure fails + the check. Style problems in `[Unreleased]`, such as a rationale clause or a + three-sentence entry, print as warnings on the pull request and do not fail + it. Releasing is documented in [docs/releasing.md](docs/releasing.md). - **`bump-cache-busting.js --check`** verifies that every `/css/` and `/js/` reference carries a `?v=` stamp. The release build recomputes the hashes, so write `?v=1` and never edit a stamp by hand. diff --git a/api/test/changelog.test.js b/api/test/changelog.test.js new file mode 100644 index 00000000..a640f9ac --- /dev/null +++ b/api/test/changelog.test.js @@ -0,0 +1,175 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { tmpDir } = require('../test-support/tmp.js'); + +/* The changelog is the release record and the source of the release notes. A + malformed one publishes a release describing the wrong version, or none. */ + +const ROOT = path.join(__dirname, '..', '..'); +const cl = require(path.join(ROOT, 'scripts', 'changelog.js')); +const { dateTheSection } = require(path.join(ROOT, 'scripts', 'release-prep.js')); +const { notesFor } = require(path.join(ROOT, 'scripts', 'release-notes.js')); + +const HEADER = `${cl.HEADER.join('\n')}\n`; + +const sample = `${HEADER} +## [Unreleased] + +### Added + +- Widget for the doorbell. + +## [1.1.0] - 2026-02-02 + +### Added + +- Folders on the dashboard. + +## [1.0.0] - 2026-01-01 + +### Added + +- First release. + +[Unreleased]: https://github.com/SandObserver/stackyard/compare/v1.1.0...HEAD +[1.1.0]: https://github.com/SandObserver/stackyard/compare/v1.0.0...v1.1.0 +[1.0.0]: https://github.com/SandObserver/stackyard/releases/tag/v1.0.0 +`; + +const check = args => { + try { + execFileSync(process.execPath, [path.join(ROOT, 'scripts', 'changelog-check.js'), ...args], { + cwd: ROOT, + stdio: 'pipe', + }); + return 0; + } catch (e) { + return e.status; + } +}; + +test('the parser separates versions, sections and links', () => { + const doc = cl.parse(sample); + assert.deepEqual( + doc.versions.map(v => v.name), + ['Unreleased', '1.1.0', '1.0.0'], + ); + assert.equal(cl.unreleased(doc).sections[0].entries[0].text, 'Widget for the doorbell.'); + assert.equal(cl.released(doc)[0].date, '2026-02-02'); + assert.equal(doc.links.length, 3); +}); + +test('a wrapped entry is read as one entry', () => { + const doc = cl.parse(`${HEADER} +## [Unreleased] + +### Fixed + +- A long line that continues + onto a second line. + +[Unreleased]: https://github.com/SandObserver/stackyard/compare/v1.0.0...HEAD +`); + const entries = cl.unreleased(doc).sections[0].entries; + assert.equal(entries.length, 1); + assert.equal(entries[0].text, 'A long line that continues onto a second line.'); +}); + +test('a date that is not a real day is rejected', () => { + assert.ok(cl.validDate('2026-02-28')); + assert.ok(!cl.validDate('2026-02-30')); + assert.ok(!cl.validDate('26-02-01')); + assert.ok(!cl.validDate(null)); +}); + +test('the implied bump follows the sections present', () => { + const bump = types => cl.impliedBump({ sections: types.map(t => ({ type: t })) }); + assert.equal(bump(['Removed']), 'major'); + assert.equal(bump(['Added', 'Fixed']), 'minor'); + assert.equal(bump(['Fixed', 'Security']), 'patch'); + assert.equal(cl.actualBump('1.6.1', '1.7.0'), 'minor'); + assert.equal(cl.actualBump('1.6.1', '1.6.2'), 'patch'); + assert.equal(cl.actualBump('1.6.1', '2.0.0'), 'major'); +}); + +test('compare links accept either capitalisation of the repository path', () => { + assert.ok(cl.linkMatches('https://github.com/SandObserver/stackyard/compare/v1.0.0...HEAD', 'compare/v1.0.0...HEAD')); + assert.ok(cl.linkMatches('https://github.com/SandObserver/Stackyard/compare/v1.0.0...HEAD', 'compare/v1.0.0...HEAD')); + assert.ok(!cl.linkMatches('https://example.com/compare/v1.0.0...HEAD', 'compare/v1.0.0...HEAD')); +}); + +test('the committed changelog passes the gate', () => { + assert.equal(check([]), 0); +}); + +test('the gate agrees with the released version, and rejects any other', () => { + const version = JSON.parse(fs.readFileSync(path.join(ROOT, 'api', 'package.json'), 'utf8')).version; + assert.equal(check(['--release', version]), 0); + assert.equal(check(['--release', '99.0.0']), 1); +}); + +/* Each mutation is one rule. A gate that has never been seen to fail is a + guess. */ +test('the gate rejects a broken structure', () => { + const dir = tmpDir('changelog'); + const cases = { + 'paraphrased header': sample.replace('are documented in this file', 'will be documented in this file'), + 'missing date': sample.replace('## [1.1.0] - 2026-02-02', '## [1.1.0]'), + 'impossible date': sample.replace('2026-02-02', '2026-02-30'), + 'versions out of order': sample.replace('## [1.1.0] - 2026-02-02', '## [0.9.0] - 2026-02-02'), + 'invented section': sample.replace('### Added\n\n- Folders', '### Tweaked\n\n- Folders'), + 'empty section': sample.replace('### Added\n\n- Folders on the dashboard.', '### Added'), + 'wrong compare link': sample.replace('compare/v1.0.0...v1.1.0', 'compare/v0.9.0...v1.1.0'), + 'missing link definition': sample.replace(/^\[1\.1\.0\]:.*$/m, ''), + 'first release not linked to its tag': sample.replace('releases/tag/v1.0.0', 'compare/v0.9.0...v1.0.0'), + }; + for (const [name, markdown] of Object.entries(cases)) { + const file = path.join(dir, `${name.replace(/\s+/g, '-')}.md`); + fs.writeFileSync(file, markdown, 'utf8'); + assert.equal(check(['--file', file]), 1, `${name} should fail the gate`); + } + fs.writeFileSync(path.join(dir, 'good.md'), sample, 'utf8'); + assert.equal(check(['--file', path.join(dir, 'good.md')]), 0, 'the sample itself must pass'); +}); + +test('style problems warn without failing', () => { + const file = path.join(tmpDir('changelog'), 'style.md'); + fs.writeFileSync( + file, + sample.replace('- Widget for the doorbell.', '- Added a widget you can use, because we wanted it.'), + 'utf8', + ); + assert.equal(check(['--file', file]), 0, 'style is advisory'); +}); + +test('release prep dates the section and writes both links', () => { + const { markdown, previous } = dateTheSection(sample, '1.2.0', '2026-03-03'); + assert.equal(previous, '1.1.0'); + assert.match(markdown, /## \[Unreleased\]\n\n## \[1\.2\.0\] - 2026-03-03\n/); + assert.match(markdown, /\[Unreleased\]: https:\/\/github\.com\/SandObserver\/stackyard\/compare\/v1\.2\.0\.\.\.HEAD/); + assert.match( + markdown, + /\[1\.2\.0\]: https:\/\/github\.com\/SandObserver\/stackyard\/compare\/v1\.1\.0\.\.\.v1\.2\.0/, + ); +}); + +test('the prepared changelog still parses as one release ahead', () => { + const { markdown } = dateTheSection(sample, '1.2.0', '2026-03-03'); + const doc = cl.parse(markdown); + assert.equal(cl.unreleased(doc).sections.length, 0); + assert.equal(cl.released(doc)[0].name, '1.2.0'); + assert.equal(cl.released(doc)[0].sections[0].entries[0].text, 'Widget for the doorbell.'); +}); + +test('release notes are the section body without the link definitions', () => { + const notes = notesFor(sample, '1.1.0'); + assert.match(notes, /### Added/); + assert.match(notes, /Folders on the dashboard\./); + assert.doesNotMatch(notes, /compare\//); + assert.doesNotMatch(notes, /First release/); + assert.equal(notesFor(sample, 'v1.1.0'), notes); + assert.equal(notesFor(sample, '9.9.9'), null); +}); diff --git a/api/test/release-automation.test.js b/api/test/release-automation.test.js new file mode 100644 index 00000000..09d36af1 --- /dev/null +++ b/api/test/release-automation.test.js @@ -0,0 +1,80 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const yaml = require('js-yaml'); + +/* The release runs unattended, and each step exists because the one before it + cannot reach the next. GITHUB_TOKEN starts no workflow run, so a tag or a + release page it creates ends the chain silently: the image never builds, or + the Community Applications listing never updates. */ + +const ROOT = path.join(__dirname, '..', '..'); +const wf = f => fs.readFileSync(path.join(ROOT, '.github', 'workflows', f), 'utf8'); + +const APP_TOKEN = 'steps.app.outputs.token'; + +test('release prep opens a pull request rather than writing to the default branch', () => { + const src = wf('release-prep.yml'); + assert.match(src, /workflow_dispatch/, 'the release has to be startable by hand'); + assert.match(src, /gh pr create/); + assert.match(src, /HEAD:\$\{BRANCH\}/, 'the push must go to the release branch'); + assert.doesNotMatch(src, /HEAD:\$\{BASE\}/, 'a direct push to the default branch is rejected by the ruleset'); +}); + +test('the tag and the release page are pushed with the app token', () => { + for (const f of ['release-tag.yml', 'release.yml']) { + const src = wf(f); + assert.ok(src.includes(APP_TOKEN), `${f} does not use the app token`); + assert.doesNotMatch( + src, + /GH_TOKEN:\s*\$\{\{\s*secrets\.GITHUB_TOKEN/, + `${f} uses GITHUB_TOKEN, which starts no workflow run`, + ); + } +}); + +test('tagging is skipped unless the changelog says this version is released', () => { + const src = wf('release-tag.yml'); + assert.match(src, /changelog-check\.js --release/, 'a version bump alone must not tag a release'); + assert.match(src, /git ls-remote --exit-code --tags/, 'an existing tag must not be pushed again'); +}); + +test('the release page is published from the changelog, after the image', () => { + const doc = yaml.load(wf('release.yml')); + const page = doc.jobs['release-page']; + assert.ok(page, 'release.yml has no release-page job'); + assert.equal(page.needs, 'build-and-push', 'the page must not describe an image that failed to publish'); + const script = page.steps.map(s => s.run || '').join('\n'); + assert.match(script, /release-notes\.js/, 'the notes must come from CHANGELOG.md'); + assert.match(script, /gh release create/); + assert.match(script, /--verify-tag/, 'the page must refuse to create its own tag'); + assert.match(script, /gh release view/, 'republishing an existing page would overwrite it'); +}); + +test('the release workflows grant the built-in token nothing', () => { + for (const f of ['release-prep.yml', 'release-tag.yml']) { + const doc = yaml.load(wf(f)); + assert.deepEqual(doc.permissions, {}, `${f} should leave GITHUB_TOKEN with no scope`); + } + const release = yaml.load(wf('release.yml')); + assert.deepEqual(release.jobs['release-page'].permissions, {}); +}); + +test('the release runs one at a time', () => { + for (const f of ['release-prep.yml', 'release-tag.yml']) { + const doc = yaml.load(wf(f)); + assert.equal(doc.concurrency?.group, 'release', `${f} needs the shared release concurrency group`); + } +}); + +test('docs/releasing.md documents the app secrets the workflows read', () => { + const doc = fs.readFileSync(path.join(ROOT, 'docs', 'releasing.md'), 'utf8'); + for (const secret of ['RELEASE_APP_ID', 'RELEASE_APP_PRIVATE_KEY']) { + assert.ok(doc.includes(secret), `${secret} is read by a workflow and not documented`); + assert.ok( + wf('release-prep.yml').includes(secret), + `${secret} is documented but no longer read; the doc would send someone to create a secret nothing uses`, + ); + } +}); diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 00000000..b2c7ba3d --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,45 @@ +# Releasing + +A release is two merges and no local commands. + +## Cutting a release + +1. Run the **Release prep** workflow from the Actions tab. Give the version + without the leading `v`, for example `1.8.0`. +2. It dates the `[Unreleased]` section, adds the compare link, bumps + `api/package.json`, pins the demo image in `render.yaml`, and opens a + `Release: X.Y.Z` pull request. +3. Review the dated section. Merge when the checks pass. +4. The merge tags `vX.Y.Z`. The tag builds, scans, signs and pushes the image, + then publishes the release page from that changelog section. +5. The published release opens a second pull request with the Community + Applications template. Merge it. + +Nothing else is manual. A `Release X.Y.Z` commit made by hand is no longer part +of the process. + +## What decides the version + +`[Unreleased]` holds the entries. The version is yours to choose: an addition +that only touches tooling or documentation is a patch, not a minor. The +changelog gate prints the bump its entries imply and does not enforce it. + +## Pre-releases + +Tag `vX.Y.Z-beta.N` for a public pre-release. Any semver prerelease identifier +keeps `latest`, the Docker Hub description and the demo on the last stable +release, and marks the release page as a pre-release. See +`scripts/is-prerelease.js`. + +## Requirements + +The prep, tagging and release-page steps authenticate as a GitHub App, through +the `RELEASE_APP_ID` and `RELEASE_APP_PRIVATE_KEY` secrets. The app needs +Contents and Pull requests write access. Without it the release cannot proceed: +the built-in token cannot push to the protected default branch, and a tag or a +release it creates starts no workflow run, so nothing would build. + +## When a release build fails + +The tag stays and nothing publishes. Fix `main`, delete the tag locally and on +the remote, then push it again. diff --git a/scripts/changelog-check.js b/scripts/changelog-check.js new file mode 100644 index 00000000..30b59256 --- /dev/null +++ b/scripts/changelog-check.js @@ -0,0 +1,239 @@ +#!/usr/bin/env node +/* Gates CHANGELOG.md. + + Structure fails the run: the Keep a Changelog header, the six section names, + a real date on every released version, descending Semantic Versioning, and a + compare link per version. Those are what make the file a usable record, and a + broken one is not repairable from the release later. + + Style only warns, and only about [Unreleased]. Whether a line is plain + English is a review call, and rewriting a shipped entry would falsify the + record. + + Usage: node scripts/changelog-check.js [--release ] [--file ] */ + +const fs = require('node:fs'); +const path = require('node:path'); +const cl = require('./changelog.js'); + +const ROOT = path.join(__dirname, '..'); +let FILE = 'CHANGELOG.md'; + +const ACTIONS = process.env.GITHUB_ACTIONS === 'true'; + +/** @type {{ line: number, message: string }[]} */ +const errors = []; +/** @type {{ line: number, message: string }[]} */ +const warnings = []; + +let releaseVersion = null; + +const fail = (line, message) => errors.push({ line, message }); +const warn = (line, message) => warnings.push({ line, message }); + +function checkHeader(doc) { + const got = doc.header.map(l => l.trimEnd()).filter(l => l !== ''); + const want = cl.HEADER.filter(l => l !== ''); + if (got.join('\n') !== want.join('\n')) { + fail(1, `the header must be the Keep a Changelog boilerplate with both links, exactly:\n${want.join('\n')}`); + } +} + +function checkVersions(doc) { + const unreleased = cl.unreleased(doc); + if (!unreleased) fail(1, 'there is no [Unreleased] section'); + else if (doc.versions[0] !== unreleased) fail(unreleased.line, '[Unreleased] must be the first section'); + if (doc.versions.filter(v => v.name === 'Unreleased').length > 1) fail(1, 'more than one [Unreleased] section'); + + const releases = cl.released(doc); + if (!releases.length) fail(1, 'no released version section'); + + for (const v of releases) { + if (!cl.parseTagName(v.name)) fail(v.line, `"${v.name}" is not a valid semantic version`); + if (!cl.validDate(v.date)) fail(v.line, `[${v.name}] needs a real date in YYYY-MM-DD form, got "${v.date ?? ''}"`); + if (!v.sections.length) fail(v.line, `[${v.name}] has no entries`); + checkSections(v); + } + + for (let i = 1; i < releases.length; i++) { + const older = releases[i]; + const newer = releases[i - 1]; + if (cl.compareVersions(newer.name, older.name) >= 0) { + fail(older.line, `[${older.name}] is not older than [${newer.name}]; versions run newest first`); + } + if (cl.validDate(newer.date) && cl.validDate(older.date) && older.date > newer.date) { + fail(older.line, `[${older.name}] is dated after [${newer.name}]`); + } + } + + if (unreleased) checkSections(unreleased); +} + +/* Section order is enforced on what is being written now. A shipped section is + a record: two of them list Security before Fixed, and reordering them would + edit history to satisfy a linter. */ +function checkSections(version) { + const strict = version.name === 'Unreleased' || version.name === releaseVersion; + const seen = []; + for (const s of version.sections) { + if (!cl.TYPES.includes(s.type)) { + fail(s.line, `"${s.type}" is not a Keep a Changelog section; use one of ${cl.TYPES.join(', ')}`); + continue; + } + if (seen.includes(s.type)) fail(s.line, `[${version.name}] repeats the ${s.type} section`); + seen.push(s.type); + if (!s.entries.length) fail(s.line, `[${version.name}] has an empty ${s.type} section`); + } + if (!strict) return; + const order = seen.map(t => cl.TYPES.indexOf(t)); + if (order.some((n, i) => i > 0 && n < order[i - 1])) { + fail(version.line, `[${version.name}] lists its sections out of order; use ${cl.TYPES.join(', ')}`); + } +} + +function checkLinks(doc) { + const releases = cl.released(doc); + const byName = new Map(); + for (const link of doc.links) { + if (byName.has(link.name)) fail(link.line, `[${link.name}] has more than one link definition`); + byName.set(link.name, link); + } + + const wanted = ['Unreleased', ...releases.map(v => v.name)]; + for (const name of wanted) { + if (!byName.has(name)) fail(doc.lines.length, `[${name}] has no link definition at the foot of the file`); + } + for (const link of doc.links) { + if (!wanted.includes(link.name)) fail(link.line, `[${link.name}] links to a section that does not exist`); + } + + const newest = releases[0]; + const unreleasedLink = byName.get('Unreleased'); + if (newest && unreleasedLink && !cl.linkMatches(unreleasedLink.url, `compare/v${newest.name}...HEAD`)) { + fail(unreleasedLink.line, `[Unreleased] must compare v${newest.name}...HEAD`); + } + + for (let i = 0; i < releases.length; i++) { + const v = releases[i]; + const link = byName.get(v.name); + if (!link) continue; + const previous = releases[i + 1]; + /* Nothing precedes the first release, so it points at its own tag. */ + const tail = previous ? `compare/v${previous.name}...v${v.name}` : `releases/tag/v${v.name}`; + if (!cl.linkMatches(link.url, tail)) fail(link.line, `[${v.name}] must link to ${tail}`); + } +} + +/* Mechanical halves of the writing standard. Everything here is a warning: the + judgement of whether a sentence is plain stays with the reviewer. */ +const BANNED = [ + [/\byou\b|\byour\b|\bwe\b|\bour\b/i, 'writes to the reader; state the fact about the software'], + [/as (discussed|shown|requested)|per your|screenshot/i, 'refers to a conversation, not to the change'], + [/\bsimply\b|\bobviously\b|\bjust\b|\beasily\b/i, 'filler'], + [/—/, 'em dash'], + [/\b(because|since|so that|in order to)\b/i, 'explains why; the entry states what changed'], + [/^(added|fixed|changed|removed|updated|improved)\b/i, 'past tense; entries are imperative'], + [/^(this|it|these)\b/i, 'starts with a pronoun rather than the thing that changed'], +]; + +const MAX_LENGTH = 160; + +function checkStyle(version) { + if (!version) return; + for (const section of version.sections) { + for (const entry of section.entries) { + const text = entry.text.replace(/`[^`]*`/g, 'x'); + for (const [pattern, why] of BANNED) { + if (pattern.test(text)) warn(entry.line, `${why}: "${short(entry.text)}"`); + } + if (entry.text.length > MAX_LENGTH) { + warn(entry.line, `entry is ${entry.text.length} characters; keep it to one line: "${short(entry.text)}"`); + } + const sentences = text.split(/(?<=[.!?])\s+/).filter(Boolean); + if (sentences.length > 2) { + warn(entry.line, `entry runs to ${sentences.length} sentences; one, or two when the second is an action`); + } + } + } +} + +const short = text => (text.length > 60 ? `${text.slice(0, 57)}...` : text); + +/** The release-time half: the changelog, the package version and the demo pin + must all name the same release. */ +function checkRelease(doc, version) { + const releases = cl.released(doc); + const newest = releases[0]; + if (!newest || newest.name !== version) { + fail(newest ? newest.line : 1, `the newest dated section is [${newest ? newest.name : 'none'}], not [${version}]`); + } + const unreleased = cl.unreleased(doc); + if (unreleased && unreleased.sections.length) { + fail(unreleased.line, '[Unreleased] still holds entries; they belong in the release being cut'); + } + + const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'api', 'package.json'), 'utf8')); + if (pkg.version !== version) fail(1, `api/package.json is ${pkg.version}, not ${version}`); + + const render = fs.readFileSync(path.join(ROOT, 'render.yaml'), 'utf8'); + if (!new RegExp(`stackyard:${version}\\b`).test(render)) { + fail(1, `render.yaml does not pin the demo to ${version}; it would stay on the old image`); + } + + const previous = releases[1]; + if (previous && newest) { + const implied = cl.impliedBump(newest); + const actual = cl.actualBump(previous.name, newest.name); + if (actual && actual !== implied) { + warn(newest.line, `entries imply a ${implied} bump, but ${previous.name} to ${version} is a ${actual} one`); + } + } +} + +function report() { + for (const w of warnings) { + if (ACTIONS) console.log(`::warning file=${FILE},line=${w.line}::${w.message}`); + else console.error(`${FILE}:${w.line}: warning: ${w.message}`); + } + for (const e of errors) { + if (ACTIONS) console.log(`::error file=${FILE},line=${e.line}::${e.message}`); + else console.error(`${FILE}:${e.line}: error: ${e.message}`); + } + const counts = `${errors.length} error(s), ${warnings.length} warning(s)`; + console.error(`changelog-check: ${counts}`); + return errors.length === 0; +} + +function main(argv) { + const releaseAt = argv.indexOf('--release'); + const version = releaseAt === -1 ? null : argv[releaseAt + 1]; + if (releaseAt !== -1 && !version) { + console.error('changelog-check: --release needs a version'); + return false; + } + const fileAt = argv.indexOf('--file'); + if (fileAt !== -1) { + if (!argv[fileAt + 1]) { + console.error('changelog-check: --file needs a path'); + return false; + } + FILE = argv[fileAt + 1]; + } + releaseVersion = version; + const doc = cl.parse(fs.readFileSync(path.resolve(ROOT, FILE), 'utf8')); + checkHeader(doc); + checkVersions(doc); + checkLinks(doc); + checkStyle(cl.unreleased(doc)); + if (version) { + checkStyle(cl.released(doc)[0]); + checkRelease(doc, version); + } + return report(); +} + +module.exports = { main }; + +if (require.main === module) { + process.exit(main(process.argv.slice(2)) ? 0 : 1); +} diff --git a/scripts/changelog.js b/scripts/changelog.js new file mode 100644 index 00000000..29da20e2 --- /dev/null +++ b/scripts/changelog.js @@ -0,0 +1,146 @@ +/* Reads CHANGELOG.md as structure rather than prose: the Keep a Changelog + headings, the Semantic Versioning numbering, and the compare links. Used by + changelog-check.js, release-prep.js and release-notes.js so the three agree + on what the file means. */ + +const { parseTag } = require('./is-prerelease.js'); + +const REPO = 'SandObserver/stackyard'; + +const HEADER = [ + '# Changelog', + '', + 'All notable changes to this project are documented in this file.', + '', + 'The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),', + 'and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).', +]; + +/* Keep a Changelog 1.1.0 defines these six and no others, in this order. */ +const TYPES = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']; + +const VERSION_HEADING = /^##\s+\[([^\]]+)\](?:\s+-\s+(.*))?\s*$/; +const SECTION_HEADING = /^###\s+(.*?)\s*$/; +const LINK_DEF = /^\[([^\]]+)\]:\s+(\S+)\s*$/; +const BULLET = /^-\s+(.*)$/; + +/** @typedef {{ text: string, line: number }} Entry */ +/** @typedef {{ type: string, line: number, entries: Entry[] }} Section */ +/** @typedef {{ name: string, date: string|null, line: number, sections: Section[] }} Version */ + +/** @param {string} markdown */ +function parse(markdown) { + const lines = String(markdown ?? '').split('\n'); + /** @type {Version[]} */ + const versions = []; + /** @type {{ name: string, url: string, line: number }[]} */ + const links = []; + let version = null; + let section = null; + let entry = null; + + lines.forEach((raw, i) => { + const line = i + 1; + const vh = VERSION_HEADING.exec(raw); + if (vh) { + version = { name: vh[1], date: vh[2] ? vh[2].trim() : null, line, sections: [] }; + versions.push(version); + section = null; + entry = null; + return; + } + const sh = SECTION_HEADING.exec(raw); + if (sh && version) { + section = { type: sh[1], line, entries: [] }; + version.sections.push(section); + entry = null; + return; + } + const ld = LINK_DEF.exec(raw); + if (ld) { + links.push({ name: ld[1], url: ld[2], line }); + return; + } + const bullet = BULLET.exec(raw); + if (bullet && section) { + entry = { text: bullet[1].trim(), line }; + section.entries.push(entry); + return; + } + /* A wrapped bullet continues the entry it is indented under. */ + if (entry && /^\s+\S/.test(raw)) { + entry.text += ` ${raw.trim()}`; + return; + } + if (!raw.trim()) entry = null; + }); + + const headerEnd = versions.length ? versions[0].line - 1 : lines.length; + return { lines, versions, links, header: lines.slice(0, headerEnd) }; +} + +/** The dated sections, newest first. @param {ReturnType} doc */ +const released = doc => doc.versions.filter(v => v.name !== 'Unreleased'); + +/** @param {ReturnType} doc */ +const unreleased = doc => doc.versions.find(v => v.name === 'Unreleased') ?? null; + +/** True when the date is a real calendar day, not just the right shape. + @param {string|null} date */ +function validDate(date) { + if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return false; + const d = new Date(`${date}T00:00:00Z`); + return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === date; +} + +/** Descending semver comparison. @param {string} a @param {string} b */ +function compareVersions(a, b) { + const pa = parseTag(a); + const pb = parseTag(b); + if (!pa || !pb) return 0; + return pb.major - pa.major || pb.minor - pa.minor || pb.patch - pa.patch; +} + +/** Compare links accept either capitalisation of the repository path: GitHub + redirects one to the other and both forms are already in the file. + @param {string} url @param {string} tail */ +function linkMatches(url, tail) { + const expected = new RegExp(`^https://github\\.com/${REPO}/${tail}$`, 'i'); + return expected.test(url); +} + +/** The bump the entries imply. Advisory only: a tooling or docs addition is a + patch, and no parser can tell that from a user-facing one. + @param {Version} version */ +function impliedBump(version) { + const types = version.sections.map(s => s.type); + if (types.includes('Removed')) return 'major'; + if (types.includes('Added') || types.includes('Changed')) return 'minor'; + return 'patch'; +} + +/** @param {string} from @param {string} to */ +function actualBump(from, to) { + const a = parseTag(from); + const b = parseTag(to); + if (!a || !b) return null; + if (b.major > a.major) return 'major'; + if (b.minor > a.minor) return 'minor'; + if (b.patch > a.patch) return 'patch'; + return null; +} + +module.exports = { + HEADER, + TYPES, + REPO, + parse, + parseTagName: parseTag, + released, + unreleased, + validDate, + compareVersions, + linkMatches, + impliedBump, + actualBump, +}; diff --git a/scripts/release-notes.js b/scripts/release-notes.js new file mode 100644 index 00000000..e3e792b4 --- /dev/null +++ b/scripts/release-notes.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node +/* Prints one changelog section as release notes, for `gh release create + --notes-file`. The reference-style link definitions are dropped: they resolve + against the file, not against a release page. + + Usage: node scripts/release-notes.js */ + +const fs = require('node:fs'); +const path = require('node:path'); +const cl = require('./changelog.js'); + +const ROOT = path.join(__dirname, '..'); + +/** @param {string} markdown @param {string} version */ +function notesFor(markdown, version) { + const doc = cl.parse(markdown); + const wanted = doc.versions.find(v => v.name === version.replace(/^v/, '')); + if (!wanted) return null; + const next = doc.versions[doc.versions.indexOf(wanted) + 1]; + const end = next ? next.line - 1 : doc.lines.length; + return doc.lines + .slice(wanted.line, end) + .filter(line => !/^\[[^\]]+\]:\s+https?:/.test(line)) + .join('\n') + .trim(); +} + +module.exports = { notesFor }; + +if (require.main === module) { + const version = process.argv[2] ?? process.env.GITHUB_REF_NAME ?? ''; + const notes = notesFor(fs.readFileSync(path.join(ROOT, 'CHANGELOG.md'), 'utf8'), version); + if (!notes) { + console.error(`release-notes: CHANGELOG.md has no section for ${version}`); + process.exit(1); + } + console.log(notes); +} diff --git a/scripts/release-prep.js b/scripts/release-prep.js new file mode 100644 index 00000000..b98c8c6d --- /dev/null +++ b/scripts/release-prep.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node +/* Prepares a release: dates the [Unreleased] section, adds its compare link, + bumps api/package.json, and moves the demo image pin in render.yaml. + + Writes files. Run it on a branch, never on the default branch. + + Usage: node scripts/release-prep.js [--date YYYY-MM-DD] */ + +const fs = require('node:fs'); +const path = require('node:path'); +const cl = require('./changelog.js'); + +const ROOT = path.join(__dirname, '..'); +const read = f => fs.readFileSync(path.join(ROOT, f), 'utf8'); +const write = (f, s) => fs.writeFileSync(path.join(ROOT, f), s, 'utf8'); + +const die = message => { + console.error(`release-prep: ${message}`); + process.exit(1); +}; + +/** @param {string} markdown @param {string} version @param {string} date */ +function dateTheSection(markdown, version, date) { + const doc = cl.parse(markdown); + const unreleased = cl.unreleased(doc); + if (!unreleased) die('CHANGELOG.md has no [Unreleased] section'); + if (!unreleased.sections.length) die('[Unreleased] is empty; there is nothing to release'); + + const previous = cl.released(doc)[0] ?? null; + if (previous && cl.compareVersions(version, previous.name) >= 0) { + die(`${version} is not newer than the last release, ${previous.name}`); + } + + const lines = markdown.split('\n'); + lines.splice(unreleased.line, 0, '', `## [${version}] - ${date}`); + + const links = cl.parse(lines.join('\n')).links; + const unreleasedLink = links.find(l => l.name === 'Unreleased'); + if (!unreleasedLink) die('CHANGELOG.md has no [Unreleased] link definition'); + const base = `https://github.com/${cl.REPO}`; + lines[unreleasedLink.line - 1] = `[Unreleased]: ${base}/compare/v${version}...HEAD`; + const tail = previous ? `compare/v${previous.name}...v${version}` : `releases/tag/v${version}`; + lines.splice(unreleasedLink.line, 0, `[${version}]: ${base}/${tail}`); + + return { markdown: lines.join('\n'), previous: previous ? previous.name : null }; +} + +function main(argv) { + const version = argv[0]; + const dateAt = argv.indexOf('--date'); + const date = dateAt === -1 ? new Date().toISOString().slice(0, 10) : argv[dateAt + 1]; + + if (!version || !cl.parseTagName(version)) die(`"${version ?? ''}" is not a valid semantic version`); + if (version.startsWith('v')) die('give the version without the leading v'); + if (!cl.validDate(date)) die(`"${date}" is not a date in YYYY-MM-DD form`); + + const changed = dateTheSection(read('CHANGELOG.md'), version, date); + write('CHANGELOG.md', changed.markdown); + + const pkgPath = 'api/package.json'; + const pkg = read(pkgPath); + const bumped = pkg.replace(/("version":\s*")[^"]+(")/, `$1${version}$2`); + if (bumped === pkg) die(`${pkgPath} has no version field to bump`); + write(pkgPath, bumped); + + const renderPath = 'render.yaml'; + const render = read(renderPath); + const pinned = render.replace(/(stackyard:)\d+\.\d+\.\d+\S*/, `$1${version}`); + if (pinned === render) die(`${renderPath} does not pin an image tag; the demo would stay on the old release`); + write(renderPath, pinned); + + console.error(`release-prep: ${changed.previous ?? 'first release'} -> ${version} (${date})`); + console.log(`version=${version}`); + console.log(`date=${date}`); + console.log(`previous=${changed.previous ?? ''}`); +} + +module.exports = { dateTheSection }; + +if (require.main === module) main(process.argv.slice(2)); From 79fad1ed1fa99a9d14999248c3201f742c67e471 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:47:16 -0400 Subject: [PATCH 2/2] Compare the demo pin as text instead of building a regex from input --- scripts/changelog-check.js | 5 +++-- scripts/changelog.js | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/changelog-check.js b/scripts/changelog-check.js index 30b59256..339fd00a 100644 --- a/scripts/changelog-check.js +++ b/scripts/changelog-check.js @@ -176,8 +176,9 @@ function checkRelease(doc, version) { if (pkg.version !== version) fail(1, `api/package.json is ${pkg.version}, not ${version}`); const render = fs.readFileSync(path.join(ROOT, 'render.yaml'), 'utf8'); - if (!new RegExp(`stackyard:${version}\\b`).test(render)) { - fail(1, `render.yaml does not pin the demo to ${version}; it would stay on the old image`); + const pinned = /stackyard:(\S+)/.exec(render); + if (!pinned || pinned[1] !== version) { + fail(1, `render.yaml pins the demo to ${pinned ? pinned[1] : 'nothing'}, not ${version}`); } const previous = releases[1]; diff --git a/scripts/changelog.js b/scripts/changelog.js index 29da20e2..4e9023ff 100644 --- a/scripts/changelog.js +++ b/scripts/changelog.js @@ -105,8 +105,9 @@ function compareVersions(a, b) { redirects one to the other and both forms are already in the file. @param {string} url @param {string} tail */ function linkMatches(url, tail) { - const expected = new RegExp(`^https://github\\.com/${REPO}/${tail}$`, 'i'); - return expected.test(url); + const prefix = `https://github.com/${REPO}/`; + if (url.slice(0, prefix.length).toLowerCase() !== prefix.toLowerCase()) return false; + return url.slice(prefix.length) === tail; } /** The bump the entries imply. Advisory only: a tooling or docs addition is a