mirror of
https://github.com/awalsh128/cache-apt-pkgs-action.git
synced 2026-11-13 21:36:56 +00:00
build(deps): install ts-apt 1.1.1 from the npm registry (#244)
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:
parent
5905c29b85
commit
e8bec41895
|
|
@ -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
8
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
86
test/check-deps-pin.test.ts
Normal file
86
test/check-deps-pin.test.ts
Normal 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"),
|
||||
]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue