added release scripts, gh pages builds on release branch

This commit is contained in:
Steven Hugg committed 2026-10-21 10:04:05 +02:00
1 parent 3ac80cfe99
commit bec3f4416b
6 files changed
+247 -2

No files matched your search

+4 -2
View File
@@ -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
+2
View File
@@ -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<version>`; 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)
+1
View File
@@ -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/"
},
+79
View File
@@ -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 `## <version> (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())}`;
}
+102
View File
@@ -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 -- <patch|minor|major|x.y.z> [--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`);
}
+59
View File
@@ -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');
});
});