From 65529ad72334b2d7c64eb99e1b84fea88bd0ab07 Mon Sep 17 00:00:00 2001 From: TzuHsuan <96853116+TzuH-Hsu@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:24:55 +0800 Subject: [PATCH 1/3] feat: check-tool-versions reads adopter-owned pins from an optional scripts/tool-pins.extra MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopters that pin tools the template does not ship (syft, uv, pnpm, ...) had to edit install-ci-tools.sh and check-tool-versions.sh — local changes every upstream sync must preserve — or leave the pin unwatched. The drift check now also reads an optional, adopter-owned plain-data file, one row per tool: ` `, with github:/, npm: or pypi: as the source. The template ships no such file. Malformed rows and unknown sources are errors, never skips; holds work the same as for kit tools. The JSON fetch timeout goes from 30 s to 90 s: PyPI's JSON for a large project is ~5 MB and a cold fetch took 45-63 s. Co-Authored-By: Claude Opus 5.5 --- docs/template/architecture.md | 2 +- scripts/check-tool-versions.sh | 64 +++++++++++++++++++++++++- skills/github-actions-hygiene/SKILL.md | 2 +- 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/docs/template/architecture.md b/docs/template/architecture.md index 39cb45e..188f4fd 100644 --- a/docs/template/architecture.md +++ b/docs/template/architecture.md @@ -27,7 +27,7 @@ Why each piece of this repository exists, and what it costs to keep. A component | `scripts/bootstrap.sh` | Applies everything a template can't ship as files; idempotent sync | Highest-cost component — E2E-verified each release (below) | | `scripts/check-*.sh` | Self-consistency: skills index, local-md hygiene, licence marker, label values repeated in forms and the labeler, the `scripts/*.test.js` suites | Near zero | | `scripts/install-ci-tools.sh` | Checksum-verified CI tool installs; single home for all five tool version pins, shared by `ci.yml` and `maintenance.yml` via `make ci-tools`; bounds `RUNNER_LABELS` to linux x86_64 | Hand-bump a pin when the drift check flags it | -| `scripts/check-tool-versions.sh` | Diffs those pins against upstream weekly and fails on drift — Dependabot cannot see them, so nothing else would | Near zero; add a row when a tool is added | +| `scripts/check-tool-versions.sh` | Diffs those pins, plus any adopter rows in the optional `scripts/tool-pins.extra`, against upstream weekly and fails on drift — Dependabot cannot see them, so nothing else would | Near zero; add a row when a kit tool is added (adopter tools go in `tool-pins.extra`) | | `docs/adr/` | Decision records; the "why" layer | Grows slowly by trigger criteria | | `docs/setup/` | Bootstrap, licensing and runner-selection reference, plus manual fallbacks | Update alongside `bootstrap.sh` and the workflows | | `docs/template/` | Template-product meta (this dir); deleted on adoption | Only exists upstream | diff --git a/scripts/check-tool-versions.sh b/scripts/check-tool-versions.sh index ee49e90..700dcd5 100755 --- a/scripts/check-tool-versions.sh +++ b/scripts/check-tool-versions.sh @@ -25,6 +25,15 @@ set -euo pipefail PINS_FILE="${PINS_FILE:-scripts/install-ci-tools.sh}" +# Optional, adopter-owned: pins for tools the template does not ship (syft, +# uv, pnpm, ...), one row per tool — ` ` — where the +# source is github:/, npm: or pypi:. The +# template ships no such file; upstream syncs never touch it, so adding a tool +# here needs no edit to this script or to install-ci-tools.sh. Blank lines and +# `#` comments are ignored; any other malformed row is an ERROR, never a skip. +# Install those tools wherever you install them, reading the version from +# this file (e.g. `awk '$1 == "syft" { print $2 }' scripts/tool-pins.extra`). +EXTRA_PINS_FILE="${EXTRA_PINS_FILE:-scripts/tool-pins.extra}" # --- deliberate holds -------------------------------------------------------- # If a newer upstream release is knowingly NOT adopted (it breaks something, or @@ -57,9 +66,12 @@ read_pin() { } # --- upstream lookups -------------------------------------------------------- +# 90 s, not less: PyPI's JSON for a large project is several MB (uv's is ~4.9 MB) +# and a cold CDN fetch of it has taken 45-55 s; this weekly job is not latency +# bound, and a timeout here is reported as an ERROR, never as a pass. fetch_json_field() { local url="$1" expr="$2" body - body="$(curl -fsSL --max-time 30 "$url")" || return 1 + body="$(curl -fsSL --max-time 90 "$url")" || return 1 printf '%s' "$body" | python3 -c " import json,sys try: @@ -108,6 +120,26 @@ latest_upstream() { esac } +# An extra pin's upstream, by source scheme. GitHub tags lose any non-numeric +# prefix (`v`, `lychee-v`, ...) so they compare directly against the pin. +latest_extra() { + case "$1" in + github:*/*) latest_github_tag "${1#github:}" | sed 's/^[^0-9]*//' ;; + npm:?*) + fetch_json_field "https://registry.npmjs.org/${1#npm:}/latest" \ + "json.load(sys.stdin)['version']" + ;; + pypi:?*) + fetch_json_field "https://pypi.org/pypi/${1#pypi:}/json" \ + "json.load(sys.stdin)['info']['version']" + ;; + *) + echo "error: unknown source '$1' (use github:/, npm: or pypi:)" >&2 + return 1 + ;; + esac +} + # --- main -------------------------------------------------------------------- if [ ! -f "$PINS_FILE" ]; then echo "error: pins file not found: ${PINS_FILE}" >&2 @@ -156,6 +188,34 @@ done < ')" + errors=$((errors + 1)) + continue + fi + tool="$1" pinned="$2" source="$3" + if ! latest="$(latest_extra "$source")" || [ -z "$latest" ]; then + printf '%-20s %-12s %-12s %s\n' "$tool" "$pinned" "?" "ERROR (lookup failed: ${source})" + errors=$((errors + 1)) + continue + fi + if [ "$pinned" = "$latest" ]; then + printf '%-20s %-12s %-12s %s\n' "$tool" "$pinned" "$latest" "ok" + elif reason="$(hold_reason "${tool}:${latest}")"; then + printf '%-20s %-12s %-12s %s\n' "$tool" "$pinned" "$latest" "HELD - ${reason}" + else + printf '%-20s %-12s %-12s %s\n' "$tool" "$pinned" "$latest" "DRIFT" + drift=$((drift + 1)) + fi + done < "$EXTRA_PINS_FILE" +fi + echo if [ "$errors" -gt 0 ]; then @@ -166,7 +226,7 @@ fi if [ "$drift" -gt 0 ]; then echo "FAIL: ${drift} tool pin(s) behind upstream." >&2 - echo " Bump the pin in ${PINS_FILE}, run 'make verify', open a PR." >&2 + echo " Bump the pin in ${PINS_FILE} (or ${EXTRA_PINS_FILE}), run 'make verify', open a PR." >&2 echo " To knowingly stay behind, add a hold_reason entry in this script." >&2 exit 1 fi diff --git a/skills/github-actions-hygiene/SKILL.md b/skills/github-actions-hygiene/SKILL.md index 60eb53f..38a9b83 100644 --- a/skills/github-actions-hygiene/SKILL.md +++ b/skills/github-actions-hygiene/SKILL.md @@ -26,7 +26,7 @@ Workflows are the highest-privilege, least-reviewed code in most repositories 7. **Destructive or irreversible dispatch workflows require a typed-confirmation guard** — the operator must type an exact phrase (e.g. the repo name) as a `workflow_dispatch` input, checked before anything destructive runs. 8. **Checksum-verify any binary downloaded and installed in a workflow** (release tools, linters) — download the checksums file alongside the asset and verify before `chmod +x` / `install`. 9. **Anti-sprawl**: a new workflow file needs a justification that no existing workflow already covers. This template's target set is four: CI (verification), issue labeling, link maintenance, release PRs. Adding a fifth is a deliberate decision, not a default — prefer adding a job or a `make` target to an existing workflow first. -10. **Every pinned tool version lives in one file, and something watches it.** SHA-pinning actions (rule 3) only covers `uses:`. Dependabot reads manifests, so a version pinned inside a `run:` step or an install script is invisible to it and will rot silently — no alert, no PR, no signal at all. Keep those pins in a single file (`scripts/install-ci-tools.sh` here) and run a scheduled check that diffs them against upstream (`make check-tool-versions`, a step in the weekly maintenance workflow). A pin nobody is watching is a pin that stays put for years. +10. **Every pinned tool version lives in one file, and something watches it.** SHA-pinning actions (rule 3) only covers `uses:`. Dependabot reads manifests, so a version pinned inside a `run:` step or an install script is invisible to it and will rot silently — no alert, no PR, no signal at all. Keep those pins in a single file (`scripts/install-ci-tools.sh` here) and run a scheduled check that diffs them against upstream (`make check-tool-versions`, a step in the weekly maintenance workflow). A pin nobody is watching is a pin that stays put for years. Tools the template does not ship go in the optional `scripts/tool-pins.extra` (` github:/|npm:|pypi:`), which the same check reads — so adding one edits neither kit script. ## How From 35bd95123749dfc26ca4c6e6abc68d6c52d0288a Mon Sep 17 00:00:00 2001 From: TzuHsuan <96853116+TzuH-Hsu@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:35:38 +0800 Subject: [PATCH 2/3] fix: the maintenance job allows 30 minutes, so slow tool lookups end in the script's report, not a cancel Co-Authored-By: Claude Opus 5.5 --- .github/workflows/maintenance.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/maintenance.yml b/.github/workflows/maintenance.yml index c553f68..977ca0a 100644 --- a/.github/workflows/maintenance.yml +++ b/.github/workflows/maintenance.yml @@ -23,7 +23,12 @@ jobs: # Unset = ubuntu-latest. A malformed value stops this job reporting any # check at all -- see docs/setup/runners.md and ci.yml's header. runs-on: ${{ fromJSON(vars.RUNNER_LABELS || '["ubuntu-latest"]') }} - timeout-minutes: 10 + # 30, not 10: `make check-tool-versions` looks each pin up in turn with a + # 90 s fetch limit — the five kit tools plus any adopter rows in + # scripts/tool-pins.extra — so a slow registry day must still end in the + # script's own ERROR report, not in the job being cancelled first. A normal + # run takes about a minute; this weekly job costs nothing extra to allow. + timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 From ddf946072a56787bffcf3c7f049fa3140bfb7693 Mon Sep 17 00:00:00 2001 From: TzuHsuan <96853116+TzuH-Hsu@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:42:02 +0800 Subject: [PATCH 3/3] fix: GitHub tool lookups go through curl with the 90 s limit, authenticated, instead of gh api `gh api` has no request timeout, so a stalled call could outlast the job before the script reported its lookup error. Every GitHub lookup now uses the same bounded curl path as the registries, with GH_TOKEN, GITHUB_TOKEN or a logged-in gh's token as a bearer header (passed on a file descriptor, not in argv), keeping the authenticated rate limit. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/maintenance.yml | 2 +- scripts/check-tool-versions.sh | 37 ++++++++++++++++++++----------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/.github/workflows/maintenance.yml b/.github/workflows/maintenance.yml index 977ca0a..6f4ae0a 100644 --- a/.github/workflows/maintenance.yml +++ b/.github/workflows/maintenance.yml @@ -57,7 +57,7 @@ jobs: # and it is three GitHub API calls plus two registry fetches per week. - name: Run maintenance checks env: - # Lets check-tool-versions.sh use `gh` (1,000 req/hr per repo) rather + # Lets check-tool-versions.sh authenticate its GitHub lookups (1,000 req/hr per repo) rather # than the shared unauthenticated 60/hr-per-IP pool. Read-only public # metadata; the job keeps its top-level `contents: read` permission. GH_TOKEN: ${{ github.token }} diff --git a/scripts/check-tool-versions.sh b/scripts/check-tool-versions.sh index 700dcd5..bdbeb0f 100755 --- a/scripts/check-tool-versions.sh +++ b/scripts/check-tool-versions.sh @@ -70,8 +70,13 @@ read_pin() { # and a cold CDN fetch of it has taken 45-55 s; this weekly job is not latency # bound, and a timeout here is reported as an ERROR, never as a pass. fetch_json_field() { - local url="$1" expr="$2" body - body="$(curl -fsSL --max-time 90 "$url")" || return 1 + local url="$1" expr="$2" auth="${3:-}" body + if [ -n "$auth" ]; then + # header from a file descriptor, so the token is not in curl's argv + body="$(curl -fsSL --max-time 90 -H @<(printf 'Authorization: Bearer %s\n' "$auth") "$url")" || return 1 + else + body="$(curl -fsSL --max-time 90 "$url")" || return 1 + fi printf '%s' "$body" | python3 -c " import json,sys try: @@ -81,18 +86,24 @@ except Exception: " || return 1 } -# Prefer gh so Actions runs authenticate with GITHUB_TOKEN (1,000 req/hr per -# repo) instead of sharing the unauthenticated 60/hr-per-IP pool. Falls back to -# the public REST endpoint so this still works without gh installed. -latest_github_tag() { - local repo="$1" tag="" - if command -v gh >/dev/null 2>&1; then - tag="$(gh api "repos/${repo}/releases/latest" --jq '.tag_name' 2>/dev/null)" || tag="" - fi - if [ -z "$tag" ]; then - tag="$(fetch_json_field "https://api.github.com/repos/${repo}/releases/latest" \ - "json.load(sys.stdin)['tag_name']")" || return 1 +# Authenticate when a token is at hand so Actions runs use GITHUB_TOKEN (1,000 +# req/hr per repo) instead of the unauthenticated 60/hr-per-IP pool: GH_TOKEN +# or GITHUB_TOKEN from the environment, else a logged-in gh's token (a local +# read, no network). The request itself always goes through curl, so it has +# the same 90 s limit as every other lookup — `gh api` has no timeout, and a +# stalled call would otherwise outlast the job. +github_token() { + if [ -n "${GH_TOKEN:-}" ]; then printf '%s' "$GH_TOKEN" + elif [ -n "${GITHUB_TOKEN:-}" ]; then printf '%s' "$GITHUB_TOKEN" + elif command -v gh >/dev/null 2>&1; then gh auth token 2>/dev/null || true fi +} + +latest_github_tag() { + local repo="$1" tag token + token="$(github_token)" + tag="$(fetch_json_field "https://api.github.com/repos/${repo}/releases/latest" \ + "json.load(sys.stdin)['tag_name']" "$token")" || return 1 [ -n "$tag" ] || return 1 printf '%s' "$tag" }