From 06c1af1b1c8c4fe18778977b701eb0a2ec6d611c Mon Sep 17 00:00:00 2001 From: Andrew Walsh Date: Sat, 7 Nov 2026 12:57:15 -0800 Subject: [PATCH] ci: guard against shipping a locally linked ts-apt (#237) 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 --- .githooks/pre-push | 6 ++++ .github/workflows/ci.yml | 5 +++ AGENTS.md | 2 ++ package.json | 4 ++- scripts/ops/check_tsapt_pin.sh | 65 ++++++++++++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 1 deletion(-) create mode 100755 .githooks/pre-push create mode 100755 scripts/ops/check_tsapt_pin.sh diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..83ae9d6 --- /dev/null +++ b/.githooks/pre-push @@ -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" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f4c987..574d422 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 3ac7e0e..a9c4489 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/package.json b/package.json index 6fc1770..2e4567b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/ops/check_tsapt_pin.sh b/scripts/ops/check_tsapt_pin.sh new file mode 100755 index 0000000..db88d47 --- /dev/null +++ b/scripts/ops/check_tsapt_pin.sh @@ -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."