ci: guard against shipping a locally linked ts-apt

Add scripts/ops/check_tsapt_pin.sh, which fails when node_modules/ts-apt
is a symlink, package.json does not pin ts-apt to a full commit SHA, or
package-lock.json resolves ts-apt to a link or a different SHA.

Run it from a .githooks/pre-push hook (installed by the npm prepare
script via core.hooksPath) and from the CI action-deps-pinned job, and
document the rule in AGENTS.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
awalsh128 2026-11-07 12:45:34 -08:00
parent a84878c774
commit cb75fec07d
5 changed files with 81 additions and 1 deletions

6
.githooks/pre-push Executable file
View file

@ -0,0 +1,6 @@
#!/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.
set -euo pipefail
exec "$(git rev-parse --show-toplevel)/scripts/ops/check_tsapt_pin.sh"

View file

@ -124,6 +124,11 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
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
shell: bash
run: npm run check:tsapt-pin
testing:
runs-on: ubuntu-latest
needs: setup-and-cache

View file

@ -86,6 +86,8 @@ 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`.
- NEVER bypass the pre-push hook with `--no-verify`; unlink ts-apt instead.
## Quick Commands

View file

@ -50,11 +50,13 @@
"bundle": "rollup -c rollup.config.mjs",
"build": "npm run -s runall -- build:lib bundle build:scripts",
"clean": "rm -rf dist coverage",
"prepare": "git rev-parse --git-dir >/dev/null 2>&1 && git config core.hooksPath .githooks || true",
"check:latest-action-pin": "node --experimental-strip-types ./scripts/ops/check_latest_action_pin.mts",
"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": "npm run -s runall -- check:latest-action-pins check:pr check:configs",
"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",
"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",

65
scripts/ops/check_tsapt_pin.sh Executable file
View file

@ -0,0 +1,65 @@
#!/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."