From db59be18e67aeef07a1edfbf9fe5c20e12b1ab2c Mon Sep 17 00:00:00 2001 From: awalsh128 Date: Sun, 8 Nov 2026 16:33:12 -0800 Subject: [PATCH] build(deps): install ts-apt 1.1.1 from the npm registry ts-apt is now published to npm, so depend on the exact release instead of a git commit SHA. check_deps_pin accepts an exact registry version whose lockfile entry resolves from registry.npmjs.org with a sha512 integrity hash, and still accepts a full SHA pin for unreleased ts-apt changes. Ranges, tags and branch refs are still rejected. 1.1.1 keeps the 1.1.0 public API; the dpkg-query listInstalled output format change does not affect the action, which doesn't call it. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 +- package-lock.json | 8 ++-- package.json | 2 +- scripts/ops/check_deps_pin.mts | 73 ++++++++++++++++++++++------- test/check-deps-pin.test.ts | 86 ++++++++++++++++++++++++++++++++++ 5 files changed, 150 insertions(+), 24 deletions(-) create mode 100644 test/check-deps-pin.test.ts diff --git a/AGENTS.md b/AGENTS.md index 6c630a1..232a23c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,8 +83,9 @@ You are an expert developer focused on TypeScript, Bash, DevOps, Git, and GitHub - Follow `../ts-apt/AGENTS.md` when editing ts-apt. - Add ts-apt unit tests for every new ts-apt method. - Use `npm run dev:tsapt:link` to test against local ts-apt; run `npm run dev:tsapt:unlink` when finished. -- `package.json` pins ts-apt to a commit SHA; local ts-apt changes are not visible to CI. -- MUST push the ts-apt change first, then bump the ts-apt SHA pin in the same change that uses it. +- `package.json` pins ts-apt to an exact npm version (e.g. `1.1.1`, never a range); local ts-apt changes are not visible to CI. +- MUST release the ts-apt change to npm first, then bump the ts-apt version pin in the same change that uses it. +- A full commit SHA pin (`git+https://...#`) is allowed only for a pushed but unreleased ts-apt change; replace it with the npm version once released. - NEVER push action code that depends on an unpushed ts-apt change. - `npm run check:deps-pin` enforces the pin; it runs in CI and in the husky `.husky/pre-push` hook installed by `npm install`. - NEVER bypass the pre-push hook with `--no-verify`; unlink ts-apt instead. diff --git a/package-lock.json b/package-lock.json index 52588a0..db5a921 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@actions/artifact": "^6.2.1", "@actions/cache": "^6.1.0", "@actions/core": "^3.0.1", - "ts-apt": "git+https://github.com/awalsh128/ts-apt.git#64aaab829c940f6d1999bb8b530817bcf26bfe58", + "ts-apt": "1.1.1", "winston": "^3.19.0" }, "devDependencies": { @@ -11915,9 +11915,9 @@ } }, "node_modules/ts-apt": { - "version": "0.0.0-semantically-released", - "resolved": "git+ssh://git@github.com/awalsh128/ts-apt.git#64aaab829c940f6d1999bb8b530817bcf26bfe58", - "integrity": "sha512-8PQQTQTvM+KFhlTORDw3ngL/jOokv9vwqI+i3NotubDSxz5QcOU/p4Y/8qQ7muQYhuODTqJBrTQ1nD1uea+tbw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ts-apt/-/ts-apt-1.1.1.tgz", + "integrity": "sha512-UmbvNRiHR+rD+Uqv74NZYoZNsfCqmuigtet5TowuAn2CeiQbkrKwoUJWvs8z4E456yDF2jk2MgvR2qU5DYqVSg==", "license": "Apache-2.0", "dependencies": { "async-mutex": "^0.5.0", diff --git a/package.json b/package.json index 3c7bdd3..7e0fae6 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "@actions/artifact": "^6.2.1", "@actions/cache": "^6.1.0", "@actions/core": "^3.0.1", - "ts-apt": "git+https://github.com/awalsh128/ts-apt.git#64aaab829c940f6d1999bb8b530817bcf26bfe58", + "ts-apt": "1.1.1", "winston": "^3.19.0" }, "devDependencies": { diff --git a/scripts/ops/check_deps_pin.mts b/scripts/ops/check_deps_pin.mts index 00f5091..813caac 100644 --- a/scripts/ops/check_deps_pin.mts +++ b/scripts/ops/check_deps_pin.mts @@ -1,13 +1,16 @@ #!/usr/bin/env -S node --experimental-strip-types /** - * Fails when a dependency is not resolved from a pushed commit SHA pin. + * Fails when a dependency is not pinned to an immutable, published source. * * Guards against shipping code built or tested against a locally linked * dependency (e.g. npm run dev:tsapt:link), which CI cannot see. For each * dependency name given as an argument, checks that: * 1. node_modules/ is not a symlink (skipped if it is absent). - * 2. package.json pins to a full commit SHA (`...#<40 hex chars>`). - * 3. package-lock.json resolves to that same SHA and not a link. + * 2. package.json pins to either an exact npm registry version + * (e.g. `1.1.1`, no ranges) or a full git commit SHA (`...#<40 hex>`). + * 3. package-lock.json resolves to that same pin and not a link. + * A registry pin must resolve from registry.npmjs.org with an integrity + * hash, since npm never lets a published version change. * * Used by .husky/pre-push and the CI action-deps-pinned job. * @@ -27,18 +30,26 @@ import { } from "../devopslib.mts"; const SHA_PIN_PATTERN = /#([0-9a-f]{40})$/; +/** Exact semver version, optionally prerelease; rejects ranges and tags. */ +const EXACT_VERSION_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z.-]+)?$/; +const NPM_REGISTRY_URL = "https://registry.npmjs.org/"; -type PackageJson = { +/** Parsed package.json, limited to the fields the check reads. */ +export type PackageJson = { dependencies?: Record; devDependencies?: Record; }; type LockEntry = { + version?: string; resolved?: string; + integrity?: string; link?: boolean; }; -type PackageLock = { +/** Parsed package-lock.json, limited to the fields the check reads. */ +export type PackageLock = { packages?: Record< string, LockEntry & { @@ -59,15 +70,24 @@ function isSymlink(filePath: string): boolean { } } -/** Returns one problem message per failed check for the dependency. */ -function checkDependencyPin( +/** + * Checks that a dependency is pinned to an immutable source. + * + * @param name Dependency name as it appears in package.json. + * @param packageJson Parsed package.json. + * @param packageLock Parsed package-lock.json. + * @param rootDir Repository root used to find node_modules. + * @returns One problem message per failed check; empty when the pin is valid. + */ +export function checkDependencyPin( name: string, packageJson: PackageJson, packageLock: PackageLock, + rootDir: string = ROOT_DIR, ): string[] { const problems: string[] = []; - const modulePath = path.join(ROOT_DIR, "node_modules", name); + const modulePath = path.join(rootDir, "node_modules", name); if (isSymlink(modulePath)) { problems.push( `node_modules/${name} is a symlink to ${fs.readlinkSync(modulePath)}; unlink it and run 'npm ci' first.`, @@ -81,16 +101,16 @@ function checkDependencyPin( return problems; } const sha = SHA_PIN_PATTERN.exec(spec)?.[1]; - if (sha === undefined) { + const isExactVersion = EXACT_VERSION_PATTERN.test(spec); + if (sha === undefined && !isExactVersion) { problems.push( - `package.json ${name} '${spec}' is not pinned to a full commit SHA.`, + `package.json ${name} '${spec}' is not pinned to an exact registry version or a full commit SHA.`, ); return problems; } const root = packageLock.packages?.[""]; - const rootSpec = - root?.dependencies?.[name] ?? root?.devDependencies?.[name]; + const rootSpec = root?.dependencies?.[name] ?? root?.devDependencies?.[name]; if (rootSpec !== spec) { problems.push( `package-lock.json root ${name} '${rootSpec}' does not match package.json '${spec}'.`, @@ -104,10 +124,26 @@ function checkDependencyPin( problems.push( `package-lock.json ${name} is a link to '${entry.resolved}'.`, ); - } else if (!entry.resolved?.endsWith(`#${sha}`)) { - problems.push( - `package-lock.json ${name} resolved '${entry.resolved}' does not match pin ${sha}.`, - ); + } else if (sha !== undefined) { + if (!entry.resolved?.endsWith(`#${sha}`)) { + problems.push( + `package-lock.json ${name} resolved '${entry.resolved}' does not match pin ${sha}.`, + ); + } + } else { + if (entry.version !== spec) { + problems.push( + `package-lock.json ${name} version '${entry.version}' does not match pin ${spec}.`, + ); + } + if (!entry.resolved?.startsWith(NPM_REGISTRY_URL)) { + problems.push( + `package-lock.json ${name} resolved '${entry.resolved}' is not from ${NPM_REGISTRY_URL}.`, + ); + } + if (!entry.integrity?.startsWith("sha512-")) { + problems.push(`package-lock.json ${name} has no sha512 integrity hash.`); + } } return problems; @@ -139,4 +175,7 @@ function main(): void { logSuccess(`Dependency pin check passed for ${names.join(", ")}.`); } -await runMain(main, 1); +// Run only as a script, so tests can import checkDependencyPin. +if (import.meta.main) { + await runMain(main, 1); +} diff --git a/test/check-deps-pin.test.ts b/test/check-deps-pin.test.ts new file mode 100644 index 0000000..1950659 --- /dev/null +++ b/test/check-deps-pin.test.ts @@ -0,0 +1,86 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + checkDependencyPin, + type PackageJson, + type PackageLock, +} from "../scripts/ops/check_deps_pin.mts"; + +const NAME = "pinned-dep"; +const VERSION = "1.2.3"; +const SHA = "0123456789abcdef0123456789abcdef01234567"; +const SHA_SPEC = `git+https://github.com/example/pinned-dep.git#${SHA}`; +const REGISTRY_RESOLVED = `https://registry.npmjs.org/${NAME}/-/${NAME}-${VERSION}.tgz`; +const INTEGRITY = "sha512-AAAA"; +// No node_modules here, so the symlink check never fires. +const ROOT_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "check-deps-pin-")); + +const packageJsonOf = (spec: string): PackageJson => ({ + dependencies: { [NAME]: spec }, +}); + +const lockOf = ( + rootSpec: string, + entry: NonNullable[string], +): PackageLock => ({ + packages: { + "": { dependencies: { [NAME]: rootSpec } }, + [`node_modules/${NAME}`]: entry, + }, +}); + +const REGISTRY_ENTRY = { + version: VERSION, + resolved: REGISTRY_RESOLVED, + integrity: INTEGRITY, +}; + +const check = (spec: string, lock: PackageLock) => + checkDependencyPin(NAME, packageJsonOf(spec), lock, ROOT_DIR); + +describe("checkDependencyPin", () => { + it("checkDependencyPin exact registry version with matching lock returns no problems", () => { + expect(check(VERSION, lockOf(VERSION, REGISTRY_ENTRY))).toEqual([]); + }); + + it("checkDependencyPin commit SHA with matching lock returns no problems", () => { + const lock = lockOf(SHA_SPEC, { resolved: `git+ssh://x.git#${SHA}` }); + + expect(check(SHA_SPEC, lock)).toEqual([]); + }); + + it.each(["^1.2.3", "~1.2.3", "latest", "1.2", "git+https://x.git#main"])( + "checkDependencyPin unpinned spec %s returns a pin problem", + (spec) => { + expect(check(spec, lockOf(spec, REGISTRY_ENTRY))).toEqual([ + expect.stringContaining("is not pinned"), + ]); + }, + ); + + it.each([ + ["version", { ...REGISTRY_ENTRY, version: "1.2.4" }, "version"], + [ + "resolved", + { ...REGISTRY_ENTRY, resolved: "https://evil.example/x.tgz" }, + "is not from", + ], + ["integrity", { ...REGISTRY_ENTRY, integrity: undefined }, "integrity"], + ["link", { ...REGISTRY_ENTRY, link: true }, "is a link"], + ])( + "checkDependencyPin registry pin with mismatched lock %s returns a problem", + (_field, entry, message) => { + expect(check(VERSION, lockOf(VERSION, entry))).toEqual([ + expect.stringContaining(message), + ]); + }, + ); + + it("checkDependencyPin lock root spec differs returns a mismatch problem", () => { + expect(check(VERSION, lockOf("1.2.4", REGISTRY_ENTRY))).toEqual([ + expect.stringContaining("does not match package.json"), + ]); + }); +});