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 <noreply@anthropic.com>
This commit is contained in:
awalsh128 2026-11-08 16:33:12 -08:00
parent 9ff16f8ab9
commit db59be18e6
5 changed files with 150 additions and 24 deletions

View file

@ -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://...#<sha>`) 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.

8
package-lock.json generated
View file

@ -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",

View file

@ -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": {

View file

@ -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/<name> is not a symlink (skipped if it is absent).
* 2. package.json pins <name> to a full commit SHA (`...#<40 hex chars>`).
* 3. package-lock.json resolves <name> to that same SHA and not a link.
* 2. package.json pins <name> 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 <name> 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<string, string>;
devDependencies?: Record<string, string>;
};
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);
}

View file

@ -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<PackageLock["packages"]>[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"),
]);
});
});