diff --git a/.githooks/pre-push b/.githooks/pre-push index 83ae9d6..5e1b302 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,6 +1,8 @@ #!/usr/bin/env bash -# Blocks pushes while ts-apt is locally linked or not pinned to a commit SHA. -# Installed by the npm "prepare" script via core.hooksPath. +# Blocks pushes while a pinned dependency (e.g. ts-apt) is locally linked or +# not pinned to a commit SHA. Installed by the npm "prepare" script via +# core.hooksPath. set -euo pipefail -exec "$(git rev-parse --show-toplevel)/scripts/ops/check_tsapt_pin.sh" +cd "$(git rev-parse --show-toplevel)" +exec npm run -s check:deps-pin diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 574d422..28b1586 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,9 +125,9 @@ jobs: run: npm run check:latest-action-pins # Fails if ts-apt is linked locally or not pinned to a pushed commit SHA. - - name: Check ts-apt Pin + - name: Check Dependency Pins shell: bash - run: npm run check:tsapt-pin + run: npm run check:deps-pin testing: runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index a9c4489..c192c8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,7 +86,7 @@ You are an expert developer focused on TypeScript, Bash, DevOps, Git, and GitHub - `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. - NEVER push action code that depends on an unpushed ts-apt change. -- `npm run check:tsapt-pin` enforces the pin; it runs in CI and in the `.githooks/pre-push` hook installed by `npm install`. +- `npm run check:deps-pin` enforces the pin; it runs in CI and in the `.githooks/pre-push` hook installed by `npm install`. - NEVER bypass the pre-push hook with `--no-verify`; unlink ts-apt instead. ## Quick Commands diff --git a/package.json b/package.json index 93f8a61..a815687 100644 --- a/package.json +++ b/package.json @@ -55,8 +55,8 @@ "check:latest-action-pins": "node --experimental-strip-types ./scripts/ops/check_latest_action_pin.mts --scan", "check:pr": "node --experimental-strip-types ./scripts/ops/pr_checks.mts", "check:configs": "node --experimental-strip-types ./scripts/ops/check_configs.mts", - "check:tsapt-pin": "./scripts/ops/check_tsapt_pin.sh", - "check": "npm run -s runall -- check:latest-action-pins check:pr check:configs check:tsapt-pin", + "check:deps-pin": "node --experimental-strip-types ./scripts/ops/check_deps_pin.mts ts-apt", + "check": "npm run -s runall -- check:latest-action-pins check:pr check:configs check:deps-pin", "dev:tsapt:link": "npm link ../ts-apt --no-save; echo \"NOTE: npm run dev:tsapt:unlink when you are finished.\"", "dev:tsapt:unlink": "npm unlink ts-apt --no-save && npm ci", "gh:auth": "gh auth status >/dev/null 2>&1 || gh auth login --web && gh auth setup-git", diff --git a/scripts/ops/check_deps_pin.mts b/scripts/ops/check_deps_pin.mts new file mode 100644 index 0000000..7866e5b --- /dev/null +++ b/scripts/ops/check_deps_pin.mts @@ -0,0 +1,142 @@ +#!/usr/bin/env -S node --experimental-strip-types +/** + * Fails when a dependency is not resolved from a pushed commit SHA pin. + * + * 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. + * + * Used by .githooks/pre-push and the CI action-deps-pinned job. + * + * Usage: check_deps_pin.mts [...] + */ + +import fs from "node:fs"; +import path from "node:path"; +import { + fail, + logError, + logSuccess, + readJsonFile, + ROOT_DIR, + runMain, + usage, +} from "../devopslib.mts"; + +const SHA_PIN_PATTERN = /#([0-9a-f]{40})$/; + +type PackageJson = { + dependencies?: Record; + devDependencies?: Record; +}; + +type LockEntry = { + resolved?: string; + link?: boolean; +}; + +type PackageLock = { + packages?: Record< + string, + LockEntry & { + dependencies?: Record; + devDependencies?: Record; + } + >; +}; + +function isSymlink(filePath: string): boolean { + try { + return fs.lstatSync(filePath).isSymbolicLink(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw error; + } +} + +/** Returns one problem message per failed check for the dependency. */ +function checkDependencyPin( + name: string, + packageJson: PackageJson, + packageLock: PackageLock, +): string[] { + const problems: string[] = []; + + const modulePath = path.join(ROOT_DIR, "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.`, + ); + } + + const spec = + packageJson.dependencies?.[name] ?? packageJson.devDependencies?.[name]; + if (spec === undefined) { + problems.push(`package.json has no ${name} dependency.`); + return problems; + } + const sha = SHA_PIN_PATTERN.exec(spec)?.[1]; + if (sha === undefined) { + problems.push( + `package.json ${name} '${spec}' is not pinned to a full commit SHA.`, + ); + return problems; + } + + const root = packageLock.packages?.[""]; + 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}'.`, + ); + } + + const entry = packageLock.packages?.[`node_modules/${name}`]; + if (entry === undefined) { + problems.push(`package-lock.json has no node_modules/${name} entry.`); + } else if (entry.link) { + 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}.`, + ); + } + + return problems; +} + +function main(): void { + const names = process.argv.slice(2); + if (names.length === 0 || names.some((name) => name.length === 0)) { + fail(usage(import.meta.url, " [...]")); + } + + const packageJson = readJsonFile( + path.join(ROOT_DIR, "package.json"), + ) as PackageJson; + const packageLock = readJsonFile( + path.join(ROOT_DIR, "package-lock.json"), + ) as PackageLock; + + const problems = names.flatMap((name) => + checkDependencyPin(name, packageJson, packageLock), + ); + for (const problem of problems) { + logError(problem); + } + if (problems.length > 0) { + fail(`Dependency pin check failed with ${problems.length} error(s).`); + } + + logSuccess(`Dependency pin check passed for ${names.join(", ")}.`); +} + +await runMain(main, 1); diff --git a/scripts/ops/check_tsapt_pin.sh b/scripts/ops/check_tsapt_pin.sh deleted file mode 100755 index db88d47..0000000 --- a/scripts/ops/check_tsapt_pin.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash -# Fails when ts-apt is not resolved from a pushed commit SHA pin. -# -# Guards against shipping code built or tested against a local ts-apt link -# (npm run dev:tsapt:link), which CI cannot see. Checks that: -# 1. node_modules/ts-apt is not a symlink (skipped if node_modules is absent). -# 2. package.json pins ts-apt to a full commit SHA. -# 3. package-lock.json resolves ts-apt to that same SHA and not a link. -# -# Used by .githooks/pre-push and the CI action-deps-pinned job. -set -euo pipefail - -ROOT_DIR="$(git rev-parse --show-toplevel)" -cd "${ROOT_DIR}" - -errors=0 -error() { - echo "ERROR: $*" >&2 - errors=$((errors + 1)) -} - -if [[ -L node_modules/ts-apt ]]; then - error "node_modules/ts-apt is a symlink to $(readlink node_modules/ts-apt); run 'npm run dev:tsapt:unlink' first." -fi - -# Prints one problem per line; package.json and package-lock.json are parsed -# with node to avoid a jq dependency. -lock_problems="$(node --input-type=module - <<'EOF' -import { readFileSync } from "node:fs"; -const pin = /^git\+https:\/\/github\.com\/awalsh128\/ts-apt\.git#([0-9a-f]{40})$/; -const spec = JSON.parse(readFileSync("package.json", "utf8")).dependencies?.["ts-apt"]; -const match = pin.exec(spec ?? ""); -if (!match) { - console.log(`package.json ts-apt '${spec}' is not pinned to a full commit SHA.`); - process.exit(0); -} -const sha = match[1]; -const lock = JSON.parse(readFileSync("package-lock.json", "utf8")); -const rootSpec = lock.packages?.[""]?.dependencies?.["ts-apt"]; -if (rootSpec !== spec) { - console.log(`package-lock.json root ts-apt '${rootSpec}' does not match package.json '${spec}'.`); -} -const entry = lock.packages?.["node_modules/ts-apt"]; -if (!entry) { - console.log("package-lock.json has no node_modules/ts-apt entry."); -} else if (entry.link) { - console.log(`package-lock.json ts-apt is a link to '${entry.resolved}'.`); -} else if (!entry.resolved?.endsWith(`#${sha}`)) { - console.log(`package-lock.json ts-apt resolved '${entry.resolved}' does not match pin ${sha}.`); -} -EOF -)" - -if [[ -n "${lock_problems}" ]]; then - while IFS= read -r problem; do - error "${problem}" - done <<<"${lock_problems}" -fi - -if ((errors > 0)); then - echo "ts-apt pin check failed with ${errors} error(s)." >&2 - exit 1 -fi - -echo "ts-apt pin check passed."