From bec3f4416bf2a2f7675b19e2706082b2c3cbef2b Mon Sep 17 00:00:00 2001 From: Steven Hugg Date: Wed, 21 Oct 2026 10:02:39 +0200 Subject: [PATCH] added release scripts, gh pages builds on release branch --- .github/workflows/node.js.yml | 6 +- CHANGELOG.md | 2 + package.json | 1 + scripts/release-lib.mjs | 79 ++++++++++++++++++++++++++ scripts/release.mjs | 102 ++++++++++++++++++++++++++++++++++ test/cli/testrelease.js | 59 ++++++++++++++++++++ 6 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 scripts/release-lib.mjs create mode 100644 scripts/release.mjs create mode 100644 test/cli/testrelease.js diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 0219d3bc..61e3de10 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -5,7 +5,8 @@ name: Node.js CI on: push: - branches: [ master ] + # master runs CI; release is what gets deployed to GitHub Pages. + branches: [ master, release ] pull_request: branches: [ master ] # Testers can build an installable VSIX from any branch on demand. @@ -63,8 +64,9 @@ jobs: archive: false if-no-files-found: error retention-days: 30 + # Publish only from the periodic release branch, never from master/HEAD. - name: Deploy - if: github.event_name == 'push' && github.ref == 'refs/heads/master' + if: github.event_name == 'push' && github.ref == 'refs/heads/release' uses: JamesIves/github-pages-deploy-action@fa24774553152dd7873cd16ebd8d959b010c5445 # v4.9.0 with: branch: gh-pages diff --git a/CHANGELOG.md b/CHANGELOG.md index df5c505f..b438db1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,8 @@ See [extension/CHANGELOG.md](extension/CHANGELOG.md). - Build is now only `esbuild` through `scripts/build.mjs`; code split into a headless `src/common/projectcore.ts` / `toolselect.ts`. - `.xa` assembler and the Dialog interactive-fiction compiler added. - New `buildpresets` script that builds and runs every preset. +- `npm run release -- patch` bumps the app version, moves the changelog's unreleased notes under the new version, commits, and tags `v`; add `--push` to push the tag. +- The web site is deployed to GitHub Pages from the `release` branch instead of `master`, so a release can be staged and published separately from ongoing work. ## 3.12.1 (2025-11-06) diff --git a/package.json b/package.json index f63b6c64..9a328e15 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "buildpresets": "NODE_PATH=$(pwd) node gen/tools/buildpresets.js", "toolversions": "node gen/tools/toolversions.js", "notices": "node scripts/url-licenses.mjs --notices --write", + "release": "node scripts/release.mjs", "doctools": "node gen/tools/doctools.js", "mkdoc": "typedoc --out web/jsdoc src/common/" }, diff --git a/scripts/release-lib.mjs b/scripts/release-lib.mjs new file mode 100644 index 00000000..708b5ed2 --- /dev/null +++ b/scripts/release-lib.mjs @@ -0,0 +1,79 @@ +/** + * Pure helpers for the release script. Kept separate from `release.mjs` so + * they can be unit tested without touching git or the filesystem. + */ + +/** + * Compute the next version. `kind` is `patch`, `minor`, `major`, or an + * explicit `x.y.z`. An explicit version may equal the current one (useful for + * the first release, where `package.json` already carries the version). + * + * @param {string} current + * @param {string} kind + * @returns {string} + */ +export function bumpVersion(current, kind) { + if (/^\d+\.\d+\.\d+$/.test(kind)) return kind; + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(current); + if (!match) { + throw new Error(`current version "${current}" is not x.y.z`); + } + let [major, minor, patch] = match.slice(1).map(Number); + switch (kind) { + case 'major': + major += 1; + minor = 0; + patch = 0; + break; + case 'minor': + minor += 1; + patch = 0; + break; + case 'patch': + patch += 1; + break; + default: + throw new Error(`unknown bump "${kind}" (use patch, minor, major, or x.y.z)`); + } + return `${major}.${minor}.${patch}`; +} + +/** + * Move the entries currently under `## [Unreleased]` under a new + * `## [version] - date` heading, leaving an empty Unreleased section on top. + * + * Older entries in this repo use `## (unreleased)` instead. When no + * standard Unreleased heading is found, the first such heading is renamed and + * an empty Unreleased section is inserted above it. + * + * @param {string} text changelog contents + * @param {string} version + * @param {string} date `YYYY-MM-DD` + * @returns {string} + */ +export function releaseChangelog(text, version, date) { + const heading = '## [Unreleased]'; + const released = `## [${version}] - ${date}`; + const index = text.indexOf(heading); + if (index !== -1) { + // Insert after the Unreleased heading and the blank line that follows it, so + // existing content ends up beneath the new version heading. + let insertAt = text.indexOf('\n', index) + 1; + if (text[insertAt] === '\n') insertAt += 1; + return `${text.slice(0, insertAt)}${released}\n\n${text.slice(insertAt)}`; + } + const unreleased = /^## [^\n]*\(unreleased\)[^\n]*$/im.exec(text); + if (unreleased) { + const start = unreleased.index; + const lineEnd = text.indexOf('\n', start); + const end = lineEnd === -1 ? text.length : lineEnd + 1; + return `${text.slice(0, start)}${heading}\n\n${released}\n${text.slice(end)}`; + } + return `${heading}\n\n${released}\n\n${text}`; +} + +/** Local calendar date as `YYYY-MM-DD`. */ +export function today(date = new Date()) { + const pad = (n) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100644 index 00000000..9d6153fe --- /dev/null +++ b/scripts/release.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +/** + * Release helper: bump the app version, move the changelog's unreleased + * entries under the new version, commit, and tag. + * + * npm run release -- patch + * npm run release -- minor --push + * npm run release -- 4.1.0 --push + * + * Runs the type check and test suite first (skip with --no-tests) and refuses + * to run on a dirty (tracked) tree. `--dry-run` prints the plan and stops + * before anything is written. + * + * The tag is what CI and the distro build see. The web site is deployed from + * the `release` branch, so to publish: + * + * git push origin release + */ +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { bumpVersion, releaseChangelog, today } from './release-lib.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const args = process.argv.slice(2); +const dryRun = args.includes('--dry-run'); +const push = args.includes('--push'); +const skipTests = args.includes('--no-tests'); +const kind = args.find((arg) => !arg.startsWith('--')); + +function fail(message) { + console.error(`release: ${message}`); + process.exit(1); +} + +if (!kind) { + fail('usage: npm run release -- [--push] [--dry-run] [--no-tests]'); +} + +const run = (cmd, cmdArgs) => execFileSync(cmd, cmdArgs, { cwd: root, stdio: 'inherit' }); +const capture = (cmd, cmdArgs) => + execFileSync(cmd, cmdArgs, { cwd: root, encoding: 'utf8' }).trim(); + +// Ignore untracked scratch files (there are usually a few in this repo), but +// refuse to commit a release on top of uncommitted tracked changes. Only the +// three release files are staged below, so untracked files are never added. +if (capture('git', ['status', '--porcelain', '--untracked-files=no'])) { + fail('working tree has uncommitted tracked changes; commit or stash first'); +} +if (capture('git', ['rev-parse', '--abbrev-ref', 'HEAD']) === 'HEAD') { + fail('detached HEAD; check out a branch first'); +} + +const packagePath = join(root, 'package.json'); +const packageLockPath = join(root, 'package-lock.json'); +const changelogPath = join(root, 'CHANGELOG.md'); +const changelog = readFileSync(changelogPath, 'utf8'); +const current = JSON.parse(readFileSync(packagePath, 'utf8')).version; +const next = bumpVersion(current, kind); +const tag = `v${next}`; +const updatedChangelog = releaseChangelog(changelog, next, today()); + +if (capture('git', ['tag', '--list', tag])) { + fail(`tag ${tag} already exists`); +} + +if (next === current) { + console.log(`release: releasing current version ${next} (no bump)`); +} else { + console.log(`release: ${current} -> ${next}`); +} +console.log(`release: changelog ${updatedChangelog === changelog ? 'unchanged' : 'updated'}`); + +if (dryRun) { + console.log(`release: dry run, would commit and tag ${tag}`); + process.exit(0); +} + +if (!skipTests) { + try { + run('npm', ['run', 'tslint']); + run('npm', ['test']); + } catch { + fail('typecheck or tests failed; nothing was bumped'); + } +} + +// Bump package.json and package-lock.json. --no-git-tag-version keeps npm from +// committing/tagging, so this script controls the commit contents and message. +run('npm', ['version', next, '--no-git-tag-version', '--allow-same-version']); +writeFileSync(changelogPath, updatedChangelog); +run('git', ['add', packagePath, packageLockPath, changelogPath]); +run('git', ['commit', '-m', tag]); +run('git', ['tag', '-a', tag, '-m', tag]); + +if (push) { + run('git', ['push', '--follow-tags']); + console.log(`release: pushed ${tag}`); +} else { + console.log(`release: done. next:\n git push --follow-tags\n git push origin release # when the release branch is ready to deploy`); +} diff --git a/test/cli/testrelease.js b/test/cli/testrelease.js new file mode 100644 index 00000000..3f17750b --- /dev/null +++ b/test/cli/testrelease.js @@ -0,0 +1,59 @@ +"use strict"; + +var assert = require('assert'); + +// Plain .mjs helpers so the release script and these tests share one copy. +var release; + +describe('Release helpers', function () { + before(async function () { + release = await import('../../scripts/release-lib.mjs'); + }); + + it('should bump patch, minor, and major versions', function () { + assert.strictEqual(release.bumpVersion('1.2.3', 'patch'), '1.2.4'); + assert.strictEqual(release.bumpVersion('1.2.3', 'minor'), '1.3.0'); + assert.strictEqual(release.bumpVersion('1.2.3', 'major'), '2.0.0'); + }); + + it('should accept an explicit version, including the current one', function () { + assert.strictEqual(release.bumpVersion('1.2.3', '0.2.0'), '0.2.0'); + assert.strictEqual(release.bumpVersion('1.2.3', '1.2.3'), '1.2.3'); + }); + + it('should reject unknown bumps and malformed versions', function () { + assert.throws(() => release.bumpVersion('1.2.3', 'huge'), /unknown bump/); + assert.throws(() => release.bumpVersion('1.2', 'patch'), /not x\.y\.z/); + }); + + it('should move Unreleased entries under the new version', function () { + var changelog = '## [Unreleased]\n\n### Added\n- thing\n'; + assert.strictEqual( + release.releaseChangelog(changelog, '1.0.0', '2026-01-02'), + '## [Unreleased]\n\n## [1.0.0] - 2026-01-02\n\n### Added\n- thing\n'); + }); + + it('should keep already-released sections intact', function () { + var changelog = '## [Unreleased]\n\n### Added\n- thing\n\n## [0.1.0] - 2025-01-01\n\n- old\n'; + assert.strictEqual( + release.releaseChangelog(changelog, '1.0.0', '2026-01-02'), + '## [Unreleased]\n\n## [1.0.0] - 2026-01-02\n\n### Added\n- thing\n\n## [0.1.0] - 2025-01-01\n\n- old\n'); + }); + + it('should handle the older "version (unreleased)" heading', function () { + var changelog = '# Changelog\n\n## 4.0.0 (unreleased)\n\n### Added\n- thing\n'; + assert.strictEqual( + release.releaseChangelog(changelog, '4.0.1', '2026-01-02'), + '# Changelog\n\n## [Unreleased]\n\n## [4.0.1] - 2026-01-02\n\n### Added\n- thing\n'); + }); + + it('should add an Unreleased section when the changelog has none', function () { + assert.ok( + release.releaseChangelog('# Changelog\n', '1.0.0', '2026-01-02') + .includes('## [Unreleased]\n\n## [1.0.0] - 2026-01-02\n')); + }); + + it('should format a local date as YYYY-MM-DD', function () { + assert.strictEqual(release.today(new Date(2026, 0, 2)), '2026-01-02'); + }); +});