mirror of
https://github.com/awalsh128/cache-apt-pkgs-action.git
synced 2026-11-13 21:36:56 +00:00
ci: generalize ts-apt pin check into check_deps_pin.mts
Replace scripts/ops/check_tsapt_pin.sh with scripts/ops/check_deps_pin.mts, which takes dependency names as arguments and checks each one for a local symlink, a full commit SHA pin in package.json, and a matching, non-link package-lock.json entry. Rename the npm script to check:deps-pin (run with ts-apt) and use it from the pre-push hook, the CI action-deps-pinned job and AGENTS.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
06c1af1b1c
commit
1bb441e99b
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
142
scripts/ops/check_deps_pin.mts
Normal file
142
scripts/ops/check_deps_pin.mts
Normal file
|
|
@ -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/<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.
|
||||
*
|
||||
* Used by .githooks/pre-push and the CI action-deps-pinned job.
|
||||
*
|
||||
* Usage: check_deps_pin.mts <dependency> [<dependency>...]
|
||||
*/
|
||||
|
||||
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<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
|
||||
type LockEntry = {
|
||||
resolved?: string;
|
||||
link?: boolean;
|
||||
};
|
||||
|
||||
type PackageLock = {
|
||||
packages?: Record<
|
||||
string,
|
||||
LockEntry & {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
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, "<dependency> [<dependency>...]"));
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -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."
|
||||
Loading…
Reference in a new issue