diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1423137..d92a842 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,28 @@ permissions: contents: write jobs: + # The tag names the release and package.json is what electron-builder stamps + # into every artifact name and into the updater feed, so a mismatch publishes a + # build that disagrees with its own tag. Gating here means a bad tag fails + # before `create-draft` runs, so it leaves no orphan draft behind either. + guard: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + + # GITHUB_REF/GITHUB_REF_NAME identify the pushed tag, so this needs no tag + # lookup and no full history; it reads package.json and nothing else. + - name: Tag matches package.json + run: npm run check:version + # Creates the release as a draft BEFORE the platform builds, so both matrix # jobs upload into the same release. Without this, macos and windows run # concurrently, each finds no release for the tag and each creates its own @@ -23,6 +45,7 @@ jobs: # its stale assets into a fresh run is worse than rebuilding the release # empty. Re-running a failed release run therefore starts from a clean slate. create-draft: + needs: guard runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 740680c..031edae 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -39,6 +39,14 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + # check:version compares package.json against the newest release tag, so the + # tags have to be here. `fetch-tags: true` does NOT do it: in the shallow + # path actions/checkout only adds the checked-out ref's own tag to the + # refspec, and a pull request ref (`refs/pull/N/merge`) has no tag. All tags + # come with all history, and this repository is small enough that the full + # fetch is not worth optimising. + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 @@ -46,6 +54,12 @@ jobs: node-version: '24' cache: 'npm' + # Deliberately before `npm ci`: it reads only package.json and the git tags, + # so a version that has fallen behind the newest release fails in seconds + # instead of after a full install. + - name: Version consistency + run: npm run check:version + - name: Install dependencies run: npm ci diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 269794b..1d05562 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,20 +39,21 @@ npm run dev ## Commands -| Command | What it does | -| ------------------------ | -------------------------------------------------------------- | -| `npm run dev` | Start the app in development with HMR. | -| `npm run typecheck` | Typecheck the main/preload, renderer and test projects. | -| `npm test` | Run the Node test suite (`test/**/*.test.ts`). | -| `npm run lint` | ESLint (see the note on the current baseline below). | -| `npm run format` | Prettier over the whole repository. | -| `npm run build` | Typecheck, then bundle with electron-vite. | -| `npm run build:unpack` | `build`, then produce an unpacked app in `dist/`. | -| `npm run smoke:packaged` | Launch the packaged app's `--smoke-test` and check it starts. | -| `npm run eval:prepare` | One-time, networked: download the pinned eval embedding model. | -| `npm run eval` | Run the RAG eval harness offline; rewrites `docs/eval/`. | -| `npm run db:generate` | Generate a Drizzle migration from `src/main/db/schema.ts`. | -| `npm run db:studio` | Inspect the development database. | +| Command | What it does | +| ------------------------ | --------------------------------------------------------------- | +| `npm run dev` | Start the app in development with HMR. | +| `npm run typecheck` | Typecheck the main/preload, renderer and test projects. | +| `npm test` | Run the Node test suite (`test/**/*.test.ts`). | +| `npm run lint` | ESLint (see the note on the current baseline below). | +| `npm run check:version` | Fail when `package.json` disagrees with the newest release tag. | +| `npm run format` | Prettier over the whole repository. | +| `npm run build` | Typecheck, then bundle with electron-vite. | +| `npm run build:unpack` | `build`, then produce an unpacked app in `dist/`. | +| `npm run smoke:packaged` | Launch the packaged app's `--smoke-test` and check it starts. | +| `npm run eval:prepare` | One-time, networked: download the pinned eval embedding model. | +| `npm run eval` | Run the RAG eval harness offline; rewrites `docs/eval/`. | +| `npm run db:generate` | Generate a Drizzle migration from `src/main/db/schema.ts`. | +| `npm run db:studio` | Inspect the development database. | ## Before you open a pull request @@ -129,6 +130,38 @@ when it does, and `breaking` when it breaks compatibility. **Version bumps and releases are done by the maintainer** — don't change `version` in `package.json` or add a tag in a PR. +`package.json`'s `version` is the single source of truth. electron-builder takes +the app version and every artifact name (`knownote-${version}-setup.exe`, the +`.dmg`, `latest.yml`) from it, the updater compares versions against it, and the +About panel reads it back out of the packaged app via `app.getVersion()`. The +git tag has to agree with it, and it has to be `v`-prefixed, because +`release.yml` triggers on `tags: v*.*.*` — a tag named `1.3.0` would push nothing +and publish no artifacts. + +The recommended procedure, which cannot drift because one command does both +halves at once: + +1. On a clean `main`, run `npm version `. It bumps + `package.json` and `package-lock.json`, commits, and creates the `v` + tag. +2. `git push --follow-tags`. Pushing the tag is what starts `release.yml`. + +Bumping `package.json` by hand and tagging the same commit separately also +works, and is how earlier releases here were cut. The mechanism matters less than +the invariant: whatever bumps the version must land on `main` before the tag is +pushed, and the tag must be `v`. + +`scripts/check-version.mjs` (`npm run check:version`) enforces that invariant, and +both workflows run it: + +- **tag build** (`release.yml`) — the tag must equal `package.json` **exactly**. + This is the release blocker: nothing is built or published on a mismatch, and + it runs before the draft release is created, so a bad tag leaves nothing behind. +- **pull request** (`verify.yml`) — `package.json` must not be **behind** the + newest tag. Equality is deliberately not required here: `npm version` is merged + before the tag is pushed, and during that window `main` is legitimately ahead + of the newest tag. + ## Where things live ```plaintext diff --git a/package.json b/package.json index ed669e0..ab79aea 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "format": "prettier --write .", "lint": "eslint --cache .", "check:design": "node scripts/check-design-tokens.mjs", + "check:version": "node scripts/check-version.mjs", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "typecheck:test": "tsc -p tsconfig.test.json", diff --git a/scripts/check-version.mjs b/scripts/check-version.mjs new file mode 100644 index 0000000..a6eaed8 --- /dev/null +++ b/scripts/check-version.mjs @@ -0,0 +1,349 @@ +#!/usr/bin/env node +/** + * Version consistency guard. + * + * `package.json`'s `version` is the single source of truth: electron-builder + * takes the app version and every artifact name (`knownote-${version}-setup.exe`, + * the `.dmg`, `latest.yml`, the updater feed) from it, and Electron's + * `app.getVersion()` - which is what the About panel shows - reads it back out of + * the packaged package.json. The git tag has to agree, because `release.yml` is + * tag-triggered and the updater compares versions: a tag that disagrees with the + * build either publishes an artifact whose filename lies, or makes the updater + * skip a release it should install. + * + * Nothing in CI used to notice. `package.json` sat at 1.2.2 while the newest + * release was v1.3.x, and only a human reading both numbers could tell. + * + * npm run check:version # compare against the newest tag + * npm run check:version -- --tag=v9.9.9 # pretend a release is being tagged + * + * Two modes, because the two situations have genuinely different invariants: + * + * tag mode (release) the tag must EQUAL `package.json`. This is the release + * blocker: it refuses to build a mismatched release. + * compare mode (PR) `package.json` must not be BEHIND the newest tag. Not + * equality - a version bump is merged before the tag is + * pushed, and during that window `main` is legitimately + * ahead of the newest tag. Requiring equality there would + * fail every unrelated PR opened in between. + * + * The parsing, ordering and tag-selection rules are exported and unit tested + * (test/versionGuard.test.ts) the same way the design-token guard is: a silent + * hole in a guard is worse than a missing rule, because everything downstream + * reads "no violations" as proof. + */ + +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const PACKAGE_JSON = resolve('package.json') + +/** `1.2.3` or `1.2.3-beta.1`, matching what this project has ever used. */ +const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/ + +/** + * @returns {{major: number, minor: number, patch: number, prerelease: string | null} | null} + * null when the value is not a version this project would use. + */ +export const parseVersion = (value) => { + const match = VERSION_RE.exec(value) + if (!match) return null + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] ?? null + } +} + +/** + * @returns {number} negative when `a` is older than `b`, 0 when equal. + * + * Pre-release ordering is the part that matters: `1.0.5-beta.1` must sort below + * `1.0.5`, or the newest-tag lookup would answer a beta and hold back the release + * that superseded it. + */ +export const compareVersions = (a, b) => { + for (const key of ['major', 'minor', 'patch']) { + if (a[key] !== b[key]) return a[key] - b[key] + } + if (a.prerelease === b.prerelease) return 0 + if (a.prerelease === null) return 1 + if (b.prerelease === null) return -1 + return comparePrerelease(a.prerelease, b.prerelease) +} + +/** + * SemVer pre-release precedence, identifier by identifier. + * + * This cannot be a string comparison. `beta.10` is **newer** than `beta.2`, but + * `'beta.10' < 'beta.2'` is true lexicographically, so a plain string comparison + * reports the wrong order - and then `newestVersionTag` picks the wrong tag and the + * guard validates against the wrong release. This project has shipped a + * pre-release before (`v1.0.5-beta.1`), so the shape is real rather than + * theoretical. + * + * SemVer 11.4, in order: identifiers are compared left to right; numeric + * identifiers compare numerically; numeric identifiers always have lower + * precedence than alphanumeric ones; and if every shared identifier is equal, the + * longer set wins. + * + * @returns {number} negative when `a` takes precedence below `b`. + */ +export const comparePrerelease = (a, b) => { + const left = a.split('.') + const right = b.split('.') + const length = Math.max(left.length, right.length) + + for (let index = 0; index < length; index += 1) { + const aPart = left[index] + const bPart = right[index] + + // `beta` < `beta.1`: a shorter set of identifiers loses once the shared ones match. + if (aPart === undefined) return -1 + if (bPart === undefined) return 1 + if (aPart === bPart) continue + + const aNumeric = /^\d+$/.test(aPart) + const bNumeric = /^\d+$/.test(bPart) + if (aNumeric && bNumeric) return Number(aPart) - Number(bPart) + // `1` < `alpha`: numeric identifiers are always lower than alphanumeric ones. + if (aNumeric) return -1 + if (bNumeric) return 1 + + return aPart < bPart ? -1 : 1 + } + + return 0 +} + +/** + * Highest release tag among `tags`. + * + * Only `v`-prefixed tags count. `release.yml` triggers on `tags: v*.*.*`, so a bare + * `1.2.3` is a tag no release will ever be built from - treating it as the newest + * release would red every pull request over a version that was never published. + * Such tags are reported separately by `bareVersionTags` instead. + * + * Takes the list rather than reading git, so the selection rule is testable. It + * re-compares with `compareVersions` instead of trusting `git tag --sort`, so a + * pre-release or a two-digit component cannot win by lexicographic accident - + * `v1.0.10` is newer than `v1.0.9`, but sorts before it as a string. + * + * @returns {string | null} the tag as given, or null when nothing is usable. + */ +export const newestVersionTag = (tags) => { + let best = null + let bestParsed = null + + for (const tag of tags) { + if (!tag.startsWith('v')) continue + const parsed = parseVersion(tag.slice(1)) + if (!parsed) continue + if (bestParsed === null || compareVersions(parsed, bestParsed) > 0) { + best = tag + bestParsed = parsed + } + } + + return best +} + +/** + * Version tags that are missing their `v` prefix - a mistake rather than a release. + * + * `release.yml` triggers on `tags: v*.*.*`, so pushing a bare `2.0.0` publishes + * nothing at all. Left unreported it is also invisible: the tag exists on the + * remote and nobody learns that the release never happened. + */ +export const bareVersionTags = (tags) => + tags.filter((tag) => !tag.startsWith('v') && parseVersion(tag) !== null) + +/** + * Under Actions an `::error::` line becomes an annotation on the failed step, the + * same way release.yml reports its packaging failures. + */ +const inActions = process.env.GITHUB_ACTIONS === 'true' +const report = (message, failed) => { + const write = failed ? console.error : console.log + write(failed && inActions ? `::error::${message.replace(/\n/g, '%0A')}` : message) + if (failed) process.exitCode = 1 +} + +const fail = (message) => report(message, true) + +const readPackageVersion = () => { + let parsed + try { + parsed = JSON.parse(readFileSync(PACKAGE_JSON, 'utf8')) + } catch (error) { + throw new Error(`cannot read ${PACKAGE_JSON}: ${error.message}`) + } + + const version = parsed.version + if (typeof version !== 'string' || version.length === 0) { + throw new Error(`${PACKAGE_JSON} has no "version" string`) + } + if (!parseVersion(version)) { + throw new Error(`${PACKAGE_JSON} version ${JSON.stringify(version)} is not X.Y.Z[-prerelease]`) + } + return version +} + +/** + * The tag this run is about, if any. + * + * `refs/tags/` is the reliable signal - `GITHUB_REF_NAME` is a branch name on + * `workflow_dispatch`. `--tag=` exists so the check can be exercised locally + * without inventing a repository state. + */ +const releaseTag = () => { + const flag = process.argv.find((argument) => argument.startsWith('--tag=')) + if (flag) return flag.slice('--tag='.length) + + if ((process.env.GITHUB_REF ?? '').startsWith('refs/tags/')) { + return process.env.GITHUB_REF_NAME ?? process.env.GITHUB_REF.slice('refs/tags/'.length) + } + + return null +} + +/** Tags in the repository, or null when it is not a clone / git is unavailable. */ +const repositoryTags = () => { + try { + return execFileSync('git', ['tag', '--list'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }) + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + } catch { + return null + } +} + +const checkTagMode = (version, tag) => { + const expectedTag = `v${version}` + if (tag === expectedTag) { + return report(`check:version — tag ${tag} matches package.json ${version}`, false) + } + + const tagVersion = tag.replace(/^v/, '') + const missingPrefix = !tag.startsWith('v') + + // Right version, wrong tag name: the only problem is the missing `v`, which is + // fatal rather than cosmetic because release.yml triggers on `v*.*.*`. + if (missingPrefix && tagVersion === version) { + return fail( + `Tag ${tag} has the right version but the wrong name: it must be ${expectedTag}.\n` + + ` release.yml triggers on \`tags: v*.*.*\`, so \`${tag}\` would push nothing and\n` + + ` publish no artifacts. Delete it and tag ${expectedTag} instead.` + ) + } + + const lines = [ + `Tag ${tag} does not match package.json ("version": "${version}").`, + ` electron-builder takes every artifact name and the updater feed from package.json,`, + ` so releasing this tag would publish a build whose version does not match its tag.`, + ` Pick one:`, + ` - you are releasing ${tagVersion}: run \`npm version ${tagVersion}\` on main, merge it, then re-tag`, + ` - you are releasing ${version}: delete this tag and tag ${expectedTag} instead` + ] + if (missingPrefix) { + lines.push( + ` Note: the tag has no "v" prefix, so release.yml (on: tags: v*.*.*) would not run for it at all.` + ) + } + + return fail(lines.join('\n')) +} + +const checkCompareMode = (version, tags) => { + // A bare version tag is a mistake: release.yml will never build from it, so it is + // invisible unless it is reported. It is also deliberately NOT counted as a + // release below, because otherwise a stray `2.0.0` tag would make every pull + // request fail against a version that was never published. + const bare = tags === null ? [] : bareVersionTags(tags) + if (bare.length > 0) { + return fail( + [ + `Unusable version tag${bare.length === 1 ? '' : 's'}: ${bare.join(', ')}`, + ` release.yml triggers on \`tags: v*.*.*\`, so ${bare.length === 1 ? 'this tag' : 'these tags'} would publish no artifacts.`, + ` ${bare.length === 1 ? 'It is' : 'They are'} not counted as a release here, or every pull request would fail against a`, + ` version that was never published.`, + ` Delete ${bare.length === 1 ? 'it' : 'them'}, or rename to v.` + ].join('\n') + ) + } + + const latest = tags === null ? null : newestVersionTag(tags) + + if (latest === null) { + // In CI this must not happen, and it is the failure this guard is most likely + // to have: a checkout that cannot see the tags compares nothing and then + // reports success, so the step looks green and checks absolutely nothing. + // That already happened once here - `fetch-tags: true` turned out to fetch no + // tags for a pull request, because it only adds the checked-out ref's own tag. + // Failing loudly is the same call as allowMissingDependencies: false in + // electron-builder.yml: a check that cannot run must not look like one that ran. + if (inActions) { + return fail( + `no version tag is reachable, so this check has nothing to compare against.\n` + + ` In CI that means the checkout did not fetch tags. verify.yml must use\n` + + ` \`fetch-depth: 0\`: actions/checkout only fetches every tag when it fetches\n` + + ` all history (\`fetch-tags\` adds just the checked-out ref's own tag).` + ) + } + + return report( + `check:version — package.json ${version}; no version tag reachable, nothing to compare ` + + `(not a git clone, or a shallow checkout without tags)`, + false + ) + } + + const latestVersion = latest.replace(/^v/, '') + const packageParsed = parseVersion(version) + const latestParsed = parseVersion(latestVersion) + + if (!packageParsed || !latestParsed) { + return fail(`could not compare ${version} with ${latest}`) + } + + if (compareVersions(packageParsed, latestParsed) < 0) { + return fail( + `package.json is at ${version} but the newest version tag is ${latest}. ` + + `The packaged app and its artifacts would claim a version older than what has ` + + `already shipped, and the updater would ignore the newer release. ` + + `Run \`npm version ${latestVersion}\` (or higher) to catch up.` + ) + } + + const relation = compareVersions(packageParsed, latestParsed) === 0 ? 'matches' : 'is ahead of' + return report( + `check:version — package.json ${version} ${relation} the newest version tag ${latest}`, + false + ) +} + +const main = () => { + let version + try { + version = readPackageVersion() + } catch (error) { + return fail(error.message) + } + + const tag = releaseTag() + return tag === null ? checkCompareMode(version, repositoryTags()) : checkTagMode(version, tag) +} + +// Only run when invoked directly (`npm run check:version`), so tests can import +// the ordering rules without executing the check. +const isDirectRun = + process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href + +if (isDirectRun) main() diff --git a/test/versionGuard.test.ts b/test/versionGuard.test.ts new file mode 100644 index 0000000..0d3ac6c --- /dev/null +++ b/test/versionGuard.test.ts @@ -0,0 +1,322 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { copyFileSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + bareVersionTags, + comparePrerelease, + compareVersions, + newestVersionTag, + parseVersion +} from '../scripts/check-version.mjs' + +/** + * A version guard is a build gate, so a silent hole in it is worse than a missing + * rule: everything downstream reads "no violations" as proof. The first version of + * the design-token guard shipped with a Windows-only hole for exactly that reason, + * so the ordering rules here are tested directly rather than only through CI. + * + * The end-to-end cases at the bottom re-run the script the way CI does, because + * "exits non-zero with a clear message" is the acceptance criterion and an exit + * code cannot be asserted from an in-process import. + */ + +const pkgVersion = JSON.parse(readFileSync('package.json', 'utf8')).version + +/** Definitely different from `pkgVersion`, so the mismatch case stays a mismatch. */ +const otherVersion = (() => { + const parsed = parseVersion(pkgVersion) + return parsed ? `${parsed.major + 1}.0.0` : '99.0.0' +})() + +test('parseVersion accepts the shapes this project uses', () => { + assert.deepEqual(parseVersion('1.3.0'), { + major: 1, + minor: 3, + patch: 0, + prerelease: null + }) + assert.deepEqual(parseVersion('1.0.5-beta.1'), { + major: 1, + minor: 0, + patch: 5, + prerelease: 'beta.1' + }) + assert.equal(parseVersion('10.20.30')?.major, 10) +}) + +test('parseVersion rejects anything that is not X.Y.Z', () => { + for (const value of ['1.3', '1.3.0.1', 'v1.3.0', 'abc', '', '1.3.x', ' 1.3.0']) { + assert.equal(parseVersion(value), null, value) + } +}) + +test('compareVersions orders numerically, not lexicographically', () => { + const gt = (a, b) => + assert.ok(compareVersions(parseVersion(a), parseVersion(b)) > 0, `${a} > ${b}`) + const eq = (a, b) => assert.equal(compareVersions(parseVersion(a), parseVersion(b)), 0) + + gt('1.0.10', '1.0.9') // the classic: "1.0.10" < "1.0.9" as strings + gt('1.10.0', '1.9.0') + gt('2.0.0', '1.99.99') + gt('1.0.1', '1.0.0') + gt('10.0.0', '9.0.0') + eq('1.3.0', '1.3.0') +}) + +test('a pre-release sorts below the release it precedes', () => { + assert.ok(compareVersions(parseVersion('1.0.5-beta.1'), parseVersion('1.0.5')) < 0) + assert.ok(compareVersions(parseVersion('1.0.5'), parseVersion('1.0.5-beta.1')) > 0) +}) + +/** + * Pre-release precedence cannot be a string comparison: `'beta.10' < 'beta.2'` + * lexicographically, but SemVer says `beta.10` is newer. Getting this wrong makes + * `newestVersionTag` pick the wrong tag, so the guard would validate against the + * wrong release. This project has shipped `v1.0.5-beta.1`, so the shape is real. + */ + +test('pre-release identifiers follow SemVer precedence, not string order', () => { + const older = (a: string, b: string): void => + assert.ok(comparePrerelease(a, b) < 0, `${a} < ${b}`) + const newer = (a: string, b: string): void => + assert.ok(comparePrerelease(a, b) > 0, `${a} > ${b}`) + + // The bug this pins: numeric identifiers compare numerically. + older('beta.2', 'beta.10') + newer('beta.10', 'beta.2') + + // Alphanumeric identifiers compare lexically. + older('alpha', 'beta') + + // Numeric identifiers always have lower precedence than alphanumeric ones. + older('1', 'alpha') + older('1', 'beta') + older('alpha.1', 'alpha.beta') + + // A shorter set loses once every shared identifier matches. + older('beta', 'beta.1') + older('alpha', 'alpha.1') + older('alpha.1', 'alpha.1.1') + + // And the equal / deeper cases. + assert.equal(comparePrerelease('beta.1', 'beta.1'), 0) + older('beta.1', 'beta.2') + older('beta.1.1', 'beta.2') +}) + +test('a pre-release comparison feeds the version comparison', () => { + const older = (a: string, b: string): void => + assert.ok(compareVersions(parseVersion(a), parseVersion(b)) < 0, `${a} < ${b}`) + + older('1.4.0-beta.2', '1.4.0-beta.10') + older('1.4.0-alpha', '1.4.0-beta') + older('1.4.0-1', '1.4.0-alpha') + older('1.4.0-beta', '1.4.0-beta.1') + older('1.4.0-beta.10', '1.4.0') + // The patch still dominates the pre-release: a later patch wins outright. + older('1.4.0-beta.10', '1.4.1-alpha') +}) + +test('newestVersionTag picks the highest version, including past a pre-release', () => { + // A pre-release of 1.0.5 is newer than the 1.0.4 release it supersedes; only its + // own release outranks it. + assert.equal(newestVersionTag(['v1.0.4', 'v1.0.5-beta.1', 'v1.0.4-rc.1']), 'v1.0.5-beta.1') + assert.equal(newestVersionTag(['v1.0.4']), 'v1.0.4') + assert.equal(newestVersionTag(['v1.0.4-rc.1', 'v1.0.4']), 'v1.0.4') + + // A pre-release must never win over the release it precedes. + assert.equal(newestVersionTag(['v1.0.5', 'v1.0.5-beta.1']), 'v1.0.5') + assert.equal(newestVersionTag(['v1.0.5-beta.1', 'v1.0.5']), 'v1.0.5') + + // Two-digit components are the case a lexicographic sort gets wrong, in both + // the patch and the pre-release identifier. + assert.equal(newestVersionTag(['v1.0.9', 'v1.0.10']), 'v1.0.10') + assert.equal(newestVersionTag(['v1.0.10', 'v1.0.9']), 'v1.0.10') + assert.equal(newestVersionTag(['v1.4.0-beta.2', 'v1.4.0-beta.10']), 'v1.4.0-beta.10') +}) + +test('newestVersionTag ignores tags that are not versions', () => { + assert.equal(newestVersionTag([]), null) + assert.equal(newestVersionTag(['archive/ai-conversation', 'release-candidate']), null) + assert.equal(newestVersionTag(['archive/ai-conversation', 'v1.2.0']), 'v1.2.0') +}) + +/** + * `release.yml` triggers on `tags: v*.*.*`, so a bare `1.2.3` is a tag no release + * is ever built from. Counting it as the newest release would fail every pull + * request against a version that was never published, so it is excluded and + * reported separately instead. + */ + +test('a bare version tag is excluded from the release tags', () => { + assert.equal(newestVersionTag(['1.2.0', 'v1.1.1']), 'v1.1.1') + assert.equal(newestVersionTag(['1.2.0']), null) + assert.equal(newestVersionTag(['v1.1.1', '1.2.0']), 'v1.1.1') +}) + +test('bareVersionTags finds the version tags that are missing their prefix', () => { + assert.deepEqual(bareVersionTags(['v1.2.0']), []) + assert.deepEqual(bareVersionTags(['archive/ai-conversation', 'v1.2.0']), []) + assert.deepEqual(bareVersionTags(['1.2.0', 'v1.1.1']), ['1.2.0']) + assert.deepEqual(bareVersionTags(['2.0.0', '1.9.0', 'v1.1.1']), ['2.0.0', '1.9.0']) + // Not a version at all, so not reported as a misnamed release tag. + assert.deepEqual(bareVersionTags(['archive/ai-conversation', 'release-candidate']), []) +}) + +/** + * End-to-end: the actual script, the actual exit code. `--tag=` forces tag mode + * without inventing a repository state, and the mismatched version is derived from + * package.json so these keep working across a release bump. + */ + +// `execFileSync` throws on a non-zero exit and types the caught value as unknown. +// `spawnSync` returns the status and streams instead, so the exit code can be +// asserted directly without narrowing a thrown value. +const runCheck = (args: string[]): { status: number | null; output: string } => { + const result = spawnSync('node', ['scripts/check-version.mjs', ...args], { encoding: 'utf8' }) + return { status: result.status, output: `${result.stdout}${result.stderr}` } +} + +const runCheckExpectingFailure = (args: string[]): { status: number | null; output: string } => { + const result = runCheck(args) + assert.notEqual(result.status, 0, `expected a non-zero exit for: ${args.join(' ')}`) + return result +} + +test('the matching tag passes', () => { + assert.equal(runCheck([`--tag=v${pkgVersion}`]).status, 0) + assert.match(runCheck([`--tag=v${pkgVersion}`]).output, /matches package\.json/) +}) + +test('a mismatched tag exits non-zero and names both versions', () => { + const { output } = runCheckExpectingFailure([`--tag=v${otherVersion}`]) + + assert.match(output, new RegExp(`v${otherVersion.replace(/\./g, '\\.')}`)) + assert.match(output, new RegExp(pkgVersion.replace(/\./g, '\\.'))) + assert.match(output, /does not match package\.json/) +}) + +test('a tag without the v prefix fails and explains why it is fatal', () => { + // Right version, wrong tag name: release.yml triggers on `tags: v*.*.*`, so this + // tag would push nothing at all. + const { output } = runCheckExpectingFailure([`--tag=${pkgVersion}`]) + + assert.match(output, /wrong name/) + assert.match(output, /v\*\.\*\.\*/) +}) + +/** + * The scenario this rule exists for: a bare `2.0.0` is pushed, `release.yml` never + * runs because it only listens for `v*.*.*`, and - if the tag were counted as a + * release - every pull request would then fail with "package.json is behind 2.0.0" + * over a version that was never published. Built in a real throwaway git + * repository, because the tag list comes from git. + */ +test('a bare version tag is reported, not mistaken for a release', () => { + const dir = mkdtempSync(join(tmpdir(), 'knownote-version-bare-')) + try { + copyFileSync('scripts/check-version.mjs', join(dir, 'check-version.mjs')) + copyFileSync('package.json', join(dir, 'package.json')) + + const git = (args: string[]): void => { + const result = spawnSync('git', args, { cwd: dir, encoding: 'utf8' }) + assert.equal(result.status, 0, `git ${args.join(' ')} failed: ${result.stderr}`) + } + git(['init', '--quiet']) + git([ + '-c', + 'user.email=test@example.com', + '-c', + 'user.name=test', + 'commit', + '--allow-empty', + '--quiet', + '-m', + 'init' + ]) + git(['tag', '2.0.0']) + + const withoutActions = { ...process.env } + delete withoutActions.GITHUB_ACTIONS + + const result = spawnSync('node', ['check-version.mjs'], { + cwd: dir, + encoding: 'utf8', + env: withoutActions + }) + const output = `${result.stdout}${result.stderr}` + + assert.notEqual(result.status, 0) + assert.match(output, /Unusable version tag/) + assert.match(output, /2\.0\.0/) + // The important half: it is never compared against as the newest release. + assert.doesNotMatch(output, /newest version tag/) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('the repository itself passes the guard', () => { + // Read-only end-to-end check of compare mode against the real tags: a plain run + // must stay green, so a version that has fallen behind the newest release fails + // the test suite as well as CI. + const result = runCheck([]) + + assert.equal(result.status, 0) + assert.match(result.output, /check:version/) +}) + +test('the documented entry point is wired to this script', () => { + // Asserted by reading package.json rather than by running `npm run check:version`: + // on Windows `npm` is an `npm.cmd` shim, and spawnSync cannot execute a .cmd + // without `shell: true`, so the subprocess returns `status: null` and the test + // fails on Windows only. CI runs the real `npm run check:version` step anyway, + // which is the stronger check. + const scripts = JSON.parse(readFileSync('package.json', 'utf8')).scripts + + assert.equal(scripts['check:version'], 'node scripts/check-version.mjs') +}) + +/** + * The failure this guard is most likely to have is not a wrong answer but a silent + * one: a checkout that cannot see any tags compares nothing and then reports + * success. That already happened once - `fetch-tags: true` turned out to fetch no + * tags for a pull request - and the step went green while checking nothing. + * + * Reproduced in a throwaway directory that is not a git repository at all, which is + * the same shape as a checkout that fetched no tags. + */ +test('in CI, having no tags to compare against fails instead of passing', () => { + const dir = mkdtempSync(join(tmpdir(), 'knownote-version-guard-')) + try { + copyFileSync('scripts/check-version.mjs', join(dir, 'check-version.mjs')) + copyFileSync('package.json', join(dir, 'package.json')) + + // The environment has to be controlled explicitly: CI sets GITHUB_ACTIONS, so + // inheriting it here would run the "not in CI" case in CI mode and the test + // would assert the wrong branch (which is how this first failed). + const withoutActions = { ...process.env } + delete withoutActions.GITHUB_ACTIONS + + const local = spawnSync('node', ['check-version.mjs'], { + cwd: dir, + encoding: 'utf8', + env: withoutActions + }) + assert.equal(local.status, 0, 'outside CI a missing tag list is tolerated') + + const ci = spawnSync('node', ['check-version.mjs'], { + cwd: dir, + encoding: 'utf8', + env: { ...process.env, GITHUB_ACTIONS: 'true' } + }) + + assert.notEqual(ci.status, 0, 'in CI it must not report success without comparing') + assert.match(`${ci.stdout}${ci.stderr}`, /fetch-depth: 0/) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +})