From 32c78d29cbb17f3010d334f10787e09879635b39 Mon Sep 17 00:00:00 2001 From: Cristian Duciuc Date: Mon, 9 Nov 2026 13:06:35 -0500 Subject: [PATCH] ci: automate validation and npm releases --- .github/workflows/ci.yml | 51 +++++++ .github/workflows/release.yml | 53 +++++++ package.json | 1 + scripts/release.mjs | 251 ++++++++++++++++++++++++++++++++++ test/release-script.test.mjs | 36 +++++ 5 files changed, 392 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 scripts/release.mjs create mode 100644 test/release-script.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7aa0ac4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test (Node ${{ matrix.node }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + node: + - 22 + - 24 + - 26 + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Node.js ${{ matrix.node }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node }} + package-manager-cache: false + + - name: Install Yarn + run: npm install --global yarn@1.22.22 + + - name: Install dependencies + run: yarn install --frozen-lockfile --non-interactive + + - name: Build and test + run: yarn test + + - name: Preview npm package + if: matrix.node == 26 + run: npm pack --dry-run --ignore-scripts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0fb4490 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,53 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +permissions: + contents: read + id-token: write + +concurrency: + group: npm-release + cancel-in-progress: false + +jobs: + publish: + name: Publish to npm + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Check out release tag + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 26 + registry-url: https://registry.npmjs.org + package-manager-cache: false + + - name: Install trusted-publishing npm version + run: npm install --global "npm@^11.5.1" + + - name: Install Yarn + run: npm install --global yarn@1.22.22 + + - name: Install dependencies + run: yarn install --frozen-lockfile --non-interactive + + - name: Verify release tag and branch + run: | + yarn release verify-tag "$GITHUB_REF_NAME" + git merge-base --is-ancestor "$GITHUB_SHA" origin/main + + - name: Build and test + run: yarn test + + - name: Publish package + run: npm publish --access public --provenance diff --git a/package.json b/package.json index 7d9e617..595560b 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "build": "node scripts/build.mjs", "test": "yarn build && node --test", "example": "yarn build && node examples/cli.mjs", + "release": "node scripts/release.mjs", "prepack": "yarn build" }, "dependencies": { diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100644 index 0000000..c252993 --- /dev/null +++ b/scripts/release.mjs @@ -0,0 +1,251 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const packagePath = fileURLToPath(new URL('../package.json', import.meta.url)); +const remote = 'origin'; +const releaseBranch = 'main'; + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + try { + main(process.argv.slice(2)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} + +function main(args) { + const command = args[0]; + if (!command || command === '--help' || command === '-h') { + printUsage(); + return; + } + + switch (command) { + case 'prepare': + prepareRelease(args.slice(1)); + return; + case 'publish': + publishRelease(args.slice(1)); + return; + case 'verify-tag': + verifyTag(args.slice(1)); + return; + default: + throw new Error('Unknown release command: ' + command + '\n\n' + usage()); + } +} + +function prepareRelease(args) { + const { positionals, push, dryRun } = parseArguments(args); + if (positionals.length !== 1) { + throw new Error('prepare requires patch, minor, major, or an exact x.y.z version'); + } + + const manifest = readManifest(); + const currentVersion = parseVersion(manifest.version); + const nextVersion = resolveNextVersion(currentVersion, positionals[0]); + const tag = 'v' + formatVersion(nextVersion); + const branch = 'release/' + tag; + + assertCleanWorkingTree(); + assertMainIsCurrent(); + assertRefDoesNotExist('refs/heads/' + branch, 'Local release branch already exists: ' + branch); + assertRefDoesNotExist( + 'refs/remotes/' + remote + '/' + branch, + 'Remote release branch already exists: ' + branch, + ); + assertRefDoesNotExist('refs/tags/' + tag, 'Release tag already exists: ' + tag); + + console.log('Preparing InputKit ' + manifest.version + ' -> ' + nextVersion.join('.')); + + if (dryRun) { + runReleaseChecks(); + console.log('Dry run complete. No branch, commit, or tag was created.'); + return; + } + + const originalManifest = readFileSync(packagePath, 'utf8'); + let committed = false; + run('git', ['switch', '-c', branch]); + + try { + manifest.version = nextVersion.join('.'); + writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n'); + runReleaseChecks(); + run('git', ['add', 'package.json']); + run('git', ['commit', '-m', 'chore(release): ' + tag]); + committed = true; + + if (push) { + run('git', ['push', '--set-upstream', remote, branch]); + console.log( + 'Open a pull request: https://github.com/worklouder/input-kit/compare/' + + releaseBranch + '...' + encodeURIComponent(branch) + '?expand=1', + ); + } else { + console.log('Release branch created locally. Push it with: git push -u origin ' + branch); + } + } catch (error) { + if (!committed) { + writeFileSync(packagePath, originalManifest); + run('git', ['restore', '--staged', 'package.json'], { allowFailure: true }); + run('git', ['switch', releaseBranch], { allowFailure: true }); + run('git', ['branch', '-D', branch], { allowFailure: true }); + } + throw error; + } +} + +function publishRelease(args) { + const { positionals, push, dryRun } = parseArguments(args); + if (positionals.length !== 0) { + throw new Error('publish does not accept a version; it releases package.json as written'); + } + + const manifest = readManifest(); + const version = formatVersion(parseVersion(manifest.version)); + const tag = 'v' + version; + + assertCleanWorkingTree(); + assertMainIsCurrent(); + assertRefDoesNotExist('refs/tags/' + tag, 'Release tag already exists: ' + tag); + runReleaseChecks(); + + if (dryRun) { + console.log('Dry run complete. ' + tag + ' is ready to be tagged.'); + return; + } + + run('git', ['tag', '--annotate', tag, '--message', tag]); + if (push) { + run('git', ['push', remote, tag]); + console.log('Pushed ' + tag + '. The release workflow will publish it to npm.'); + } else { + console.log('Created local tag ' + tag + '. Push it with: git push origin ' + tag); + } +} + +function verifyTag(args) { + if (args.length > 1) throw new Error('verify-tag accepts at most one tag'); + const manifest = readManifest(); + const expected = 'v' + formatVersion(parseVersion(manifest.version)); + const actual = args[0] ?? process.env.GITHUB_REF_NAME; + if (!actual) throw new Error('Provide a tag or set GITHUB_REF_NAME'); + if (actual !== expected) { + throw new Error('Release tag ' + actual + ' does not match package version ' + expected); + } + console.log(actual + ' matches package.json'); +} + +function parseArguments(args) { + const positionals = []; + let push = false; + let dryRun = false; + + for (const argument of args) { + if (argument === '--push') push = true; + else if (argument === '--dry-run') dryRun = true; + else if (argument.startsWith('--')) throw new Error('Unknown option: ' + argument); + else positionals.push(argument); + } + if (push && dryRun) throw new Error('--push and --dry-run cannot be used together'); + return { positionals, push, dryRun }; +} + +function assertCleanWorkingTree() { + if (capture('git', ['status', '--porcelain'])) { + throw new Error('The working tree must be clean before preparing or publishing a release'); + } +} + +function assertMainIsCurrent() { + const branch = capture('git', ['branch', '--show-current']); + if (branch !== releaseBranch) { + throw new Error('Releases must start from ' + releaseBranch + '; current branch is ' + branch); + } + + run('git', ['fetch', '--prune', '--tags', remote]); + const localHead = capture('git', ['rev-parse', 'HEAD']); + const remoteHead = capture('git', ['rev-parse', remote + '/' + releaseBranch]); + if (localHead !== remoteHead) { + throw new Error(releaseBranch + ' must exactly match ' + remote + '/' + releaseBranch); + } +} + +function assertRefDoesNotExist(ref, message) { + try { + execFileSync('git', ['show-ref', '--verify', '--quiet', ref], { cwd: root }); + } catch { + return; + } + throw new Error(message); +} + +function runReleaseChecks() { + run('yarn', ['test']); + run('npm', ['pack', '--dry-run', '--ignore-scripts']); +} + +function readManifest() { + return JSON.parse(readFileSync(packagePath, 'utf8')); +} + +export function parseVersion(value) { + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(value); + if (!match) throw new Error('Expected a stable semantic version in x.y.z form, received: ' + value); + return match.slice(1).map(Number); +} + +export function resolveNextVersion(current, selector) { + if (selector === 'major') return [current[0] + 1, 0, 0]; + if (selector === 'minor') return [current[0], current[1] + 1, 0]; + if (selector === 'patch') return [current[0], current[1], current[2] + 1]; + + const exact = parseVersion(selector); + if (compareVersions(exact, current) <= 0) { + throw new Error(selector + ' must be greater than the current version ' + formatVersion(current)); + } + return exact; +} + +export function compareVersions(left, right) { + for (let index = 0; index < 3; index += 1) { + if (left[index] !== right[index]) return left[index] - right[index]; + } + return 0; +} + +export function formatVersion(version) { + return version.join('.'); +} + +function run(command, args, options = {}) { + try { + execFileSync(command, args, { cwd: root, stdio: 'inherit' }); + } catch (error) { + if (options.allowFailure) return; + throw new Error('Command failed: ' + command + ' ' + args.join(' '), { cause: error }); + } +} + +function capture(command, args) { + return execFileSync(command, args, { cwd: root, encoding: 'utf8' }).trim(); +} + +function printUsage() { + console.log(usage()); +} + +function usage() { + return `Usage: + yarn release prepare [--push|--dry-run] + yarn release publish [--push|--dry-run] + yarn release verify-tag [vX.Y.Z] + +prepare creates a release branch and version commit for pull-request review. +publish validates main, creates its version tag, and optionally pushes the tag. +verify-tag checks that a release tag exactly matches package.json.`; +} diff --git a/test/release-script.test.mjs b/test/release-script.test.mjs new file mode 100644 index 0000000..73e33d5 --- /dev/null +++ b/test/release-script.test.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + compareVersions, + formatVersion, + parseVersion, + resolveNextVersion, +} from '../scripts/release.mjs'; + +test('parses and formats stable semantic versions', () => { + assert.deepEqual(parseVersion('0.1.0'), [0, 1, 0]); + assert.deepEqual(parseVersion('22.14.3'), [22, 14, 3]); + assert.equal(formatVersion([22, 14, 3]), '22.14.3'); + + for (const invalid of ['v1.2.3', '1.2', '1.2.3-beta.1', '01.2.3', '1.02.3', '1.2.03']) { + assert.throws(() => parseVersion(invalid), /stable semantic version/); + } +}); + +test('calculates release versions and rejects non-increasing exact versions', () => { + const current = [0, 1, 0]; + assert.deepEqual(resolveNextVersion(current, 'patch'), [0, 1, 1]); + assert.deepEqual(resolveNextVersion(current, 'minor'), [0, 2, 0]); + assert.deepEqual(resolveNextVersion(current, 'major'), [1, 0, 0]); + assert.deepEqual(resolveNextVersion(current, '0.5.0'), [0, 5, 0]); + + assert.throws(() => resolveNextVersion(current, '0.1.0'), /must be greater/); + assert.throws(() => resolveNextVersion(current, '0.0.9'), /must be greater/); +}); + +test('compares semantic version tuples', () => { + assert.equal(compareVersions([1, 0, 0], [0, 99, 99]) > 0, true); + assert.equal(compareVersions([1, 2, 0], [1, 1, 99]) > 0, true); + assert.equal(compareVersions([1, 2, 3], [1, 2, 3]), 0); + assert.equal(compareVersions([1, 2, 2], [1, 2, 3]) < 0, true); +});