From 2a14b081eb795d4ff339696bec6ecd557a50b6d1 Mon Sep 17 00:00:00 2001 From: Justin Shank Date: Sat, 8 Aug 2026 20:10:10 -0400 Subject: [PATCH] ci: enforce typecheck and version sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit esbuild strips types without checking them, so neither `build` nor `test` (which runs with TS_NODE_TRANSPILE_ONLY=1) could catch a type error. Nothing in CI ran tsc, so a type regression could have shipped. Adding that gate surfaced three pre-existing errors in the test project: - Two TS5097: tests import with explicit .ts extensions so ts-node/ESM can resolve them at runtime. Enabled allowImportingTsExtensions + noEmit in tsconfig.test.json so tsc accepts the same paths. - One TS2741: NoteStats.lastOpened is declared required, but metrics.ts falls back to mtime when it is absent (SPEC 3.2). store.ts guarantees the field post-sanitization, so the production invariant is correct and the test was constructing a deliberately degraded record. Fixed in the test with a cast rather than weakening the type, which would have made store.ts's guarantee meaningless. Also adds scripts/check-version-sync.mjs to enforce the release invariants CLAUDE.md previously documented as manual: manifest/package version parity, a versions.json entry mapping to minAppVersion, and (on release) tag equality. Obsidian's plugin store matches the tag against manifest.json as a plain string, and `on: push: tags: ["*"]` fired for any tag with nothing validating it. CI runs the check without the tag arg so a mis-bump fails at PR time; the release workflow runs it first, before npm ci, so a mistyped tag fails in seconds. No release artifact changes: main.js, styles.css and manifest.json are byte-identical to the published 0.5.0 (main.js sha256 561dc75d…), so no version bump or re-release is required. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 6 ++++ .github/workflows/release.yml | 7 ++++ CLAUDE.md | 13 ++++++-- package.json | 2 ++ scripts/check-version-sync.mjs | 61 ++++++++++++++++++++++++++++++++++ tests/glow-score.test.ts | 6 ++-- tsconfig.test.json | 6 +++- 7 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 scripts/check-version-sync.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7a0e3c..d681c38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,13 @@ jobs: with: node-version: 20 cache: npm + # Catches a mis-bump (manifest/package drift, missing versions.json entry) + # at PR time rather than at tag-push time. + - run: npm run check:versions - run: npm ci - run: npm run lint + # esbuild strips types without checking them, so build alone cannot catch + # a type error. This is the only gate that does. + - run: npm run typecheck - run: npm run build - run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35c9640..d4c0f8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,12 +25,19 @@ jobs: node-version: "20" cache: "npm" + # Runs before `npm ci` so a mistyped tag costs seconds, not a whole run. + - name: Verify tag matches manifest, package and versions.json + run: node scripts/check-version-sync.mjs "${GITHUB_REF_NAME}" + - name: Install dependencies run: npm ci - name: Lint run: npm run lint + - name: Typecheck + run: npm run typecheck + - name: Test run: npm test diff --git a/CLAUDE.md b/CLAUDE.md index 82cca71..31b9f6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,10 @@ visual glow instead of hunting filenames. - **Repo:** https://github.com/WhatsYourWhy/Cognitive-Glow - **Build:** `npm run build` (esbuild → `main.js` at repo root) - **Lint:** `npm run lint` (must pass clean before any release) +- **Typecheck:** `npm run typecheck` — esbuild strips types WITHOUT checking them, + so this is the only gate that catches a type error. Covers both tsconfigs. - **Test:** `npm test` +- **Version sync:** `npm run check:versions` (see Release process below) ## Release process — DO NOT bypass the workflow @@ -14,8 +17,14 @@ Releases ship via `.github/workflows/release.yml`. The workflow builds, signs the artifacts with GitHub artifact attestations, and uploads `main.js`, `styles.css`, and `manifest.json` to the release. -The release workflow runs `lint` + `test` before `build`, so a broken commit -cannot ship. CI also runs on push-to-main as a second gate. +The release workflow runs `check:versions` + `lint` + `typecheck` + `test` before +`build`, so a broken commit cannot ship. CI also runs on push-to-main as a second +gate. + +Steps 1, 2 and 4 below are enforced by `scripts/check-version-sync.mjs`, which +runs in CI (without the tag check) and as the FIRST release step, before +`npm ci` — so a mistyped tag fails in seconds. It is a guard, not a substitute: +you still have to do the bumps. To cut a release: diff --git a/package.json b/package.json index 23d0dbf..cfa5663 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "build": "node esbuild.config.mjs", "dev": "node esbuild.config.mjs --watch", "lint": "eslint plugin/ core/ ui/ manifest.json", + "typecheck": "tsc --noEmit && tsc -p tsconfig.test.json --noEmit", + "check:versions": "node scripts/check-version-sync.mjs", "test": "cross-env TS_NODE_PROJECT=tsconfig.test.json TS_NODE_TRANSPILE_ONLY=1 node --test --loader ts-node/esm tests/glow-score.test.ts tests/store-sanitize.test.ts" }, "devDependencies": { diff --git a/scripts/check-version-sync.mjs b/scripts/check-version-sync.mjs new file mode 100644 index 0000000..2ca3ff6 --- /dev/null +++ b/scripts/check-version-sync.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +// Enforces the release version invariants from CLAUDE.md so a mis-bump cannot +// reach a tag: +// +// 1. manifest.json version === package.json version +// 2. versions.json has an entry for that version, mapping to minAppVersion +// 3. when a tag is supplied, tag === manifest.json version +// +// Obsidian's plugin store matches the release tag against manifest.json as a +// plain string, so a mismatch publishes a release the store cannot resolve. +// +// Usage: +// node scripts/check-version-sync.mjs # checks 1 + 2 (CI, any branch) +// node scripts/check-version-sync.mjs 0.5.0 # also checks 3 (release) + +import { readFileSync } from "node:fs"; + +const read = (name) => + JSON.parse(readFileSync(new URL(`../${name}`, import.meta.url), "utf8")); + +const manifest = read("manifest.json"); +const pkg = read("package.json"); +const versions = read("versions.json"); + +const tag = (process.argv[2] ?? "").trim(); +const errors = []; + +if (pkg.version !== manifest.version) { + errors.push( + `package.json version '${pkg.version}' does not match manifest.json version '${manifest.version}'`, + ); +} + +const mapped = versions[manifest.version]; +if (mapped === undefined) { + errors.push( + `versions.json has no entry for '${manifest.version}'. Add "${manifest.version}": "${manifest.minAppVersion}".`, + ); +} else if (mapped !== manifest.minAppVersion) { + errors.push( + `versions.json['${manifest.version}'] is '${mapped}' but manifest.json minAppVersion is '${manifest.minAppVersion}'`, + ); +} + +if (tag && tag !== manifest.version) { + errors.push( + `tag '${tag}' does not match manifest.json version '${manifest.version}'`, + ); +} + +if (errors.length > 0) { + const prefix = process.env.GITHUB_ACTIONS ? "::error::" : "error: "; + for (const message of errors) console.error(`${prefix}${message}`); + process.exit(1); +} + +console.log( + `version sync OK — manifest=${manifest.version} package=${pkg.version} ` + + `minAppVersion=${manifest.minAppVersion} versions.json=${mapped}` + + (tag ? ` tag=${tag}` : " (no tag checked)"), +); diff --git a/tests/glow-score.test.ts b/tests/glow-score.test.ts index b4b7c41..60a55a0 100644 --- a/tests/glow-score.test.ts +++ b/tests/glow-score.test.ts @@ -53,10 +53,12 @@ test("computeGlowScore matches the expected formula", () => { }); test("computeGlowScore uses fallback mtime when lastOpened is missing", () => { - const stats: NoteStats = { + // Deliberately degraded record: store.ts guarantees lastOpened post-sanitize, + // so the cast is what lets us reach the SPEC 3.2 mtime-fallback branch. + const stats = { path: "notes/fallback.md", hitCount: 1, - }; + } as NoteStats; const fallbackMtime = now - 2 * 24 * 60 * 60 * 1000; const expected = expectedGlowScore(stats, fallbackMtime); diff --git a/tsconfig.test.json b/tsconfig.test.json index 37c15db..24a3547 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -3,7 +3,11 @@ "compilerOptions": { "types": [ "node" - ] + ], + // Tests import with explicit .ts extensions so ts-node/ESM can resolve them + // at runtime; this makes `tsc --noEmit` accept the same paths. + "allowImportingTsExtensions": true, + "noEmit": true }, "include": [ "core/**/*.ts",