From 403cce8c5e69502e725ff9a220f0b5a2ae26253b Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 12:32:24 -0600 Subject: [PATCH 01/33] Add BATS harness and baseline tests for validate-action-pins Refactor validate-action-pins to be sourceable and retargetable so it can be unit- and integration-tested. Behavior is byte-identical when env vars are unset (verified by diff-guard against current output). Testability changes: - Wrap top-level body in main(); guard with BASH_SOURCE[0] == $0 so the script can be sourced without running. - GITHUB_API_BASE env var (default https://api.github.com) threads into gh_api and the connectivity probe. Tests set it to file://fixtures. - VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY env var bypasses the rate_limit probe for tests that do not provide that fixture. - Move RESOLVE_CACHE, FAILURES, FOUND_PINS, seen_in_file, and the auth_header population out of module scope into main() so functions no longer depend on module-level state; init_auth_header is callable from tests when needed. Test harness: - New tests/bats/ root, mirroring images// under tests/bats/images// per convention. - tests/bats/helpers/common.bash loads bats-support/assert/file and normalises the test environment (GITHUB_API_BASE to fixtures, GITHUB_TOKEN cleared, connectivity probe skipped). - 14 baseline tests pinning --help, --version, no-args, missing-file, tag match/mismatch, annotated-tag deref, duplicate-pin dedupe, connectivity failure, missing curl/jq, and sourced resolve_tag unit calls. - Makefile target "make test-bats" runs the suite inside the ci-tools image; lint-sh extended to cover tests/bats/**. Co-Authored-By: Claude Opus 4.7 (1M context) --- Makefile | 18 +- images/ci-tools/bin/validate-action-pins | 211 ++++++++++-------- tests/bats/helpers/common.bash | 55 +++++ .../images/ci-tools/fixtures/api/rate_limit | 10 + .../api/repos/foo/annotated/git/ref/tags/v2 | 7 + .../1111111111111111111111111111111111111111 | 7 + .../api/repos/foo/bar/git/ref/tags/v1 | 7 + .../ci-tools/fixtures/workflows/annotated.yml | 7 + .../fixtures/workflows/duplicate-pins.yml | 9 + .../fixtures/workflows/tag-mismatch.yml | 7 + .../ci-tools/fixtures/workflows/tag-ok.yml | 7 + .../images/ci-tools/validate-action-pins.bats | 137 ++++++++++++ 12 files changed, 385 insertions(+), 97 deletions(-) create mode 100644 tests/bats/helpers/common.bash create mode 100644 tests/bats/images/ci-tools/fixtures/api/rate_limit create mode 100644 tests/bats/images/ci-tools/fixtures/api/repos/foo/annotated/git/ref/tags/v2 create mode 100644 tests/bats/images/ci-tools/fixtures/api/repos/foo/annotated/git/tags/1111111111111111111111111111111111111111 create mode 100644 tests/bats/images/ci-tools/fixtures/api/repos/foo/bar/git/ref/tags/v1 create mode 100644 tests/bats/images/ci-tools/fixtures/workflows/annotated.yml create mode 100644 tests/bats/images/ci-tools/fixtures/workflows/duplicate-pins.yml create mode 100644 tests/bats/images/ci-tools/fixtures/workflows/tag-mismatch.yml create mode 100644 tests/bats/images/ci-tools/fixtures/workflows/tag-ok.yml create mode 100644 tests/bats/images/ci-tools/validate-action-pins.bats diff --git a/Makefile b/Makefile index a19b8d1..b54423f 100644 --- a/Makefile +++ b/Makefile @@ -7,10 +7,14 @@ IMAGE_TAG ?= $(IMAGE):local VALIDATE_ACTION_PINS := $(shell \ command -v validate-action-pins 2>/dev/null \ || echo images/ci-tools/bin/validate-action-pins) +# Pass `-t` to `docker run` when stdin is a terminal, so TTY-aware tools +# (bats pretty output, etc.) see a real terminal inside the container. +# Override with `DOCKER_TTY=` (empty) or `DOCKER_TTY=-t` (force) as needed. +DOCKER_TTY ?= $(shell test -t 0 && echo -t) .PHONY: sync resolve build verify scan clean \ lint lint-fix lint-lockfile lint-docker lint-sh lint-sh-fmt lint-sh-fmt-fix \ - lint-actions lint-md lint-md-fix lint-man man test-package help + lint-actions lint-md lint-md-fix lint-man man test-package test-bats help # Resolve latest versions, build, and verify image sync: resolve build verify @@ -28,7 +32,7 @@ build: # Verify all tools in the built image verify: - @docker run --rm \ + @docker run --rm $(DOCKER_TTY) \ -v $(CURDIR)/scripts:/scripts \ -v $(CURDIR)/images/$(IMAGE)/versions.lock:/versions.lock:ro \ $(IMAGE_TAG) /scripts/$(IMAGE)/verify.sh @@ -36,7 +40,7 @@ verify: # Scan image for vulnerabilities scan: build @echo "Scanning $(IMAGE_TAG) for vulnerabilities..." - @docker run --rm \ + @docker run --rm $(DOCKER_TTY) \ -v /var/run/docker.sock:/var/run/docker.sock \ -v $(CURDIR)/images/$(IMAGE)/.trivyignore:/.trivyignore:ro \ aquasec/trivy:0.70.0 image \ @@ -63,6 +67,7 @@ lint-docker: lint-sh: @echo "Linting shell scripts..." \ && shellcheck scripts/*.sh scripts/*/*.sh tests/deb/*.sh images/*/bin/* \ + && shellcheck tests/bats/*/*.bash tests/bats/*/*/*.bats \ && echo "OK" # Check shell script formatting @@ -106,6 +111,12 @@ man: test-package: @./tests/deb/test-all.sh +# Run BATS tests inside the ci-tools image. +test-bats: + @docker run --rm $(DOCKER_TTY) \ + -v $(CURDIR):/work -w /work \ + $(IMAGE_TAG) bats -r tests/bats/ + # Remove local image clean: @echo "Removing $(IMAGE_TAG) ..." @@ -135,6 +146,7 @@ help: @echo " make lint-sh-fmt Check shell script formatting" @echo " make lint-sh-fmt-fix Fix shell script formatting" @echo " make man Preview man pages" + @echo " make test-bats Run BATS tests inside the ci-tools image" @echo " make test-package Build and test deb package locally" @echo " make help Show this message" @echo "" diff --git a/images/ci-tools/bin/validate-action-pins b/images/ci-tools/bin/validate-action-pins index da6fb16..73fff4d 100755 --- a/images/ci-tools/bin/validate-action-pins +++ b/images/ci-tools/bin/validate-action-pins @@ -16,8 +16,15 @@ set -euo pipefail # curl, jq — if either is missing the script warns and exits 0 # # Environment: -# GITHUB_TOKEN - (Optional) GitHub token for higher API rate limits -# (60 req/hr unauthenticated → 5000 req/hr with token) +# GITHUB_TOKEN - (Optional) GitHub token for higher +# API rate limits (60 req/hr +# unauthenticated → 5000 req/hr with +# token) +# GITHUB_API_BASE - (Optional) Override API base URL. +# Defaults to https://api.github.com. +# Tests point this at file:// fixtures. +# VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY - (Optional) If set, skip the +# /rate_limit probe. For tests. # # Exit codes: # 0 - All pins match, no pins found, or validation skipped (missing @@ -28,6 +35,8 @@ set -euo pipefail readonly PROGRAM="validate-action-pins" readonly VERSION="${VALIDATE_ACTION_PINS_VERSION:-unknown}" +: "${GITHUB_API_BASE:=https://api.github.com}" + # ── usage / version ────────────────────────────────────────────────── usage() { @@ -49,45 +58,19 @@ Environment: USAGE } -if [[ $# -eq 0 ]]; then - usage >&2 - exit 1 -fi - -for arg in "${@}"; do - case "${arg}" in - --help) - usage - exit 0 - ;; - --version) - echo "${PROGRAM} ${VERSION}" - exit 0 - ;; - --) break ;; - *) ;; - esac -done - -# ── dependency check ────────────────────────────────────────────────── - -for cmd in curl jq; do - if ! command -v "${cmd}" > /dev/null 2>&1; then - echo "WARN: ${cmd} not found — skipping pin validation" - exit 0 - fi -done - # ── helpers ────────────────────────────────────────────────────────── -FAILURES=0 - -declare -A RESOLVE_CACHE # "owner/repo#tag" → SHA on success, "!" on failure - +# Auth header array, populated by init_auth_header. Empty by default so the +# module is safe to source without side effects on $GITHUB_TOKEN. auth_header=() -if [[ -n "${GITHUB_TOKEN:-}" ]]; then - auth_header=(-H "Authorization: token ${GITHUB_TOKEN}") -fi + +# Populate auth_header from GITHUB_TOKEN if set. Safe to call multiple times. +init_auth_header() { + auth_header=() + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + auth_header=(-H "Authorization: token ${GITHUB_TOKEN}") + fi +} # GitHub API GET with optional auth and error handling. # @@ -97,7 +80,7 @@ fi # Outputs: # JSON response on stdout, or empty string on failure gh_api() { - local url="https://api.github.com${1}" + local url="${GITHUB_API_BASE}${1}" curl -fsSL "${auth_header[@]+"${auth_header[@]}"}" \ -H "Accept: application/vnd.github+json" \ "${url}" 2> /dev/null || true @@ -151,75 +134,115 @@ resolve_tag() { return 1 } -# ── connectivity check ─────────────────────────────────────────────── +# ── main ───────────────────────────────────────────────────────────── -if ! curl -fsSL "${auth_header[@]+"${auth_header[@]}"}" \ - -H "Accept: application/vnd.github+json" \ - "https://api.github.com/rate_limit" > /dev/null 2>&1; then - echo "WARN: cannot reach GitHub API — skipping pin validation" - exit 0 -fi +main() { + if [[ $# -eq 0 ]]; then + usage >&2 + exit 1 + fi -# ── scan and validate ──────────────────────────────────────────────── + local arg + for arg in "${@}"; do + case "${arg}" in + --help) + usage + exit 0 + ;; + --version) + echo "${PROGRAM} ${VERSION}" + exit 0 + ;; + --) break ;; + *) ;; + esac + done + + local cmd + for cmd in curl jq; do + if ! command -v "${cmd}" > /dev/null 2>&1; then + echo "WARN: ${cmd} not found — skipping pin validation" + exit 0 + fi + done -FOUND_PINS=0 + init_auth_header -for file in "${@}"; do - [[ "${file}" == --* ]] && continue - if [[ ! -f "${file}" ]]; then - echo "WARN: ${file} not found, skipping" - continue + if [[ -z "${VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY:-}" ]]; then + if ! curl -fsSL "${auth_header[@]+"${auth_header[@]}"}" \ + -H "Accept: application/vnd.github+json" \ + "${GITHUB_API_BASE}/rate_limit" > /dev/null 2>&1; then + echo "WARN: cannot reach GitHub API — skipping pin validation" + exit 0 + fi fi - basename="${file##*/}" - declare -A seen_in_file=() + local -i failures=0 + local -i found_pins=0 + local -A resolve_cache=() - # Match lines like: uses: owner/repo@<40-hex-sha> # - while IFS= read -r line; do - # Extract owner/repo, SHA, and tag from the uses line. - if [[ "${line}" =~ uses:[[:space:]]*([^@]+)@([0-9a-f]{40})[[:space:]]*#[[:space:]]*([^[:space:]]+) ]]; then - action="${BASH_REMATCH[1]}" - pinned_sha="${BASH_REMATCH[2]}" - claimed_tag="${BASH_REMATCH[3]}" - FOUND_PINS=1 + local file basename line + local action pinned_sha claimed_tag cache_key cached resolved_sha + local -A seen_in_file - cache_key="${action}#${claimed_tag}" + for file in "${@}"; do + [[ "${file}" == --* ]] && continue + if [[ ! -f "${file}" ]]; then + echo "WARN: ${file} not found, skipping" + continue + fi - # Skip duplicates within the same file. - if [[ -n "${seen_in_file[${cache_key}]+x}" ]]; then - continue - fi - seen_in_file["${cache_key}"]=1 + basename="${file##*/}" + seen_in_file=() - # Resolve via API only once across all files. - if [[ -z "${RESOLVE_CACHE[${cache_key}]+x}" ]]; then - resolved_sha="" - if resolved_sha="$(resolve_tag "${action}" "${claimed_tag}")"; then - RESOLVE_CACHE["${cache_key}"]="${resolved_sha}" - else - RESOLVE_CACHE["${cache_key}"]="!" + # Match lines like: uses: owner/repo@<40-hex-sha> # + while IFS= read -r line; do + if [[ "${line}" =~ uses:[[:space:]]*([^@]+)@([0-9a-f]{40})[[:space:]]*#[[:space:]]*([^[:space:]]+) ]]; then + action="${BASH_REMATCH[1]}" + pinned_sha="${BASH_REMATCH[2]}" + claimed_tag="${BASH_REMATCH[3]}" + found_pins=1 + + cache_key="${action}#${claimed_tag}" + + if [[ -n "${seen_in_file[${cache_key}]+x}" ]]; then + continue + fi + seen_in_file["${cache_key}"]=1 + + if [[ -z "${resolve_cache[${cache_key}]+x}" ]]; then + resolved_sha="" + if resolved_sha="$(resolve_tag "${action}" "${claimed_tag}")"; then + resolve_cache["${cache_key}"]="${resolved_sha}" + else + resolve_cache["${cache_key}"]="!" + fi fi - fi - cached="${RESOLVE_CACHE[${cache_key}]}" + cached="${resolve_cache[${cache_key}]}" - if [[ "${cached}" == "!" ]]; then - echo "WARN ${basename}: ${action}@${pinned_sha:0:12}... — could not resolve tag ${claimed_tag}" - elif [[ "${pinned_sha}" == "${cached}" ]]; then - echo "OK ${basename}: ${action}@${pinned_sha:0:12}... matches ${claimed_tag}" - else - echo "FAIL ${basename}: ${action}@${pinned_sha:0:12}... does NOT match ${claimed_tag} (expected ${cached:0:12}...)" - FAILURES=$((FAILURES + 1)) + if [[ "${cached}" == "!" ]]; then + echo "WARN ${basename}: ${action}@${pinned_sha:0:12}... — could not resolve tag ${claimed_tag}" + elif [[ "${pinned_sha}" == "${cached}" ]]; then + echo "OK ${basename}: ${action}@${pinned_sha:0:12}... matches ${claimed_tag}" + else + echo "FAIL ${basename}: ${action}@${pinned_sha:0:12}... does NOT match ${claimed_tag} (expected ${cached:0:12}...)" + failures=$((failures + 1)) + fi fi - fi - done < "${file}" -done + done < "${file}" + done -if [[ "${FOUND_PINS}" -eq 0 ]]; then - echo "No SHA-pinned actions found in the provided files." -fi + if [[ "${found_pins}" -eq 0 ]]; then + echo "No SHA-pinned actions found in the provided files." + fi + + if [[ "${failures}" -gt 0 ]]; then + echo "FAIL: ${failures} pin(s) did not match" + exit 1 + fi +} -if [[ "${FAILURES}" -gt 0 ]]; then - echo "FAIL: ${FAILURES} pin(s) did not match" - exit 1 +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" fi diff --git a/tests/bats/helpers/common.bash b/tests/bats/helpers/common.bash new file mode 100644 index 0000000..927bf48 --- /dev/null +++ b/tests/bats/helpers/common.bash @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# +# Common bats helpers loaded by every suite under tests/bats/. +# +# Layout expectations: +# REPO_ROOT is computed from BATS_TEST_DIRNAME by walking up to the first +# ancestor containing a Makefile. Suite files should call `common_setup` from +# their own setup() — it normalises the environment for deterministic runs. + +# BATS_* variables are set by bats at runtime. +# shellcheck disable=SC2154 + +bats_load_library bats-support +bats_load_library bats-assert +bats_load_library bats-file + +# Resolve the repo root by walking up from the test file. +_resolve_repo_root() { + local dir="${BATS_TEST_DIRNAME}" + while [[ "${dir}" != "/" && ! -f "${dir}/Makefile" ]]; do + dir="$(dirname "${dir}")" + done + echo "${dir}" +} + +# Set up a deterministic environment for a test. +# +# - REPO_ROOT absolute path to the repo +# - FIXTURES_DIR tests/bats/images//fixtures (if present) +# - API_FIXTURES_DIR ${FIXTURES_DIR}/api +# - GITHUB_API_BASE file://${API_FIXTURES_DIR} when it exists +# - GITHUB_TOKEN cleared; tests that want auth must set explicitly +# - VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY set, so the rate_limit probe is +# bypassed by default +common_setup() { + REPO_ROOT="$(_resolve_repo_root)" + export REPO_ROOT + + # Per-suite fixtures directory convention: + # tests/bats/images//fixtures + local suite_dir="${BATS_TEST_DIRNAME}" + if [[ -d "${suite_dir}/fixtures" ]]; then + FIXTURES_DIR="${suite_dir}/fixtures" + export FIXTURES_DIR + if [[ -d "${FIXTURES_DIR}/api" ]]; then + API_FIXTURES_DIR="${FIXTURES_DIR}/api" + export API_FIXTURES_DIR + export GITHUB_API_BASE="file://${API_FIXTURES_DIR}" + fi + fi + + export GITHUB_TOKEN="" + export VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY=1 +} diff --git a/tests/bats/images/ci-tools/fixtures/api/rate_limit b/tests/bats/images/ci-tools/fixtures/api/rate_limit new file mode 100644 index 0000000..955f2b1 --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/api/rate_limit @@ -0,0 +1,10 @@ +{ + "resources": { + "core": { + "limit": 5000, + "remaining": 4999, + "reset": 1700000000, + "used": 1 + } + } +} diff --git a/tests/bats/images/ci-tools/fixtures/api/repos/foo/annotated/git/ref/tags/v2 b/tests/bats/images/ci-tools/fixtures/api/repos/foo/annotated/git/ref/tags/v2 new file mode 100644 index 0000000..0856809 --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/api/repos/foo/annotated/git/ref/tags/v2 @@ -0,0 +1,7 @@ +{ + "ref": "refs/tags/v2", + "object": { + "type": "tag", + "sha": "1111111111111111111111111111111111111111" + } +} diff --git a/tests/bats/images/ci-tools/fixtures/api/repos/foo/annotated/git/tags/1111111111111111111111111111111111111111 b/tests/bats/images/ci-tools/fixtures/api/repos/foo/annotated/git/tags/1111111111111111111111111111111111111111 new file mode 100644 index 0000000..0795862 --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/api/repos/foo/annotated/git/tags/1111111111111111111111111111111111111111 @@ -0,0 +1,7 @@ +{ + "tag": "v2", + "object": { + "type": "commit", + "sha": "cccccccccccccccccccccccccccccccccccccccc" + } +} diff --git a/tests/bats/images/ci-tools/fixtures/api/repos/foo/bar/git/ref/tags/v1 b/tests/bats/images/ci-tools/fixtures/api/repos/foo/bar/git/ref/tags/v1 new file mode 100644 index 0000000..3f639fb --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/api/repos/foo/bar/git/ref/tags/v1 @@ -0,0 +1,7 @@ +{ + "ref": "refs/tags/v1", + "object": { + "type": "commit", + "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } +} diff --git a/tests/bats/images/ci-tools/fixtures/workflows/annotated.yml b/tests/bats/images/ci-tools/fixtures/workflows/annotated.yml new file mode 100644 index 0000000..6a27f80 --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/workflows/annotated.yml @@ -0,0 +1,7 @@ +name: annotated +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: foo/annotated@cccccccccccccccccccccccccccccccccccccccc # v2 diff --git a/tests/bats/images/ci-tools/fixtures/workflows/duplicate-pins.yml b/tests/bats/images/ci-tools/fixtures/workflows/duplicate-pins.yml new file mode 100644 index 0000000..6c323b7 --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/workflows/duplicate-pins.yml @@ -0,0 +1,9 @@ +name: duplicate-pins +on: push +jobs: + a: + runs-on: ubuntu-latest + steps: + - uses: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1 + - uses: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1 + - uses: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1 diff --git a/tests/bats/images/ci-tools/fixtures/workflows/tag-mismatch.yml b/tests/bats/images/ci-tools/fixtures/workflows/tag-mismatch.yml new file mode 100644 index 0000000..224ec56 --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/workflows/tag-mismatch.yml @@ -0,0 +1,7 @@ +name: tag-mismatch +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: foo/bar@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb # v1 diff --git a/tests/bats/images/ci-tools/fixtures/workflows/tag-ok.yml b/tests/bats/images/ci-tools/fixtures/workflows/tag-ok.yml new file mode 100644 index 0000000..d1af9e9 --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/workflows/tag-ok.yml @@ -0,0 +1,7 @@ +name: tag-ok +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1 diff --git a/tests/bats/images/ci-tools/validate-action-pins.bats b/tests/bats/images/ci-tools/validate-action-pins.bats new file mode 100644 index 0000000..6e5073d --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins.bats @@ -0,0 +1,137 @@ +#!/usr/bin/env bats +# shellcheck shell=bash +# +# REPO_ROOT, FIXTURES_DIR, API_FIXTURES_DIR, SCRIPT are populated by +# common_setup and the per-test setup; BATS_* are populated by bats at runtime. +# shellcheck disable=SC2154 + +load ../../helpers/common + +setup() { + common_setup + SCRIPT="${REPO_ROOT}/images/ci-tools/bin/validate-action-pins" + export SCRIPT +} + +# ── --help / --version / no-args / missing file ───────────────────── + +@test "--help prints usage and exits 0" { + run "${SCRIPT}" --help + assert_success + assert_output --partial "Usage: validate-action-pins" +} + +@test "--version prints program and version and exits 0" { + run "${SCRIPT}" --version + assert_success + assert_output --regexp '^validate-action-pins ' +} + +@test "no args prints usage to stderr and exits 1" { + run "${SCRIPT}" + assert_failure 1 + assert_output --partial "Usage: validate-action-pins" +} + +@test "nonexistent file warns and returns 0 with no pins" { + run "${SCRIPT}" "${BATS_TEST_TMPDIR}/does-not-exist.yml" + assert_success + assert_output --partial "WARN: ${BATS_TEST_TMPDIR}/does-not-exist.yml not found, skipping" + assert_output --partial "No SHA-pinned actions found" +} + +# ── pin resolution (file:// API) ──────────────────────────────────── + +@test "tag pin matching resolved SHA prints OK and exits 0" { + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + assert_output --partial "OK tag-ok.yml: foo/bar@aaaaaaaaaaaa..." + assert_output --partial "matches v1" +} + +@test "tag pin mismatching resolved SHA prints FAIL and exits 1" { + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-mismatch.yml" + assert_failure 1 + assert_output --partial "FAIL tag-mismatch.yml: foo/bar@bbbbbbbbbbbb..." + assert_output --partial "does NOT match v1" + assert_output --partial "FAIL: 1 pin(s) did not match" +} + +@test "annotated tag ref is dereferenced to the commit SHA" { + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/annotated.yml" + assert_success + assert_output --partial "OK annotated.yml: foo/annotated@cccccccccccc..." + assert_output --partial "matches v2" +} + +@test "duplicate pins within one file produce a single OK line" { + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/duplicate-pins.yml" + assert_success + local ok_count + ok_count="$(grep -c '^OK ' <<< "${output}" || true)" + assert_equal "${ok_count}" "1" +} + +# ── connectivity probe ────────────────────────────────────────────── + +@test "connectivity probe failure warns and exits 0" { + unset VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY + export GITHUB_API_BASE="file://${BATS_TEST_TMPDIR}/empty" + mkdir -p "${BATS_TEST_TMPDIR}/empty" + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + assert_output --partial "WARN: cannot reach GitHub API" +} + +# ── missing dependencies ──────────────────────────────────────────── + +@test "missing curl warns and exits 0" { + # PATH contains only bash so the shebang can resolve; curl check fails first. + local stubdir="${BATS_TEST_TMPDIR}/nocurl" + local bash_bin + bash_bin="$(command -v bash)" + mkdir -p "${stubdir}" + ln -s "${bash_bin}" "${stubdir}/bash" + PATH="${stubdir}" run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + assert_output --partial "WARN: curl not found" +} + +@test "missing jq warns and exits 0" { + local stubdir="${BATS_TEST_TMPDIR}/nojq" + local bash_bin curl_bin + bash_bin="$(command -v bash)" + curl_bin="$(command -v curl)" + mkdir -p "${stubdir}" + ln -s "${bash_bin}" "${stubdir}/bash" + ln -s "${curl_bin}" "${stubdir}/curl" + PATH="${stubdir}" run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + assert_output --partial "WARN: jq not found" +} + +# ── unit-level: source the script, call functions directly ───────── + +@test "resolve_tag returns commit SHA for a lightweight tag ref" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run resolve_tag "foo/bar" "v1" + assert_success + assert_output "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +} + +@test "resolve_tag dereferences an annotated tag ref" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run resolve_tag "foo/annotated" "v2" + assert_success + assert_output "cccccccccccccccccccccccccccccccccccccccc" +} + +@test "resolve_tag returns nonzero when ref does not exist" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run resolve_tag "foo/bar" "v999" + assert_failure + assert_output "" +} From 836ce3cb81b75e86d9ba8d4602f75b3c15ae3253 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 12:36:06 -0600 Subject: [PATCH 02/33] Introduce subcommand dispatch for validate-action-pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split arg parsing from pin verification so the tool can grow new subcommands. Bare `validate-action-pins FILE...` continues to route to the pin-check path; `validate-action-pins check FILE...` is the explicit equivalent. Other shapes (`list`, `updates`) are reserved but not yet wired up. - main() now handles a three-stage parse: scan-all-args for --help/--version, detect a known subcommand at position 1 (falling through to the default `check` otherwise), then collect files while rejecting unknown flags with exit 2. - The pin-check body moves verbatim into cmd_check so it can be dispatched by name. Exit code semantics for that path are preserved — 0 on match, 1 on mismatch, 0 on transient skip. - Unknown flags previously fell through to the file loop where they were silently skipped; they now fail fast with exit 2. Non-flag words that do not match a subcommand are still treated as files, so any workflow path — even one spelled like a reserved word — is accepted. - Man page gains a SUBCOMMANDS section and an EXIT STATUS 2 entry. - 7 new bats cases cover the dispatch surface: bare vs explicit equivalence, `check --version`, long/short unknown flags, non-subcommand word → file, `check` with no files, and `--` terminator. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/man/man1/validate-action-pins.1 | 14 ++ images/ci-tools/bin/validate-action-pins | 157 ++++++++++++------ .../images/ci-tools/validate-action-pins.bats | 48 ++++++ 3 files changed, 172 insertions(+), 47 deletions(-) diff --git a/docs/man/man1/validate-action-pins.1 b/docs/man/man1/validate-action-pins.1 index c480661..0c64805 100644 --- a/docs/man/man1/validate-action-pins.1 +++ b/docs/man/man1/validate-action-pins.1 @@ -9,6 +9,9 @@ .Op Fl -help .Op Fl -version .Ar file ... +.Nm +.Cm check +.Ar file ... .Sh DESCRIPTION .Nm scans GitHub Actions workflow files for @@ -30,6 +33,15 @@ Output lines are prefixed with the workflow filename: .Pp If the API is unreachable or a required dependency is missing, the check is skipped and the tool exits successfully. +.Sh SUBCOMMANDS +.Bl -tag -width indent +.It Cm check Ar file ... +Verify SHA pins in the given workflow files. +This is the default when no subcommand is given; bare +.Ic validate-action-pins Ar file ... +is equivalent to +.Ic validate-action-pins check Ar file ... . +.El .Sh OPTIONS .Bl -tag -width indent .It Fl -help @@ -53,6 +65,8 @@ the GitHub API is unreachable. .It 1 One or more pins do not match their claimed tag, or no files were provided. +.It 2 +Usage error \(em an unknown flag was supplied. .El .Sh EXAMPLES Validate all workflows in a repository: diff --git a/images/ci-tools/bin/validate-action-pins b/images/ci-tools/bin/validate-action-pins index 73fff4d..b89793e 100755 --- a/images/ci-tools/bin/validate-action-pins +++ b/images/ci-tools/bin/validate-action-pins @@ -9,6 +9,7 @@ set -euo pipefail # # Usage: # validate-action-pins .github/workflows/*.yml +# validate-action-pins check .github/workflows/*.yml # validate-action-pins --help # validate-action-pins --version # @@ -31,6 +32,7 @@ set -euo pipefail # dependencies or unreachable GitHub API) # 1 - One or more pins do not match their claimed tag, or no files # provided +# 2 - Usage error (unknown flag) readonly PROGRAM="validate-action-pins" readonly VERSION="${VALIDATE_ACTION_PINS_VERSION:-unknown}" @@ -41,7 +43,7 @@ readonly VERSION="${VALIDATE_ACTION_PINS_VERSION:-unknown}" usage() { cat << 'USAGE' -Usage: validate-action-pins [OPTIONS] FILE... +Usage: validate-action-pins [OPTIONS] [SUBCOMMAND] FILE... Verify that GitHub Actions SHA pins match their claimed tags. @@ -49,6 +51,9 @@ Scans workflow files for lines like: uses: actions/checkout@de0fac2e... # v6 and checks that the pinned SHA resolves to the stated tag via the GitHub API. +Subcommands: + check Verify SHA pins (default when no subcommand is given) + Options: --help Show this help message and exit --version Print the version and exit @@ -134,59 +139,30 @@ resolve_tag() { return 1 } -# ── main ───────────────────────────────────────────────────────────── - -main() { - if [[ $# -eq 0 ]]; then - usage >&2 - exit 1 - fi - - local arg - for arg in "${@}"; do - case "${arg}" in - --help) - usage - exit 0 - ;; - --version) - echo "${PROGRAM} ${VERSION}" - exit 0 - ;; - --) break ;; - *) ;; - esac - done - - local cmd - for cmd in curl jq; do - if ! command -v "${cmd}" > /dev/null 2>&1; then - echo "WARN: ${cmd} not found — skipping pin validation" - exit 0 - fi - done - - init_auth_header - - if [[ -z "${VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY:-}" ]]; then - if ! curl -fsSL "${auth_header[@]+"${auth_header[@]}"}" \ - -H "Accept: application/vnd.github+json" \ - "${GITHUB_API_BASE}/rate_limit" > /dev/null 2>&1; then - echo "WARN: cannot reach GitHub API — skipping pin validation" - exit 0 - fi - fi +# ── subcommands ────────────────────────────────────────────────────── +# Verify SHA pins in the given workflow files. +# +# Emits one line per unique (action, tag) pair: +# OK matches +# FAIL pin does not match the tag's resolved SHA +# WARN ref could not be resolved +# +# Arguments: +# $@ - One or more workflow file paths +# +# Returns: +# 0 - all pins match, or no pins found +# 1 - one or more pins do not match +cmd_check() { local -i failures=0 local -i found_pins=0 local -A resolve_cache=() - + local -A seen_in_file=() local file basename line local action pinned_sha claimed_tag cache_key cached resolved_sha - local -A seen_in_file for file in "${@}"; do - [[ "${file}" == --* ]] && continue if [[ ! -f "${file}" ]]; then echo "WARN: ${file} not found, skipping" continue @@ -195,7 +171,6 @@ main() { basename="${file##*/}" seen_in_file=() - # Match lines like: uses: owner/repo@<40-hex-sha> # while IFS= read -r line; do if [[ "${line}" =~ uses:[[:space:]]*([^@]+)@([0-9a-f]{40})[[:space:]]*#[[:space:]]*([^[:space:]]+) ]]; then action="${BASH_REMATCH[1]}" @@ -239,8 +214,96 @@ main() { if [[ "${failures}" -gt 0 ]]; then echo "FAIL: ${failures} pin(s) did not match" + return 1 + fi + return 0 +} + +# ── main ───────────────────────────────────────────────────────────── + +main() { + if [[ $# -eq 0 ]]; then + usage >&2 exit 1 fi + + # Short-circuit --help/--version at any position, before we try to + # interpret a subcommand or flags. + local arg + for arg in "${@}"; do + case "${arg}" in + --help) + usage + exit 0 + ;; + --version) + echo "${PROGRAM} ${VERSION}" + exit 0 + ;; + --) break ;; + *) ;; + esac + done + + # Detect subcommand. Anything else stays as a positional file arg, + # preserving back-compat for bare-file invocations. + local subcmd="check" + if [[ $# -gt 0 ]]; then + case "${1}" in + check) + subcmd="${1}" + shift + ;; + esac + fi + + # Collect files; reject unknown flags with exit 2. + local files=() + while [[ $# -gt 0 ]]; do + case "${1}" in + --) + shift + files+=("${@}") + break + ;; + -*) + echo "${PROGRAM}: unknown flag: ${1}" >&2 + exit 2 + ;; + *) + files+=("${1}") + shift + ;; + esac + done + + if [[ "${#files[@]}" -eq 0 ]]; then + usage >&2 + exit 1 + fi + + local cmd + for cmd in curl jq; do + if ! command -v "${cmd}" > /dev/null 2>&1; then + echo "WARN: ${cmd} not found — skipping pin validation" + exit 0 + fi + done + + init_auth_header + + if [[ -z "${VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY:-}" ]]; then + if ! curl -fsSL "${auth_header[@]+"${auth_header[@]}"}" \ + -H "Accept: application/vnd.github+json" \ + "${GITHUB_API_BASE}/rate_limit" > /dev/null 2>&1; then + echo "WARN: cannot reach GitHub API — skipping pin validation" + exit 0 + fi + fi + + case "${subcmd}" in + check) cmd_check "${files[@]}" ;; + esac } if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then diff --git a/tests/bats/images/ci-tools/validate-action-pins.bats b/tests/bats/images/ci-tools/validate-action-pins.bats index 6e5073d..d16538e 100644 --- a/tests/bats/images/ci-tools/validate-action-pins.bats +++ b/tests/bats/images/ci-tools/validate-action-pins.bats @@ -72,6 +72,54 @@ setup() { assert_equal "${ok_count}" "1" } +# ── subcommand dispatch ───────────────────────────────────────────── + +@test "bare FILE and explicit 'check FILE' produce identical output" { + local bare_out subcmd_out + bare_out="$("${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml")" + subcmd_out="$("${SCRIPT}" check "${FIXTURES_DIR}/workflows/tag-ok.yml")" + assert_equal "${bare_out}" "${subcmd_out}" +} + +@test "'check --version' still prints the version" { + run "${SCRIPT}" check --version + assert_success + assert_output --regexp '^validate-action-pins ' +} + +@test "unknown flag exits 2 with a usage error" { + run "${SCRIPT}" --bogus "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_failure 2 + assert_output --partial "unknown flag: --bogus" +} + +@test "short unknown flag exits 2" { + run "${SCRIPT}" -x "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_failure 2 + assert_output --partial "unknown flag: -x" +} + +@test "non-subcommand word is treated as a file path, not rejected" { + # 'foo' is not a known subcommand, so it stays as $1 and is consumed as a + # filename. Missing-file WARN confirms the non-reject path. + run "${SCRIPT}" foo "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + assert_output --partial "WARN: foo not found" + assert_output --partial "OK tag-ok.yml:" +} + +@test "'check' without any files exits 1 with usage" { + run "${SCRIPT}" check + assert_failure 1 + assert_output --partial "Usage: validate-action-pins" +} + +@test "'--' terminator passes subsequent args as files" { + run "${SCRIPT}" check -- "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + assert_output --partial "OK tag-ok.yml:" +} + # ── connectivity probe ────────────────────────────────────────────── @test "connectivity probe failure warns and exits 0" { From 0a0bd097e1d8205cfca42e45b315556ce667de32 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 12:41:23 -0600 Subject: [PATCH 03/33] Add list subcommand to validate-action-pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate-action-pins list inventories every `uses:` pin found in the input workflow files with no API calls. Useful for audits and for piping a machine-readable view into other tooling. - parse_uses_line extracts action, ref, kind, and trailing-comment word from a `uses:` line using a loose regex. Kind is "sha" for 40-hex refs and "ref" for symbolic refs; distinguishing tag from branch requires API calls and is deferred to resolver-backed subcommands. Local `./` actions and `docker://` schemes are skipped. - cmd_list iterates files, parses each line, and emits records in either plain (`: @ (# )`) or TSV (`\t\t\t\t`) format. No deduplication — every occurrence is reported. - main gains --format=plain|tsv parsing (two-token and = forms), with invalid values rejected with exit 2. --format is currently list-scoped; check accepts it silently. - check_api_preflight extracts the curl/jq dep check, auth init, and rate_limit connectivity probe into a reusable helper, called only from cmd_check. cmd_list skips all of it, so listing works offline and without jq installed. - Man page gains a list synopsis, a SUBCOMMANDS entry with output formats and the skip rules, a --format OPTION, and two new EXAMPLES. - 11 new bats cases cover list output in both formats, the skip rules for non-remote actions, --format parsing and validation, offline behaviour (no curl/jq, no probe), and sourced parse_uses_line unit calls. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/man/man1/validate-action-pins.1 | 38 ++++ images/ci-tools/bin/validate-action-pins | 186 +++++++++++++++--- .../ci-tools/fixtures/workflows/mixed.yml | 12 ++ .../images/ci-tools/validate-action-pins.bats | 109 +++++++++- 4 files changed, 315 insertions(+), 30 deletions(-) create mode 100644 tests/bats/images/ci-tools/fixtures/workflows/mixed.yml diff --git a/docs/man/man1/validate-action-pins.1 b/docs/man/man1/validate-action-pins.1 index 0c64805..3af4077 100644 --- a/docs/man/man1/validate-action-pins.1 +++ b/docs/man/man1/validate-action-pins.1 @@ -12,6 +12,10 @@ .Nm .Cm check .Ar file ... +.Nm +.Cm list +.Op Fl -format Ar plain Ns | Ns Ar tsv +.Ar file ... .Sh DESCRIPTION .Nm scans GitHub Actions workflow files for @@ -41,9 +45,35 @@ This is the default when no subcommand is given; bare .Ic validate-action-pins Ar file ... is equivalent to .Ic validate-action-pins check Ar file ... . +.It Cm list Oo Fl -format Ar plain Ns | Ns Ar tsv Oc Ar file ... +Print every +.Ic uses:\& +pin found in the input files. +Performs no API calls, so it runs offline and without authentication. +Plain output has the form +.Dl : @ (# ) +TSV output (one record per line, no header) has the fields: +.Dl \etab\etab\etab\etab +where +.Em kind +is +.Em sha +for 40-hex pins and +.Em ref +for symbolic references (tag or branch; not distinguished without an API call). +Actions pinned to a +.Ic docker:// +scheme or a local +.Ic ./ +path are skipped. .El .Sh OPTIONS .Bl -tag -width indent +.It Fl -format Ar plain Ns | Ns Ar tsv +Output format for +.Cm list . +Default is +.Em plain . .It Fl -help Show a usage summary and exit. .It Fl -version @@ -76,6 +106,14 @@ Validate all workflows in a repository: Validate a single file: .Pp .Dl validate-action-pins .github/workflows/ci.yml +.Pp +Inventory every pin across the workflows: +.Pp +.Dl validate-action-pins list .github/workflows/*.yml +.Pp +Export a machine-readable pin list: +.Pp +.Dl validate-action-pins list --format=tsv .github/workflows/*.yml .Sh DEPENDENCIES .Nm requires diff --git a/images/ci-tools/bin/validate-action-pins b/images/ci-tools/bin/validate-action-pins index b89793e..1b5ff4e 100755 --- a/images/ci-tools/bin/validate-action-pins +++ b/images/ci-tools/bin/validate-action-pins @@ -10,6 +10,7 @@ set -euo pipefail # Usage: # validate-action-pins .github/workflows/*.yml # validate-action-pins check .github/workflows/*.yml +# validate-action-pins list [--format=plain|tsv] .github/workflows/*.yml # validate-action-pins --help # validate-action-pins --version # @@ -45,18 +46,17 @@ usage() { cat << 'USAGE' Usage: validate-action-pins [OPTIONS] [SUBCOMMAND] FILE... -Verify that GitHub Actions SHA pins match their claimed tags. - -Scans workflow files for lines like: - uses: actions/checkout@de0fac2e... # v6 -and checks that the pinned SHA resolves to the stated tag via the GitHub API. +Verify, inventory, or report on GitHub Actions pins in workflow files. Subcommands: - check Verify SHA pins (default when no subcommand is given) + check Verify SHA pins against their claimed tags via the GitHub API. + This is the default when no subcommand is given. + list Print every `uses:` pin found in the files. No API calls. Options: - --help Show this help message and exit - --version Print the version and exit + --format=plain|tsv Output format for `list` (default: plain) + --help Show this help message and exit + --version Print the version and exit Environment: GITHUB_TOKEN Optional token for higher API rate limits @@ -139,6 +139,85 @@ resolve_tag() { return 1 } +# Parse a single `uses:` line into its component fields. +# +# Arguments: +# $1 - A line of text +# +# Output (on success, tab-separated, single line): +# \t\t\t +# kind is "sha" if the ref is a 40-hex SHA, "ref" otherwise +# comment is the first word after "#" (may be empty) +# +# Returns: +# 0 - parsed a remote action pin +# 1 - line did not contain a pin (skipped), or action is local/docker +parse_uses_line() { + local line="${1}" + local action ref comment kind + if ! [[ "${line}" =~ uses:[[:space:]]*([^@[:space:]]+)@([^[:space:]#]+)[[:space:]]*(#[[:space:]]*([^[:space:]]+))? ]]; then + return 1 + fi + action="${BASH_REMATCH[1]}" + ref="${BASH_REMATCH[2]}" + comment="${BASH_REMATCH[4]:-}" + + # Skip anything that is not a remote owner/repo reference: + # - no slash (malformed) + # - scheme-prefixed (docker://alpine) + # - local paths (./local-action) + if [[ "${action}" != */* || "${action}" == *://* || "${action}" == .* ]]; then + return 1 + fi + + if [[ "${ref}" =~ ^[0-9a-f]{40}$ ]]; then + kind="sha" + else + kind="ref" + fi + + # Defensive: strip tabs/newlines from fields for TSV safety. + action="${action//$'\t'/}" + ref="${ref//$'\t'/}" + comment="${comment//$'\t'/ }" + action="${action//$'\n'/}" + ref="${ref//$'\n'/}" + comment="${comment//$'\n'/ }" + + printf '%s\t%s\t%s\t%s\n' "${action}" "${ref}" "${kind}" "${comment}" +} + +# Preflight for API-dependent subcommands: dep check, auth init, and a +# connectivity probe. Emits WARN and returns 1 when the caller should +# gracefully skip validation. +# +# Returns: +# 0 - curl, jq, and the API are available +# 1 - a dependency is missing or the API is unreachable (WARN emitted) +check_api_preflight() { + local cmd + for cmd in curl jq; do + if ! command -v "${cmd}" > /dev/null 2>&1; then + echo "WARN: ${cmd} not found — skipping pin validation" + return 1 + fi + done + + init_auth_header + + if [[ -n "${VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY:-}" ]]; then + return 0 + fi + + if ! curl -fsSL "${auth_header[@]+"${auth_header[@]}"}" \ + -H "Accept: application/vnd.github+json" \ + "${GITHUB_API_BASE}/rate_limit" > /dev/null 2>&1; then + echo "WARN: cannot reach GitHub API — skipping pin validation" + return 1 + fi + return 0 +} + # ── subcommands ────────────────────────────────────────────────────── # Verify SHA pins in the given workflow files. @@ -155,6 +234,8 @@ resolve_tag() { # 0 - all pins match, or no pins found # 1 - one or more pins do not match cmd_check() { + check_api_preflight || return 0 + local -i failures=0 local -i found_pins=0 local -A resolve_cache=() @@ -219,6 +300,50 @@ cmd_check() { return 0 } +# List pinned actions found in the given workflow files. No API calls. +# +# Arguments: +# $1 - format: "plain" or "tsv" +# $2.. - workflow file paths +# +# Plain output (one line per occurrence): +# : @ (# ) +# TSV output (one line per occurrence, header-less): +# \t\t\t\t +# +# Returns 0 always; per-file read errors are reported as WARN on stderr. +cmd_list() { + local format="${1}" + shift + local file line parsed action ref kind comment + + for file in "${@}"; do + if [[ ! -f "${file}" ]]; then + echo "WARN: ${file} not found, skipping" >&2 + continue + fi + while IFS= read -r line; do + if parsed="$(parse_uses_line "${line}")"; then + IFS=$'\t' read -r action ref kind comment <<< "${parsed}" + case "${format}" in + tsv) + printf '%s\t%s\t%s\t%s\t%s\n' \ + "${file}" "${action}" "${ref}" "${kind}" "${comment}" + ;; + *) + if [[ -n "${comment}" ]]; then + printf '%s: %s@%s (# %s)\n' \ + "${file##*/}" "${action}" "${ref}" "${comment}" + else + printf '%s: %s@%s\n' "${file##*/}" "${action}" "${ref}" + fi + ;; + esac + fi + done < "${file}" + done +} + # ── main ───────────────────────────────────────────────────────────── main() { @@ -250,17 +375,30 @@ main() { local subcmd="check" if [[ $# -gt 0 ]]; then case "${1}" in - check) + check | list) subcmd="${1}" shift ;; esac fi - # Collect files; reject unknown flags with exit 2. + # Collect files and any subcommand-scoped flags. + local format="plain" local files=() while [[ $# -gt 0 ]]; do case "${1}" in + --format=*) + format="${1#*=}" + shift + ;; + --format) + if [[ $# -lt 2 ]]; then + echo "${PROGRAM}: --format requires a value" >&2 + exit 2 + fi + format="${2}" + shift 2 + ;; --) shift files+=("${@}") @@ -277,32 +415,22 @@ main() { esac done + case "${format}" in + plain | tsv) ;; + *) + echo "${PROGRAM}: invalid --format: ${format} (expected 'plain' or 'tsv')" >&2 + exit 2 + ;; + esac + if [[ "${#files[@]}" -eq 0 ]]; then usage >&2 exit 1 fi - local cmd - for cmd in curl jq; do - if ! command -v "${cmd}" > /dev/null 2>&1; then - echo "WARN: ${cmd} not found — skipping pin validation" - exit 0 - fi - done - - init_auth_header - - if [[ -z "${VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY:-}" ]]; then - if ! curl -fsSL "${auth_header[@]+"${auth_header[@]}"}" \ - -H "Accept: application/vnd.github+json" \ - "${GITHUB_API_BASE}/rate_limit" > /dev/null 2>&1; then - echo "WARN: cannot reach GitHub API — skipping pin validation" - exit 0 - fi - fi - case "${subcmd}" in check) cmd_check "${files[@]}" ;; + list) cmd_list "${format}" "${files[@]}" ;; esac } diff --git a/tests/bats/images/ci-tools/fixtures/workflows/mixed.yml b/tests/bats/images/ci-tools/fixtures/workflows/mixed.yml new file mode 100644 index 0000000..7ee8be4 --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/workflows/mixed.yml @@ -0,0 +1,12 @@ +name: mixed +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1 + - uses: actions/checkout@v6.0.2 + - uses: ./local-action + - uses: docker://alpine:3.19 + - uses: org/repo@main + - uses: org/no-comment@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb diff --git a/tests/bats/images/ci-tools/validate-action-pins.bats b/tests/bats/images/ci-tools/validate-action-pins.bats index d16538e..3f188fb 100644 --- a/tests/bats/images/ci-tools/validate-action-pins.bats +++ b/tests/bats/images/ci-tools/validate-action-pins.bats @@ -3,7 +3,9 @@ # # REPO_ROOT, FIXTURES_DIR, API_FIXTURES_DIR, SCRIPT are populated by # common_setup and the per-test setup; BATS_* are populated by bats at runtime. -# shellcheck disable=SC2154 +# Each @test runs in its own subshell, so exports are scoped per test — the +# subshell warnings are expected. +# shellcheck disable=SC2030,SC2031,SC2154 load ../../helpers/common @@ -120,6 +122,111 @@ setup() { assert_output --partial "OK tag-ok.yml:" } +# ── list subcommand ───────────────────────────────────────────────── + +@test "list plain emits every parseable pin occurrence" { + run "${SCRIPT}" list "${FIXTURES_DIR}/workflows/mixed.yml" + assert_success + assert_output --partial "mixed.yml: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa (# v1)" + assert_output --partial "mixed.yml: actions/checkout@v6.0.2" + assert_output --partial "mixed.yml: org/repo@main" + assert_output --partial "mixed.yml: org/no-comment@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +} + +@test "list skips docker:// and local path actions" { + run "${SCRIPT}" list "${FIXTURES_DIR}/workflows/mixed.yml" + assert_success + refute_output --partial "docker://" + refute_output --partial "./local-action" +} + +@test "list tsv emits five tab-separated columns per row" { + run "${SCRIPT}" list --format=tsv "${FIXTURES_DIR}/workflows/mixed.yml" + assert_success + # Every non-empty line must have exactly 4 tabs (5 fields). + local line + while IFS= read -r line; do + [[ -z "${line}" ]] && continue + local tab_count="${line//[^$'\t']/}" + assert_equal "${#tab_count}" "4" + done <<< "${output}" + # And we should see at least one sha and one ref kind. + assert_output --partial $'\tsha\t' + assert_output --partial $'\tref\t' +} + +@test "list accepts --format tsv as two tokens" { + run "${SCRIPT}" list --format tsv "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + assert_output --regexp $'\tfoo/bar\t' +} + +@test "list rejects an invalid --format" { + run "${SCRIPT}" list --format=yaml "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_failure 2 + assert_output --partial "invalid --format: yaml" +} + +@test "list works without curl in PATH (no API calls)" { + local stubdir="${BATS_TEST_TMPDIR}/nocurl-list" + local bash_bin + bash_bin="$(command -v bash)" + mkdir -p "${stubdir}" + ln -s "${bash_bin}" "${stubdir}/bash" + PATH="${stubdir}" run "${SCRIPT}" list "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + assert_output --partial "tag-ok.yml: foo/bar@" +} + +@test "list works without jq in PATH" { + local stubdir="${BATS_TEST_TMPDIR}/nojq-list" + local bash_bin + bash_bin="$(command -v bash)" + mkdir -p "${stubdir}" + ln -s "${bash_bin}" "${stubdir}/bash" + PATH="${stubdir}" run "${SCRIPT}" list "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + assert_output --partial "tag-ok.yml: foo/bar@" +} + +@test "list does not run the connectivity probe" { + # Point API base at a missing dir; clear SKIP — check would WARN here. + unset VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY + export GITHUB_API_BASE="file://${BATS_TEST_TMPDIR}/empty" + mkdir -p "${BATS_TEST_TMPDIR}/empty" + run "${SCRIPT}" list "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + refute_output --partial "cannot reach GitHub API" + assert_output --partial "tag-ok.yml: foo/bar@" +} + +# ── parse_uses_line (sourced) ─────────────────────────────────────── + +@test "parse_uses_line returns sha kind for 40-hex refs" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run parse_uses_line " - uses: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1" + assert_success + assert_output $'foo/bar\taaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\tsha\tv1' +} + +@test "parse_uses_line returns ref kind for non-sha refs" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run parse_uses_line " - uses: org/repo@main" + assert_success + assert_output $'org/repo\tmain\tref\t' +} + +@test "parse_uses_line rejects docker:// and local paths" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run parse_uses_line " - uses: docker://alpine:3.19" + assert_failure + run parse_uses_line " - uses: ./local-action" + assert_failure +} + # ── connectivity probe ────────────────────────────────────────────── @test "connectivity probe failure warns and exits 0" { From 93f17711ef37b904965e447b62d5f289b1efbf63 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 12:45:28 -0600 Subject: [PATCH 04/33] Support branch pins in validate-action-pins check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SHA-pinned actions with a branch-name comment — e.g. `Homebrew/actions/setup-homebrew@ # main` — were previously reported as an unresolvable ref because the resolver only probed `/git/ref/tags`. This extends check to follow the Dependabot comment convention: probe the tag endpoint first and fall back to `/git/ref/heads`, classifying the result as either a tag or a branch. - resolve_ref replaces resolve_tag, returning a tab-separated `\t` tuple so callers can route on the kind. Annotated-tag dereference is preserved. - compare_behind calls `/repos/.../compare/base...head` and returns the `.behind_by` integer. A branch pin that has moved is not a failure — the repo owner chose to track HEAD — so cmd_check emits WARN with the commit count, or "diverges from HEAD" when `.behind_by == 0` (the pin is on an unrelated line of history). - The cache sentinel flips from `"!"` to `"NONE"` so it no longer collides with the tab-delimited resolved value. A sibling compare_cache memoises compare_behind calls. - Unresolvable-ref message flips from "could not resolve tag" to "could not resolve ref" to reflect that either endpoint may have answered. - Man page gains a Dependabot-convention paragraph, documents the WARN-not-FAIL branch-drift semantics, and shows the four possible check output shapes. - 6 new integration cases cover branch match, behind HEAD, diverged, unresolvable, tag-mismatch regression, and duplicate branch-pin dedupe; 5 new unit cases cover resolve_ref and compare_behind. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/man/man1/validate-action-pins.1 | 30 +++- images/ci-tools/bin/validate-action-pins | 154 ++++++++++++------ .....bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb | 6 + .../repos/foo/br-behind/git/ref/heads/main | 7 + .....dddddddddddddddddddddddddddddddddddddddd | 6 + .../repos/foo/br-diverge/git/ref/heads/main | 7 + .../api/repos/foo/br-ok/git/ref/heads/main | 7 + .../fixtures/workflows/branch-behind.yml | 7 + .../fixtures/workflows/branch-diverge.yml | 7 + .../ci-tools/fixtures/workflows/branch-ok.yml | 7 + .../workflows/duplicate-branch-pins.yml | 9 + .../fixtures/workflows/unresolvable.yml | 7 + .../images/ci-tools/validate-action-pins.bats | 89 +++++++++- 13 files changed, 279 insertions(+), 64 deletions(-) create mode 100644 tests/bats/images/ci-tools/fixtures/api/repos/foo/br-behind/compare/cccccccccccccccccccccccccccccccccccccccc...bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb create mode 100644 tests/bats/images/ci-tools/fixtures/api/repos/foo/br-behind/git/ref/heads/main create mode 100644 tests/bats/images/ci-tools/fixtures/api/repos/foo/br-diverge/compare/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee...dddddddddddddddddddddddddddddddddddddddd create mode 100644 tests/bats/images/ci-tools/fixtures/api/repos/foo/br-diverge/git/ref/heads/main create mode 100644 tests/bats/images/ci-tools/fixtures/api/repos/foo/br-ok/git/ref/heads/main create mode 100644 tests/bats/images/ci-tools/fixtures/workflows/branch-behind.yml create mode 100644 tests/bats/images/ci-tools/fixtures/workflows/branch-diverge.yml create mode 100644 tests/bats/images/ci-tools/fixtures/workflows/branch-ok.yml create mode 100644 tests/bats/images/ci-tools/fixtures/workflows/duplicate-branch-pins.yml create mode 100644 tests/bats/images/ci-tools/fixtures/workflows/unresolvable.yml diff --git a/docs/man/man1/validate-action-pins.1 b/docs/man/man1/validate-action-pins.1 index 3af4077..87b2021 100644 --- a/docs/man/man1/validate-action-pins.1 +++ b/docs/man/man1/validate-action-pins.1 @@ -3,7 +3,7 @@ .Os .Sh NAME .Nm validate-action-pins -.Nd verify GitHub Actions SHA pins match their claimed tags +.Nd verify GitHub Actions SHA pins and inventory their refs .Sh SYNOPSIS .Nm .Op Fl -help @@ -20,20 +20,36 @@ .Nm scans GitHub Actions workflow files for .Ic uses:\& -lines pinned to a commit SHA with a tag comment, for example: +lines pinned to a commit SHA with a ref comment, for example: .Pp .Dl uses: actions/checkout@de0fac2e... # v6 +.Dl uses: Homebrew/actions/setup-homebrew@1234abcd... # main .Pp -For each match it queries the GitHub REST API to verify that the pinned -SHA resolves to the stated tag. -Each unique action/tag pair is resolved once; subsequent occurrences +The comment follows the Dependabot convention \(em the ref may be a tag +or a branch name, with no prefix to distinguish them. +.Nm +probes the GitHub REST API for a matching tag first and falls back to a +branch of the same name. +Each unique action/ref pair is resolved once; subsequent occurrences within the same file are deduplicated. .Pp +For tag pins, a SHA mismatch is a hard failure +.Pq Li FAIL , exit 1 . +For branch pins, a SHA mismatch is informational +.Pq Li WARN , exit 0 +because branch heads move continuously; the tool reports how many +commits the pin is behind the branch head +.Po from +.Ic /repos/\:{owner}/{repo}/compare/{base}...{head} Pc , +or notes that the pin has diverged from the current history. +.Pp Output lines are prefixed with the workflow filename: .Pp .Dl OK \ \ \ ci.yml: actions/checkout@de0fac2e4500... matches v6 -.Dl FAIL \ publish.yml: actions/checkout@de0fac2e4500... does NOT match v6 (expected abc123456789...) -.Dl WARN \ ci.yml: actions/checkout@de0fac2e4500... \(em could not resolve tag v6 +.Dl FAIL \ ci.yml: actions/checkout@de0fac2e4500... does NOT match v6 (expected abc123456789...) +.Dl WARN \ ci.yml: Homebrew/actions/setup-homebrew@de0fac2e4500... is 3 commit(s) behind main HEAD (at abc123456789...) +.Dl WARN \ ci.yml: org/action@de0fac2e4500... diverges from main HEAD (at abc123456789...) +.Dl WARN \ ci.yml: org/action@de0fac2e4500... \(em could not resolve ref v6 .Pp If the API is unreachable or a required dependency is missing, the check is skipped and the tool exits successfully. diff --git a/images/ci-tools/bin/validate-action-pins b/images/ci-tools/bin/validate-action-pins index 1b5ff4e..d157e1b 100755 --- a/images/ci-tools/bin/validate-action-pins +++ b/images/ci-tools/bin/validate-action-pins @@ -91,47 +91,55 @@ gh_api() { "${url}" 2> /dev/null || true } -# Resolve a tag to its commit SHA, handling both lightweight and annotated tags. +# Resolve a ref (tag or branch) to its commit SHA, classifying the ref +# by which GitHub endpoint answered. Tags are probed first to match the +# Dependabot convention of `owner/repo@ # ` where is +# usually a tag; branch references fall back to /git/ref/heads. # # Arguments: # $1 - Repository (owner/repo) -# $2 - Tag name (e.g. v6) +# $2 - Ref name (e.g. v6, main) # -# Outputs: -# 40-character commit SHA, or empty string on failure -resolve_tag() { - local repo="${1}" tag="${2}" - - local ref_json - ref_json="$(gh_api "/repos/${repo}/git/ref/tags/${tag}")" - if [[ -z "${ref_json}" ]]; then - return 1 - fi - - local obj_type obj_sha - obj_type="$(echo "${ref_json}" | jq -r '.object.type // empty')" - obj_sha="$(echo "${ref_json}" | jq -r '.object.sha // empty')" - - if [[ -z "${obj_type}" || -z "${obj_sha}" ]]; then - return 1 - fi - - if [[ "${obj_type}" == "commit" ]]; then - echo "${obj_sha}" - return 0 +# Output (on success, tab-separated, single line): +# <40-hex-commit-sha>\t +# +# Returns: +# 0 - resolved as a tag or a branch +# 1 - ref is neither a known tag nor a known branch +resolve_ref() { + local repo="${1}" ref="${2}" + + # Tag first (lightweight + annotated). + local tag_json + tag_json="$(gh_api "/repos/${repo}/git/ref/tags/${ref}")" + if [[ -n "${tag_json}" ]]; then + local obj_type obj_sha + obj_type="$(echo "${tag_json}" | jq -r '.object.type // empty')" + obj_sha="$(echo "${tag_json}" | jq -r '.object.sha // empty')" + if [[ "${obj_type}" == "commit" && -n "${obj_sha}" ]]; then + printf '%s\ttag\n' "${obj_sha}" + return 0 + fi + if [[ "${obj_type}" == "tag" && -n "${obj_sha}" ]]; then + local tag_obj_json commit_sha + tag_obj_json="$(gh_api "/repos/${repo}/git/tags/${obj_sha}")" + if [[ -n "${tag_obj_json}" ]]; then + commit_sha="$(echo "${tag_obj_json}" | jq -r '.object.sha // empty')" + if [[ -n "${commit_sha}" ]]; then + printf '%s\ttag\n' "${commit_sha}" + return 0 + fi + fi + fi fi - # Annotated tag — dereference to the commit. - if [[ "${obj_type}" == "tag" ]]; then - local tag_json - tag_json="$(gh_api "/repos/${repo}/git/tags/${obj_sha}")" - if [[ -z "${tag_json}" ]]; then - return 1 - fi - local commit_sha - commit_sha="$(echo "${tag_json}" | jq -r '.object.sha // empty')" - if [[ -n "${commit_sha}" ]]; then - echo "${commit_sha}" + # Branch fallback. + local branch_json branch_sha + branch_json="$(gh_api "/repos/${repo}/git/ref/heads/${ref}")" + if [[ -n "${branch_json}" ]]; then + branch_sha="$(echo "${branch_json}" | jq -r '.object.sha // empty')" + if [[ -n "${branch_sha}" ]]; then + printf '%s\tbranch\n' "${branch_sha}" return 0 fi fi @@ -139,6 +147,29 @@ resolve_tag() { return 1 } +# Count how many commits $base is behind $head using the GitHub compare API. +# +# Arguments: +# $1 - Repository (owner/repo) +# $2 - Base SHA (the pinned commit) +# $3 - Head SHA (the current branch HEAD) +# +# Output: +# Integer on success (0 if the two SHAs are on diverged history); empty +# on API failure. +# +# Returns: +# 0 on success, 1 on API failure +compare_behind() { + local repo="${1}" base="${2}" head="${3}" + local json + json="$(gh_api "/repos/${repo}/compare/${base}...${head}")" + if [[ -z "${json}" ]]; then + return 1 + fi + jq -r '.behind_by // empty' <<< "${json}" +} + # Parse a single `uses:` line into its component fields. # # Arguments: @@ -239,9 +270,11 @@ cmd_check() { local -i failures=0 local -i found_pins=0 local -A resolve_cache=() + local -A compare_cache=() local -A seen_in_file=() local file basename line - local action pinned_sha claimed_tag cache_key cached resolved_sha + local action pinned_sha claimed_ref cache_key cached resolved kind + local compare_key behind for file in "${@}"; do if [[ ! -f "${file}" ]]; then @@ -256,10 +289,10 @@ cmd_check() { if [[ "${line}" =~ uses:[[:space:]]*([^@]+)@([0-9a-f]{40})[[:space:]]*#[[:space:]]*([^[:space:]]+) ]]; then action="${BASH_REMATCH[1]}" pinned_sha="${BASH_REMATCH[2]}" - claimed_tag="${BASH_REMATCH[3]}" + claimed_ref="${BASH_REMATCH[3]}" found_pins=1 - cache_key="${action}#${claimed_tag}" + cache_key="${action}#${claimed_ref}" if [[ -n "${seen_in_file[${cache_key}]+x}" ]]; then continue @@ -267,24 +300,47 @@ cmd_check() { seen_in_file["${cache_key}"]=1 if [[ -z "${resolve_cache[${cache_key}]+x}" ]]; then - resolved_sha="" - if resolved_sha="$(resolve_tag "${action}" "${claimed_tag}")"; then - resolve_cache["${cache_key}"]="${resolved_sha}" + if cached="$(resolve_ref "${action}" "${claimed_ref}")"; then + resolve_cache["${cache_key}"]="${cached}" else - resolve_cache["${cache_key}"]="!" + resolve_cache["${cache_key}"]="NONE" fi fi cached="${resolve_cache[${cache_key}]}" - if [[ "${cached}" == "!" ]]; then - echo "WARN ${basename}: ${action}@${pinned_sha:0:12}... — could not resolve tag ${claimed_tag}" - elif [[ "${pinned_sha}" == "${cached}" ]]; then - echo "OK ${basename}: ${action}@${pinned_sha:0:12}... matches ${claimed_tag}" - else - echo "FAIL ${basename}: ${action}@${pinned_sha:0:12}... does NOT match ${claimed_tag} (expected ${cached:0:12}...)" - failures=$((failures + 1)) + if [[ "${cached}" == "NONE" ]]; then + echo "WARN ${basename}: ${action}@${pinned_sha:0:12}... — could not resolve ref ${claimed_ref}" + continue fi + + resolved="${cached%%$'\t'*}" + kind="${cached#*$'\t'}" + + if [[ "${pinned_sha}" == "${resolved}" ]]; then + echo "OK ${basename}: ${action}@${pinned_sha:0:12}... matches ${claimed_ref}" + continue + fi + + if [[ "${kind}" == "branch" ]]; then + compare_key="${action}#${pinned_sha}#${resolved}" + if [[ -z "${compare_cache[${compare_key}]+x}" ]]; then + behind="$(compare_behind "${action}" "${pinned_sha}" "${resolved}" || true)" + compare_cache["${compare_key}"]="${behind}" + fi + behind="${compare_cache[${compare_key}]}" + + if [[ -n "${behind}" && "${behind}" -gt 0 ]]; then + echo "WARN ${basename}: ${action}@${pinned_sha:0:12}... is ${behind} commit(s) behind ${claimed_ref} HEAD (at ${resolved:0:12}...)" + else + echo "WARN ${basename}: ${action}@${pinned_sha:0:12}... diverges from ${claimed_ref} HEAD (at ${resolved:0:12}...)" + fi + continue + fi + + # Tag kind — mismatch is a hard failure. + echo "FAIL ${basename}: ${action}@${pinned_sha:0:12}... does NOT match ${claimed_ref} (expected ${resolved:0:12}...)" + failures=$((failures + 1)) fi done < "${file}" done diff --git a/tests/bats/images/ci-tools/fixtures/api/repos/foo/br-behind/compare/cccccccccccccccccccccccccccccccccccccccc...bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb b/tests/bats/images/ci-tools/fixtures/api/repos/foo/br-behind/compare/cccccccccccccccccccccccccccccccccccccccc...bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb new file mode 100644 index 0000000..d1bb63b --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/api/repos/foo/br-behind/compare/cccccccccccccccccccccccccccccccccccccccc...bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb @@ -0,0 +1,6 @@ +{ + "status": "ahead", + "ahead_by": 0, + "behind_by": 3, + "total_commits": 3 +} diff --git a/tests/bats/images/ci-tools/fixtures/api/repos/foo/br-behind/git/ref/heads/main b/tests/bats/images/ci-tools/fixtures/api/repos/foo/br-behind/git/ref/heads/main new file mode 100644 index 0000000..507723d --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/api/repos/foo/br-behind/git/ref/heads/main @@ -0,0 +1,7 @@ +{ + "ref": "refs/heads/main", + "object": { + "type": "commit", + "sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } +} diff --git a/tests/bats/images/ci-tools/fixtures/api/repos/foo/br-diverge/compare/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee...dddddddddddddddddddddddddddddddddddddddd b/tests/bats/images/ci-tools/fixtures/api/repos/foo/br-diverge/compare/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee...dddddddddddddddddddddddddddddddddddddddd new file mode 100644 index 0000000..789c53e --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/api/repos/foo/br-diverge/compare/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee...dddddddddddddddddddddddddddddddddddddddd @@ -0,0 +1,6 @@ +{ + "status": "diverged", + "ahead_by": 2, + "behind_by": 0, + "total_commits": 2 +} diff --git a/tests/bats/images/ci-tools/fixtures/api/repos/foo/br-diverge/git/ref/heads/main b/tests/bats/images/ci-tools/fixtures/api/repos/foo/br-diverge/git/ref/heads/main new file mode 100644 index 0000000..4d4e652 --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/api/repos/foo/br-diverge/git/ref/heads/main @@ -0,0 +1,7 @@ +{ + "ref": "refs/heads/main", + "object": { + "type": "commit", + "sha": "dddddddddddddddddddddddddddddddddddddddd" + } +} diff --git a/tests/bats/images/ci-tools/fixtures/api/repos/foo/br-ok/git/ref/heads/main b/tests/bats/images/ci-tools/fixtures/api/repos/foo/br-ok/git/ref/heads/main new file mode 100644 index 0000000..e7afca1 --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/api/repos/foo/br-ok/git/ref/heads/main @@ -0,0 +1,7 @@ +{ + "ref": "refs/heads/main", + "object": { + "type": "commit", + "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } +} diff --git a/tests/bats/images/ci-tools/fixtures/workflows/branch-behind.yml b/tests/bats/images/ci-tools/fixtures/workflows/branch-behind.yml new file mode 100644 index 0000000..2256244 --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/workflows/branch-behind.yml @@ -0,0 +1,7 @@ +name: branch-behind +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: foo/br-behind@cccccccccccccccccccccccccccccccccccccccc # main diff --git a/tests/bats/images/ci-tools/fixtures/workflows/branch-diverge.yml b/tests/bats/images/ci-tools/fixtures/workflows/branch-diverge.yml new file mode 100644 index 0000000..7ca41fb --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/workflows/branch-diverge.yml @@ -0,0 +1,7 @@ +name: branch-diverge +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: foo/br-diverge@eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee # main diff --git a/tests/bats/images/ci-tools/fixtures/workflows/branch-ok.yml b/tests/bats/images/ci-tools/fixtures/workflows/branch-ok.yml new file mode 100644 index 0000000..853026e --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/workflows/branch-ok.yml @@ -0,0 +1,7 @@ +name: branch-ok +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: foo/br-ok@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # main diff --git a/tests/bats/images/ci-tools/fixtures/workflows/duplicate-branch-pins.yml b/tests/bats/images/ci-tools/fixtures/workflows/duplicate-branch-pins.yml new file mode 100644 index 0000000..3f96f5f --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/workflows/duplicate-branch-pins.yml @@ -0,0 +1,9 @@ +name: duplicate-branch-pins +on: push +jobs: + a: + runs-on: ubuntu-latest + steps: + - uses: foo/br-behind@cccccccccccccccccccccccccccccccccccccccc # main + - uses: foo/br-behind@cccccccccccccccccccccccccccccccccccccccc # main + - uses: foo/br-behind@cccccccccccccccccccccccccccccccccccccccc # main diff --git a/tests/bats/images/ci-tools/fixtures/workflows/unresolvable.yml b/tests/bats/images/ci-tools/fixtures/workflows/unresolvable.yml new file mode 100644 index 0000000..354802f --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/workflows/unresolvable.yml @@ -0,0 +1,7 @@ +name: unresolvable +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: foo/nosuch@ffffffffffffffffffffffffffffffffffffffff # nosuch diff --git a/tests/bats/images/ci-tools/validate-action-pins.bats b/tests/bats/images/ci-tools/validate-action-pins.bats index 3f188fb..2ee5897 100644 --- a/tests/bats/images/ci-tools/validate-action-pins.bats +++ b/tests/bats/images/ci-tools/validate-action-pins.bats @@ -265,28 +265,101 @@ setup() { assert_output --partial "WARN: jq not found" } +# ── branch-pin support in `check` ────────────────────────────────── + +@test "branch pin matching HEAD prints OK and exits 0" { + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/branch-ok.yml" + assert_success + assert_output --partial "OK branch-ok.yml: foo/br-ok@aaaaaaaaaaaa..." + assert_output --partial "matches main" +} + +@test "branch pin behind HEAD prints WARN with commit count and exits 0" { + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/branch-behind.yml" + assert_success + assert_output --partial "WARN branch-behind.yml: foo/br-behind@cccccccccccc..." + assert_output --partial "is 3 commit(s) behind main HEAD" +} + +@test "branch pin diverged from HEAD prints WARN diverges and exits 0" { + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/branch-diverge.yml" + assert_success + assert_output --partial "WARN branch-diverge.yml: foo/br-diverge@eeeeeeeeeeee..." + assert_output --partial "diverges from main HEAD" +} + +@test "unresolvable ref prints WARN and exits 0" { + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/unresolvable.yml" + assert_success + assert_output --partial "WARN unresolvable.yml: foo/nosuch@ffffffffffff..." + assert_output --partial "could not resolve ref nosuch" +} + +@test "tag mismatch still prints FAIL and exits 1 (regression guard)" { + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-mismatch.yml" + assert_failure 1 + assert_output --partial "FAIL tag-mismatch.yml:" + assert_output --partial "does NOT match v1" +} + +@test "duplicate branch pins on the same ref produce a single WARN" { + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/duplicate-branch-pins.yml" + assert_success + local warn_count + warn_count="$(grep -c '^WARN ' <<< "${output}" || true)" + assert_equal "${warn_count}" "1" +} + # ── unit-level: source the script, call functions directly ───────── -@test "resolve_tag returns commit SHA for a lightweight tag ref" { +@test "resolve_ref returns \\ttag for a lightweight tag" { # shellcheck disable=SC1090 source "${SCRIPT}" - run resolve_tag "foo/bar" "v1" + run resolve_ref "foo/bar" "v1" assert_success - assert_output "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + assert_output $'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\ttag' } -@test "resolve_tag dereferences an annotated tag ref" { +@test "resolve_ref returns \\ttag for an annotated tag" { # shellcheck disable=SC1090 source "${SCRIPT}" - run resolve_tag "foo/annotated" "v2" + run resolve_ref "foo/annotated" "v2" assert_success - assert_output "cccccccccccccccccccccccccccccccccccccccc" + assert_output $'cccccccccccccccccccccccccccccccccccccccc\ttag' } -@test "resolve_tag returns nonzero when ref does not exist" { +@test "resolve_ref returns \\tbranch for a branch ref" { # shellcheck disable=SC1090 source "${SCRIPT}" - run resolve_tag "foo/bar" "v999" + run resolve_ref "foo/br-ok" "main" + assert_success + assert_output $'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\tbranch' +} + +@test "resolve_ref returns nonzero when ref is neither tag nor branch" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run resolve_ref "foo/bar" "v999" assert_failure assert_output "" } + +@test "compare_behind returns the .behind_by integer" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run compare_behind "foo/br-behind" \ + "cccccccccccccccccccccccccccccccccccccccc" \ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + assert_success + assert_output "3" +} + +@test "compare_behind returns 0 for diverged histories" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run compare_behind "foo/br-diverge" \ + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" \ + "dddddddddddddddddddddddddddddddddddddddd" + assert_success + assert_output "0" +} From ebca57bcae38f4005d5bc58d34d9a3cb3e6fea43 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 12:51:39 -0600 Subject: [PATCH 05/33] Add updates subcommand to validate-action-pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate-action-pins updates scans workflow files and, for each unique pin, reports the newest upgrade available — the latest same-major tag and the overall latest tag for tag pins, or the current branch HEAD for branch pins. Output is informational; the subcommand always exits 0 on a successful run regardless of whether upgrades exist. - latest_tag fetches /repos/.../tags and uses jq to keep strict three-part semver names only (v6.0.2), optionally filter to a given major, and return the numerically-highest entry. Pre-release and non-semver tag names are skipped silently. A single page of /tags is consulted, which suffices for typical action repos; this is called out as a known limitation in the man page. - head_sha returns the commit SHA at the head of a branch via /git/ref/heads; reused from the same endpoint pattern resolve_ref uses internally. - cmd_updates classifies each pin's effective ref (the trailing comment for SHA pins, or the symbolic ref itself) by pattern: a three-part semver triggers the tag-lookup path, everything else goes through head_sha. Results are deduplicated per unique action@effective-ref. - check_api_preflight now accepts an operation-name argument so WARN messages read correctly for the invoking subcommand ("update check" vs. the default "pin validation"). The default preserves existing output and tests. - main routes `updates` through the same --format=plain|tsv parser as list. The plain form shows "up-to-date" or "current=X latest=Y (major: Z)" with a kind suffix; the TSV form is six columns (file, action, current_ref, major_latest, overall_latest, kind). - Usage and man page gain updates documentation, including a note that GITHUB_TOKEN is effectively required at scale (unauthenticated rate limit is 60 req/hr). A new EXAMPLES entry shows piping --format=tsv output into awk. - 6 new integration cases cover tag with new major + newer patch, up-to-date tag, branch at HEAD, stale branch, TSV column count, and the exit-0 contract. 5 new sourced cases cover latest_tag major filter, overall picking, pre-release skip, non-semver skip, and head_sha. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/man/man1/validate-action-pins.1 | 39 ++++ images/ci-tools/bin/validate-action-pins | 197 +++++++++++++++++- .../ci-tools/fixtures/api/repos/foo/bar/tags | 10 + .../fixtures/workflows/updates-at-latest.yml | 7 + .../fixtures/workflows/updates-mixed.yml | 9 + .../fixtures/workflows/updates-new-major.yml | 7 + .../images/ci-tools/validate-action-pins.bats | 103 +++++++++ 7 files changed, 367 insertions(+), 5 deletions(-) create mode 100644 tests/bats/images/ci-tools/fixtures/api/repos/foo/bar/tags create mode 100644 tests/bats/images/ci-tools/fixtures/workflows/updates-at-latest.yml create mode 100644 tests/bats/images/ci-tools/fixtures/workflows/updates-mixed.yml create mode 100644 tests/bats/images/ci-tools/fixtures/workflows/updates-new-major.yml diff --git a/docs/man/man1/validate-action-pins.1 b/docs/man/man1/validate-action-pins.1 index 87b2021..98635df 100644 --- a/docs/man/man1/validate-action-pins.1 +++ b/docs/man/man1/validate-action-pins.1 @@ -16,6 +16,10 @@ .Cm list .Op Fl -format Ar plain Ns | Ns Ar tsv .Ar file ... +.Nm +.Cm updates +.Op Fl -format Ar plain Ns | Ns Ar tsv +.Ar file ... .Sh DESCRIPTION .Nm scans GitHub Actions workflow files for @@ -82,6 +86,31 @@ Actions pinned to a scheme or a local .Ic ./ path are skipped. +.It Cm updates Oo Fl -format Ar plain Ns | Ns Ar tsv Oc Ar file ... +Report the latest upgrade available for each unique pin. +For tag pins the current major's latest and the overall latest are both +reported; for branch pins the current HEAD SHA is reported. +Output is informational \(em +.Cm updates +exits 0 whether or not upgrades are available. +Plain output: +.Dl : current= [up-to-date] () +.Dl : current= latest=
    (major: ) () +TSV output (six columns, no header): +.Dl \etab\etab\etab\etab\etab +Each unique action/ref pair is resolved with at most two GitHub API +calls (one for tag listings, one for branch HEAD). +Setting +.Ev GITHUB_TOKEN +is effectively required for any workflow with more than a handful of +pins because the unauthenticated rate limit is 60 requests per hour. +Only strict three-part semver tags are considered for +.Ic latest ; +pre-release and non-semver tag names are skipped. +The first page of +.Ic /tags +is consulted (30 entries); repos with many tags across majors may need +pagination, which is not implemented in this iteration. .El .Sh OPTIONS .Bl -tag -width indent @@ -130,6 +159,16 @@ Inventory every pin across the workflows: Export a machine-readable pin list: .Pp .Dl validate-action-pins list --format=tsv .github/workflows/*.yml +.Pp +Report available upgrades for every pin (authenticated to avoid rate limits): +.Pp +.Dl GITHUB_TOKEN=... validate-action-pins updates .github/workflows/*.yml +.Pp +Pipe the upgrade report into +.Xr awk 1 +for scripted processing: +.Pp +.Dl GITHUB_TOKEN=... validate-action-pins updates --format=tsv .github/workflows/*.yml | awk -F\et '$5 != $3 { print $2 " is out of date" }' .Sh DEPENDENCIES .Nm requires diff --git a/images/ci-tools/bin/validate-action-pins b/images/ci-tools/bin/validate-action-pins index d157e1b..8147ada 100755 --- a/images/ci-tools/bin/validate-action-pins +++ b/images/ci-tools/bin/validate-action-pins @@ -52,9 +52,11 @@ Subcommands: check Verify SHA pins against their claimed tags via the GitHub API. This is the default when no subcommand is given. list Print every `uses:` pin found in the files. No API calls. + updates Report available upgrades for each unique pin. Honours + GITHUB_TOKEN for higher rate limits (effectively required). Options: - --format=plain|tsv Output format for `list` (default: plain) + --format=plain|tsv Output format for `list` and `updates` (default: plain) --help Show this help message and exit --version Print the version and exit @@ -220,16 +222,20 @@ parse_uses_line() { # Preflight for API-dependent subcommands: dep check, auth init, and a # connectivity probe. Emits WARN and returns 1 when the caller should -# gracefully skip validation. +# gracefully skip its work. +# +# Arguments: +# $1 - operation name for the WARN message (default "pin validation") # # Returns: # 0 - curl, jq, and the API are available # 1 - a dependency is missing or the API is unreachable (WARN emitted) check_api_preflight() { + local op="${1:-pin validation}" local cmd for cmd in curl jq; do if ! command -v "${cmd}" > /dev/null 2>&1; then - echo "WARN: ${cmd} not found — skipping pin validation" + echo "WARN: ${cmd} not found — skipping ${op}" return 1 fi done @@ -243,12 +249,74 @@ check_api_preflight() { if ! curl -fsSL "${auth_header[@]+"${auth_header[@]}"}" \ -H "Accept: application/vnd.github+json" \ "${GITHUB_API_BASE}/rate_limit" > /dev/null 2>&1; then - echo "WARN: cannot reach GitHub API — skipping pin validation" + echo "WARN: cannot reach GitHub API — skipping ${op}" return 1 fi return 0 } +# Pick the newest semver tag from a repo. +# +# Arguments: +# $1 - Repository (owner/repo) +# $2 - Optional major version filter (e.g. "6" returns only v6.x.y); +# empty string returns the absolute latest across majors +# +# Output: +# Tag name (e.g. v6.0.2) on stdout, or empty if no matching tag exists +# +# Notes: +# - Only strict three-part semver tags (vX.Y.Z) are considered; pre- +# release suffixes (-rc1, -beta) and non-semver names (nightly, +# main-20240101) are skipped. +# - The first page of /tags is consulted (30 tags). Repos with many +# tags across majors may need pagination, which is not implemented +# in this iteration. +latest_tag() { + local repo="${1}" major="${2:-}" + local json + json="$(gh_api "/repos/${repo}/tags")" + if [[ -z "${json}" ]]; then + return 1 + fi + jq -r --arg m "${major}" ' + [ .[].name + | select(test("^v?[0-9]+\\.[0-9]+\\.[0-9]+$")) + | select($m == "" or test("^v?" + $m + "\\.")) + ] as $xs + | $xs + | map({ n: ., k: (sub("^v"; "") | split(".") | map(tonumber? // 0)) }) + | sort_by(.k) + | last + | (.n // empty) + ' <<< "${json}" +} + +# Return the current HEAD commit SHA of a branch. +# +# Arguments: +# $1 - Repository (owner/repo) +# $2 - Branch name +# +# Output: +# 40-hex SHA on stdout, or empty on failure +# +# Returns: +# 0 on success, 1 on API failure +head_sha() { + local repo="${1}" branch="${2}" + local json sha + json="$(gh_api "/repos/${repo}/git/ref/heads/${branch}")" + if [[ -z "${json}" ]]; then + return 1 + fi + sha="$(jq -r '.object.sha // empty' <<< "${json}")" + if [[ -z "${sha}" ]]; then + return 1 + fi + echo "${sha}" +} + # ── subcommands ────────────────────────────────────────────────────── # Verify SHA pins in the given workflow files. @@ -356,6 +424,124 @@ cmd_check() { return 0 } +# Report available upgrades for each pinned action. Always informational +# — exit 0 on success regardless of whether upgrades are available. +# +# Arguments: +# $1 - format: "plain" or "tsv" +# $2.. - workflow file paths +# +# Plain output (one line per unique pin): +# : current= [up-to-date] () +# : current= latest=
      (major: ) () +# +# TSV output (6 columns, no header): +# \t\t\t\t\t +# +# Per-action API calls: +# - Tag ref: 2 calls to /repos/.../tags (same endpoint, per-major +# and overall filters applied via jq) +# - Branch ref: 1 call to /repos/.../git/ref/heads/ +# - SHA pin without a # comment: no calls, recorded as "unknown" +cmd_updates() { + local format="${1}" + shift + + check_api_preflight "update check" || return 0 + + local -A seen_in_run=() + local file basename line parsed + local action ref kind comment effective_ref effective_kind + local major current_major_latest overall_latest head_full + local is_up_to_date key + + for file in "${@}"; do + if [[ ! -f "${file}" ]]; then + echo "WARN: ${file} not found, skipping" >&2 + continue + fi + basename="${file##*/}" + + while IFS= read -r line; do + if ! parsed="$(parse_uses_line "${line}")"; then + continue + fi + IFS=$'\t' read -r action ref kind comment <<< "${parsed}" + + # Prefer the trailing comment as the ref hint (Dependabot + # convention). Symbolic (non-sha) refs use their own value. + if [[ "${kind}" == "sha" ]]; then + effective_ref="${comment}" + else + effective_ref="${ref}" + fi + + key="${action}@${effective_ref}" + if [[ -n "${seen_in_run[${key}]+x}" ]]; then + continue + fi + seen_in_run["${key}"]=1 + + current_major_latest="" + overall_latest="" + head_full="" + + if [[ -z "${effective_ref}" ]]; then + effective_kind="unknown" + elif [[ "${effective_ref}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + effective_kind="tag" + major="${effective_ref#v}" + major="${major%%.*}" + current_major_latest="$(latest_tag "${action}" "${major}" || true)" + overall_latest="$(latest_tag "${action}" "" || true)" + else + effective_kind="branch" + head_full="$(head_sha "${action}" "${effective_ref}" || true)" + overall_latest="${head_full:0:12}" + fi + + is_up_to_date="no" + case "${effective_kind}" in + tag) + if [[ -n "${overall_latest}" && "${effective_ref}" == "${overall_latest}" ]]; then + is_up_to_date="yes" + fi + ;; + branch) + if [[ "${kind}" == "sha" ]]; then + if [[ -n "${head_full}" && "${ref}" == "${head_full}" ]]; then + is_up_to_date="yes" + fi + else + is_up_to_date="yes" + fi + ;; + esac + + case "${format}" in + tsv) + printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ + "${file}" "${action}" "${effective_ref}" \ + "${current_major_latest}" "${overall_latest}" "${effective_kind}" + ;; + *) + if [[ "${effective_kind}" == "unknown" ]]; then + printf '%s: %s current=%s [no ref hint]\n' \ + "${basename}" "${action}" "${ref}" + elif [[ "${is_up_to_date}" == "yes" ]]; then + printf '%s: %s current=%s [up-to-date] (%s)\n' \ + "${basename}" "${action}" "${effective_ref}" "${effective_kind}" + else + printf '%s: %s current=%s latest=%s (major: %s) (%s)\n' \ + "${basename}" "${action}" "${effective_ref}" \ + "${overall_latest}" "${current_major_latest}" "${effective_kind}" + fi + ;; + esac + done < "${file}" + done +} + # List pinned actions found in the given workflow files. No API calls. # # Arguments: @@ -431,7 +617,7 @@ main() { local subcmd="check" if [[ $# -gt 0 ]]; then case "${1}" in - check | list) + check | list | updates) subcmd="${1}" shift ;; @@ -487,6 +673,7 @@ main() { case "${subcmd}" in check) cmd_check "${files[@]}" ;; list) cmd_list "${format}" "${files[@]}" ;; + updates) cmd_updates "${format}" "${files[@]}" ;; esac } diff --git a/tests/bats/images/ci-tools/fixtures/api/repos/foo/bar/tags b/tests/bats/images/ci-tools/fixtures/api/repos/foo/bar/tags new file mode 100644 index 0000000..abaf32c --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/api/repos/foo/bar/tags @@ -0,0 +1,10 @@ +[ + {"name": "v7.0.0"}, + {"name": "v6.0.2"}, + {"name": "v6.0.1"}, + {"name": "v6.0.0"}, + {"name": "v1.0.0-beta"}, + {"name": "v0.9.0"}, + {"name": "nightly"}, + {"name": "main-20240101"} +] diff --git a/tests/bats/images/ci-tools/fixtures/workflows/updates-at-latest.yml b/tests/bats/images/ci-tools/fixtures/workflows/updates-at-latest.yml new file mode 100644 index 0000000..9d6954f --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/workflows/updates-at-latest.yml @@ -0,0 +1,7 @@ +name: updates-at-latest +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v7.0.0 diff --git a/tests/bats/images/ci-tools/fixtures/workflows/updates-mixed.yml b/tests/bats/images/ci-tools/fixtures/workflows/updates-mixed.yml new file mode 100644 index 0000000..9cff22a --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/workflows/updates-mixed.yml @@ -0,0 +1,9 @@ +name: updates-mixed +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v6.0.0 + - uses: foo/br-ok@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # main + - uses: foo/br-behind@cccccccccccccccccccccccccccccccccccccccc # main diff --git a/tests/bats/images/ci-tools/fixtures/workflows/updates-new-major.yml b/tests/bats/images/ci-tools/fixtures/workflows/updates-new-major.yml new file mode 100644 index 0000000..660f2f7 --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/workflows/updates-new-major.yml @@ -0,0 +1,7 @@ +name: updates-new-major +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v6.0.0 diff --git a/tests/bats/images/ci-tools/validate-action-pins.bats b/tests/bats/images/ci-tools/validate-action-pins.bats index 2ee5897..fc49e23 100644 --- a/tests/bats/images/ci-tools/validate-action-pins.bats +++ b/tests/bats/images/ci-tools/validate-action-pins.bats @@ -363,3 +363,106 @@ setup() { assert_success assert_output "0" } + +# ── updates subcommand ────────────────────────────────────────────── + +@test "updates reports newer same-major and overall latest for a tag pin" { + run "${SCRIPT}" updates "${FIXTURES_DIR}/workflows/updates-new-major.yml" + assert_success + assert_output --partial "foo/bar" + assert_output --partial "current=v6.0.0" + assert_output --partial "latest=v7.0.0" + assert_output --partial "major: v6.0.2" + assert_output --partial "(tag)" +} + +@test "updates reports up-to-date when pin is at the overall latest tag" { + run "${SCRIPT}" updates "${FIXTURES_DIR}/workflows/updates-at-latest.yml" + assert_success + assert_output --partial "current=v7.0.0" + assert_output --partial "[up-to-date]" + assert_output --partial "(tag)" +} + +@test "updates reports up-to-date when branch pin matches HEAD" { + run "${SCRIPT}" updates "${FIXTURES_DIR}/workflows/branch-ok.yml" + assert_success + assert_output --partial "current=main" + assert_output --partial "[up-to-date]" + assert_output --partial "(branch)" +} + +@test "updates reports short HEAD for a stale branch pin" { + run "${SCRIPT}" updates "${FIXTURES_DIR}/workflows/branch-behind.yml" + assert_success + assert_output --partial "current=main" + assert_output --partial "latest=bbbbbbbbbbbb" + assert_output --partial "(branch)" + refute_output --partial "[up-to-date]" +} + +@test "updates tsv emits six tab-separated columns per row" { + run "${SCRIPT}" updates --format=tsv "${FIXTURES_DIR}/workflows/updates-mixed.yml" + assert_success + local line_count + line_count="$(grep -cE '[^[:space:]]' <<< "${output}" || true)" + assert_equal "${line_count}" "3" + local line + while IFS= read -r line; do + [[ -z "${line}" ]] && continue + local tab_count="${line//[^$'\t']/}" + assert_equal "${#tab_count}" "5" + done <<< "${output}" +} + +@test "updates exits 0 even when updates are available" { + run "${SCRIPT}" updates "${FIXTURES_DIR}/workflows/updates-new-major.yml" + assert_success +} + +# ── latest_tag / head_sha (sourced) ───────────────────────────────── + +@test "latest_tag picks the highest strict-semver tag overall" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run latest_tag "foo/bar" "" + assert_success + assert_output "v7.0.0" +} + +@test "latest_tag filters to the given major" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run latest_tag "foo/bar" "6" + assert_success + assert_output "v6.0.2" +} + +@test "latest_tag skips pre-release tags" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run latest_tag "foo/bar" "1" + assert_success + # The fixture only has v1.0.0-beta for major 1; strict-semver filter + # drops it, so no match is returned. + assert_output "" +} + +@test "latest_tag skips non-semver tags (nightly, date-stamped)" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run latest_tag "foo/bar" "" + assert_success + # Non-semver fixture entries (nightly, main-20240101) must never be + # returned as "latest". + refute_output --partial "nightly" + refute_output --partial "main-" +} + +@test "head_sha returns the branch HEAD SHA" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run head_sha "foo/br-behind" "main" + assert_success + assert_output "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +} From 47995a5d8703ef95abef54abe86cb10a386b8dbb Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 14:51:36 -0600 Subject: [PATCH 06/33] Add cross-file resolve_cache coverage for validate-action-pins Within a single file, seen_in_file short-circuits cmd_check before the resolve lookup, so same-file duplicates never hit the API. Across files, seen_in_file is reset per file and dedup relies on resolve_cache instead. The existing "duplicate pins within one file" test covered the first path; this adds a fixture and test for the second, asserting exactly one OK line per file when the same pin appears in two workflows. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ci-tools/fixtures/workflows/tag-ok-2.yml | 7 +++++++ .../images/ci-tools/validate-action-pins.bats | 15 +++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 tests/bats/images/ci-tools/fixtures/workflows/tag-ok-2.yml diff --git a/tests/bats/images/ci-tools/fixtures/workflows/tag-ok-2.yml b/tests/bats/images/ci-tools/fixtures/workflows/tag-ok-2.yml new file mode 100644 index 0000000..cf0605d --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/workflows/tag-ok-2.yml @@ -0,0 +1,7 @@ +name: tag-ok-2 +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1 diff --git a/tests/bats/images/ci-tools/validate-action-pins.bats b/tests/bats/images/ci-tools/validate-action-pins.bats index fc49e23..3135e2f 100644 --- a/tests/bats/images/ci-tools/validate-action-pins.bats +++ b/tests/bats/images/ci-tools/validate-action-pins.bats @@ -74,6 +74,21 @@ setup() { assert_equal "${ok_count}" "1" } +@test "same pin across multiple files produces one OK per file (resolve_cache)" { + # seen_in_file resets per file, so the second file would re-enter the + # resolve path — but resolve_cache short-circuits the API call. We verify + # the output shape: exactly one OK per file, and each lists its own basename. + run "${SCRIPT}" \ + "${FIXTURES_DIR}/workflows/tag-ok.yml" \ + "${FIXTURES_DIR}/workflows/tag-ok-2.yml" + assert_success + local ok_count + ok_count="$(grep -c '^OK ' <<< "${output}" || true)" + assert_equal "${ok_count}" "2" + assert_output --partial "OK tag-ok.yml:" + assert_output --partial "OK tag-ok-2.yml:" +} + # ── subcommand dispatch ───────────────────────────────────────────── @test "bare FILE and explicit 'check FILE' produce identical output" { From 1aac567bbfcac113a526931a7307dc5f87a662e0 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 15:25:39 -0600 Subject: [PATCH 07/33] Reframe updates as an upgrade inventory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut of `validate-action-pins updates` tried to pick "the" next version for each pin — the latest same-major tag and the overall latest — which forces a policy on an intentionally unopinionated audit tool. Real-world action tagging is messy (v6, v6.0, v6.0.2, 0.35.0 without v, major-only aliases, mixed conventions), and Dependabot does not open major-version PRs by default, so the value of this subcommand is listing everything a periodic audit should consider, not choosing for the user. Behaviour changes: - list_newer_tags replaces latest_tag. It emits every tag strictly newer than the current ref, sorted ascending, so both minor and major bumps are visible in one place. - Ref classification in cmd_updates is loosened to accept one-, two-, and three-part numeric tags (with or without a v prefix), so `@v6`-style major aliases route through the tag path instead of falling through to a branch lookup. - Comparison normalises v6, v6.0, and v6.0.0 to [6,0,0] so major-only pins correctly report every three-part tag above them. - Plain format: `newer= ...` replaces `latest=... (major: ...)` for tag pins; `head=` replaces the previous two-column latest form for stale branch pins. - TSV format drops from six columns to five: file, action, ref, available (space-separated newer tags or short HEAD SHA), kind. Consumers split column four on spaces to iterate versions. - Man page and EXAMPLES updated for the new shape and the awk pipeline now checks column 4. Tests: - "classifies major-only aliases (e.g. @v6) as tag pins" — covers the broader classification regex and the normalisation rule. - "tsv puts the space-separated newer list in column 4" — pins the TSV column assignment. - list_newer_tags sourced cases cover empty result, major-only ref, pre-release skip, and non-semver skip. - Old latest_tag unit cases removed; the function no longer exists. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/man/man1/validate-action-pins.1 | 43 +++-- images/ci-tools/bin/validate-action-pins | 155 ++++++++++-------- .../workflows/updates-major-alias.yml | 7 + .../images/ci-tools/validate-action-pins.bats | 63 ++++--- 4 files changed, 163 insertions(+), 105 deletions(-) create mode 100644 tests/bats/images/ci-tools/fixtures/workflows/updates-major-alias.yml diff --git a/docs/man/man1/validate-action-pins.1 b/docs/man/man1/validate-action-pins.1 index 98635df..f2c3111 100644 --- a/docs/man/man1/validate-action-pins.1 +++ b/docs/man/man1/validate-action-pins.1 @@ -87,26 +87,42 @@ scheme or a local .Ic ./ path are skipped. .It Cm updates Oo Fl -format Ar plain Ns | Ns Ar tsv Oc Ar file ... -Report the latest upgrade available for each unique pin. -For tag pins the current major's latest and the overall latest are both -reported; for branch pins the current HEAD SHA is reported. +Report upgrade inventory for each unique pin. +For tag pins, every tag strictly newer than the current ref is listed +\(em across both minor and major bumps, since Dependabot does not open +major-version pull requests by default. +For branch pins, the current branch HEAD SHA is reported so +SHA-to-branch pins that have drifted are visible at a glance. Output is informational \(em .Cm updates exits 0 whether or not upgrades are available. Plain output: .Dl : current= [up-to-date] () -.Dl : current= latest=
        (major: ) () -TSV output (six columns, no header): -.Dl \etab\etab\etab\etab\etab -Each unique action/ref pair is resolved with at most two GitHub API -calls (one for tag listings, one for branch HEAD). +.Dl : current= newer= ... (tag) +.Dl : current= head= (branch) +TSV output (five columns, no header): +.Dl \etab\etab\etab\etab +where +.Em available +is a space-separated list of newer tags for tag pins, the short HEAD +SHA for stale branch pins, or empty when up-to-date. +Tag refs are parsed as +.Em v?X[.Y[.Z]] +with missing components treated as zero, so +.Em v6 , +.Em v6.0 , +and +.Em v6.0.0 +compare equal. +Pre-release (e.g. +.Em -rc1 , +.Em -beta ) +and non-semver tag names are skipped. +Each unique action/ref pair costs at most one GitHub API call. Setting .Ev GITHUB_TOKEN is effectively required for any workflow with more than a handful of pins because the unauthenticated rate limit is 60 requests per hour. -Only strict three-part semver tags are considered for -.Ic latest ; -pre-release and non-semver tag names are skipped. The first page of .Ic /tags is consulted (30 entries); repos with many tags across majors may need @@ -166,9 +182,10 @@ Report available upgrades for every pin (authenticated to avoid rate limits): .Pp Pipe the upgrade report into .Xr awk 1 -for scripted processing: +for scripted processing \(em e.g. list every action that has at least +one newer tag available: .Pp -.Dl GITHUB_TOKEN=... validate-action-pins updates --format=tsv .github/workflows/*.yml | awk -F\et '$5 != $3 { print $2 " is out of date" }' +.Dl GITHUB_TOKEN=... validate-action-pins updates --format=tsv .github/workflows/*.yml | awk -F\et '$4 != "" { print $2, "->", $4 }' .Sh DEPENDENCIES .Nm requires diff --git a/images/ci-tools/bin/validate-action-pins b/images/ci-tools/bin/validate-action-pins index 8147ada..b9826f4 100755 --- a/images/ci-tools/bin/validate-action-pins +++ b/images/ci-tools/bin/validate-action-pins @@ -255,40 +255,46 @@ check_api_preflight() { return 0 } -# Pick the newest semver tag from a repo. +# List all tags strictly newer than $current, sorted ascending. # # Arguments: # $1 - Repository (owner/repo) -# $2 - Optional major version filter (e.g. "6" returns only v6.x.y); -# empty string returns the absolute latest across majors +# $2 - Current ref (e.g. v6.0.0, v6, 1.2.3); parsed as v?X[.Y[.Z]] # # Output: -# Tag name (e.g. v6.0.2) on stdout, or empty if no matching tag exists +# One tag name per line, oldest → newest; empty if none. # # Notes: -# - Only strict three-part semver tags (vX.Y.Z) are considered; pre- -# release suffixes (-rc1, -beta) and non-semver names (nightly, -# main-20240101) are skipped. -# - The first page of /tags is consulted (30 tags). Repos with many -# tags across majors may need pagination, which is not implemented -# in this iteration. -latest_tag() { - local repo="${1}" major="${2:-}" +# - Accepts one-, two-, or three-part numeric tags with an optional +# v prefix (v6, v6.0, v6.0.2, 1.2.3). Missing components are +# treated as zero for comparison so v6 and v6.0.0 are equal. +# - Pre-release (-rc, -beta) and non-numeric tag names are skipped +# by default. +# - One page of /tags is consulted (30 tags). Repos with very many +# tags across majors may need pagination, which is not +# implemented in this iteration. +list_newer_tags() { + local repo="${1}" current="${2}" local json json="$(gh_api "/repos/${repo}/tags")" if [[ -z "${json}" ]]; then return 1 fi - jq -r --arg m "${major}" ' + jq -r --arg c "${current}" ' + def tok(s): + (s | sub("^v"; "") | split(".")) + | [range(3) as $i | .[$i] // "0"] + | map(tonumber? // 0); + [ .[].name - | select(test("^v?[0-9]+\\.[0-9]+\\.[0-9]+$")) - | select($m == "" or test("^v?" + $m + "\\.")) - ] as $xs - | $xs - | map({ n: ., k: (sub("^v"; "") | split(".") | map(tonumber? // 0)) }) - | sort_by(.k) - | last - | (.n // empty) + | select(test("^v?[0-9]+(\\.[0-9]+){0,2}$")) + | { name: ., key: tok(.) } + ] as $tags + | (tok($c)) as $ck + | $tags + | map(select(.key > $ck)) + | sort_by(.key) + | .[].name ' <<< "${json}" } @@ -424,25 +430,34 @@ cmd_check() { return 0 } -# Report available upgrades for each pinned action. Always informational +# Report upgrade inventory for each unique pin. Always informational # — exit 0 on success regardless of whether upgrades are available. # +# The goal is to save time on periodic audits, not to pick "the" next +# version: for tag pins, every tag newer than the current one is listed +# (across minor AND major bumps, since Dependabot does not open major +# bumps by default). For branch pins, the current branch HEAD is +# reported so stale SHA-to-branch pins are visible at a glance. +# # Arguments: # $1 - format: "plain" or "tsv" # $2.. - workflow file paths # # Plain output (one line per unique pin): # : current= [up-to-date] () -# : current= latest=
          (major: ) () +# : current= newer= ... (tag) +# : current= head= (branch) # -# TSV output (6 columns, no header): -# \t\t\t\t\t +# TSV output (5 columns, no header): +# \t\t\t\t +# where is a space-separated list of newer tags for +# tag pins, or the short HEAD SHA for branch pins (empty when +# up-to-date in either case). # -# Per-action API calls: -# - Tag ref: 2 calls to /repos/.../tags (same endpoint, per-major -# and overall filters applied via jq) +# Per-unique-pin API cost: +# - Tag ref: 1 call to /repos/.../tags # - Branch ref: 1 call to /repos/.../git/ref/heads/ -# - SHA pin without a # comment: no calls, recorded as "unknown" +# - SHA pin with no # comment: no calls, recorded as "unknown" cmd_updates() { local format="${1}" shift @@ -452,8 +467,7 @@ cmd_updates() { local -A seen_in_run=() local file basename line parsed local action ref kind comment effective_ref effective_kind - local major current_major_latest overall_latest head_full - local is_up_to_date key + local newer_tags head_full available key for file in "${@}"; do if [[ ! -f "${file}" ]]; then @@ -482,61 +496,62 @@ cmd_updates() { fi seen_in_run["${key}"]=1 - current_major_latest="" - overall_latest="" + newer_tags="" head_full="" + available="" if [[ -z "${effective_ref}" ]]; then effective_kind="unknown" - elif [[ "${effective_ref}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + elif [[ "${effective_ref}" =~ ^v?[0-9]+(\.[0-9]+){0,2}$ ]]; then effective_kind="tag" - major="${effective_ref#v}" - major="${major%%.*}" - current_major_latest="$(latest_tag "${action}" "${major}" || true)" - overall_latest="$(latest_tag "${action}" "" || true)" + newer_tags="$(list_newer_tags "${action}" "${effective_ref}" || true)" + if [[ -n "${newer_tags}" ]]; then + available="$(tr '\n' ' ' <<< "${newer_tags}" | sed 's/ *$//')" + fi else effective_kind="branch" head_full="$(head_sha "${action}" "${effective_ref}" || true)" - overall_latest="${head_full:0:12}" + if [[ "${kind}" == "sha" && "${ref}" != "${head_full}" && -n "${head_full}" ]]; then + available="${head_full:0:12}" + fi fi - is_up_to_date="no" - case "${effective_kind}" in - tag) - if [[ -n "${overall_latest}" && "${effective_ref}" == "${overall_latest}" ]]; then - is_up_to_date="yes" - fi - ;; - branch) - if [[ "${kind}" == "sha" ]]; then - if [[ -n "${head_full}" && "${ref}" == "${head_full}" ]]; then - is_up_to_date="yes" - fi - else - is_up_to_date="yes" - fi - ;; - esac - case "${format}" in tsv) - printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ - "${file}" "${action}" "${effective_ref}" \ - "${current_major_latest}" "${overall_latest}" "${effective_kind}" - ;; - *) if [[ "${effective_kind}" == "unknown" ]]; then - printf '%s: %s current=%s [no ref hint]\n' \ - "${basename}" "${action}" "${ref}" - elif [[ "${is_up_to_date}" == "yes" ]]; then - printf '%s: %s current=%s [up-to-date] (%s)\n' \ - "${basename}" "${action}" "${effective_ref}" "${effective_kind}" + printf '%s\t%s\t%s\t%s\t%s\n' \ + "${file}" "${action}" "${ref}" "" "${effective_kind}" else - printf '%s: %s current=%s latest=%s (major: %s) (%s)\n' \ - "${basename}" "${action}" "${effective_ref}" \ - "${overall_latest}" "${current_major_latest}" "${effective_kind}" + printf '%s\t%s\t%s\t%s\t%s\n' \ + "${file}" "${action}" "${effective_ref}" "${available}" "${effective_kind}" fi ;; + *) + case "${effective_kind}" in + unknown) + printf '%s: %s current=%s [no ref hint]\n' \ + "${basename}" "${action}" "${ref}" + ;; + tag) + if [[ -z "${available}" ]]; then + printf '%s: %s current=%s [up-to-date] (tag)\n' \ + "${basename}" "${action}" "${effective_ref}" + else + printf '%s: %s current=%s newer=%s (tag)\n' \ + "${basename}" "${action}" "${effective_ref}" "${available}" + fi + ;; + branch) + if [[ -z "${available}" ]]; then + printf '%s: %s current=%s [up-to-date] (branch)\n' \ + "${basename}" "${action}" "${effective_ref}" + else + printf '%s: %s current=%s head=%s (branch)\n' \ + "${basename}" "${action}" "${effective_ref}" "${available}" + fi + ;; + esac + ;; esac done < "${file}" done diff --git a/tests/bats/images/ci-tools/fixtures/workflows/updates-major-alias.yml b/tests/bats/images/ci-tools/fixtures/workflows/updates-major-alias.yml new file mode 100644 index 0000000..d2e6cee --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/workflows/updates-major-alias.yml @@ -0,0 +1,7 @@ +name: updates-major-alias +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: foo/bar@v6 diff --git a/tests/bats/images/ci-tools/validate-action-pins.bats b/tests/bats/images/ci-tools/validate-action-pins.bats index 3135e2f..cc94e20 100644 --- a/tests/bats/images/ci-tools/validate-action-pins.bats +++ b/tests/bats/images/ci-tools/validate-action-pins.bats @@ -381,17 +381,17 @@ setup() { # ── updates subcommand ────────────────────────────────────────────── -@test "updates reports newer same-major and overall latest for a tag pin" { +@test "updates lists every newer tag across minors and majors" { run "${SCRIPT}" updates "${FIXTURES_DIR}/workflows/updates-new-major.yml" assert_success assert_output --partial "foo/bar" assert_output --partial "current=v6.0.0" - assert_output --partial "latest=v7.0.0" - assert_output --partial "major: v6.0.2" + assert_output --partial "newer=v6.0.1 v6.0.2 v7.0.0" assert_output --partial "(tag)" + refute_output --partial "[up-to-date]" } -@test "updates reports up-to-date when pin is at the overall latest tag" { +@test "updates reports up-to-date when no newer tag exists" { run "${SCRIPT}" updates "${FIXTURES_DIR}/workflows/updates-at-latest.yml" assert_success assert_output --partial "current=v7.0.0" @@ -399,6 +399,16 @@ setup() { assert_output --partial "(tag)" } +@test "updates classifies major-only aliases (e.g. @v6) as tag pins" { + run "${SCRIPT}" updates "${FIXTURES_DIR}/workflows/updates-major-alias.yml" + assert_success + assert_output --partial "current=v6" + # v6 normalises to [6,0,0]; all three-part tags strictly greater + # (v6.0.1, v6.0.2, v7.0.0) should be listed. + assert_output --partial "newer=v6.0.1 v6.0.2 v7.0.0" + assert_output --partial "(tag)" +} + @test "updates reports up-to-date when branch pin matches HEAD" { run "${SCRIPT}" updates "${FIXTURES_DIR}/workflows/branch-ok.yml" assert_success @@ -411,12 +421,12 @@ setup() { run "${SCRIPT}" updates "${FIXTURES_DIR}/workflows/branch-behind.yml" assert_success assert_output --partial "current=main" - assert_output --partial "latest=bbbbbbbbbbbb" + assert_output --partial "head=bbbbbbbbbbbb" assert_output --partial "(branch)" refute_output --partial "[up-to-date]" } -@test "updates tsv emits six tab-separated columns per row" { +@test "updates tsv emits five tab-separated columns per row" { run "${SCRIPT}" updates --format=tsv "${FIXTURES_DIR}/workflows/updates-mixed.yml" assert_success local line_count @@ -426,50 +436,59 @@ setup() { while IFS= read -r line; do [[ -z "${line}" ]] && continue local tab_count="${line//[^$'\t']/}" - assert_equal "${#tab_count}" "5" + assert_equal "${#tab_count}" "4" done <<< "${output}" } +@test "updates tsv puts the space-separated newer list in column 4" { + run "${SCRIPT}" updates --format=tsv "${FIXTURES_DIR}/workflows/updates-new-major.yml" + assert_success + local col4 + col4="$(awk -F'\t' 'NR==1 {print $4}' <<< "${output}")" + assert_equal "${col4}" "v6.0.1 v6.0.2 v7.0.0" +} + @test "updates exits 0 even when updates are available" { run "${SCRIPT}" updates "${FIXTURES_DIR}/workflows/updates-new-major.yml" assert_success } -# ── latest_tag / head_sha (sourced) ───────────────────────────────── +# ── list_newer_tags / head_sha (sourced) ──────────────────────────── -@test "latest_tag picks the highest strict-semver tag overall" { +@test "list_newer_tags returns all tags strictly newer, sorted ascending" { # shellcheck disable=SC1090 source "${SCRIPT}" - run latest_tag "foo/bar" "" + run list_newer_tags "foo/bar" "v6.0.0" assert_success - assert_output "v7.0.0" + assert_output "v6.0.1 +v6.0.2 +v7.0.0" } -@test "latest_tag filters to the given major" { +@test "list_newer_tags accepts a major-only ref (v6 normalises to [6,0,0])" { # shellcheck disable=SC1090 source "${SCRIPT}" - run latest_tag "foo/bar" "6" + run list_newer_tags "foo/bar" "v6" assert_success - assert_output "v6.0.2" + assert_output "v6.0.1 +v6.0.2 +v7.0.0" } -@test "latest_tag skips pre-release tags" { +@test "list_newer_tags returns empty when pin is at the newest tag" { # shellcheck disable=SC1090 source "${SCRIPT}" - run latest_tag "foo/bar" "1" + run list_newer_tags "foo/bar" "v7.0.0" assert_success - # The fixture only has v1.0.0-beta for major 1; strict-semver filter - # drops it, so no match is returned. assert_output "" } -@test "latest_tag skips non-semver tags (nightly, date-stamped)" { +@test "list_newer_tags skips pre-release and non-semver tags" { # shellcheck disable=SC1090 source "${SCRIPT}" - run latest_tag "foo/bar" "" + run list_newer_tags "foo/bar" "v0.9.0" assert_success - # Non-semver fixture entries (nightly, main-20240101) must never be - # returned as "latest". + refute_output --partial "-beta" refute_output --partial "nightly" refute_output --partial "main-" } From 5f9b218b92123f3ea760efd559e87f4d5ebe2e4f Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 15:26:14 -0600 Subject: [PATCH 08/33] Add missing words to cspell configuration --- cspell.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cspell.json b/cspell.json index aa3be42..cf73db6 100644 --- a/cspell.json +++ b/cspell.json @@ -25,8 +25,10 @@ "startswith", "stdlib", "stylelint", + "subcmd", "syscall", "tinyglobby", + "tonumber", "trivy", "xmlstarlet" ] From 26b19ba0484de87a10462626063193f94685b7aa Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 15:30:45 -0600 Subject: [PATCH 09/33] Break list and updates man sections into scannable paragraphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both subcommand descriptions had grown into single long paragraphs once the tsv field definitions, parsing rules, and rate-limit caveats were stacked on top of the prose. Adding .Pp separators between the intro, plain-output, tsv-output, semantics, and API-cost portions makes the rendered output easier to skim without changing any prose. Same idiom the DESCRIPTION section already uses — valid mdoc inside a .Bl -tag item, no rendering tradeoffs. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/man/man1/validate-action-pins.1 | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/man/man1/validate-action-pins.1 b/docs/man/man1/validate-action-pins.1 index f2c3111..88fe51d 100644 --- a/docs/man/man1/validate-action-pins.1 +++ b/docs/man/man1/validate-action-pins.1 @@ -70,8 +70,10 @@ Print every .Ic uses:\& pin found in the input files. Performs no API calls, so it runs offline and without authentication. +.Pp Plain output has the form .Dl : @ (# ) +.Pp TSV output (one record per line, no header) has the fields: .Dl \etab\etab\etab\etab where @@ -81,6 +83,7 @@ is for 40-hex pins and .Em ref for symbolic references (tag or branch; not distinguished without an API call). +.Pp Actions pinned to a .Ic docker:// scheme or a local @@ -96,16 +99,19 @@ SHA-to-branch pins that have drifted are visible at a glance. Output is informational \(em .Cm updates exits 0 whether or not upgrades are available. +.Pp Plain output: .Dl : current= [up-to-date] () .Dl : current= newer= ... (tag) .Dl : current= head= (branch) +.Pp TSV output (five columns, no header): .Dl \etab\etab\etab\etab where .Em available is a space-separated list of newer tags for tag pins, the short HEAD SHA for stale branch pins, or empty when up-to-date. +.Pp Tag refs are parsed as .Em v?X[.Y[.Z]] with missing components treated as zero, so @@ -118,6 +124,7 @@ Pre-release (e.g. .Em -rc1 , .Em -beta ) and non-semver tag names are skipped. +.Pp Each unique action/ref pair costs at most one GitHub API call. Setting .Ev GITHUB_TOKEN From 494c3b305ea791b45e692df0ba7c673f564751b9 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 15:32:47 -0600 Subject: [PATCH 10/33] Update cspell configuration and fix spelling in tests --- cspell.json | 4 ++++ tests/bats/images/ci-tools/validate-action-pins.bats | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cspell.json b/cspell.json index cf73db6..7a81681 100644 --- a/cspell.json +++ b/cspell.json @@ -17,6 +17,9 @@ "mikefarah", "minimatch", "nfpm", + "nocurl", + "nojq", + "nosuch", "picomatch", "rsync", "shellcheck", @@ -24,6 +27,7 @@ "sigstore", "startswith", "stdlib", + "stubdir", "stylelint", "subcmd", "syscall", diff --git a/tests/bats/images/ci-tools/validate-action-pins.bats b/tests/bats/images/ci-tools/validate-action-pins.bats index cc94e20..4dd8892 100644 --- a/tests/bats/images/ci-tools/validate-action-pins.bats +++ b/tests/bats/images/ci-tools/validate-action-pins.bats @@ -403,7 +403,7 @@ setup() { run "${SCRIPT}" updates "${FIXTURES_DIR}/workflows/updates-major-alias.yml" assert_success assert_output --partial "current=v6" - # v6 normalises to [6,0,0]; all three-part tags strictly greater + # v6 normalizes to [6,0,0]; all three-part tags strictly greater # (v6.0.1, v6.0.2, v7.0.0) should be listed. assert_output --partial "newer=v6.0.1 v6.0.2 v7.0.0" assert_output --partial "(tag)" @@ -465,7 +465,7 @@ v6.0.2 v7.0.0" } -@test "list_newer_tags accepts a major-only ref (v6 normalises to [6,0,0])" { +@test "list_newer_tags accepts a major-only ref (v6 normalizes to [6,0,0])" { # shellcheck disable=SC1090 source "${SCRIPT}" run list_newer_tags "foo/bar" "v6" From e2eaae8213722ba18df2bfacdbab3b0a4d0bddc7 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 15:35:21 -0600 Subject: [PATCH 11/33] Narrow shellcheck disables in validate-action-pins.bats to SC2154 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SC2030 and SC2031 fired on `export GITHUB_API_BASE=...` inside @test blocks because shellcheck sees bats's per-test subshell and warns that the export is scoped to it. The idiomatic shell workaround is inlining the env var on the `run` invocation — same scoping, no warning — so rewrite the two connectivity-probe tests to do that and drop both codes from the file-level disable. SC2154 stays disabled. bats sets `output`, `BATS_TEST_DIRNAME`, and `BATS_TEST_TMPDIR` at runtime, and `common_setup` exports `REPO_ROOT`, `FIXTURES_DIR`, `API_FIXTURES_DIR`, and `SCRIPT` — shellcheck can't trace any of them, and declaring each at file top would be noisier than the blanket disable. This matches the bats community norm for mixing shellcheck with bats-core. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../images/ci-tools/validate-action-pins.bats | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/tests/bats/images/ci-tools/validate-action-pins.bats b/tests/bats/images/ci-tools/validate-action-pins.bats index 4dd8892..409b0b3 100644 --- a/tests/bats/images/ci-tools/validate-action-pins.bats +++ b/tests/bats/images/ci-tools/validate-action-pins.bats @@ -2,10 +2,10 @@ # shellcheck shell=bash # # REPO_ROOT, FIXTURES_DIR, API_FIXTURES_DIR, SCRIPT are populated by -# common_setup and the per-test setup; BATS_* are populated by bats at runtime. -# Each @test runs in its own subshell, so exports are scoped per test — the -# subshell warnings are expected. -# shellcheck disable=SC2030,SC2031,SC2154 +# common_setup and the per-test setup; BATS_* and `output` are populated +# by bats at runtime. shellcheck can't see any of those, so SC2154 is +# suppressed at file scope — the bats community norm. +# shellcheck disable=SC2154 load ../../helpers/common @@ -205,11 +205,12 @@ setup() { } @test "list does not run the connectivity probe" { - # Point API base at a missing dir; clear SKIP — check would WARN here. - unset VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY - export GITHUB_API_BASE="file://${BATS_TEST_TMPDIR}/empty" + # Point API base at a missing dir and clear SKIP on the command line — + # check would WARN here, but list should not probe at all. mkdir -p "${BATS_TEST_TMPDIR}/empty" - run "${SCRIPT}" list "${FIXTURES_DIR}/workflows/tag-ok.yml" + VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY='' \ + GITHUB_API_BASE="file://${BATS_TEST_TMPDIR}/empty" \ + run "${SCRIPT}" list "${FIXTURES_DIR}/workflows/tag-ok.yml" assert_success refute_output --partial "cannot reach GitHub API" assert_output --partial "tag-ok.yml: foo/bar@" @@ -245,10 +246,10 @@ setup() { # ── connectivity probe ────────────────────────────────────────────── @test "connectivity probe failure warns and exits 0" { - unset VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY - export GITHUB_API_BASE="file://${BATS_TEST_TMPDIR}/empty" mkdir -p "${BATS_TEST_TMPDIR}/empty" - run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml" + VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY='' \ + GITHUB_API_BASE="file://${BATS_TEST_TMPDIR}/empty" \ + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml" assert_success assert_output --partial "WARN: cannot reach GitHub API" } From 2abd9167d12106711eea64c07407a9c7124a999d Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 15:36:57 -0600 Subject: [PATCH 12/33] Scope bats SC2154 suppression to the Makefile, not per-file Each .bats file had to carry `# shellcheck disable=SC2154` at the top because bats sets variables (`output`, `BATS_TEST_DIRNAME`, `BATS_TEST_TMPDIR`, ...) and `common_setup` exports more that shellcheck can't trace. A per-file pragma is a tax on every future suite. A subdirectory .shellcheckrc would duplicate the root rules (shellcheck uses the nearest .shellcheckrc, not a merged stack), so that route is fragile. Instead, pass `-e SC2154` on the bats-only shellcheck invocation in `lint-sh`. The root .shellcheckrc's strict rules still apply; only this one code is waived, and only for files under tests/bats/. - Makefile: `shellcheck -e SC2154 tests/bats/*/*.bash tests/bats/*/*/*.bats` - Drop the file-level `# shellcheck disable=SC2154` comment block from validate-action-pins.bats; new suites inherit the exemption automatically. Co-Authored-By: Claude Opus 4.7 (1M context) --- Makefile | 10 ++++++++-- tests/bats/images/ci-tools/validate-action-pins.bats | 6 ------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index b54423f..3230607 100644 --- a/Makefile +++ b/Makefile @@ -63,11 +63,17 @@ lint-lockfile: lint-docker: @echo "Linting Dockerfiles..." && hadolint images/*/Dockerfile && echo "OK" -# Lint shell scripts +# Lint shell scripts. +# +# Bats files reference variables bats sets at runtime (output, +# BATS_TEST_DIRNAME, BATS_TEST_TMPDIR, ...) plus helper-exported ones +# that shellcheck can't trace across bats_load_library. SC2154 +# ("referenced but not assigned") is suppressed for that directory +# only, not globally. lint-sh: @echo "Linting shell scripts..." \ && shellcheck scripts/*.sh scripts/*/*.sh tests/deb/*.sh images/*/bin/* \ - && shellcheck tests/bats/*/*.bash tests/bats/*/*/*.bats \ + && shellcheck -e SC2154 tests/bats/*/*.bash tests/bats/*/*/*.bats \ && echo "OK" # Check shell script formatting diff --git a/tests/bats/images/ci-tools/validate-action-pins.bats b/tests/bats/images/ci-tools/validate-action-pins.bats index 409b0b3..2bd0c7e 100644 --- a/tests/bats/images/ci-tools/validate-action-pins.bats +++ b/tests/bats/images/ci-tools/validate-action-pins.bats @@ -1,11 +1,5 @@ #!/usr/bin/env bats # shellcheck shell=bash -# -# REPO_ROOT, FIXTURES_DIR, API_FIXTURES_DIR, SCRIPT are populated by -# common_setup and the per-test setup; BATS_* and `output` are populated -# by bats at runtime. shellcheck can't see any of those, so SC2154 is -# suppressed at file scope — the bats community norm. -# shellcheck disable=SC2154 load ../../helpers/common From ba9509f26fd6e9fb91b9d349ebe73d0a3d90b10a Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 15:44:12 -0600 Subject: [PATCH 13/33] Refactor validate-action-pins for clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three orthogonal improvements, behaviour-preserving, covered by the existing 55 bats cases: 1. cmd_check now uses parse_uses_line instead of its own inline regex. The tool had two parsers for the same `uses:` syntax — the strict one in cmd_check and the looser one used by list and updates — which meant any future tweak to the pin shape had to land in two places. cmd_check filters parse_uses_line's output to `kind=sha && comment!=""` so its contract (validate SHA pins with an explicit ref comment) is unchanged, and every subcommand now shares a single source of truth for "what is a uses line?" 2. resolve_ref's nested three-level if-tree is split into _resolve_as_tag (handles lightweight and annotated tags) and a reuse of head_sha for the branch fallback. resolve_ref becomes a straight-through "try tag, try branch, else 1" with printf-formatted tuple output, easier to read at a glance and trivially extendable if we ever need another ref kind. 3. Cmd_check's four scattered `echo "OK ${basename}: ${action}@..."` statements collapse into _emit_check calls. The 4-char level column, the `%s@...` prefix, and the trailing detail now live in one printf format string, so a future output tweak (say, colour codes or a JSON mode) is a one-function change. No rendered-output or exit-code change from the repo's own workflows; real-world `check` against .github/workflows/*.yml produces the same line-for-line output. All bats cases pass without modification. Co-Authored-By: Claude Opus 4.7 (1M context) --- images/ci-tools/bin/validate-action-pins | 217 ++++++++++++++--------- 1 file changed, 129 insertions(+), 88 deletions(-) diff --git a/images/ci-tools/bin/validate-action-pins b/images/ci-tools/bin/validate-action-pins index b9826f4..bb3b124 100755 --- a/images/ci-tools/bin/validate-action-pins +++ b/images/ci-tools/bin/validate-action-pins @@ -93,10 +93,50 @@ gh_api() { "${url}" 2> /dev/null || true } +# Resolve a tag ref (lightweight or annotated) to its commit SHA. +# +# Arguments: +# $1 - Repository (owner/repo) +# $2 - Tag name (e.g. v6) +# +# Output: +# 40-hex commit SHA on stdout; empty on failure. +# +# Returns: +# 0 - resolved as a tag +# 1 - ref is not a known tag +_resolve_as_tag() { + local repo="${1}" ref="${2}" + local json obj_type obj_sha + json="$(gh_api "/repos/${repo}/git/ref/tags/${ref}")" + [[ -z "${json}" ]] && return 1 + + obj_type="$(jq -r '.object.type // empty' <<< "${json}")" + obj_sha="$(jq -r '.object.sha // empty' <<< "${json}")" + [[ -z "${obj_type}" || -z "${obj_sha}" ]] && return 1 + + if [[ "${obj_type}" == "commit" ]]; then + echo "${obj_sha}" + return 0 + fi + if [[ "${obj_type}" == "tag" ]]; then + # Annotated tag — dereference the tag object to its commit. + local tag_obj_json commit_sha + tag_obj_json="$(gh_api "/repos/${repo}/git/tags/${obj_sha}")" + [[ -z "${tag_obj_json}" ]] && return 1 + commit_sha="$(jq -r '.object.sha // empty' <<< "${tag_obj_json}")" + [[ -z "${commit_sha}" ]] && return 1 + echo "${commit_sha}" + return 0 + fi + return 1 +} + # Resolve a ref (tag or branch) to its commit SHA, classifying the ref # by which GitHub endpoint answered. Tags are probed first to match the # Dependabot convention of `owner/repo@ # ` where is -# usually a tag; branch references fall back to /git/ref/heads. +# usually a tag; branch references fall back to /git/ref/heads via +# head_sha. # # Arguments: # $1 - Repository (owner/repo) @@ -109,43 +149,15 @@ gh_api() { # 0 - resolved as a tag or a branch # 1 - ref is neither a known tag nor a known branch resolve_ref() { - local repo="${1}" ref="${2}" - - # Tag first (lightweight + annotated). - local tag_json - tag_json="$(gh_api "/repos/${repo}/git/ref/tags/${ref}")" - if [[ -n "${tag_json}" ]]; then - local obj_type obj_sha - obj_type="$(echo "${tag_json}" | jq -r '.object.type // empty')" - obj_sha="$(echo "${tag_json}" | jq -r '.object.sha // empty')" - if [[ "${obj_type}" == "commit" && -n "${obj_sha}" ]]; then - printf '%s\ttag\n' "${obj_sha}" - return 0 - fi - if [[ "${obj_type}" == "tag" && -n "${obj_sha}" ]]; then - local tag_obj_json commit_sha - tag_obj_json="$(gh_api "/repos/${repo}/git/tags/${obj_sha}")" - if [[ -n "${tag_obj_json}" ]]; then - commit_sha="$(echo "${tag_obj_json}" | jq -r '.object.sha // empty')" - if [[ -n "${commit_sha}" ]]; then - printf '%s\ttag\n' "${commit_sha}" - return 0 - fi - fi - fi + local repo="${1}" ref="${2}" sha + if sha="$(_resolve_as_tag "${repo}" "${ref}")"; then + printf '%s\ttag\n' "${sha}" + return 0 fi - - # Branch fallback. - local branch_json branch_sha - branch_json="$(gh_api "/repos/${repo}/git/ref/heads/${ref}")" - if [[ -n "${branch_json}" ]]; then - branch_sha="$(echo "${branch_json}" | jq -r '.object.sha // empty')" - if [[ -n "${branch_sha}" ]]; then - printf '%s\tbranch\n' "${branch_sha}" - return 0 - fi + if sha="$(head_sha "${repo}" "${ref}")"; then + printf '%s\tbranch\n' "${sha}" + return 0 fi - return 1 } @@ -325,12 +337,34 @@ head_sha() { # ── subcommands ────────────────────────────────────────────────────── +# Emit a single line of `check` output. +# +# Arguments: +# $1 - level: "OK", "FAIL", or "WARN" +# $2 - file basename +# $3 - action (owner/repo) +# $4 - pinned SHA (truncated to 12 chars for display) +# $5 - detail tail (everything after the SHA prefix) +# +# Format (level is padded to a 4-char column): +# : @... +_emit_check() { + printf '%-4s %s: %s@%s... %s\n' \ + "${1}" "${2}" "${3}" "${4:0:12}" "${5}" +} + # Verify SHA pins in the given workflow files. # -# Emits one line per unique (action, tag) pair: -# OK matches -# FAIL pin does not match the tag's resolved SHA -# WARN ref could not be resolved +# Only SHA pins with an explicit `# ` comment are validated — the +# comment is the ref hint resolve_ref consults. Floating pins +# (kind=ref) and SHA pins without a comment are skipped, consistent +# with the tool's original contract. +# +# Emits one line per unique (action, ref) pair: +# OK pinned SHA matches the resolved ref +# FAIL tag ref resolves to a different SHA (hard failure) +# WARN branch ref has drifted, or ref could not be resolved at all +# (informational — never counted as a failure) # # Arguments: # $@ - One or more workflow file paths @@ -346,8 +380,8 @@ cmd_check() { local -A resolve_cache=() local -A compare_cache=() local -A seen_in_file=() - local file basename line - local action pinned_sha claimed_ref cache_key cached resolved kind + local file basename line parsed + local action ref kind comment cache_key cached resolved resolved_kind local compare_key behind for file in "${@}"; do @@ -355,67 +389,74 @@ cmd_check() { echo "WARN: ${file} not found, skipping" continue fi - basename="${file##*/}" seen_in_file=() while IFS= read -r line; do - if [[ "${line}" =~ uses:[[:space:]]*([^@]+)@([0-9a-f]{40})[[:space:]]*#[[:space:]]*([^[:space:]]+) ]]; then - action="${BASH_REMATCH[1]}" - pinned_sha="${BASH_REMATCH[2]}" - claimed_ref="${BASH_REMATCH[3]}" - found_pins=1 + if ! parsed="$(parse_uses_line "${line}")"; then + continue + fi + IFS=$'\t' read -r action ref kind comment <<< "${parsed}" - cache_key="${action}#${claimed_ref}" + # Only SHA pins with a ref comment are in scope for `check`; + # floating refs (@main) and bare SHA pins have nothing to + # compare against. + if [[ "${kind}" != "sha" || -z "${comment}" ]]; then + continue + fi + found_pins=1 - if [[ -n "${seen_in_file[${cache_key}]+x}" ]]; then - continue - fi - seen_in_file["${cache_key}"]=1 + cache_key="${action}#${comment}" + if [[ -n "${seen_in_file[${cache_key}]+x}" ]]; then + continue + fi + seen_in_file["${cache_key}"]=1 - if [[ -z "${resolve_cache[${cache_key}]+x}" ]]; then - if cached="$(resolve_ref "${action}" "${claimed_ref}")"; then - resolve_cache["${cache_key}"]="${cached}" - else - resolve_cache["${cache_key}"]="NONE" - fi + if [[ -z "${resolve_cache[${cache_key}]+x}" ]]; then + if cached="$(resolve_ref "${action}" "${comment}")"; then + resolve_cache["${cache_key}"]="${cached}" + else + resolve_cache["${cache_key}"]="NONE" fi + fi + cached="${resolve_cache[${cache_key}]}" - cached="${resolve_cache[${cache_key}]}" + if [[ "${cached}" == "NONE" ]]; then + _emit_check WARN "${basename}" "${action}" "${ref}" \ + "— could not resolve ref ${comment}" + continue + fi - if [[ "${cached}" == "NONE" ]]; then - echo "WARN ${basename}: ${action}@${pinned_sha:0:12}... — could not resolve ref ${claimed_ref}" - continue - fi + resolved="${cached%%$'\t'*}" + resolved_kind="${cached#*$'\t'}" - resolved="${cached%%$'\t'*}" - kind="${cached#*$'\t'}" + if [[ "${ref}" == "${resolved}" ]]; then + _emit_check OK "${basename}" "${action}" "${ref}" "matches ${comment}" + continue + fi - if [[ "${pinned_sha}" == "${resolved}" ]]; then - echo "OK ${basename}: ${action}@${pinned_sha:0:12}... matches ${claimed_ref}" - continue + if [[ "${resolved_kind}" == "branch" ]]; then + compare_key="${action}#${ref}#${resolved}" + if [[ -z "${compare_cache[${compare_key}]+x}" ]]; then + behind="$(compare_behind "${action}" "${ref}" "${resolved}" || true)" + compare_cache["${compare_key}"]="${behind}" fi - - if [[ "${kind}" == "branch" ]]; then - compare_key="${action}#${pinned_sha}#${resolved}" - if [[ -z "${compare_cache[${compare_key}]+x}" ]]; then - behind="$(compare_behind "${action}" "${pinned_sha}" "${resolved}" || true)" - compare_cache["${compare_key}"]="${behind}" - fi - behind="${compare_cache[${compare_key}]}" - - if [[ -n "${behind}" && "${behind}" -gt 0 ]]; then - echo "WARN ${basename}: ${action}@${pinned_sha:0:12}... is ${behind} commit(s) behind ${claimed_ref} HEAD (at ${resolved:0:12}...)" - else - echo "WARN ${basename}: ${action}@${pinned_sha:0:12}... diverges from ${claimed_ref} HEAD (at ${resolved:0:12}...)" - fi - continue + behind="${compare_cache[${compare_key}]}" + + if [[ -n "${behind}" && "${behind}" -gt 0 ]]; then + _emit_check WARN "${basename}" "${action}" "${ref}" \ + "is ${behind} commit(s) behind ${comment} HEAD (at ${resolved:0:12}...)" + else + _emit_check WARN "${basename}" "${action}" "${ref}" \ + "diverges from ${comment} HEAD (at ${resolved:0:12}...)" fi - - # Tag kind — mismatch is a hard failure. - echo "FAIL ${basename}: ${action}@${pinned_sha:0:12}... does NOT match ${claimed_ref} (expected ${resolved:0:12}...)" - failures=$((failures + 1)) + continue fi + + # Tag kind — mismatch is a hard failure. + _emit_check FAIL "${basename}" "${action}" "${ref}" \ + "does NOT match ${comment} (expected ${resolved:0:12}...)" + failures=$((failures + 1)) done < "${file}" done From 6d55502e9a2f9bbf5e1c3e574509d74591c012de Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 15:51:53 -0600 Subject: [PATCH 14/33] Consolidate dedup idiom and effective-ref derivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both cmd_check and cmd_updates were open-coding the same patterns: - "check if key is in associative array, skip if so, else mark it seen" — five lines repeated in each subcommand. - "for SHA pins use the comment as the ref hint, else use the ref itself" — the Dependabot convention, expressed differently in each caller. Extract two small helpers: - _once_per — nameref-based first-occurrence gate. Callers keep their own associative array and decide when to reset it, so the three subcommands still have three different dedup scopes (per-file for check, per-run for updates, none for list), but the bookkeeping is one line at each use site. - _effective_ref — returns the ref hint a resolver should consult, centralising the Dependabot rule in one place. The scope difference is deliberate and stays: check reports per file because file context matters ("which workflow has the bad pin"), updates reports once per unique pin because it's a single action item, list reports every occurrence because it's a raw inventory. SC2034 ("appears unused") suppressed on the seen_in_{file,run} declarations and resets, since shellcheck can't trace uses through nameref. The disables are one-line-scoped and annotated. No behaviour or output changes — 55 bats cases pass unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- images/ci-tools/bin/validate-action-pins | 60 +++++++++++++++++------- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/images/ci-tools/bin/validate-action-pins b/images/ci-tools/bin/validate-action-pins index bb3b124..0fc6289 100755 --- a/images/ci-tools/bin/validate-action-pins +++ b/images/ci-tools/bin/validate-action-pins @@ -184,6 +184,44 @@ compare_behind() { jq -r '.behind_by // empty' <<< "${json}" } +# Return the "effective" ref for a parsed pin — the Dependabot-style +# ref hint a caller should resolve against. +# +# Arguments: +# $1 - kind (from parse_uses_line): "sha" or "ref" +# $2 - ref (the @ portion) +# $3 - comment (the first word after `#`, may be empty) +# +# Output: +# For SHA pins, the comment (empty if absent — caller decides how to +# treat that). For floating refs (kind=ref), the ref itself. +_effective_ref() { + if [[ "${1}" == "sha" ]]; then + echo "${3}" + else + echo "${2}" + fi +} + +# First-occurrence gate for per-command dedup. Callers decide the +# dedup scope by choosing when to reset the associative array (per +# file, per run, never). +# +# Arguments: +# $1 - name of an associative array (the caller's dedup set) +# $2 - key to check +# +# Returns: +# 0 - key is new (first seen); array is marked +# 1 - key has already been seen in this set +_once_per() { + # shellcheck disable=SC2178 # nameref points at caller's assoc array + local -n _seen="${1}" + [[ -n "${_seen[${2}]+x}" ]] && return 1 + _seen["${2}"]=1 + return 0 +} + # Parse a single `uses:` line into its component fields. # # Arguments: @@ -379,6 +417,7 @@ cmd_check() { local -i found_pins=0 local -A resolve_cache=() local -A compare_cache=() + # shellcheck disable=SC2034 # consumed by _once_per via nameref local -A seen_in_file=() local file basename line parsed local action ref kind comment cache_key cached resolved resolved_kind @@ -390,6 +429,7 @@ cmd_check() { continue fi basename="${file##*/}" + # shellcheck disable=SC2034 # consumed by _once_per via nameref seen_in_file=() while IFS= read -r line; do @@ -407,10 +447,7 @@ cmd_check() { found_pins=1 cache_key="${action}#${comment}" - if [[ -n "${seen_in_file[${cache_key}]+x}" ]]; then - continue - fi - seen_in_file["${cache_key}"]=1 + _once_per seen_in_file "${cache_key}" || continue if [[ -z "${resolve_cache[${cache_key}]+x}" ]]; then if cached="$(resolve_ref "${action}" "${comment}")"; then @@ -505,6 +542,7 @@ cmd_updates() { check_api_preflight "update check" || return 0 + # shellcheck disable=SC2034 # consumed by _once_per via nameref local -A seen_in_run=() local file basename line parsed local action ref kind comment effective_ref effective_kind @@ -523,19 +561,9 @@ cmd_updates() { fi IFS=$'\t' read -r action ref kind comment <<< "${parsed}" - # Prefer the trailing comment as the ref hint (Dependabot - # convention). Symbolic (non-sha) refs use their own value. - if [[ "${kind}" == "sha" ]]; then - effective_ref="${comment}" - else - effective_ref="${ref}" - fi - + effective_ref="$(_effective_ref "${kind}" "${ref}" "${comment}")" key="${action}@${effective_ref}" - if [[ -n "${seen_in_run[${key}]+x}" ]]; then - continue - fi - seen_in_run["${key}"]=1 + _once_per seen_in_run "${key}" || continue newer_tags="" head_full="" From 6805def98eab6e179a69b032d8083edf49e1dca8 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 15:55:52 -0600 Subject: [PATCH 15/33] Promote dedup sets to module scope and drop nameref indirection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous dedup consolidation used a single _once_per helper that took the associative-array name by string and accessed it via declare -n. That worked but needed a SC2034 suppression at every `local -A seen_in_*` and every per-file reset, because shellcheck does not trace reads/writes through namerefs. Move the two sets to module scope as seen_in_file and seen_in_run, and split the helper into two scope-specialised functions that access the corresponding global directly: - _once_per_file / _once_per_run: one-line predicates; first-seen returns 0, already-seen returns 1. Call sites read as `_once_per_file "${key}" || continue`. - seen_in_file resets per file in cmd_check's loop; seen_in_run resets on entry to cmd_updates. Each subcommand also resets its set on entry so a sourced test that invokes the subcommand twice starts from a clean state. With the access now visible inside the helpers, every SC2034 suppression is gone. The scope boundaries that matter (per-file vs per-run vs none) are now encoded in the helper names rather than implicit in where the caller chose to declare a local. cmd_list continues to skip dedup entirely. No behaviour, output, or test change — 55 bats cases green. Co-Authored-By: Claude Opus 4.7 (1M context) --- images/ci-tools/bin/validate-action-pins | 59 ++++++++++++++---------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/images/ci-tools/bin/validate-action-pins b/images/ci-tools/bin/validate-action-pins index 0fc6289..6788ee1 100755 --- a/images/ci-tools/bin/validate-action-pins +++ b/images/ci-tools/bin/validate-action-pins @@ -203,22 +203,32 @@ _effective_ref() { fi } -# First-occurrence gate for per-command dedup. Callers decide the -# dedup scope by choosing when to reset the associative array (per -# file, per run, never). -# -# Arguments: -# $1 - name of an associative array (the caller's dedup set) -# $2 - key to check -# -# Returns: -# 0 - key is new (first seen); array is marked -# 1 - key has already been seen in this set -_once_per() { - # shellcheck disable=SC2178 # nameref points at caller's assoc array - local -n _seen="${1}" - [[ -n "${_seen[${2}]+x}" ]] && return 1 - _seen["${2}"]=1 +# Module-scope dedup sets, one per scope boundary that the +# subcommands care about. The arrays are reset at their respective +# boundaries (per-file loop in cmd_check, per-invocation entry in +# cmd_updates) so sourced tests that call a subcommand twice start +# from a clean state. +# +# Scope conventions: +# seen_in_file - cmd_check resets inside the per-file loop; one +# OK/FAIL/WARN line per unique pin per file. +# seen_in_run - cmd_updates resets on entry; one record per +# unique pin across the whole invocation. +# (cmd_list uses no dedup — every occurrence is listed.) +declare -A seen_in_file=() +declare -A seen_in_run=() + +# First-occurrence gates. Return 0 the first time a key is seen in +# the corresponding scope, 1 thereafter. Callers idiomatically chain +# as `_once_per_X "${key}" || continue`. +_once_per_file() { + [[ -n "${seen_in_file[${1}]+x}" ]] && return 1 + seen_in_file["${1}"]=1 + return 0 +} +_once_per_run() { + [[ -n "${seen_in_run[${1}]+x}" ]] && return 1 + seen_in_run["${1}"]=1 return 0 } @@ -417,20 +427,20 @@ cmd_check() { local -i found_pins=0 local -A resolve_cache=() local -A compare_cache=() - # shellcheck disable=SC2034 # consumed by _once_per via nameref - local -A seen_in_file=() local file basename line parsed local action ref kind comment cache_key cached resolved resolved_kind local compare_key behind + # Clean slate in case a sourced test re-enters the subcommand. + seen_in_file=() + for file in "${@}"; do if [[ ! -f "${file}" ]]; then echo "WARN: ${file} not found, skipping" continue fi basename="${file##*/}" - # shellcheck disable=SC2034 # consumed by _once_per via nameref - seen_in_file=() + seen_in_file=() # per-file dedup scope while IFS= read -r line; do if ! parsed="$(parse_uses_line "${line}")"; then @@ -447,7 +457,7 @@ cmd_check() { found_pins=1 cache_key="${action}#${comment}" - _once_per seen_in_file "${cache_key}" || continue + _once_per_file "${cache_key}" || continue if [[ -z "${resolve_cache[${cache_key}]+x}" ]]; then if cached="$(resolve_ref "${action}" "${comment}")"; then @@ -542,12 +552,13 @@ cmd_updates() { check_api_preflight "update check" || return 0 - # shellcheck disable=SC2034 # consumed by _once_per via nameref - local -A seen_in_run=() local file basename line parsed local action ref kind comment effective_ref effective_kind local newer_tags head_full available key + # Per-invocation dedup: report each unique pin once across all files. + seen_in_run=() + for file in "${@}"; do if [[ ! -f "${file}" ]]; then echo "WARN: ${file} not found, skipping" >&2 @@ -563,7 +574,7 @@ cmd_updates() { effective_ref="$(_effective_ref "${kind}" "${ref}" "${comment}")" key="${action}@${effective_ref}" - _once_per seen_in_run "${key}" || continue + _once_per_run "${key}" || continue newer_tags="" head_full="" From 1c027e4812df8e338ee3b78a2d1f88eb243f1c9f Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 15:59:49 -0600 Subject: [PATCH 16/33] Add "dedup" and "nameref" to cspell dictionary --- cspell.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cspell.json b/cspell.json index 7a81681..53981ad 100644 --- a/cspell.json +++ b/cspell.json @@ -7,6 +7,7 @@ "busted", "chktex", "cosign", + "dedup", "devops", "extglob", "hadolint", @@ -16,6 +17,7 @@ "markdownlint", "mikefarah", "minimatch", + "nameref", "nfpm", "nocurl", "nojq", From 6bfee9c8ae27e914daae05af538ca26655ef97d2 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 16:25:41 -0600 Subject: [PATCH 17/33] Add --only filter for check and updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automation scenarios — e.g. a scheduled workflow that only cares about branch-pinned actions drifting from HEAD — previously had to grep the subcommand output or post-process TSV in awk. The driving use case is knight-owl-dev/homebrew-tap, which SHA-pins Homebrew/actions/setup-homebrew@main and needs a one-liner to monitor that kind of pin without tripping on unrelated tag pins. --only=all|tag|branch filters check and updates output by the classified kind (default all). - check applies the filter on the authoritative kind returned by the API (resolve_ref's tag vs branch tuple). Unresolvable refs are classified as "unknown" and reported only with --only=all. - updates applies the filter on the routing kind (which API endpoint was consulted — /tags for tag-shaped refs, /git/ref/heads for everything else). Same "unknown" classification for pins that have no ref hint at all. - list is offline by design and cannot authoritatively classify refs without API calls, so main accepts and ignores the flag there; list output is unaffected. Naming consistency while I was in the area: plain updates output now says `[unknown]` to match the tsv kind column (was `[no ref hint]`). One classification token in both formats. The filter runs post-API in check and post-classification in updates, so API call counts are unchanged — this is strictly an output filter, not a processing shortcut. A shared _include_pin helper encapsulates the one-line comparison so both subcommands route through the same predicate. No separate "classify" helper — the inline classification already exists in cmd_updates as part of the routing decision, and the check path reuses resolve_ref's authoritative output. Man page and usage updated; 9 new bats cases cover the filter behavior across both subcommands plus the invalid-value exit 2. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/man/man1/validate-action-pins.1 | 26 ++++++- images/ci-tools/bin/validate-action-pins | 76 +++++++++++++++++-- .../images/ci-tools/validate-action-pins.bats | 72 ++++++++++++++++++ 3 files changed, 166 insertions(+), 8 deletions(-) diff --git a/docs/man/man1/validate-action-pins.1 b/docs/man/man1/validate-action-pins.1 index 88fe51d..13b4735 100644 --- a/docs/man/man1/validate-action-pins.1 +++ b/docs/man/man1/validate-action-pins.1 @@ -11,6 +11,7 @@ .Ar file ... .Nm .Cm check +.Op Fl -only Ar all Ns | Ns Ar tag Ns | Ns Ar branch .Ar file ... .Nm .Cm list @@ -19,6 +20,7 @@ .Nm .Cm updates .Op Fl -format Ar plain Ns | Ns Ar tsv +.Op Fl -only Ar all Ns | Ns Ar tag Ns | Ns Ar branch .Ar file ... .Sh DESCRIPTION .Nm @@ -139,9 +141,31 @@ pagination, which is not implemented in this iteration. .Bl -tag -width indent .It Fl -format Ar plain Ns | Ns Ar tsv Output format for -.Cm list . +.Cm list +and +.Cm updates . Default is .Em plain . +.It Fl -only Ar all Ns | Ns Ar tag Ns | Ns Ar branch +Restrict +.Cm check +and +.Cm updates +output to a single classified kind. +For +.Cm check , +the classification is the authoritative kind returned by the API +(tag vs branch); unresolvable refs are reported only with +.Em all . +For +.Cm updates , +the classification is the routing kind (which API endpoint was +consulted). +Default is +.Em all . +.Cm list +is offline by design and cannot authoritatively classify refs, so the +flag is accepted and ignored for that subcommand. .It Fl -help Show a usage summary and exit. .It Fl -version diff --git a/images/ci-tools/bin/validate-action-pins b/images/ci-tools/bin/validate-action-pins index 6788ee1..c1adc53 100755 --- a/images/ci-tools/bin/validate-action-pins +++ b/images/ci-tools/bin/validate-action-pins @@ -57,6 +57,10 @@ Subcommands: Options: --format=plain|tsv Output format for `list` and `updates` (default: plain) + --only=all|tag|branch + Restrict `check`/`updates` output to a single + classified kind. Default: all. Ignored by `list` + (offline by design, cannot authoritatively classify). --help Show this help message and exit --version Print the version and exit @@ -203,6 +207,20 @@ _effective_ref() { fi } +# Decide whether a pin should be included given the --only filter. +# +# Arguments: +# $1 - filter value: "all", "tag", or "branch" (and "unknown" for +# refs that could not be classified authoritatively) +# $2 - the pin's classified kind +# +# Returns: +# 0 - pin matches the filter (include) +# 1 - pin is filtered out (skip) +_include_pin() { + [[ "${1}" == "all" || "${1}" == "${2}" ]] +} + # Module-scope dedup sets, one per scope boundary that the # subcommands care about. The arrays are reset at their respective # boundaries (per-file loop in cmd_check, per-invocation entry in @@ -415,12 +433,15 @@ _emit_check() { # (informational — never counted as a failure) # # Arguments: -# $@ - One or more workflow file paths +# $1 - --only filter: "all", "tag", or "branch" +# $2.. - One or more workflow file paths # # Returns: # 0 - all pins match, or no pins found # 1 - one or more pins do not match cmd_check() { + local only="${1}" + shift check_api_preflight || return 0 local -i failures=0 @@ -469,6 +490,9 @@ cmd_check() { cached="${resolve_cache[${cache_key}]}" if [[ "${cached}" == "NONE" ]]; then + # Unresolvable refs have no authoritative kind; include them + # only when the user asked for all kinds. + _include_pin "${only}" "unknown" || continue _emit_check WARN "${basename}" "${action}" "${ref}" \ "— could not resolve ref ${comment}" continue @@ -477,6 +501,9 @@ cmd_check() { resolved="${cached%%$'\t'*}" resolved_kind="${cached#*$'\t'}" + # Apply --only filter on the authoritative kind (post-API). + _include_pin "${only}" "${resolved_kind}" || continue + if [[ "${ref}" == "${resolved}" ]]; then _emit_check OK "${basename}" "${action}" "${ref}" "matches ${comment}" continue @@ -529,12 +556,14 @@ cmd_check() { # # Arguments: # $1 - format: "plain" or "tsv" -# $2.. - workflow file paths +# $2 - --only filter: "all", "tag", or "branch" +# $3.. - workflow file paths # # Plain output (one line per unique pin): # : current= [up-to-date] () # : current= newer= ... (tag) # : current= head= (branch) +# : current= [unknown] # # TSV output (5 columns, no header): # \t\t\t\t @@ -547,8 +576,8 @@ cmd_check() { # - Branch ref: 1 call to /repos/.../git/ref/heads/ # - SHA pin with no # comment: no calls, recorded as "unknown" cmd_updates() { - local format="${1}" - shift + local format="${1}" only="${2}" + shift 2 check_api_preflight "update check" || return 0 @@ -580,6 +609,8 @@ cmd_updates() { head_full="" available="" + # Classify and route: semver-shaped refs fetch tags, everything + # else is treated as a branch, empty refs have no hint. if [[ -z "${effective_ref}" ]]; then effective_kind="unknown" elif [[ "${effective_ref}" =~ ^v?[0-9]+(\.[0-9]+){0,2}$ ]]; then @@ -596,6 +627,9 @@ cmd_updates() { fi fi + # Apply --only filter on the classified kind before emitting. + _include_pin "${only}" "${effective_kind}" || continue + case "${format}" in tsv) if [[ "${effective_kind}" == "unknown" ]]; then @@ -609,7 +643,7 @@ cmd_updates() { *) case "${effective_kind}" in unknown) - printf '%s: %s current=%s [no ref hint]\n' \ + printf '%s: %s current=%s [unknown]\n' \ "${basename}" "${action}" "${ref}" ;; tag) @@ -639,6 +673,12 @@ cmd_updates() { # List pinned actions found in the given workflow files. No API calls. # +# `list` is offline-by-design: it cannot authoritatively classify +# refs as tag vs branch without hitting the API, so the --only +# filter has no effect here (main accepts and ignores it for list). +# Use `check --only=...` or `updates --only=...` when authoritative +# classification is needed. +# # Arguments: # $1 - format: "plain" or "tsv" # $2.. - workflow file paths @@ -647,6 +687,7 @@ cmd_updates() { # : @ (# ) # TSV output (one line per occurrence, header-less): # \t\t\t\t +# (`kind` is the pin format: sha or ref.) # # Returns 0 always; per-file read errors are reported as WARN on stderr. cmd_list() { @@ -721,6 +762,7 @@ main() { # Collect files and any subcommand-scoped flags. local format="plain" + local only="all" local files=() while [[ $# -gt 0 ]]; do case "${1}" in @@ -736,6 +778,18 @@ main() { format="${2}" shift 2 ;; + --only=*) + only="${1#*=}" + shift + ;; + --only) + if [[ $# -lt 2 ]]; then + echo "${PROGRAM}: --only requires a value" >&2 + exit 2 + fi + only="${2}" + shift 2 + ;; --) shift files+=("${@}") @@ -760,15 +814,23 @@ main() { ;; esac + case "${only}" in + all | tag | branch) ;; + *) + echo "${PROGRAM}: invalid --only: ${only} (expected 'all', 'tag', or 'branch')" >&2 + exit 2 + ;; + esac + if [[ "${#files[@]}" -eq 0 ]]; then usage >&2 exit 1 fi case "${subcmd}" in - check) cmd_check "${files[@]}" ;; + check) cmd_check "${only}" "${files[@]}" ;; list) cmd_list "${format}" "${files[@]}" ;; - updates) cmd_updates "${format}" "${files[@]}" ;; + updates) cmd_updates "${format}" "${only}" "${files[@]}" ;; esac } diff --git a/tests/bats/images/ci-tools/validate-action-pins.bats b/tests/bats/images/ci-tools/validate-action-pins.bats index 2bd0c7e..77b2a25 100644 --- a/tests/bats/images/ci-tools/validate-action-pins.bats +++ b/tests/bats/images/ci-tools/validate-action-pins.bats @@ -320,6 +320,78 @@ setup() { assert_equal "${warn_count}" "1" } +# ── --only filter (shared across check and updates) ───────────────── + +@test "check --only=branch on a tag-pin file emits nothing" { + run "${SCRIPT}" check --only=branch "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + refute_output --partial "OK " + refute_output --partial "FAIL " + refute_output --partial "WARN " +} + +@test "check --only=branch on a branch-pin file emits the WARN" { + run "${SCRIPT}" check --only=branch "${FIXTURES_DIR}/workflows/branch-behind.yml" + assert_success + assert_output --partial "WARN branch-behind.yml:" + assert_output --partial "3 commit(s) behind main HEAD" +} + +@test "check --only=tag on a branch-pin file emits nothing" { + run "${SCRIPT}" check --only=tag "${FIXTURES_DIR}/workflows/branch-ok.yml" + assert_success + refute_output --partial "OK " + refute_output --partial "WARN " +} + +@test "check --only=tag keeps tag mismatches visible (and failing)" { + run "${SCRIPT}" check --only=tag "${FIXTURES_DIR}/workflows/tag-mismatch.yml" + assert_failure 1 + assert_output --partial "FAIL tag-mismatch.yml:" + assert_output --partial "does NOT match v1" +} + +@test "updates --only=branch on a mixed file drops tag records" { + run "${SCRIPT}" updates --only=branch "${FIXTURES_DIR}/workflows/updates-mixed.yml" + assert_success + refute_output --partial "foo/bar" + assert_output --partial "foo/br-ok" + assert_output --partial "foo/br-behind" + assert_output --partial "(branch)" + refute_output --partial "(tag)" +} + +@test "updates --only=tag on a mixed file drops branch records" { + run "${SCRIPT}" updates --only=tag "${FIXTURES_DIR}/workflows/updates-mixed.yml" + assert_success + assert_output --partial "foo/bar" + refute_output --partial "foo/br-ok" + refute_output --partial "foo/br-behind" + assert_output --partial "(tag)" + refute_output --partial "(branch)" +} + +@test "--only=branch does not affect list (offline by design)" { + run "${SCRIPT}" list --only=branch "${FIXTURES_DIR}/workflows/mixed.yml" + assert_success + # Every non-docker/non-local pin appears regardless of filter. + assert_output --partial "actions/checkout@v6.0.2" + assert_output --partial "org/repo@main" + assert_output --partial "foo/bar@" +} + +@test "--only accepts space-separated form" { + run "${SCRIPT}" check --only branch "${FIXTURES_DIR}/workflows/branch-ok.yml" + assert_success + assert_output --partial "OK branch-ok.yml:" +} + +@test "invalid --only value exits 2" { + run "${SCRIPT}" updates --only=bogus "${FIXTURES_DIR}/workflows/updates-mixed.yml" + assert_failure 2 + assert_output --partial "invalid --only: bogus" +} + # ── unit-level: source the script, call functions directly ───────── @test "resolve_ref returns \\ttag for a lightweight tag" { From 5309d0106fb44d066317fb157cb725b1c27e6e38 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 16:31:15 -0600 Subject: [PATCH 18/33] Make list --only tap the API for authoritative classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list was strictly offline, so --only had to be ignored there. That cut users off from the most useful inventory query: "list only the branch-pinned actions across my workflows" without writing a grep pipeline. Keep list offline by default (--only=all, no API calls, no authentication) and switch to API-backed classification only when the caller explicitly narrows with --only=tag or --only=branch. Under that path, each unique pin's effective ref (comment for SHA pins, the @ref itself otherwise) is resolved through resolve_ref and the authoritative tag-vs-branch result drives the filter. Unresolvable refs and pins with no ref hint classify as "unknown" and are emitted only with --only=all. API cost mirrors check: 1–2 calls per unique (action, effective_ref) pair, memoised in a local cache. The same preflight that check and updates use handles missing deps / unreachable API with a WARN and exit 0 — list is only offline in the default mode, not in the filtered mode. Man page, usage, and synopsis updated to reflect the dual-mode semantics. Replaces the "filter is ignored by list" test with three new cases: filter-by-tag, filter-by-branch, and the preflight-fail warning for --only=X when the API is unreachable. Added a guard test that --only=all (default) still performs no API calls. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/man/man1/validate-action-pins.1 | 33 +++++-- images/ci-tools/bin/validate-action-pins | 93 +++++++++++++------ .../images/ci-tools/validate-action-pins.bats | 42 +++++++-- 3 files changed, 125 insertions(+), 43 deletions(-) diff --git a/docs/man/man1/validate-action-pins.1 b/docs/man/man1/validate-action-pins.1 index 13b4735..b526099 100644 --- a/docs/man/man1/validate-action-pins.1 +++ b/docs/man/man1/validate-action-pins.1 @@ -16,6 +16,7 @@ .Nm .Cm list .Op Fl -format Ar plain Ns | Ns Ar tsv +.Op Fl -only Ar all Ns | Ns Ar tag Ns | Ns Ar branch .Ar file ... .Nm .Cm updates @@ -67,11 +68,21 @@ This is the default when no subcommand is given; bare .Ic validate-action-pins Ar file ... is equivalent to .Ic validate-action-pins check Ar file ... . -.It Cm list Oo Fl -format Ar plain Ns | Ns Ar tsv Oc Ar file ... +.It Cm list Oo Fl -format Ar plain Ns | Ns Ar tsv Oc Oo Fl -only Ar all Ns | Ns Ar tag Ns | Ns Ar branch Oc Ar file ... Print every .Ic uses:\& pin found in the input files. -Performs no API calls, so it runs offline and without authentication. +Offline by default +.Pq Fl -only Ar all : +no API calls, no authentication. +When +.Fl -only +is set to +.Em tag +or +.Em branch , +each unique pin is classified authoritatively via the GitHub API +and only matching pins are emitted. .Pp Plain output has the form .Dl : @ (# ) @@ -147,11 +158,7 @@ and Default is .Em plain . .It Fl -only Ar all Ns | Ns Ar tag Ns | Ns Ar branch -Restrict -.Cm check -and -.Cm updates -output to a single classified kind. +Restrict output to a single classified kind. For .Cm check , the classification is the authoritative kind returned by the API @@ -161,11 +168,17 @@ For .Cm updates , the classification is the routing kind (which API endpoint was consulted). +For +.Cm list , +.Em all +is offline and lists every parseable pin; passing +.Em tag +or +.Em branch +taps the GitHub API per unique pin to classify authoritatively +before filtering. Default is .Em all . -.Cm list -is offline by design and cannot authoritatively classify refs, so the -flag is accepted and ignored for that subcommand. .It Fl -help Show a usage summary and exit. .It Fl -version diff --git a/images/ci-tools/bin/validate-action-pins b/images/ci-tools/bin/validate-action-pins index c1adc53..c1a23d7 100755 --- a/images/ci-tools/bin/validate-action-pins +++ b/images/ci-tools/bin/validate-action-pins @@ -58,9 +58,12 @@ Subcommands: Options: --format=plain|tsv Output format for `list` and `updates` (default: plain) --only=all|tag|branch - Restrict `check`/`updates` output to a single - classified kind. Default: all. Ignored by `list` - (offline by design, cannot authoritatively classify). + Restrict output to a single classified kind. Default: + all. `check` uses the authoritative kind from + resolve_ref; `updates` uses the routing kind. `list` + is offline when --only=all (default) and taps the + GitHub API for authoritative classification when + --only=tag or --only=branch. --help Show this help message and exit --version Print the version and exit @@ -671,17 +674,20 @@ cmd_updates() { done } -# List pinned actions found in the given workflow files. No API calls. +# List pinned actions found in the given workflow files. # -# `list` is offline-by-design: it cannot authoritatively classify -# refs as tag vs branch without hitting the API, so the --only -# filter has no effect here (main accepts and ignores it for list). -# Use `check --only=...` or `updates --only=...` when authoritative -# classification is needed. +# Offline by default: with --only=all (the default) no API calls +# are made and every parseable pin is emitted. When --only is +# set to tag or branch, list consults the GitHub API to +# authoritatively classify each pin's effective ref (the comment +# for SHA pins, the ref itself for floating pins) and filters the +# output accordingly. Unresolvable refs and pins with no ref hint +# are classified as "unknown" and appear only with --only=all. # # Arguments: # $1 - format: "plain" or "tsv" -# $2.. - workflow file paths +# $2 - --only filter: "all", "tag", or "branch" +# $3.. - workflow file paths # # Plain output (one line per occurrence): # : @ (# ) @@ -691,9 +697,17 @@ cmd_updates() { # # Returns 0 always; per-file read errors are reported as WARN on stderr. cmd_list() { - local format="${1}" - shift + local format="${1}" only="${2}" + shift 2 + local api_mode=false + if [[ "${only}" != "all" ]]; then + check_api_preflight "list --only=${only}" || return 0 + api_mode=true + fi + + local -A resolve_cache=() local file line parsed action ref kind comment + local effective_ref cache_key cached resolved_kind for file in "${@}"; do if [[ ! -f "${file}" ]]; then @@ -701,23 +715,48 @@ cmd_list() { continue fi while IFS= read -r line; do - if parsed="$(parse_uses_line "${line}")"; then - IFS=$'\t' read -r action ref kind comment <<< "${parsed}" - case "${format}" in - tsv) - printf '%s\t%s\t%s\t%s\t%s\n' \ - "${file}" "${action}" "${ref}" "${kind}" "${comment}" - ;; - *) - if [[ -n "${comment}" ]]; then - printf '%s: %s@%s (# %s)\n' \ - "${file##*/}" "${action}" "${ref}" "${comment}" + if ! parsed="$(parse_uses_line "${line}")"; then + continue + fi + IFS=$'\t' read -r action ref kind comment <<< "${parsed}" + + if ${api_mode}; then + effective_ref="$(_effective_ref "${kind}" "${ref}" "${comment}")" + if [[ -z "${effective_ref}" ]]; then + resolved_kind="unknown" + else + cache_key="${action}#${effective_ref}" + if [[ -z "${resolve_cache[${cache_key}]+x}" ]]; then + if cached="$(resolve_ref "${action}" "${effective_ref}")"; then + resolve_cache["${cache_key}"]="${cached}" else - printf '%s: %s@%s\n' "${file##*/}" "${action}" "${ref}" + resolve_cache["${cache_key}"]="NONE" fi - ;; - esac + fi + cached="${resolve_cache[${cache_key}]}" + if [[ "${cached}" == "NONE" ]]; then + resolved_kind="unknown" + else + resolved_kind="${cached#*$'\t'}" + fi + fi + _include_pin "${only}" "${resolved_kind}" || continue fi + + case "${format}" in + tsv) + printf '%s\t%s\t%s\t%s\t%s\n' \ + "${file}" "${action}" "${ref}" "${kind}" "${comment}" + ;; + *) + if [[ -n "${comment}" ]]; then + printf '%s: %s@%s (# %s)\n' \ + "${file##*/}" "${action}" "${ref}" "${comment}" + else + printf '%s: %s@%s\n' "${file##*/}" "${action}" "${ref}" + fi + ;; + esac done < "${file}" done } @@ -829,7 +868,7 @@ main() { case "${subcmd}" in check) cmd_check "${only}" "${files[@]}" ;; - list) cmd_list "${format}" "${files[@]}" ;; + list) cmd_list "${format}" "${only}" "${files[@]}" ;; updates) cmd_updates "${format}" "${only}" "${files[@]}" ;; esac } diff --git a/tests/bats/images/ci-tools/validate-action-pins.bats b/tests/bats/images/ci-tools/validate-action-pins.bats index 77b2a25..9ff922e 100644 --- a/tests/bats/images/ci-tools/validate-action-pins.bats +++ b/tests/bats/images/ci-tools/validate-action-pins.bats @@ -371,13 +371,43 @@ setup() { refute_output --partial "(branch)" } -@test "--only=branch does not affect list (offline by design)" { - run "${SCRIPT}" list --only=branch "${FIXTURES_DIR}/workflows/mixed.yml" +@test "list --only=tag filters to tag pins via authoritative API" { + run "${SCRIPT}" list --only=tag \ + "${FIXTURES_DIR}/workflows/tag-ok.yml" \ + "${FIXTURES_DIR}/workflows/branch-ok.yml" + assert_success + assert_output --partial "tag-ok.yml: foo/bar@" + refute_output --partial "branch-ok.yml" +} + +@test "list --only=branch filters to branch pins via authoritative API" { + run "${SCRIPT}" list --only=branch \ + "${FIXTURES_DIR}/workflows/tag-ok.yml" \ + "${FIXTURES_DIR}/workflows/branch-ok.yml" assert_success - # Every non-docker/non-local pin appears regardless of filter. - assert_output --partial "actions/checkout@v6.0.2" - assert_output --partial "org/repo@main" - assert_output --partial "foo/bar@" + assert_output --partial "branch-ok.yml: foo/br-ok@" + refute_output --partial "tag-ok.yml" +} + +@test "list --only=all stays offline (no API, no preflight)" { + # Clear SKIP and point API base at a missing dir — preflight would + # fail. With --only=all (the default), list must not probe at all. + mkdir -p "${BATS_TEST_TMPDIR}/empty" + VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY='' \ + GITHUB_API_BASE="file://${BATS_TEST_TMPDIR}/empty" \ + run "${SCRIPT}" list --only=all "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + refute_output --partial "cannot reach GitHub API" + assert_output --partial "tag-ok.yml: foo/bar@" +} + +@test "list --only=branch warns and exits 0 when the API is unreachable" { + mkdir -p "${BATS_TEST_TMPDIR}/empty" + VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY='' \ + GITHUB_API_BASE="file://${BATS_TEST_TMPDIR}/empty" \ + run "${SCRIPT}" list --only=branch "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + assert_output --partial "WARN: cannot reach GitHub API" } @test "--only accepts space-separated form" { From 79634cc340fbc2c1973322d806f1be1107805de4 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 16:50:37 -0600 Subject: [PATCH 19/33] Harden gh_api: curl retries, verbose stderr, auth vs reach, rate-limit awareness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator-visibility improvements surfaced by the DevOps review of the #122 branch: - All curl calls use `--retry 3 --retry-delay 2` so transient 5xx, DNS blips, and GitHub incident-window hiccups no longer read as "unresolvable ref". --retry is silently ignored on file://, so tests keep working. - VALIDATE_ACTION_PINS_VERBOSE=1 passes curl stderr through instead of swallowing it. Previously a bad GITHUB_TOKEN or a malformed URL produced no trail at all — operators had no way to tell auth failure from network failure. A module-scope _CURL_ERRFD routes stderr uniformly. - check_api_preflight now distinguishes four failure modes in its WARN message: missing curl/jq, auth rejection (HTTP 401/403), transport failure (curl exit → code "000"), and any other unexpected HTTP status. The auth branch includes the specific actionable hint (`check GITHUB_TOKEN`) that the old generic "cannot reach" message hid. - Rate-limit remaining is read from the /rate_limit probe response and a WARN is emitted (non-fatal) when it falls below 20. Gives operators a heads-up on a large sweep before individual pin lookups start 403-ing mid-run. - The file:// path continues to be the test harness; the rate-limit check now runs in that path too so the threshold warning is test-covered. Two new bats cases cover the rate-limit WARN and verbose curl- stderr passthrough. 69/69 green, shellcheck/shfmt/mandoc clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- images/ci-tools/bin/validate-action-pins | 102 ++++++++++++++++-- .../images/ci-tools/validate-action-pins.bats | 31 ++++++ 2 files changed, 123 insertions(+), 10 deletions(-) diff --git a/images/ci-tools/bin/validate-action-pins b/images/ci-tools/bin/validate-action-pins index c1a23d7..3e88fc7 100755 --- a/images/ci-tools/bin/validate-action-pins +++ b/images/ci-tools/bin/validate-action-pins @@ -27,6 +27,11 @@ set -euo pipefail # Tests point this at file:// fixtures. # VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY - (Optional) If set, skip the # /rate_limit probe. For tests. +# VALIDATE_ACTION_PINS_VERBOSE - (Optional) If set, pass curl +# stderr through instead of +# silencing it. Useful for +# diagnosing 4xx/5xx or DNS +# issues in CI. # # Exit codes: # 0 - All pins match, no pins found, or validation skipped (missing @@ -40,6 +45,21 @@ readonly VERSION="${VALIDATE_ACTION_PINS_VERSION:-unknown}" : "${GITHUB_API_BASE:=https://api.github.com}" +# Destination for curl's stderr. Silent by default so CI logs stay +# clean; set VALIDATE_ACTION_PINS_VERBOSE=1 to surface transport / +# HTTP errors during diagnosis. +_CURL_ERRFD="/dev/null" +if [[ -n "${VALIDATE_ACTION_PINS_VERBOSE:-}" ]]; then + _CURL_ERRFD="/dev/stderr" +fi +readonly _CURL_ERRFD + +# curl flags used everywhere. Retries cover transient 5xx / DNS +# blips; --fail converts HTTP 4xx/5xx into nonzero exit so callers +# don't mistake an error body for a valid response. `file://` URLs +# silently ignore --retry. +readonly _CURL_FLAGS=(-fsSL --retry 3 --retry-delay 2) + # ── usage / version ────────────────────────────────────────────────── usage() { @@ -92,12 +112,16 @@ init_auth_header() { # $1 - API URL path (e.g. /repos/owner/repo/git/ref/tags/v1) # # Outputs: -# JSON response on stdout, or empty string on failure +# JSON response on stdout, or empty string on failure. +# +# Transport errors (DNS, refused, 5xx) are retried up to 3 times via +# curl --retry. Set VALIDATE_ACTION_PINS_VERBOSE=1 to see curl's +# stderr when diagnosing auth or network issues. gh_api() { local url="${GITHUB_API_BASE}${1}" - curl -fsSL "${auth_header[@]+"${auth_header[@]}"}" \ + curl "${_CURL_FLAGS[@]}" "${auth_header[@]+"${auth_header[@]}"}" \ -H "Accept: application/vnd.github+json" \ - "${url}" 2> /dev/null || true + "${url}" 2> "${_CURL_ERRFD}" || true } # Resolve a tag ref (lightweight or annotated) to its commit SHA. @@ -305,12 +329,21 @@ parse_uses_line() { # connectivity probe. Emits WARN and returns 1 when the caller should # gracefully skip its work. # +# Distinguishes four failure modes so operators can act on the WARN: +# - missing curl or jq +# - cannot reach the API host (DNS/network) +# - authentication rejected (HTTP 401/403 — bad GITHUB_TOKEN) +# - unexpected HTTP status (generic fallback) +# +# When the probe succeeds, also checks rate-limit remaining and emits +# a WARN (still returning 0) if the budget looks tight. +# # Arguments: # $1 - operation name for the WARN message (default "pin validation") # # Returns: -# 0 - curl, jq, and the API are available -# 1 - a dependency is missing or the API is unreachable (WARN emitted) +# 0 - curl, jq, and the API are available (subcommand may proceed) +# 1 - a dependency, connectivity, or auth problem (skip the work) check_api_preflight() { local op="${1:-pin validation}" local cmd @@ -327,11 +360,60 @@ check_api_preflight() { return 0 fi - if ! curl -fsSL "${auth_header[@]+"${auth_header[@]}"}" \ - -H "Accept: application/vnd.github+json" \ - "${GITHUB_API_BASE}/rate_limit" > /dev/null 2>&1; then - echo "WARN: cannot reach GitHub API — skipping ${op}" - return 1 + local body_file + body_file="$(mktemp)" + + if [[ "${GITHUB_API_BASE}" == file://* ]]; then + # file:// — test harness path. No HTTP status; curl's exit code + # is the only signal. Body is still captured for the rate-limit + # check below. + if ! curl "${_CURL_FLAGS[@]}" "${auth_header[@]+"${auth_header[@]}"}" \ + -H "Accept: application/vnd.github+json" \ + "${GITHUB_API_BASE}/rate_limit" > "${body_file}" 2> "${_CURL_ERRFD}"; then + echo "WARN: cannot reach GitHub API — skipping ${op}" + rm -f "${body_file}" + return 1 + fi + else + # HTTP(s) — extract status so we can distinguish transport failure + # (code "000") from auth rejection (401/403) from any other + # unexpected response. + local http_code + http_code="$(curl -sS --retry 3 --retry-delay 2 \ + -o "${body_file}" -w '%{http_code}' \ + "${auth_header[@]+"${auth_header[@]}"}" \ + -H "Accept: application/vnd.github+json" \ + "${GITHUB_API_BASE}/rate_limit" 2> "${_CURL_ERRFD}" \ + || echo "000")" + + case "${http_code}" in + 200) ;; + 401 | 403) + echo "WARN: GitHub API authentication failed (HTTP ${http_code}) — skipping ${op}" + echo " check GITHUB_TOKEN; an unset or expired token produces this response" + rm -f "${body_file}" + return 1 + ;; + 000) + echo "WARN: cannot reach GitHub API — skipping ${op}" + rm -f "${body_file}" + return 1 + ;; + *) + echo "WARN: GitHub API returned unexpected HTTP ${http_code} — skipping ${op}" + rm -f "${body_file}" + return 1 + ;; + esac + fi + + # Surface a tight rate-limit budget so operators know to authenticate + # before a long sweep. Non-fatal — we still proceed. + local remaining + remaining="$(jq -r '.resources.core.remaining // empty' < "${body_file}" 2> "${_CURL_ERRFD}" || true)" + rm -f "${body_file}" + if [[ -n "${remaining}" && "${remaining}" -lt 20 ]]; then + echo "WARN: GitHub API rate limit is low (${remaining} remaining); set GITHUB_TOKEN if ${op} stalls mid-run" fi return 0 } diff --git a/tests/bats/images/ci-tools/validate-action-pins.bats b/tests/bats/images/ci-tools/validate-action-pins.bats index 9ff922e..b28070f 100644 --- a/tests/bats/images/ci-tools/validate-action-pins.bats +++ b/tests/bats/images/ci-tools/validate-action-pins.bats @@ -239,6 +239,37 @@ setup() { # ── connectivity probe ────────────────────────────────────────────── +@test "preflight warns when rate-limit remaining is tight" { + # Custom fixture: /rate_limit with remaining=5 (below the 20 threshold). + mkdir -p "${BATS_TEST_TMPDIR}/api-low" + cat > "${BATS_TEST_TMPDIR}/api-low/rate_limit" <<'JSON' +{ "resources": { "core": { "limit": 60, "remaining": 5, "reset": 0 } } } +JSON + VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY='' \ + GITHUB_API_BASE="file://${BATS_TEST_TMPDIR}/api-low" \ + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml" + # Still exits 0 — the warning is informational. + assert_success + assert_output --partial "rate limit is low (5 remaining)" + assert_output --partial "set GITHUB_TOKEN" +} + +@test "VALIDATE_ACTION_PINS_VERBOSE surfaces curl stderr" { + # Point at a missing fixture dir so curl fails; verbose mode lets + # the "file not found" or similar message through. + mkdir -p "${BATS_TEST_TMPDIR}/empty" + VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY='' \ + VALIDATE_ACTION_PINS_VERBOSE=1 \ + GITHUB_API_BASE="file://${BATS_TEST_TMPDIR}/empty" \ + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + # The WARN summary is still there. + assert_output --partial "cannot reach GitHub API" + # And curl's own error shows through (exact wording varies across + # curl versions; match the minimum common substring). + assert_output --partial "curl" +} + @test "connectivity probe failure warns and exits 0" { mkdir -p "${BATS_TEST_TMPDIR}/empty" VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY='' \ From e2841edad7750d55c22580c7fc910a1645eacc06 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 16:51:59 -0600 Subject: [PATCH 20/33] Extract the semver-like tag pattern to a single module constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grammar for "what looks like a semver-shaped ref" appeared in two places: the bash classifier inside cmd_updates routing, and the jq test() filter inside list_newer_tags. If that grammar evolves (calver support, pre-release inclusion, etc.), both needed to stay in sync. Define _SEMVER_RE once at module scope with a comment on the rationale (what's included, what's deliberately excluded, why). cmd_updates uses it directly in its =~ match; list_newer_tags passes it to jq via --arg re so the same string drives both engines without escape translation. Pure refactor — 69/69 bats cases pass unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- images/ci-tools/bin/validate-action-pins | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/images/ci-tools/bin/validate-action-pins b/images/ci-tools/bin/validate-action-pins index 3e88fc7..5ab52d4 100755 --- a/images/ci-tools/bin/validate-action-pins +++ b/images/ci-tools/bin/validate-action-pins @@ -60,6 +60,15 @@ readonly _CURL_ERRFD # silently ignore --retry. readonly _CURL_FLAGS=(-fsSL --retry 3 --retry-delay 2) +# Accepted "semver-like" tag shape, shared by the bash classifier +# and the jq tag filter. Matches: +# v1, v1.2, v1.2.3, and the same without the leading v. +# Missing minor/patch components are treated as zero in comparisons +# (see list_newer_tags). Pre-release and non-numeric suffixes are +# intentionally excluded — users pinning to v1.0.0-rc1 or nightly +# don't want those reported as upgrade targets. +readonly _SEMVER_RE='^v?[0-9]+(\.[0-9]+){0,2}$' + # ── usage / version ────────────────────────────────────────────────── usage() { @@ -443,14 +452,16 @@ list_newer_tags() { if [[ -z "${json}" ]]; then return 1 fi - jq -r --arg c "${current}" ' + # Shared grammar with the bash classifier; passed as a jq --arg so + # the pattern lives in one place (_SEMVER_RE above). + jq -r --arg c "${current}" --arg re "${_SEMVER_RE}" ' def tok(s): (s | sub("^v"; "") | split(".")) | [range(3) as $i | .[$i] // "0"] | map(tonumber? // 0); [ .[].name - | select(test("^v?[0-9]+(\\.[0-9]+){0,2}$")) + | select(test($re)) | { name: ., key: tok(.) } ] as $tags | (tok($c)) as $ck @@ -698,7 +709,7 @@ cmd_updates() { # else is treated as a branch, empty refs have no hint. if [[ -z "${effective_ref}" ]]; then effective_kind="unknown" - elif [[ "${effective_ref}" =~ ^v?[0-9]+(\.[0-9]+){0,2}$ ]]; then + elif [[ "${effective_ref}" =~ ${_SEMVER_RE} ]]; then effective_kind="tag" newer_tags="$(list_newer_tags "${action}" "${effective_ref}" || true)" if [[ -n "${newer_tags}" ]]; then From 6b6655bf64829884d9ea130317f34033141a9314 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 16:52:53 -0600 Subject: [PATCH 21/33] Skip uses: inside YAML comment lines when parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse_uses_line regex-matched any line containing `uses: owner/repo@ref`, including lines whose first non-whitespace character was `#` — so a commented-out pin like # - uses: actions/checkout@ # v5 - uses: actions/checkout@ # v6 was parsed as two live pins. check would emit two OK/FAIL/WARN lines from the same logical occurrence, and updates would report twice for the same action. Add a line-prefix guard that rejects `^[[:space:]]*#` lines before the main regex runs. Fixture `commented-uses.yml` exercises both the leading-hash and indented-comment-with-hash cases. Two new bats cases cover the parse-level and check-level behaviour. Edge cases deliberately not handled: block-literal YAML (`|-`), flow-mapping inline values (`uses: [...]`). Those are not produced by real workflow authors and not supported by GitHub Actions' parser either. 71/71 green, shellcheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- images/ci-tools/bin/validate-action-pins | 6 +++++ .../fixtures/workflows/commented-uses.yml | 9 ++++++++ .../images/ci-tools/validate-action-pins.bats | 22 +++++++++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 tests/bats/images/ci-tools/fixtures/workflows/commented-uses.yml diff --git a/images/ci-tools/bin/validate-action-pins b/images/ci-tools/bin/validate-action-pins index 5ab52d4..e2b89fc 100755 --- a/images/ci-tools/bin/validate-action-pins +++ b/images/ci-tools/bin/validate-action-pins @@ -302,6 +302,12 @@ _once_per_run() { parse_uses_line() { local line="${1}" local action ref comment kind + # Skip YAML comment lines — anything whose first non-whitespace + # character is `#`. Without this, a commented-out `uses:` block + # would be parsed as a live pin. + if [[ "${line}" =~ ^[[:space:]]*# ]]; then + return 1 + fi if ! [[ "${line}" =~ uses:[[:space:]]*([^@[:space:]]+)@([^[:space:]#]+)[[:space:]]*(#[[:space:]]*([^[:space:]]+))? ]]; then return 1 fi diff --git a/tests/bats/images/ci-tools/fixtures/workflows/commented-uses.yml b/tests/bats/images/ci-tools/fixtures/workflows/commented-uses.yml new file mode 100644 index 0000000..c076a01 --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/workflows/commented-uses.yml @@ -0,0 +1,9 @@ +name: commented-uses +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + # old revision we're holding back for reference: + # - uses: foo/bar@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb # v0 + - uses: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1 diff --git a/tests/bats/images/ci-tools/validate-action-pins.bats b/tests/bats/images/ci-tools/validate-action-pins.bats index b28070f..796e90d 100644 --- a/tests/bats/images/ci-tools/validate-action-pins.bats +++ b/tests/bats/images/ci-tools/validate-action-pins.bats @@ -237,6 +237,28 @@ setup() { assert_failure } +@test "parse_uses_line skips YAML comment lines" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run parse_uses_line "# - uses: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1" + assert_failure + run parse_uses_line " # - uses: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1" + assert_failure + # A real use line with an inline trailing `# comment` is still parsed. + run parse_uses_line " - uses: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1" + assert_success +} + +@test "check ignores a commented-out uses: pin" { + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/commented-uses.yml" + assert_success + # Only the live line is validated; the commented one isn't. + local ok_count + ok_count="$(grep -c '^OK ' <<< "${output}" || true)" + assert_equal "${ok_count}" "1" + refute_output --partial "bbbbbbbbbbbb" +} + # ── connectivity probe ────────────────────────────────────────────── @test "preflight warns when rate-limit remaining is tight" { From 79074713ad98a5e3b5ff676cc0a943da9db398ca Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 16:53:36 -0600 Subject: [PATCH 22/33] Document the fixtures/ URL-to-path mirroring convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test harness points GITHUB_API_BASE at file://fixtures/api so every gh_api call becomes a file read. New contributors opening the directory see paths like api/repos/foo/br-behind/compare/cccc...bbbb and have to reverse-engineer why there's a literal `...` in a filename and why nothing has a .json extension. Add a README that documents the mirroring rule, the per-repo fixture inventory, and a step-by-step for adding a new case. Doc only — no code or test change. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/bats/images/ci-tools/fixtures/README.md | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/bats/images/ci-tools/fixtures/README.md diff --git a/tests/bats/images/ci-tools/fixtures/README.md b/tests/bats/images/ci-tools/fixtures/README.md new file mode 100644 index 0000000..0f32730 --- /dev/null +++ b/tests/bats/images/ci-tools/fixtures/README.md @@ -0,0 +1,77 @@ +# validate-action-pins — test fixtures + +This directory backs the bats suite at +`tests/bats/images/ci-tools/validate-action-pins.bats`. + +## Layout + +```text +fixtures/ +├── workflows/ input `.yml` files for `check` / `list` / `updates` +└── api/ file:// stand-in for the GitHub REST API +``` + +Each test sets `GITHUB_API_BASE=file://${FIXTURES_DIR}/api`, so every +`gh_api "/repos/..."` call becomes a file read against this tree. +No network, no rate limit, no flakiness. + +## How `api/` mirrors GitHub endpoints + +File paths under `api/` map 1:1 onto the URL suffix after +`https://api.github.com`: + +| GitHub endpoint | File path | +| ------------------------------------------------ | ------------------------------------------------------------------------- | +| `GET /rate_limit` | `api/rate_limit` | +| `GET /repos/{o}/{r}/git/ref/tags/{tag}` | `api/repos/{o}/{r}/git/ref/tags/{tag}` | +| `GET /repos/{o}/{r}/git/ref/heads/{branch}` | `api/repos/{o}/{r}/git/ref/heads/{branch}` | +| `GET /repos/{o}/{r}/git/tags/{sha}` | `api/repos/{o}/{r}/git/tags/{sha}` | +| `GET /repos/{o}/{r}/tags` | `api/repos/{o}/{r}/tags` | +| `GET /repos/{o}/{r}/compare/{base}...{head}` | `api/repos/{o}/{r}/compare/{base}...{head}` | + +The file content is the JSON body GitHub would return. No headers, +no status line — `curl file://...` just reads the file and succeeds +with 200-equivalent semantics (a missing file surfaces as curl +failure, which the tool treats as "ref not found" via its existing +empty-response handling). + +A couple of consequences worth knowing: + +- **Compare URLs use a literal `...` in the filename.** The compare + endpoint is `/compare/{base}...{head}`, so the fixture lives at + `api/repos/{o}/{r}/compare/<40-hex>...<40-hex>` — no extension, + dots and all. Supported on macOS, Linux, and Git; doesn't play + well with Windows without WSL. +- **No `.json` extension** on any fixture. The mirror has to match + the URL exactly, and GitHub's URLs don't include extensions. + +## Which repos exist + +| Owner/Repo | Fixtures provided | Purpose | +| ----------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| `foo/bar` | `git/ref/tags/v1`, `tags` (list for updates), several workflows | Canonical tag-pin and upgrade-inventory case | +| `foo/annotated` | `git/ref/tags/v2` (object.type=tag), `git/tags/{sha}` for deref | Annotated-tag dereference path | +| `foo/br-ok` | `git/ref/heads/main` | Branch pin at HEAD (OK) | +| `foo/br-behind` | `git/ref/heads/main`, `compare/...` with `behind_by=3` | Branch pin 3 commits behind | +| `foo/br-diverge` | `git/ref/heads/main`, `compare/...` with `behind_by=0` | Branch pin diverged (same `behind_by=0` but distinct SHA) | +| `foo/nosuch` | (nothing) | Unresolvable-ref path | + +The workflows under `workflows/` pin against these repos by SHA or +symbolic ref to exercise each subcommand branch. If you add a new +case, add the corresponding `api/...` file(s) so the resolver has +something to read. + +## Adding a new fixture + +1. Pick an owner/repo name (`foo/`) that doesn't collide. +2. Create the workflow in `workflows/.yml` with one or more + `uses:` lines. +3. For every unique `(owner/repo, ref)` the new workflow pins, add + the API fixtures the resolver will consult: + - `check` uses `/git/ref/tags/` then `/git/ref/heads/` + (falls back) and, on annotated tags, `/git/tags/`. + - `updates` uses `/tags` for semver-shaped refs and + `/git/ref/heads/` for branch-shaped refs. + - `list --only=...` goes through the same `resolve_ref` as `check`. +4. Write the test(s). Follow the existing convention: one `@test` + per fixture-plus-assertion pair. From a04dee731864aa7e69981d5e77d7b472b1e89ce3 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 17:08:49 -0600 Subject: [PATCH 23/33] Reorganize bats tests by abstraction layer and per-tool directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related changes that make the test suite survive adding more ci-tools binaries later. 1. Layered split — the monolithic validate-action-pins.bats (67 tests across dispatch, parse, check, list, updates, helpers, preflight) was hard to navigate because each subcommand mixed CLI-surface tests, sourced unit tests, and integration tests. Files are now grouped by abstraction level: cli.bats — --help, --version, subcommand dispatch, flag validation, `--` terminator preflight.bats — missing deps, connectivity, auth-vs-reach, rate-limit WARN, verbose mode helpers.bats — sourced unit tests for parse_uses_line, resolve_ref, compare_behind, list_newer_tags, head_sha check.bats — `check` subcommand end-to-end list.bats — `list` subcommand end-to-end updates.bats — `updates` subcommand end-to-end Unchanged test bodies; only the file a given @test lives in has moved. Test count (71) is preserved. 2. Per-tool subdirectory — each binary under ci-tools now owns a namespace under tests/bats/images/ci-tools/. validate-action-pins specifically moves from tests/bats/images/ci-tools/{validate-action-pins-*.bats,fixtures/} to tests/bats/images/ci-tools/validate-action-pins/ ├── {cli,preflight,helpers,check,list,updates}.bats └── fixtures/{workflows,api,README.md} When we add check-deps or another ci-tools binary, it gets its own sibling directory with no naming collisions on fixtures or test-file names. The fixtures README documenting the URL-to-path mirroring convention moves with the fixtures. Mechanical consequences: - `load ../../helpers/common` → `load ../../../helpers/common` (one directory deeper). - `make lint-sh` shellcheck glob gains a path level: `tests/bats/*/*/*.bats` → `tests/bats/*/*/*/*.bats`. 71/71 pass; full lint + test-bats green. Co-Authored-By: Claude Opus 4.7 (1M context) --- Makefile | 2 +- tests/bats/helpers/common.bash | 2 +- .../images/ci-tools/validate-action-pins.bats | 652 ------------------ .../ci-tools/validate-action-pins/check.bats | 153 ++++ .../ci-tools/validate-action-pins/cli.bats | 103 +++ .../fixtures/README.md | 0 .../fixtures/api/rate_limit | 0 .../api/repos/foo/annotated/git/ref/tags/v2 | 0 .../1111111111111111111111111111111111111111 | 0 .../api/repos/foo/bar/git/ref/tags/v1 | 0 .../fixtures/api/repos/foo/bar/tags | 0 .....bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb | 0 .../repos/foo/br-behind/git/ref/heads/main | 0 .....dddddddddddddddddddddddddddddddddddddddd | 0 .../repos/foo/br-diverge/git/ref/heads/main | 0 .../api/repos/foo/br-ok/git/ref/heads/main | 0 .../fixtures/workflows/annotated.yml | 0 .../fixtures/workflows/branch-behind.yml | 0 .../fixtures/workflows/branch-diverge.yml | 0 .../fixtures/workflows/branch-ok.yml | 0 .../fixtures/workflows/commented-uses.yml | 0 .../workflows/duplicate-branch-pins.yml | 0 .../fixtures/workflows/duplicate-pins.yml | 0 .../fixtures/workflows/mixed.yml | 0 .../fixtures/workflows/tag-mismatch.yml | 0 .../fixtures/workflows/tag-ok-2.yml | 0 .../fixtures/workflows/tag-ok.yml | 0 .../fixtures/workflows/unresolvable.yml | 0 .../fixtures/workflows/updates-at-latest.yml | 0 .../workflows/updates-major-alias.yml | 0 .../fixtures/workflows/updates-mixed.yml | 0 .../fixtures/workflows/updates-new-major.yml | 0 .../validate-action-pins/helpers.bats | 160 +++++ .../ci-tools/validate-action-pins/list.bats | 137 ++++ .../validate-action-pins/preflight.bats | 91 +++ .../validate-action-pins/updates.bats | 116 ++++ 36 files changed, 762 insertions(+), 654 deletions(-) delete mode 100644 tests/bats/images/ci-tools/validate-action-pins.bats create mode 100644 tests/bats/images/ci-tools/validate-action-pins/check.bats create mode 100644 tests/bats/images/ci-tools/validate-action-pins/cli.bats rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/README.md (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/api/rate_limit (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/api/repos/foo/annotated/git/ref/tags/v2 (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/api/repos/foo/annotated/git/tags/1111111111111111111111111111111111111111 (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/api/repos/foo/bar/git/ref/tags/v1 (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/api/repos/foo/bar/tags (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/api/repos/foo/br-behind/compare/cccccccccccccccccccccccccccccccccccccccc...bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/api/repos/foo/br-behind/git/ref/heads/main (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/api/repos/foo/br-diverge/compare/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee...dddddddddddddddddddddddddddddddddddddddd (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/api/repos/foo/br-diverge/git/ref/heads/main (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/api/repos/foo/br-ok/git/ref/heads/main (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/workflows/annotated.yml (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/workflows/branch-behind.yml (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/workflows/branch-diverge.yml (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/workflows/branch-ok.yml (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/workflows/commented-uses.yml (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/workflows/duplicate-branch-pins.yml (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/workflows/duplicate-pins.yml (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/workflows/mixed.yml (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/workflows/tag-mismatch.yml (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/workflows/tag-ok-2.yml (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/workflows/tag-ok.yml (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/workflows/unresolvable.yml (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/workflows/updates-at-latest.yml (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/workflows/updates-major-alias.yml (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/workflows/updates-mixed.yml (100%) rename tests/bats/images/ci-tools/{ => validate-action-pins}/fixtures/workflows/updates-new-major.yml (100%) create mode 100644 tests/bats/images/ci-tools/validate-action-pins/helpers.bats create mode 100644 tests/bats/images/ci-tools/validate-action-pins/list.bats create mode 100644 tests/bats/images/ci-tools/validate-action-pins/preflight.bats create mode 100644 tests/bats/images/ci-tools/validate-action-pins/updates.bats diff --git a/Makefile b/Makefile index 3230607..c337ea0 100644 --- a/Makefile +++ b/Makefile @@ -73,7 +73,7 @@ lint-docker: lint-sh: @echo "Linting shell scripts..." \ && shellcheck scripts/*.sh scripts/*/*.sh tests/deb/*.sh images/*/bin/* \ - && shellcheck -e SC2154 tests/bats/*/*.bash tests/bats/*/*/*.bats \ + && shellcheck -e SC2154 tests/bats/*/*.bash tests/bats/*/*/*/*.bats \ && echo "OK" # Check shell script formatting diff --git a/tests/bats/helpers/common.bash b/tests/bats/helpers/common.bash index 927bf48..4dd6ecd 100644 --- a/tests/bats/helpers/common.bash +++ b/tests/bats/helpers/common.bash @@ -6,7 +6,7 @@ # Layout expectations: # REPO_ROOT is computed from BATS_TEST_DIRNAME by walking up to the first # ancestor containing a Makefile. Suite files should call `common_setup` from -# their own setup() — it normalises the environment for deterministic runs. +# their own setup() — it normalizes the environment for deterministic runs. # BATS_* variables are set by bats at runtime. # shellcheck disable=SC2154 diff --git a/tests/bats/images/ci-tools/validate-action-pins.bats b/tests/bats/images/ci-tools/validate-action-pins.bats deleted file mode 100644 index 796e90d..0000000 --- a/tests/bats/images/ci-tools/validate-action-pins.bats +++ /dev/null @@ -1,652 +0,0 @@ -#!/usr/bin/env bats -# shellcheck shell=bash - -load ../../helpers/common - -setup() { - common_setup - SCRIPT="${REPO_ROOT}/images/ci-tools/bin/validate-action-pins" - export SCRIPT -} - -# ── --help / --version / no-args / missing file ───────────────────── - -@test "--help prints usage and exits 0" { - run "${SCRIPT}" --help - assert_success - assert_output --partial "Usage: validate-action-pins" -} - -@test "--version prints program and version and exits 0" { - run "${SCRIPT}" --version - assert_success - assert_output --regexp '^validate-action-pins ' -} - -@test "no args prints usage to stderr and exits 1" { - run "${SCRIPT}" - assert_failure 1 - assert_output --partial "Usage: validate-action-pins" -} - -@test "nonexistent file warns and returns 0 with no pins" { - run "${SCRIPT}" "${BATS_TEST_TMPDIR}/does-not-exist.yml" - assert_success - assert_output --partial "WARN: ${BATS_TEST_TMPDIR}/does-not-exist.yml not found, skipping" - assert_output --partial "No SHA-pinned actions found" -} - -# ── pin resolution (file:// API) ──────────────────────────────────── - -@test "tag pin matching resolved SHA prints OK and exits 0" { - run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml" - assert_success - assert_output --partial "OK tag-ok.yml: foo/bar@aaaaaaaaaaaa..." - assert_output --partial "matches v1" -} - -@test "tag pin mismatching resolved SHA prints FAIL and exits 1" { - run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-mismatch.yml" - assert_failure 1 - assert_output --partial "FAIL tag-mismatch.yml: foo/bar@bbbbbbbbbbbb..." - assert_output --partial "does NOT match v1" - assert_output --partial "FAIL: 1 pin(s) did not match" -} - -@test "annotated tag ref is dereferenced to the commit SHA" { - run "${SCRIPT}" "${FIXTURES_DIR}/workflows/annotated.yml" - assert_success - assert_output --partial "OK annotated.yml: foo/annotated@cccccccccccc..." - assert_output --partial "matches v2" -} - -@test "duplicate pins within one file produce a single OK line" { - run "${SCRIPT}" "${FIXTURES_DIR}/workflows/duplicate-pins.yml" - assert_success - local ok_count - ok_count="$(grep -c '^OK ' <<< "${output}" || true)" - assert_equal "${ok_count}" "1" -} - -@test "same pin across multiple files produces one OK per file (resolve_cache)" { - # seen_in_file resets per file, so the second file would re-enter the - # resolve path — but resolve_cache short-circuits the API call. We verify - # the output shape: exactly one OK per file, and each lists its own basename. - run "${SCRIPT}" \ - "${FIXTURES_DIR}/workflows/tag-ok.yml" \ - "${FIXTURES_DIR}/workflows/tag-ok-2.yml" - assert_success - local ok_count - ok_count="$(grep -c '^OK ' <<< "${output}" || true)" - assert_equal "${ok_count}" "2" - assert_output --partial "OK tag-ok.yml:" - assert_output --partial "OK tag-ok-2.yml:" -} - -# ── subcommand dispatch ───────────────────────────────────────────── - -@test "bare FILE and explicit 'check FILE' produce identical output" { - local bare_out subcmd_out - bare_out="$("${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml")" - subcmd_out="$("${SCRIPT}" check "${FIXTURES_DIR}/workflows/tag-ok.yml")" - assert_equal "${bare_out}" "${subcmd_out}" -} - -@test "'check --version' still prints the version" { - run "${SCRIPT}" check --version - assert_success - assert_output --regexp '^validate-action-pins ' -} - -@test "unknown flag exits 2 with a usage error" { - run "${SCRIPT}" --bogus "${FIXTURES_DIR}/workflows/tag-ok.yml" - assert_failure 2 - assert_output --partial "unknown flag: --bogus" -} - -@test "short unknown flag exits 2" { - run "${SCRIPT}" -x "${FIXTURES_DIR}/workflows/tag-ok.yml" - assert_failure 2 - assert_output --partial "unknown flag: -x" -} - -@test "non-subcommand word is treated as a file path, not rejected" { - # 'foo' is not a known subcommand, so it stays as $1 and is consumed as a - # filename. Missing-file WARN confirms the non-reject path. - run "${SCRIPT}" foo "${FIXTURES_DIR}/workflows/tag-ok.yml" - assert_success - assert_output --partial "WARN: foo not found" - assert_output --partial "OK tag-ok.yml:" -} - -@test "'check' without any files exits 1 with usage" { - run "${SCRIPT}" check - assert_failure 1 - assert_output --partial "Usage: validate-action-pins" -} - -@test "'--' terminator passes subsequent args as files" { - run "${SCRIPT}" check -- "${FIXTURES_DIR}/workflows/tag-ok.yml" - assert_success - assert_output --partial "OK tag-ok.yml:" -} - -# ── list subcommand ───────────────────────────────────────────────── - -@test "list plain emits every parseable pin occurrence" { - run "${SCRIPT}" list "${FIXTURES_DIR}/workflows/mixed.yml" - assert_success - assert_output --partial "mixed.yml: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa (# v1)" - assert_output --partial "mixed.yml: actions/checkout@v6.0.2" - assert_output --partial "mixed.yml: org/repo@main" - assert_output --partial "mixed.yml: org/no-comment@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" -} - -@test "list skips docker:// and local path actions" { - run "${SCRIPT}" list "${FIXTURES_DIR}/workflows/mixed.yml" - assert_success - refute_output --partial "docker://" - refute_output --partial "./local-action" -} - -@test "list tsv emits five tab-separated columns per row" { - run "${SCRIPT}" list --format=tsv "${FIXTURES_DIR}/workflows/mixed.yml" - assert_success - # Every non-empty line must have exactly 4 tabs (5 fields). - local line - while IFS= read -r line; do - [[ -z "${line}" ]] && continue - local tab_count="${line//[^$'\t']/}" - assert_equal "${#tab_count}" "4" - done <<< "${output}" - # And we should see at least one sha and one ref kind. - assert_output --partial $'\tsha\t' - assert_output --partial $'\tref\t' -} - -@test "list accepts --format tsv as two tokens" { - run "${SCRIPT}" list --format tsv "${FIXTURES_DIR}/workflows/tag-ok.yml" - assert_success - assert_output --regexp $'\tfoo/bar\t' -} - -@test "list rejects an invalid --format" { - run "${SCRIPT}" list --format=yaml "${FIXTURES_DIR}/workflows/tag-ok.yml" - assert_failure 2 - assert_output --partial "invalid --format: yaml" -} - -@test "list works without curl in PATH (no API calls)" { - local stubdir="${BATS_TEST_TMPDIR}/nocurl-list" - local bash_bin - bash_bin="$(command -v bash)" - mkdir -p "${stubdir}" - ln -s "${bash_bin}" "${stubdir}/bash" - PATH="${stubdir}" run "${SCRIPT}" list "${FIXTURES_DIR}/workflows/tag-ok.yml" - assert_success - assert_output --partial "tag-ok.yml: foo/bar@" -} - -@test "list works without jq in PATH" { - local stubdir="${BATS_TEST_TMPDIR}/nojq-list" - local bash_bin - bash_bin="$(command -v bash)" - mkdir -p "${stubdir}" - ln -s "${bash_bin}" "${stubdir}/bash" - PATH="${stubdir}" run "${SCRIPT}" list "${FIXTURES_DIR}/workflows/tag-ok.yml" - assert_success - assert_output --partial "tag-ok.yml: foo/bar@" -} - -@test "list does not run the connectivity probe" { - # Point API base at a missing dir and clear SKIP on the command line — - # check would WARN here, but list should not probe at all. - mkdir -p "${BATS_TEST_TMPDIR}/empty" - VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY='' \ - GITHUB_API_BASE="file://${BATS_TEST_TMPDIR}/empty" \ - run "${SCRIPT}" list "${FIXTURES_DIR}/workflows/tag-ok.yml" - assert_success - refute_output --partial "cannot reach GitHub API" - assert_output --partial "tag-ok.yml: foo/bar@" -} - -# ── parse_uses_line (sourced) ─────────────────────────────────────── - -@test "parse_uses_line returns sha kind for 40-hex refs" { - # shellcheck disable=SC1090 - source "${SCRIPT}" - run parse_uses_line " - uses: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1" - assert_success - assert_output $'foo/bar\taaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\tsha\tv1' -} - -@test "parse_uses_line returns ref kind for non-sha refs" { - # shellcheck disable=SC1090 - source "${SCRIPT}" - run parse_uses_line " - uses: org/repo@main" - assert_success - assert_output $'org/repo\tmain\tref\t' -} - -@test "parse_uses_line rejects docker:// and local paths" { - # shellcheck disable=SC1090 - source "${SCRIPT}" - run parse_uses_line " - uses: docker://alpine:3.19" - assert_failure - run parse_uses_line " - uses: ./local-action" - assert_failure -} - -@test "parse_uses_line skips YAML comment lines" { - # shellcheck disable=SC1090 - source "${SCRIPT}" - run parse_uses_line "# - uses: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1" - assert_failure - run parse_uses_line " # - uses: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1" - assert_failure - # A real use line with an inline trailing `# comment` is still parsed. - run parse_uses_line " - uses: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1" - assert_success -} - -@test "check ignores a commented-out uses: pin" { - run "${SCRIPT}" "${FIXTURES_DIR}/workflows/commented-uses.yml" - assert_success - # Only the live line is validated; the commented one isn't. - local ok_count - ok_count="$(grep -c '^OK ' <<< "${output}" || true)" - assert_equal "${ok_count}" "1" - refute_output --partial "bbbbbbbbbbbb" -} - -# ── connectivity probe ────────────────────────────────────────────── - -@test "preflight warns when rate-limit remaining is tight" { - # Custom fixture: /rate_limit with remaining=5 (below the 20 threshold). - mkdir -p "${BATS_TEST_TMPDIR}/api-low" - cat > "${BATS_TEST_TMPDIR}/api-low/rate_limit" <<'JSON' -{ "resources": { "core": { "limit": 60, "remaining": 5, "reset": 0 } } } -JSON - VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY='' \ - GITHUB_API_BASE="file://${BATS_TEST_TMPDIR}/api-low" \ - run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml" - # Still exits 0 — the warning is informational. - assert_success - assert_output --partial "rate limit is low (5 remaining)" - assert_output --partial "set GITHUB_TOKEN" -} - -@test "VALIDATE_ACTION_PINS_VERBOSE surfaces curl stderr" { - # Point at a missing fixture dir so curl fails; verbose mode lets - # the "file not found" or similar message through. - mkdir -p "${BATS_TEST_TMPDIR}/empty" - VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY='' \ - VALIDATE_ACTION_PINS_VERBOSE=1 \ - GITHUB_API_BASE="file://${BATS_TEST_TMPDIR}/empty" \ - run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml" - assert_success - # The WARN summary is still there. - assert_output --partial "cannot reach GitHub API" - # And curl's own error shows through (exact wording varies across - # curl versions; match the minimum common substring). - assert_output --partial "curl" -} - -@test "connectivity probe failure warns and exits 0" { - mkdir -p "${BATS_TEST_TMPDIR}/empty" - VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY='' \ - GITHUB_API_BASE="file://${BATS_TEST_TMPDIR}/empty" \ - run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml" - assert_success - assert_output --partial "WARN: cannot reach GitHub API" -} - -# ── missing dependencies ──────────────────────────────────────────── - -@test "missing curl warns and exits 0" { - # PATH contains only bash so the shebang can resolve; curl check fails first. - local stubdir="${BATS_TEST_TMPDIR}/nocurl" - local bash_bin - bash_bin="$(command -v bash)" - mkdir -p "${stubdir}" - ln -s "${bash_bin}" "${stubdir}/bash" - PATH="${stubdir}" run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml" - assert_success - assert_output --partial "WARN: curl not found" -} - -@test "missing jq warns and exits 0" { - local stubdir="${BATS_TEST_TMPDIR}/nojq" - local bash_bin curl_bin - bash_bin="$(command -v bash)" - curl_bin="$(command -v curl)" - mkdir -p "${stubdir}" - ln -s "${bash_bin}" "${stubdir}/bash" - ln -s "${curl_bin}" "${stubdir}/curl" - PATH="${stubdir}" run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml" - assert_success - assert_output --partial "WARN: jq not found" -} - -# ── branch-pin support in `check` ────────────────────────────────── - -@test "branch pin matching HEAD prints OK and exits 0" { - run "${SCRIPT}" "${FIXTURES_DIR}/workflows/branch-ok.yml" - assert_success - assert_output --partial "OK branch-ok.yml: foo/br-ok@aaaaaaaaaaaa..." - assert_output --partial "matches main" -} - -@test "branch pin behind HEAD prints WARN with commit count and exits 0" { - run "${SCRIPT}" "${FIXTURES_DIR}/workflows/branch-behind.yml" - assert_success - assert_output --partial "WARN branch-behind.yml: foo/br-behind@cccccccccccc..." - assert_output --partial "is 3 commit(s) behind main HEAD" -} - -@test "branch pin diverged from HEAD prints WARN diverges and exits 0" { - run "${SCRIPT}" "${FIXTURES_DIR}/workflows/branch-diverge.yml" - assert_success - assert_output --partial "WARN branch-diverge.yml: foo/br-diverge@eeeeeeeeeeee..." - assert_output --partial "diverges from main HEAD" -} - -@test "unresolvable ref prints WARN and exits 0" { - run "${SCRIPT}" "${FIXTURES_DIR}/workflows/unresolvable.yml" - assert_success - assert_output --partial "WARN unresolvable.yml: foo/nosuch@ffffffffffff..." - assert_output --partial "could not resolve ref nosuch" -} - -@test "tag mismatch still prints FAIL and exits 1 (regression guard)" { - run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-mismatch.yml" - assert_failure 1 - assert_output --partial "FAIL tag-mismatch.yml:" - assert_output --partial "does NOT match v1" -} - -@test "duplicate branch pins on the same ref produce a single WARN" { - run "${SCRIPT}" "${FIXTURES_DIR}/workflows/duplicate-branch-pins.yml" - assert_success - local warn_count - warn_count="$(grep -c '^WARN ' <<< "${output}" || true)" - assert_equal "${warn_count}" "1" -} - -# ── --only filter (shared across check and updates) ───────────────── - -@test "check --only=branch on a tag-pin file emits nothing" { - run "${SCRIPT}" check --only=branch "${FIXTURES_DIR}/workflows/tag-ok.yml" - assert_success - refute_output --partial "OK " - refute_output --partial "FAIL " - refute_output --partial "WARN " -} - -@test "check --only=branch on a branch-pin file emits the WARN" { - run "${SCRIPT}" check --only=branch "${FIXTURES_DIR}/workflows/branch-behind.yml" - assert_success - assert_output --partial "WARN branch-behind.yml:" - assert_output --partial "3 commit(s) behind main HEAD" -} - -@test "check --only=tag on a branch-pin file emits nothing" { - run "${SCRIPT}" check --only=tag "${FIXTURES_DIR}/workflows/branch-ok.yml" - assert_success - refute_output --partial "OK " - refute_output --partial "WARN " -} - -@test "check --only=tag keeps tag mismatches visible (and failing)" { - run "${SCRIPT}" check --only=tag "${FIXTURES_DIR}/workflows/tag-mismatch.yml" - assert_failure 1 - assert_output --partial "FAIL tag-mismatch.yml:" - assert_output --partial "does NOT match v1" -} - -@test "updates --only=branch on a mixed file drops tag records" { - run "${SCRIPT}" updates --only=branch "${FIXTURES_DIR}/workflows/updates-mixed.yml" - assert_success - refute_output --partial "foo/bar" - assert_output --partial "foo/br-ok" - assert_output --partial "foo/br-behind" - assert_output --partial "(branch)" - refute_output --partial "(tag)" -} - -@test "updates --only=tag on a mixed file drops branch records" { - run "${SCRIPT}" updates --only=tag "${FIXTURES_DIR}/workflows/updates-mixed.yml" - assert_success - assert_output --partial "foo/bar" - refute_output --partial "foo/br-ok" - refute_output --partial "foo/br-behind" - assert_output --partial "(tag)" - refute_output --partial "(branch)" -} - -@test "list --only=tag filters to tag pins via authoritative API" { - run "${SCRIPT}" list --only=tag \ - "${FIXTURES_DIR}/workflows/tag-ok.yml" \ - "${FIXTURES_DIR}/workflows/branch-ok.yml" - assert_success - assert_output --partial "tag-ok.yml: foo/bar@" - refute_output --partial "branch-ok.yml" -} - -@test "list --only=branch filters to branch pins via authoritative API" { - run "${SCRIPT}" list --only=branch \ - "${FIXTURES_DIR}/workflows/tag-ok.yml" \ - "${FIXTURES_DIR}/workflows/branch-ok.yml" - assert_success - assert_output --partial "branch-ok.yml: foo/br-ok@" - refute_output --partial "tag-ok.yml" -} - -@test "list --only=all stays offline (no API, no preflight)" { - # Clear SKIP and point API base at a missing dir — preflight would - # fail. With --only=all (the default), list must not probe at all. - mkdir -p "${BATS_TEST_TMPDIR}/empty" - VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY='' \ - GITHUB_API_BASE="file://${BATS_TEST_TMPDIR}/empty" \ - run "${SCRIPT}" list --only=all "${FIXTURES_DIR}/workflows/tag-ok.yml" - assert_success - refute_output --partial "cannot reach GitHub API" - assert_output --partial "tag-ok.yml: foo/bar@" -} - -@test "list --only=branch warns and exits 0 when the API is unreachable" { - mkdir -p "${BATS_TEST_TMPDIR}/empty" - VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY='' \ - GITHUB_API_BASE="file://${BATS_TEST_TMPDIR}/empty" \ - run "${SCRIPT}" list --only=branch "${FIXTURES_DIR}/workflows/tag-ok.yml" - assert_success - assert_output --partial "WARN: cannot reach GitHub API" -} - -@test "--only accepts space-separated form" { - run "${SCRIPT}" check --only branch "${FIXTURES_DIR}/workflows/branch-ok.yml" - assert_success - assert_output --partial "OK branch-ok.yml:" -} - -@test "invalid --only value exits 2" { - run "${SCRIPT}" updates --only=bogus "${FIXTURES_DIR}/workflows/updates-mixed.yml" - assert_failure 2 - assert_output --partial "invalid --only: bogus" -} - -# ── unit-level: source the script, call functions directly ───────── - -@test "resolve_ref returns \\ttag for a lightweight tag" { - # shellcheck disable=SC1090 - source "${SCRIPT}" - run resolve_ref "foo/bar" "v1" - assert_success - assert_output $'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\ttag' -} - -@test "resolve_ref returns \\ttag for an annotated tag" { - # shellcheck disable=SC1090 - source "${SCRIPT}" - run resolve_ref "foo/annotated" "v2" - assert_success - assert_output $'cccccccccccccccccccccccccccccccccccccccc\ttag' -} - -@test "resolve_ref returns \\tbranch for a branch ref" { - # shellcheck disable=SC1090 - source "${SCRIPT}" - run resolve_ref "foo/br-ok" "main" - assert_success - assert_output $'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\tbranch' -} - -@test "resolve_ref returns nonzero when ref is neither tag nor branch" { - # shellcheck disable=SC1090 - source "${SCRIPT}" - run resolve_ref "foo/bar" "v999" - assert_failure - assert_output "" -} - -@test "compare_behind returns the .behind_by integer" { - # shellcheck disable=SC1090 - source "${SCRIPT}" - run compare_behind "foo/br-behind" \ - "cccccccccccccccccccccccccccccccccccccccc" \ - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - assert_success - assert_output "3" -} - -@test "compare_behind returns 0 for diverged histories" { - # shellcheck disable=SC1090 - source "${SCRIPT}" - run compare_behind "foo/br-diverge" \ - "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" \ - "dddddddddddddddddddddddddddddddddddddddd" - assert_success - assert_output "0" -} - -# ── updates subcommand ────────────────────────────────────────────── - -@test "updates lists every newer tag across minors and majors" { - run "${SCRIPT}" updates "${FIXTURES_DIR}/workflows/updates-new-major.yml" - assert_success - assert_output --partial "foo/bar" - assert_output --partial "current=v6.0.0" - assert_output --partial "newer=v6.0.1 v6.0.2 v7.0.0" - assert_output --partial "(tag)" - refute_output --partial "[up-to-date]" -} - -@test "updates reports up-to-date when no newer tag exists" { - run "${SCRIPT}" updates "${FIXTURES_DIR}/workflows/updates-at-latest.yml" - assert_success - assert_output --partial "current=v7.0.0" - assert_output --partial "[up-to-date]" - assert_output --partial "(tag)" -} - -@test "updates classifies major-only aliases (e.g. @v6) as tag pins" { - run "${SCRIPT}" updates "${FIXTURES_DIR}/workflows/updates-major-alias.yml" - assert_success - assert_output --partial "current=v6" - # v6 normalizes to [6,0,0]; all three-part tags strictly greater - # (v6.0.1, v6.0.2, v7.0.0) should be listed. - assert_output --partial "newer=v6.0.1 v6.0.2 v7.0.0" - assert_output --partial "(tag)" -} - -@test "updates reports up-to-date when branch pin matches HEAD" { - run "${SCRIPT}" updates "${FIXTURES_DIR}/workflows/branch-ok.yml" - assert_success - assert_output --partial "current=main" - assert_output --partial "[up-to-date]" - assert_output --partial "(branch)" -} - -@test "updates reports short HEAD for a stale branch pin" { - run "${SCRIPT}" updates "${FIXTURES_DIR}/workflows/branch-behind.yml" - assert_success - assert_output --partial "current=main" - assert_output --partial "head=bbbbbbbbbbbb" - assert_output --partial "(branch)" - refute_output --partial "[up-to-date]" -} - -@test "updates tsv emits five tab-separated columns per row" { - run "${SCRIPT}" updates --format=tsv "${FIXTURES_DIR}/workflows/updates-mixed.yml" - assert_success - local line_count - line_count="$(grep -cE '[^[:space:]]' <<< "${output}" || true)" - assert_equal "${line_count}" "3" - local line - while IFS= read -r line; do - [[ -z "${line}" ]] && continue - local tab_count="${line//[^$'\t']/}" - assert_equal "${#tab_count}" "4" - done <<< "${output}" -} - -@test "updates tsv puts the space-separated newer list in column 4" { - run "${SCRIPT}" updates --format=tsv "${FIXTURES_DIR}/workflows/updates-new-major.yml" - assert_success - local col4 - col4="$(awk -F'\t' 'NR==1 {print $4}' <<< "${output}")" - assert_equal "${col4}" "v6.0.1 v6.0.2 v7.0.0" -} - -@test "updates exits 0 even when updates are available" { - run "${SCRIPT}" updates "${FIXTURES_DIR}/workflows/updates-new-major.yml" - assert_success -} - -# ── list_newer_tags / head_sha (sourced) ──────────────────────────── - -@test "list_newer_tags returns all tags strictly newer, sorted ascending" { - # shellcheck disable=SC1090 - source "${SCRIPT}" - run list_newer_tags "foo/bar" "v6.0.0" - assert_success - assert_output "v6.0.1 -v6.0.2 -v7.0.0" -} - -@test "list_newer_tags accepts a major-only ref (v6 normalizes to [6,0,0])" { - # shellcheck disable=SC1090 - source "${SCRIPT}" - run list_newer_tags "foo/bar" "v6" - assert_success - assert_output "v6.0.1 -v6.0.2 -v7.0.0" -} - -@test "list_newer_tags returns empty when pin is at the newest tag" { - # shellcheck disable=SC1090 - source "${SCRIPT}" - run list_newer_tags "foo/bar" "v7.0.0" - assert_success - assert_output "" -} - -@test "list_newer_tags skips pre-release and non-semver tags" { - # shellcheck disable=SC1090 - source "${SCRIPT}" - run list_newer_tags "foo/bar" "v0.9.0" - assert_success - refute_output --partial "-beta" - refute_output --partial "nightly" - refute_output --partial "main-" -} - -@test "head_sha returns the branch HEAD SHA" { - # shellcheck disable=SC1090 - source "${SCRIPT}" - run head_sha "foo/br-behind" "main" - assert_success - assert_output "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" -} diff --git a/tests/bats/images/ci-tools/validate-action-pins/check.bats b/tests/bats/images/ci-tools/validate-action-pins/check.bats new file mode 100644 index 0000000..f154045 --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/check.bats @@ -0,0 +1,153 @@ +#!/usr/bin/env bats +# shellcheck shell=bash +# +# `check` subcommand — end-to-end behavior: pin resolution (tag, +# annotated, branch), dedup, unresolvable refs, and the --only filter +# applied to check's authoritative classification. +# +# Lower-level helpers are covered in validate-action-pins-helpers.bats. +# Preflight / connectivity / missing-dep behavior is covered in +# validate-action-pins-preflight.bats. + +load ../../../helpers/common + +setup() { + common_setup + SCRIPT="${REPO_ROOT}/images/ci-tools/bin/validate-action-pins" + export SCRIPT +} + +# ── tag-pin resolution ────────────────────────────────────────────── + +@test "tag pin matching resolved SHA prints OK and exits 0" { + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + assert_output --partial "OK tag-ok.yml: foo/bar@aaaaaaaaaaaa..." + assert_output --partial "matches v1" +} + +@test "tag pin mismatching resolved SHA prints FAIL and exits 1" { + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-mismatch.yml" + assert_failure 1 + assert_output --partial "FAIL tag-mismatch.yml: foo/bar@bbbbbbbbbbbb..." + assert_output --partial "does NOT match v1" + assert_output --partial "FAIL: 1 pin(s) did not match" +} + +@test "annotated tag ref is dereferenced to the commit SHA" { + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/annotated.yml" + assert_success + assert_output --partial "OK annotated.yml: foo/annotated@cccccccccccc..." + assert_output --partial "matches v2" +} + +# ── dedup across files and within files ───────────────────────────── + +@test "duplicate pins within one file produce a single OK line" { + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/duplicate-pins.yml" + assert_success + local ok_count + ok_count="$(grep -c '^OK ' <<< "${output}" || true)" + assert_equal "${ok_count}" "1" +} + +@test "same pin across multiple files produces one OK per file (resolve_cache)" { + # seen_in_file resets per file, so the second file would re-enter the + # resolve path — but resolve_cache short-circuits the API call. We verify + # the output shape: exactly one OK per file, and each lists its own basename. + run "${SCRIPT}" \ + "${FIXTURES_DIR}/workflows/tag-ok.yml" \ + "${FIXTURES_DIR}/workflows/tag-ok-2.yml" + assert_success + local ok_count + ok_count="$(grep -c '^OK ' <<< "${output}" || true)" + assert_equal "${ok_count}" "2" + assert_output --partial "OK tag-ok.yml:" + assert_output --partial "OK tag-ok-2.yml:" +} + +@test "check ignores a commented-out uses: pin" { + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/commented-uses.yml" + assert_success + # Only the live line is validated; the commented one isn't. + local ok_count + ok_count="$(grep -c '^OK ' <<< "${output}" || true)" + assert_equal "${ok_count}" "1" + refute_output --partial "bbbbbbbbbbbb" +} + +# ── branch-pin support ────────────────────────────────────────────── + +@test "branch pin matching HEAD prints OK and exits 0" { + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/branch-ok.yml" + assert_success + assert_output --partial "OK branch-ok.yml: foo/br-ok@aaaaaaaaaaaa..." + assert_output --partial "matches main" +} + +@test "branch pin behind HEAD prints WARN with commit count and exits 0" { + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/branch-behind.yml" + assert_success + assert_output --partial "WARN branch-behind.yml: foo/br-behind@cccccccccccc..." + assert_output --partial "is 3 commit(s) behind main HEAD" +} + +@test "branch pin diverged from HEAD prints WARN diverges and exits 0" { + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/branch-diverge.yml" + assert_success + assert_output --partial "WARN branch-diverge.yml: foo/br-diverge@eeeeeeeeeeee..." + assert_output --partial "diverges from main HEAD" +} + +@test "unresolvable ref prints WARN and exits 0" { + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/unresolvable.yml" + assert_success + assert_output --partial "WARN unresolvable.yml: foo/nosuch@ffffffffffff..." + assert_output --partial "could not resolve ref nosuch" +} + +@test "tag mismatch still prints FAIL and exits 1 (regression guard)" { + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-mismatch.yml" + assert_failure 1 + assert_output --partial "FAIL tag-mismatch.yml:" + assert_output --partial "does NOT match v1" +} + +@test "duplicate branch pins on the same ref produce a single WARN" { + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/duplicate-branch-pins.yml" + assert_success + local warn_count + warn_count="$(grep -c '^WARN ' <<< "${output}" || true)" + assert_equal "${warn_count}" "1" +} + +# ── --only filter on check (authoritative, post-API) ──────────────── + +@test "check --only=branch on a tag-pin file emits nothing" { + run "${SCRIPT}" check --only=branch "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + refute_output --partial "OK " + refute_output --partial "FAIL " + refute_output --partial "WARN " +} + +@test "check --only=branch on a branch-pin file emits the WARN" { + run "${SCRIPT}" check --only=branch "${FIXTURES_DIR}/workflows/branch-behind.yml" + assert_success + assert_output --partial "WARN branch-behind.yml:" + assert_output --partial "3 commit(s) behind main HEAD" +} + +@test "check --only=tag on a branch-pin file emits nothing" { + run "${SCRIPT}" check --only=tag "${FIXTURES_DIR}/workflows/branch-ok.yml" + assert_success + refute_output --partial "OK " + refute_output --partial "WARN " +} + +@test "check --only=tag keeps tag mismatches visible (and failing)" { + run "${SCRIPT}" check --only=tag "${FIXTURES_DIR}/workflows/tag-mismatch.yml" + assert_failure 1 + assert_output --partial "FAIL tag-mismatch.yml:" + assert_output --partial "does NOT match v1" +} diff --git a/tests/bats/images/ci-tools/validate-action-pins/cli.bats b/tests/bats/images/ci-tools/validate-action-pins/cli.bats new file mode 100644 index 0000000..33a29bf --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/cli.bats @@ -0,0 +1,103 @@ +#!/usr/bin/env bats +# shellcheck shell=bash +# +# CLI surface: --help, --version, subcommand dispatch, flag validation, +# `--` terminator. These tests don't care what the subcommands do — +# only that the command line is parsed and routed correctly. + +load ../../../helpers/common + +setup() { + common_setup + SCRIPT="${REPO_ROOT}/images/ci-tools/bin/validate-action-pins" + export SCRIPT +} + +# ── --help / --version / no-args / missing file ───────────────────── + +@test "--help prints usage and exits 0" { + run "${SCRIPT}" --help + assert_success + assert_output --partial "Usage: validate-action-pins" +} + +@test "--version prints program and version and exits 0" { + run "${SCRIPT}" --version + assert_success + assert_output --regexp '^validate-action-pins ' +} + +@test "no args prints usage to stderr and exits 1" { + run "${SCRIPT}" + assert_failure 1 + assert_output --partial "Usage: validate-action-pins" +} + +@test "nonexistent file warns and returns 0 with no pins" { + run "${SCRIPT}" "${BATS_TEST_TMPDIR}/does-not-exist.yml" + assert_success + assert_output --partial "WARN: ${BATS_TEST_TMPDIR}/does-not-exist.yml not found, skipping" + assert_output --partial "No SHA-pinned actions found" +} + +# ── subcommand dispatch ───────────────────────────────────────────── + +@test "bare FILE and explicit 'check FILE' produce identical output" { + local bare_out subcmd_out + bare_out="$("${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml")" + subcmd_out="$("${SCRIPT}" check "${FIXTURES_DIR}/workflows/tag-ok.yml")" + assert_equal "${bare_out}" "${subcmd_out}" +} + +@test "'check --version' still prints the version" { + run "${SCRIPT}" check --version + assert_success + assert_output --regexp '^validate-action-pins ' +} + +@test "non-subcommand word is treated as a file path, not rejected" { + # 'foo' is not a known subcommand, so it stays as $1 and is consumed as a + # filename. Missing-file WARN confirms the non-reject path. + run "${SCRIPT}" foo "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + assert_output --partial "WARN: foo not found" + assert_output --partial "OK tag-ok.yml:" +} + +@test "'check' without any files exits 1 with usage" { + run "${SCRIPT}" check + assert_failure 1 + assert_output --partial "Usage: validate-action-pins" +} + +@test "'--' terminator passes subsequent args as files" { + run "${SCRIPT}" check -- "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + assert_output --partial "OK tag-ok.yml:" +} + +# ── flag validation ───────────────────────────────────────────────── + +@test "unknown flag exits 2 with a usage error" { + run "${SCRIPT}" --bogus "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_failure 2 + assert_output --partial "unknown flag: --bogus" +} + +@test "short unknown flag exits 2" { + run "${SCRIPT}" -x "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_failure 2 + assert_output --partial "unknown flag: -x" +} + +@test "--only accepts space-separated form" { + run "${SCRIPT}" check --only branch "${FIXTURES_DIR}/workflows/branch-ok.yml" + assert_success + assert_output --partial "OK branch-ok.yml:" +} + +@test "invalid --only value exits 2" { + run "${SCRIPT}" updates --only=bogus "${FIXTURES_DIR}/workflows/updates-mixed.yml" + assert_failure 2 + assert_output --partial "invalid --only: bogus" +} diff --git a/tests/bats/images/ci-tools/fixtures/README.md b/tests/bats/images/ci-tools/validate-action-pins/fixtures/README.md similarity index 100% rename from tests/bats/images/ci-tools/fixtures/README.md rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/README.md diff --git a/tests/bats/images/ci-tools/fixtures/api/rate_limit b/tests/bats/images/ci-tools/validate-action-pins/fixtures/api/rate_limit similarity index 100% rename from tests/bats/images/ci-tools/fixtures/api/rate_limit rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/api/rate_limit diff --git a/tests/bats/images/ci-tools/fixtures/api/repos/foo/annotated/git/ref/tags/v2 b/tests/bats/images/ci-tools/validate-action-pins/fixtures/api/repos/foo/annotated/git/ref/tags/v2 similarity index 100% rename from tests/bats/images/ci-tools/fixtures/api/repos/foo/annotated/git/ref/tags/v2 rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/api/repos/foo/annotated/git/ref/tags/v2 diff --git a/tests/bats/images/ci-tools/fixtures/api/repos/foo/annotated/git/tags/1111111111111111111111111111111111111111 b/tests/bats/images/ci-tools/validate-action-pins/fixtures/api/repos/foo/annotated/git/tags/1111111111111111111111111111111111111111 similarity index 100% rename from tests/bats/images/ci-tools/fixtures/api/repos/foo/annotated/git/tags/1111111111111111111111111111111111111111 rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/api/repos/foo/annotated/git/tags/1111111111111111111111111111111111111111 diff --git a/tests/bats/images/ci-tools/fixtures/api/repos/foo/bar/git/ref/tags/v1 b/tests/bats/images/ci-tools/validate-action-pins/fixtures/api/repos/foo/bar/git/ref/tags/v1 similarity index 100% rename from tests/bats/images/ci-tools/fixtures/api/repos/foo/bar/git/ref/tags/v1 rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/api/repos/foo/bar/git/ref/tags/v1 diff --git a/tests/bats/images/ci-tools/fixtures/api/repos/foo/bar/tags b/tests/bats/images/ci-tools/validate-action-pins/fixtures/api/repos/foo/bar/tags similarity index 100% rename from tests/bats/images/ci-tools/fixtures/api/repos/foo/bar/tags rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/api/repos/foo/bar/tags diff --git a/tests/bats/images/ci-tools/fixtures/api/repos/foo/br-behind/compare/cccccccccccccccccccccccccccccccccccccccc...bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb b/tests/bats/images/ci-tools/validate-action-pins/fixtures/api/repos/foo/br-behind/compare/cccccccccccccccccccccccccccccccccccccccc...bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb similarity index 100% rename from tests/bats/images/ci-tools/fixtures/api/repos/foo/br-behind/compare/cccccccccccccccccccccccccccccccccccccccc...bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/api/repos/foo/br-behind/compare/cccccccccccccccccccccccccccccccccccccccc...bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb diff --git a/tests/bats/images/ci-tools/fixtures/api/repos/foo/br-behind/git/ref/heads/main b/tests/bats/images/ci-tools/validate-action-pins/fixtures/api/repos/foo/br-behind/git/ref/heads/main similarity index 100% rename from tests/bats/images/ci-tools/fixtures/api/repos/foo/br-behind/git/ref/heads/main rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/api/repos/foo/br-behind/git/ref/heads/main diff --git a/tests/bats/images/ci-tools/fixtures/api/repos/foo/br-diverge/compare/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee...dddddddddddddddddddddddddddddddddddddddd b/tests/bats/images/ci-tools/validate-action-pins/fixtures/api/repos/foo/br-diverge/compare/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee...dddddddddddddddddddddddddddddddddddddddd similarity index 100% rename from tests/bats/images/ci-tools/fixtures/api/repos/foo/br-diverge/compare/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee...dddddddddddddddddddddddddddddddddddddddd rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/api/repos/foo/br-diverge/compare/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee...dddddddddddddddddddddddddddddddddddddddd diff --git a/tests/bats/images/ci-tools/fixtures/api/repos/foo/br-diverge/git/ref/heads/main b/tests/bats/images/ci-tools/validate-action-pins/fixtures/api/repos/foo/br-diverge/git/ref/heads/main similarity index 100% rename from tests/bats/images/ci-tools/fixtures/api/repos/foo/br-diverge/git/ref/heads/main rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/api/repos/foo/br-diverge/git/ref/heads/main diff --git a/tests/bats/images/ci-tools/fixtures/api/repos/foo/br-ok/git/ref/heads/main b/tests/bats/images/ci-tools/validate-action-pins/fixtures/api/repos/foo/br-ok/git/ref/heads/main similarity index 100% rename from tests/bats/images/ci-tools/fixtures/api/repos/foo/br-ok/git/ref/heads/main rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/api/repos/foo/br-ok/git/ref/heads/main diff --git a/tests/bats/images/ci-tools/fixtures/workflows/annotated.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/annotated.yml similarity index 100% rename from tests/bats/images/ci-tools/fixtures/workflows/annotated.yml rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/annotated.yml diff --git a/tests/bats/images/ci-tools/fixtures/workflows/branch-behind.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/branch-behind.yml similarity index 100% rename from tests/bats/images/ci-tools/fixtures/workflows/branch-behind.yml rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/branch-behind.yml diff --git a/tests/bats/images/ci-tools/fixtures/workflows/branch-diverge.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/branch-diverge.yml similarity index 100% rename from tests/bats/images/ci-tools/fixtures/workflows/branch-diverge.yml rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/branch-diverge.yml diff --git a/tests/bats/images/ci-tools/fixtures/workflows/branch-ok.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/branch-ok.yml similarity index 100% rename from tests/bats/images/ci-tools/fixtures/workflows/branch-ok.yml rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/branch-ok.yml diff --git a/tests/bats/images/ci-tools/fixtures/workflows/commented-uses.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/commented-uses.yml similarity index 100% rename from tests/bats/images/ci-tools/fixtures/workflows/commented-uses.yml rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/commented-uses.yml diff --git a/tests/bats/images/ci-tools/fixtures/workflows/duplicate-branch-pins.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/duplicate-branch-pins.yml similarity index 100% rename from tests/bats/images/ci-tools/fixtures/workflows/duplicate-branch-pins.yml rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/duplicate-branch-pins.yml diff --git a/tests/bats/images/ci-tools/fixtures/workflows/duplicate-pins.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/duplicate-pins.yml similarity index 100% rename from tests/bats/images/ci-tools/fixtures/workflows/duplicate-pins.yml rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/duplicate-pins.yml diff --git a/tests/bats/images/ci-tools/fixtures/workflows/mixed.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/mixed.yml similarity index 100% rename from tests/bats/images/ci-tools/fixtures/workflows/mixed.yml rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/mixed.yml diff --git a/tests/bats/images/ci-tools/fixtures/workflows/tag-mismatch.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/tag-mismatch.yml similarity index 100% rename from tests/bats/images/ci-tools/fixtures/workflows/tag-mismatch.yml rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/tag-mismatch.yml diff --git a/tests/bats/images/ci-tools/fixtures/workflows/tag-ok-2.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/tag-ok-2.yml similarity index 100% rename from tests/bats/images/ci-tools/fixtures/workflows/tag-ok-2.yml rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/tag-ok-2.yml diff --git a/tests/bats/images/ci-tools/fixtures/workflows/tag-ok.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/tag-ok.yml similarity index 100% rename from tests/bats/images/ci-tools/fixtures/workflows/tag-ok.yml rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/tag-ok.yml diff --git a/tests/bats/images/ci-tools/fixtures/workflows/unresolvable.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/unresolvable.yml similarity index 100% rename from tests/bats/images/ci-tools/fixtures/workflows/unresolvable.yml rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/unresolvable.yml diff --git a/tests/bats/images/ci-tools/fixtures/workflows/updates-at-latest.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/updates-at-latest.yml similarity index 100% rename from tests/bats/images/ci-tools/fixtures/workflows/updates-at-latest.yml rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/updates-at-latest.yml diff --git a/tests/bats/images/ci-tools/fixtures/workflows/updates-major-alias.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/updates-major-alias.yml similarity index 100% rename from tests/bats/images/ci-tools/fixtures/workflows/updates-major-alias.yml rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/updates-major-alias.yml diff --git a/tests/bats/images/ci-tools/fixtures/workflows/updates-mixed.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/updates-mixed.yml similarity index 100% rename from tests/bats/images/ci-tools/fixtures/workflows/updates-mixed.yml rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/updates-mixed.yml diff --git a/tests/bats/images/ci-tools/fixtures/workflows/updates-new-major.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/updates-new-major.yml similarity index 100% rename from tests/bats/images/ci-tools/fixtures/workflows/updates-new-major.yml rename to tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/updates-new-major.yml diff --git a/tests/bats/images/ci-tools/validate-action-pins/helpers.bats b/tests/bats/images/ci-tools/validate-action-pins/helpers.bats new file mode 100644 index 0000000..c80ae33 --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/helpers.bats @@ -0,0 +1,160 @@ +#!/usr/bin/env bats +# shellcheck shell=bash +# +# Low-level helpers — sourced unit tests that call the internal +# functions directly. No subcommand dispatch, no CLI parsing, no +# subprocess. Each test `source`s the script and invokes one helper +# to verify behavior in isolation. + +load ../../../helpers/common + +setup() { + common_setup + SCRIPT="${REPO_ROOT}/images/ci-tools/bin/validate-action-pins" + export SCRIPT +} + +# ── parse_uses_line ───────────────────────────────────────────────── + +@test "parse_uses_line returns sha kind for 40-hex refs" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run parse_uses_line " - uses: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1" + assert_success + assert_output $'foo/bar\taaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\tsha\tv1' +} + +@test "parse_uses_line returns ref kind for non-sha refs" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run parse_uses_line " - uses: org/repo@main" + assert_success + assert_output $'org/repo\tmain\tref\t' +} + +@test "parse_uses_line rejects docker:// and local paths" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run parse_uses_line " - uses: docker://alpine:3.19" + assert_failure + run parse_uses_line " - uses: ./local-action" + assert_failure +} + +@test "parse_uses_line skips YAML comment lines" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run parse_uses_line "# - uses: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1" + assert_failure + run parse_uses_line " # - uses: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1" + assert_failure + # A real use line with an inline trailing `# comment` is still parsed. + run parse_uses_line " - uses: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1" + assert_success +} + +# ── resolve_ref ───────────────────────────────────────────────────── + +@test "resolve_ref returns \\ttag for a lightweight tag" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run resolve_ref "foo/bar" "v1" + assert_success + assert_output $'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\ttag' +} + +@test "resolve_ref returns \\ttag for an annotated tag" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run resolve_ref "foo/annotated" "v2" + assert_success + assert_output $'cccccccccccccccccccccccccccccccccccccccc\ttag' +} + +@test "resolve_ref returns \\tbranch for a branch ref" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run resolve_ref "foo/br-ok" "main" + assert_success + assert_output $'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\tbranch' +} + +@test "resolve_ref returns nonzero when ref is neither tag nor branch" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run resolve_ref "foo/bar" "v999" + assert_failure + assert_output "" +} + +# ── compare_behind ────────────────────────────────────────────────── + +@test "compare_behind returns the .behind_by integer" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run compare_behind "foo/br-behind" \ + "cccccccccccccccccccccccccccccccccccccccc" \ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + assert_success + assert_output "3" +} + +@test "compare_behind returns 0 for diverged histories" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run compare_behind "foo/br-diverge" \ + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" \ + "dddddddddddddddddddddddddddddddddddddddd" + assert_success + assert_output "0" +} + +# ── list_newer_tags ───────────────────────────────────────────────── + +@test "list_newer_tags returns all tags strictly newer, sorted ascending" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run list_newer_tags "foo/bar" "v6.0.0" + assert_success + assert_output "v6.0.1 +v6.0.2 +v7.0.0" +} + +@test "list_newer_tags accepts a major-only ref (v6 normalizes to [6,0,0])" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run list_newer_tags "foo/bar" "v6" + assert_success + assert_output "v6.0.1 +v6.0.2 +v7.0.0" +} + +@test "list_newer_tags returns empty when pin is at the newest tag" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run list_newer_tags "foo/bar" "v7.0.0" + assert_success + assert_output "" +} + +@test "list_newer_tags skips pre-release and non-semver tags" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run list_newer_tags "foo/bar" "v0.9.0" + assert_success + refute_output --partial "-beta" + refute_output --partial "nightly" + refute_output --partial "main-" +} + +# ── head_sha ──────────────────────────────────────────────────────── + +@test "head_sha returns the branch HEAD SHA" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run head_sha "foo/br-behind" "main" + assert_success + assert_output "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +} diff --git a/tests/bats/images/ci-tools/validate-action-pins/list.bats b/tests/bats/images/ci-tools/validate-action-pins/list.bats new file mode 100644 index 0000000..3255f80 --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/list.bats @@ -0,0 +1,137 @@ +#!/usr/bin/env bats +# shellcheck shell=bash +# +# `list` subcommand — end-to-end behavior: plain + tsv output, docker/ +# local skip rules, --format validation, offline-by-default guarantees, +# and the API-backed --only filter path. +# +# Lower-level helpers are covered in validate-action-pins-helpers.bats. + +load ../../../helpers/common + +setup() { + common_setup + SCRIPT="${REPO_ROOT}/images/ci-tools/bin/validate-action-pins" + export SCRIPT +} + +# ── plain + tsv output, formatting ────────────────────────────────── + +@test "list plain emits every parseable pin occurrence" { + run "${SCRIPT}" list "${FIXTURES_DIR}/workflows/mixed.yml" + assert_success + assert_output --partial "mixed.yml: foo/bar@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa (# v1)" + assert_output --partial "mixed.yml: actions/checkout@v6.0.2" + assert_output --partial "mixed.yml: org/repo@main" + assert_output --partial "mixed.yml: org/no-comment@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +} + +@test "list skips docker:// and local path actions" { + run "${SCRIPT}" list "${FIXTURES_DIR}/workflows/mixed.yml" + assert_success + refute_output --partial "docker://" + refute_output --partial "./local-action" +} + +@test "list tsv emits five tab-separated columns per row" { + run "${SCRIPT}" list --format=tsv "${FIXTURES_DIR}/workflows/mixed.yml" + assert_success + # Every non-empty line must have exactly 4 tabs (5 fields). + local line + while IFS= read -r line; do + [[ -z "${line}" ]] && continue + local tab_count="${line//[^$'\t']/}" + assert_equal "${#tab_count}" "4" + done <<< "${output}" + # And we should see at least one sha and one ref kind. + assert_output --partial $'\tsha\t' + assert_output --partial $'\tref\t' +} + +@test "list accepts --format tsv as two tokens" { + run "${SCRIPT}" list --format tsv "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + assert_output --regexp $'\tfoo/bar\t' +} + +@test "list rejects an invalid --format" { + run "${SCRIPT}" list --format=yaml "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_failure 2 + assert_output --partial "invalid --format: yaml" +} + +# ── offline-by-default: no API, no jq, no curl needed ─────────────── + +@test "list works without curl in PATH (no API calls)" { + local stubdir="${BATS_TEST_TMPDIR}/nocurl-list" + local bash_bin + bash_bin="$(command -v bash)" + mkdir -p "${stubdir}" + ln -s "${bash_bin}" "${stubdir}/bash" + PATH="${stubdir}" run "${SCRIPT}" list "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + assert_output --partial "tag-ok.yml: foo/bar@" +} + +@test "list works without jq in PATH" { + local stubdir="${BATS_TEST_TMPDIR}/nojq-list" + local bash_bin + bash_bin="$(command -v bash)" + mkdir -p "${stubdir}" + ln -s "${bash_bin}" "${stubdir}/bash" + PATH="${stubdir}" run "${SCRIPT}" list "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + assert_output --partial "tag-ok.yml: foo/bar@" +} + +@test "list does not run the connectivity probe" { + # Point API base at a missing dir; clear SKIP — check would WARN here. + mkdir -p "${BATS_TEST_TMPDIR}/empty" + VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY='' \ + GITHUB_API_BASE="file://${BATS_TEST_TMPDIR}/empty" \ + run "${SCRIPT}" list "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + refute_output --partial "cannot reach GitHub API" + assert_output --partial "tag-ok.yml: foo/bar@" +} + +# ── --only filter: online mode (authoritative classification) ─────── + +@test "list --only=tag filters to tag pins via authoritative API" { + run "${SCRIPT}" list --only=tag \ + "${FIXTURES_DIR}/workflows/tag-ok.yml" \ + "${FIXTURES_DIR}/workflows/branch-ok.yml" + assert_success + assert_output --partial "tag-ok.yml: foo/bar@" + refute_output --partial "branch-ok.yml" +} + +@test "list --only=branch filters to branch pins via authoritative API" { + run "${SCRIPT}" list --only=branch \ + "${FIXTURES_DIR}/workflows/tag-ok.yml" \ + "${FIXTURES_DIR}/workflows/branch-ok.yml" + assert_success + assert_output --partial "branch-ok.yml: foo/br-ok@" + refute_output --partial "tag-ok.yml" +} + +@test "list --only=all stays offline (no API, no preflight)" { + # Clear SKIP and point API base at a missing dir — preflight would + # fail. With --only=all (the default), list must not probe at all. + mkdir -p "${BATS_TEST_TMPDIR}/empty" + VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY='' \ + GITHUB_API_BASE="file://${BATS_TEST_TMPDIR}/empty" \ + run "${SCRIPT}" list --only=all "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + refute_output --partial "cannot reach GitHub API" + assert_output --partial "tag-ok.yml: foo/bar@" +} + +@test "list --only=branch warns and exits 0 when the API is unreachable" { + mkdir -p "${BATS_TEST_TMPDIR}/empty" + VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY='' \ + GITHUB_API_BASE="file://${BATS_TEST_TMPDIR}/empty" \ + run "${SCRIPT}" list --only=branch "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + assert_output --partial "WARN: cannot reach GitHub API" +} diff --git a/tests/bats/images/ci-tools/validate-action-pins/preflight.bats b/tests/bats/images/ci-tools/validate-action-pins/preflight.bats new file mode 100644 index 0000000..99483a6 --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/preflight.bats @@ -0,0 +1,91 @@ +#!/usr/bin/env bats +# shellcheck shell=bash +# +# Preflight — the graceful-skip path shared by check, updates, and +# list --only=X. Exercises missing dependencies, connectivity loss, +# rate-limit warnings, and the verbose-stderr escape hatch. +# +# These tests drive the preflight through the check subcommand (the +# default dispatch) since the logic is the same regardless of which +# API-dependent subcommand triggers it. + +load ../../../helpers/common + +setup() { + common_setup + SCRIPT="${REPO_ROOT}/images/ci-tools/bin/validate-action-pins" + export SCRIPT +} + +# ── connectivity / authentication ─────────────────────────────────── + +@test "connectivity probe failure warns and exits 0" { + mkdir -p "${BATS_TEST_TMPDIR}/empty" + VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY='' \ + GITHUB_API_BASE="file://${BATS_TEST_TMPDIR}/empty" \ + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + assert_output --partial "WARN: cannot reach GitHub API" +} + +# ── rate-limit awareness ──────────────────────────────────────────── + +@test "preflight warns when rate-limit remaining is tight" { + # Custom fixture: /rate_limit with remaining=5 (below the 20 threshold). + mkdir -p "${BATS_TEST_TMPDIR}/api-low" + cat > "${BATS_TEST_TMPDIR}/api-low/rate_limit" << 'JSON' +{ "resources": { "core": { "limit": 60, "remaining": 5, "reset": 0 } } } +JSON + VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY='' \ + GITHUB_API_BASE="file://${BATS_TEST_TMPDIR}/api-low" \ + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml" + # Still exits 0 — the warning is informational. + assert_success + assert_output --partial "rate limit is low (5 remaining)" + assert_output --partial "set GITHUB_TOKEN" +} + +# ── verbose escape hatch ──────────────────────────────────────────── + +@test "VALIDATE_ACTION_PINS_VERBOSE surfaces curl stderr" { + # Point at a missing fixture dir so curl fails; verbose mode lets + # the "file not found" or similar message through. + mkdir -p "${BATS_TEST_TMPDIR}/empty" + VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY='' \ + VALIDATE_ACTION_PINS_VERBOSE=1 \ + GITHUB_API_BASE="file://${BATS_TEST_TMPDIR}/empty" \ + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + # The WARN summary is still there. + assert_output --partial "cannot reach GitHub API" + # And curl's own error shows through (exact wording varies across + # curl versions; match the minimum common substring). + assert_output --partial "curl" +} + +# ── missing dependencies ──────────────────────────────────────────── + +@test "missing curl warns and exits 0" { + # PATH contains only bash so the shebang can resolve; curl check fails first. + local stubdir="${BATS_TEST_TMPDIR}/nocurl" + local bash_bin + bash_bin="$(command -v bash)" + mkdir -p "${stubdir}" + ln -s "${bash_bin}" "${stubdir}/bash" + PATH="${stubdir}" run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + assert_output --partial "WARN: curl not found" +} + +@test "missing jq warns and exits 0" { + local stubdir="${BATS_TEST_TMPDIR}/nojq" + local bash_bin curl_bin + bash_bin="$(command -v bash)" + curl_bin="$(command -v curl)" + mkdir -p "${stubdir}" + ln -s "${bash_bin}" "${stubdir}/bash" + ln -s "${curl_bin}" "${stubdir}/curl" + PATH="${stubdir}" run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + assert_output --partial "WARN: jq not found" +} diff --git a/tests/bats/images/ci-tools/validate-action-pins/updates.bats b/tests/bats/images/ci-tools/validate-action-pins/updates.bats new file mode 100644 index 0000000..19f786a --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/updates.bats @@ -0,0 +1,116 @@ +#!/usr/bin/env bats +# shellcheck shell=bash +# +# `updates` subcommand — end-to-end behavior: newer-tag inventory, +# branch-HEAD drift, up-to-date shortcut, TSV column contract, and +# the --only filter applied to updates' routing-kind classification. +# +# Lower-level helpers are covered in validate-action-pins-helpers.bats. + +load ../../../helpers/common + +setup() { + common_setup + SCRIPT="${REPO_ROOT}/images/ci-tools/bin/validate-action-pins" + export SCRIPT +} + +# ── tag pins: newer-tag inventory + up-to-date ────────────────────── + +@test "updates lists every newer tag across minors and majors" { + run "${SCRIPT}" updates "${FIXTURES_DIR}/workflows/updates-new-major.yml" + assert_success + assert_output --partial "foo/bar" + assert_output --partial "current=v6.0.0" + assert_output --partial "newer=v6.0.1 v6.0.2 v7.0.0" + assert_output --partial "(tag)" + refute_output --partial "[up-to-date]" +} + +@test "updates reports up-to-date when no newer tag exists" { + run "${SCRIPT}" updates "${FIXTURES_DIR}/workflows/updates-at-latest.yml" + assert_success + assert_output --partial "current=v7.0.0" + assert_output --partial "[up-to-date]" + assert_output --partial "(tag)" +} + +@test "updates classifies major-only aliases (e.g. @v6) as tag pins" { + run "${SCRIPT}" updates "${FIXTURES_DIR}/workflows/updates-major-alias.yml" + assert_success + assert_output --partial "current=v6" + # v6 normalises to [6,0,0]; newer three-part tags (v6.0.1, v6.0.2, + # v7.0.0) are strictly greater and should be listed. + assert_output --partial "newer=v6.0.1 v6.0.2 v7.0.0" + assert_output --partial "(tag)" +} + +# ── branch pins: HEAD drift + up-to-date ──────────────────────────── + +@test "updates reports up-to-date when branch pin matches HEAD" { + run "${SCRIPT}" updates "${FIXTURES_DIR}/workflows/branch-ok.yml" + assert_success + assert_output --partial "current=main" + assert_output --partial "[up-to-date]" + assert_output --partial "(branch)" +} + +@test "updates reports short HEAD for a stale branch pin" { + run "${SCRIPT}" updates "${FIXTURES_DIR}/workflows/branch-behind.yml" + assert_success + assert_output --partial "current=main" + assert_output --partial "head=bbbbbbbbbbbb" + assert_output --partial "(branch)" + refute_output --partial "[up-to-date]" +} + +# ── TSV contract + exit code ──────────────────────────────────────── + +@test "updates tsv emits five tab-separated columns per row" { + run "${SCRIPT}" updates --format=tsv "${FIXTURES_DIR}/workflows/updates-mixed.yml" + assert_success + local line_count + line_count="$(grep -cE '[^[:space:]]' <<< "${output}" || true)" + assert_equal "${line_count}" "3" + local line + while IFS= read -r line; do + [[ -z "${line}" ]] && continue + local tab_count="${line//[^$'\t']/}" + assert_equal "${#tab_count}" "4" + done <<< "${output}" +} + +@test "updates tsv puts the space-separated newer list in column 4" { + run "${SCRIPT}" updates --format=tsv "${FIXTURES_DIR}/workflows/updates-new-major.yml" + assert_success + local col4 + col4="$(awk -F'\t' 'NR==1 {print $4}' <<< "${output}")" + assert_equal "${col4}" "v6.0.1 v6.0.2 v7.0.0" +} + +@test "updates exits 0 even when updates are available" { + run "${SCRIPT}" updates "${FIXTURES_DIR}/workflows/updates-new-major.yml" + assert_success +} + +# ── --only filter on updates (pattern-based classification) ───────── + +@test "updates --only=branch on a mixed file drops tag records" { + run "${SCRIPT}" updates --only=branch "${FIXTURES_DIR}/workflows/updates-mixed.yml" + assert_success + refute_output --partial "foo/bar" + assert_output --partial "foo/br-ok" + assert_output --partial "foo/br-behind" + assert_output --partial "(branch)" + refute_output --partial "(tag)" +} + +@test "updates --only=tag on a mixed file drops branch records" { + run "${SCRIPT}" updates --only=tag "${FIXTURES_DIR}/workflows/updates-mixed.yml" + assert_success + assert_output --partial "foo/bar" + refute_output --partial "foo/br-ok" + refute_output --partial "foo/br-behind" + assert_output --partial "(tag)" + refute_output --partial "(branch)" +} From 19c79690b6da8b08e44fa2cdd7f327fc5c60a37b Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 17:09:50 -0600 Subject: [PATCH 24/33] Prefix subcommand bats files with `subcommand-` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three subcommand integration files (check, list, updates) now share a `subcommand-` prefix, making the tier visually obvious when listing the directory: cli.bats helpers.bats preflight.bats subcommand-check.bats subcommand-list.bats subcommand-updates.bats Cross-cutting files (cli, preflight, helpers) keep their bare names since they don't share a tier. File moves only — no test body or expectation change. 71/71 pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../validate-action-pins/{check.bats => subcommand-check.bats} | 0 .../validate-action-pins/{list.bats => subcommand-list.bats} | 0 .../{updates.bats => subcommand-updates.bats} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename tests/bats/images/ci-tools/validate-action-pins/{check.bats => subcommand-check.bats} (100%) rename tests/bats/images/ci-tools/validate-action-pins/{list.bats => subcommand-list.bats} (100%) rename tests/bats/images/ci-tools/validate-action-pins/{updates.bats => subcommand-updates.bats} (100%) diff --git a/tests/bats/images/ci-tools/validate-action-pins/check.bats b/tests/bats/images/ci-tools/validate-action-pins/subcommand-check.bats similarity index 100% rename from tests/bats/images/ci-tools/validate-action-pins/check.bats rename to tests/bats/images/ci-tools/validate-action-pins/subcommand-check.bats diff --git a/tests/bats/images/ci-tools/validate-action-pins/list.bats b/tests/bats/images/ci-tools/validate-action-pins/subcommand-list.bats similarity index 100% rename from tests/bats/images/ci-tools/validate-action-pins/list.bats rename to tests/bats/images/ci-tools/validate-action-pins/subcommand-list.bats diff --git a/tests/bats/images/ci-tools/validate-action-pins/updates.bats b/tests/bats/images/ci-tools/validate-action-pins/subcommand-updates.bats similarity index 100% rename from tests/bats/images/ci-tools/validate-action-pins/updates.bats rename to tests/bats/images/ci-tools/validate-action-pins/subcommand-updates.bats From f7dff975b68060affc512853bd7229660f24282e Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 17:22:03 -0600 Subject: [PATCH 25/33] Wire test-bats into CI and escalate preflight on a zero rate-limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related changes so that the new bats suite runs on every PR, and the preflight doesn't degrade silently when GitHub rate-limits. CI integration: - New `bats` job in .github/workflows/ci.yml, parallel to `lint`, running in the same ghcr.io/knight-owl-dev/ci-tools:latest container. Calls `make test-bats` for convention, with the BATS_RUNNER override pointed at the container-native `bats` so we skip a docker-in-docker round trip. Makefile: - test-bats gains a BATS_RUNNER ?= variable. Default is the docker-wrapped invocation (for macOS hosts without bats). CI overrides to run bats directly. Same target, one knob. Preflight tightening: - When GitHub reports `remaining=0`, preflight now returns 1 with a single "rate limit exhausted — skipping" WARN instead of letting the main loop run and emit one "could not resolve" per pin. The old low-but-nonzero path (< 20) still just warns and proceeds. - New bats case in preflight.bats pins the behavior: remaining=0 fixture → skip cleanly, no per-pin output. 72/72 green, shellcheck/shfmt/mandoc clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 14 +++++++++++++ Makefile | 11 ++++++---- images/ci-tools/bin/validate-action-pins | 17 ++++++++++++---- .../validate-action-pins/preflight.bats | 20 +++++++++++++++++++ 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb69e38..12276e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,20 @@ jobs: - name: Lint run: make lint + bats: + name: BATS + runs-on: ubuntu-latest + container: ghcr.io/knight-owl-dev/ci-tools:latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # The default BATS_RUNNER docker-runs the ci-tools image for local + # macOS users who may not have bats installed. In CI we're already + # inside that image, so override to run bats directly. + - name: Run BATS tests + run: make test-bats BATS_RUNNER=bats + # Builds run on ubuntu-latest (x86_64) for both architectures. The tools # are bash scripts, so nfpm produces valid debs regardless of host arch. # Architecture-specific testing happens in test-deb using native runners. diff --git a/Makefile b/Makefile index c337ea0..4fd2f1f 100644 --- a/Makefile +++ b/Makefile @@ -117,11 +117,14 @@ man: test-package: @./tests/deb/test-all.sh -# Run BATS tests inside the ci-tools image. +# Run BATS tests. BATS_RUNNER defaults to running inside the ci-tools +# container via `docker run`, so `make test-bats` works from a stock +# macOS host without needing bats installed. CI (already inside the +# container) overrides with `BATS_RUNNER=bats` to avoid +# docker-in-docker. +BATS_RUNNER ?= docker run --rm $(DOCKER_TTY) -v $(CURDIR):/work -w /work $(IMAGE_TAG) bats test-bats: - @docker run --rm $(DOCKER_TTY) \ - -v $(CURDIR):/work -w /work \ - $(IMAGE_TAG) bats -r tests/bats/ + @$(BATS_RUNNER) -r tests/bats/ # Remove local image clean: diff --git a/images/ci-tools/bin/validate-action-pins b/images/ci-tools/bin/validate-action-pins index e2b89fc..3669988 100755 --- a/images/ci-tools/bin/validate-action-pins +++ b/images/ci-tools/bin/validate-action-pins @@ -422,13 +422,22 @@ check_api_preflight() { esac fi - # Surface a tight rate-limit budget so operators know to authenticate - # before a long sweep. Non-fatal — we still proceed. + # Rate-limit awareness. Zero budget is a full skip — every subsequent + # API call would 403 silently and surface as "could not resolve", + # which misleads operators about the real cause. Low-but-nonzero is + # a WARN only, so a short sweep can still finish. local remaining remaining="$(jq -r '.resources.core.remaining // empty' < "${body_file}" 2> "${_CURL_ERRFD}" || true)" rm -f "${body_file}" - if [[ -n "${remaining}" && "${remaining}" -lt 20 ]]; then - echo "WARN: GitHub API rate limit is low (${remaining} remaining); set GITHUB_TOKEN if ${op} stalls mid-run" + if [[ -n "${remaining}" ]]; then + if [[ "${remaining}" -le 0 ]]; then + echo "WARN: GitHub API rate limit exhausted — skipping ${op}" + echo " set GITHUB_TOKEN to raise the limit from 60 to 5,000 per hour" + return 1 + fi + if [[ "${remaining}" -lt 20 ]]; then + echo "WARN: GitHub API rate limit is low (${remaining} remaining); set GITHUB_TOKEN if ${op} stalls mid-run" + fi fi return 0 } diff --git a/tests/bats/images/ci-tools/validate-action-pins/preflight.bats b/tests/bats/images/ci-tools/validate-action-pins/preflight.bats index 99483a6..fcd7892 100644 --- a/tests/bats/images/ci-tools/validate-action-pins/preflight.bats +++ b/tests/bats/images/ci-tools/validate-action-pins/preflight.bats @@ -45,6 +45,26 @@ JSON assert_output --partial "set GITHUB_TOKEN" } +@test "preflight skips work entirely when rate limit is exhausted" { + # With remaining=0 every subsequent API call would 403 silently and + # surface as "could not resolve" — which misleads operators about the + # real cause. Preflight should stop cleanly with a single clear + # message instead of spamming per-pin WARNs. + mkdir -p "${BATS_TEST_TMPDIR}/api-zero" + cat > "${BATS_TEST_TMPDIR}/api-zero/rate_limit" << 'JSON' +{ "resources": { "core": { "limit": 60, "remaining": 0, "reset": 0 } } } +JSON + VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY='' \ + GITHUB_API_BASE="file://${BATS_TEST_TMPDIR}/api-zero" \ + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + assert_output --partial "rate limit exhausted" + assert_output --partial "skipping pin validation" + # Confirm the main loop didn't run — no per-pin output. + refute_output --partial "could not resolve" + refute_output --partial "OK " +} + # ── verbose escape hatch ──────────────────────────────────────────── @test "VALIDATE_ACTION_PINS_VERBOSE surfaces curl stderr" { From 795e069996ed0cf036ca08d8dc856b412b4ee0fc Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 17:34:58 -0600 Subject: [PATCH 26/33] Harden curl flags: --max-time, shared probe constant, HTTP 429 path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DevOps round-2 findings against gh_api + preflight: 1. Retry policy was duplicated. gh_api used _CURL_FLAGS (-fsSL --retry 3 --retry-delay 2); the preflight built its own inline `curl -sS --retry 3 --retry-delay 2 -o ... -w ...`. Tune one and the other drifts. Split into two named constants with the same retry policy — _CURL_FLAGS (gh_api, with --fail) and _CURL_PROBE_FLAGS (preflight, without --fail so it can inspect error bodies) — and reuse each. 2. No per-request timeout. --retry 3 with --retry-delay 2 meant a single stalled DNS or SYN could eat 15–20s before curl gave up, and that time was multiplied across pins in large workflows. Add --max-time 10 to both flag sets; file:// URLs silently ignore it. 3. HTTP 429 (secondary rate limit) was hidden in the "unexpected code" bucket, emitting a generic WARN instead of naming the cause. Extract the status-code dispatch into _preflight_classify_status — a pure function callable per-code from sourced tests — and give 429 its own branch: "secondary rate limit hit (HTTP 429) — skipping {op}; slow down, set GITHUB_TOKEN, or retry after the Retry-After interval". Matching the tone of the primary-limit messages. 6 new bats cases cover each classifier branch (200/401/403/429/000/ 503) by sourcing the script and calling the helper directly — no fake HTTP server needed. 78/78 green. Co-Authored-By: Claude Opus 4.7 (1M context) --- images/ci-tools/bin/validate-action-pins | 82 +++++++++++++------ .../validate-action-pins/helpers.bats | 53 ++++++++++++ 2 files changed, 109 insertions(+), 26 deletions(-) diff --git a/images/ci-tools/bin/validate-action-pins b/images/ci-tools/bin/validate-action-pins index 3669988..bde0f5a 100755 --- a/images/ci-tools/bin/validate-action-pins +++ b/images/ci-tools/bin/validate-action-pins @@ -54,11 +54,18 @@ if [[ -n "${VALIDATE_ACTION_PINS_VERBOSE:-}" ]]; then fi readonly _CURL_ERRFD -# curl flags used everywhere. Retries cover transient 5xx / DNS +# curl flags used by gh_api. Retries cover transient 5xx / DNS # blips; --fail converts HTTP 4xx/5xx into nonzero exit so callers -# don't mistake an error body for a valid response. `file://` URLs -# silently ignore --retry. -readonly _CURL_FLAGS=(-fsSL --retry 3 --retry-delay 2) +# don't mistake an error body for a valid response; --max-time caps +# any single attempt so a stalled connection can't compound across +# retries. `file://` URLs silently ignore --retry and --max-time. +readonly _CURL_FLAGS=(-fsSL --retry 3 --retry-delay 2 --max-time 10) + +# Curl flags used by the preflight rate_limit probe. Same retry + +# max-time policy as gh_api, but without --fail because the probe +# inspects the HTTP status code itself (401/403/429/etc.) and needs +# a body even on error. +readonly _CURL_PROBE_FLAGS=(-sS --retry 3 --retry-delay 2 --max-time 10) # Accepted "semver-like" tag shape, shared by the bash classifier # and the jq tag filter. Matches: @@ -340,6 +347,44 @@ parse_uses_line() { printf '%s\t%s\t%s\t%s\n' "${action}" "${ref}" "${kind}" "${comment}" } +# Translate a preflight HTTP status code into a (warn, skip?) decision. +# Extracted so each code path is test-covered without needing a fake +# HTTP server — tests source the script and call this directly. +# +# Arguments: +# $1 - HTTP status code, or "000" for transport failure (no response) +# $2 - operation name used in the WARN message +# +# Output: +# WARN line(s) to stdout for every non-200 code +# +# Returns: +# 0 - status is benign; caller may proceed +# 1 - caller should skip its work +_preflight_classify_status() { + case "${1}" in + 200) return 0 ;; + 401 | 403) + echo "WARN: GitHub API authentication failed (HTTP ${1}) — skipping ${2}" + echo " check GITHUB_TOKEN; an unset or expired token produces this response" + return 1 + ;; + 429) + echo "WARN: GitHub API secondary rate limit hit (HTTP 429) — skipping ${2}" + echo " slow down, set GITHUB_TOKEN, or retry after the Retry-After interval" + return 1 + ;; + 000) + echo "WARN: cannot reach GitHub API — skipping ${2}" + return 1 + ;; + *) + echo "WARN: GitHub API returned unexpected HTTP ${1} — skipping ${2}" + return 1 + ;; + esac +} + # Preflight for API-dependent subcommands: dep check, auth init, and a # connectivity probe. Emits WARN and returns 1 when the caller should # gracefully skip its work. @@ -391,35 +436,20 @@ check_api_preflight() { fi else # HTTP(s) — extract status so we can distinguish transport failure - # (code "000") from auth rejection (401/403) from any other - # unexpected response. + # (code "000") from auth rejection (401/403) from secondary rate + # limit (429) from any other unexpected response. local http_code - http_code="$(curl -sS --retry 3 --retry-delay 2 \ + http_code="$(curl "${_CURL_PROBE_FLAGS[@]}" \ -o "${body_file}" -w '%{http_code}' \ "${auth_header[@]+"${auth_header[@]}"}" \ -H "Accept: application/vnd.github+json" \ "${GITHUB_API_BASE}/rate_limit" 2> "${_CURL_ERRFD}" \ || echo "000")" - case "${http_code}" in - 200) ;; - 401 | 403) - echo "WARN: GitHub API authentication failed (HTTP ${http_code}) — skipping ${op}" - echo " check GITHUB_TOKEN; an unset or expired token produces this response" - rm -f "${body_file}" - return 1 - ;; - 000) - echo "WARN: cannot reach GitHub API — skipping ${op}" - rm -f "${body_file}" - return 1 - ;; - *) - echo "WARN: GitHub API returned unexpected HTTP ${http_code} — skipping ${op}" - rm -f "${body_file}" - return 1 - ;; - esac + if ! _preflight_classify_status "${http_code}" "${op}"; then + rm -f "${body_file}" + return 1 + fi fi # Rate-limit awareness. Zero budget is a full skip — every subsequent diff --git a/tests/bats/images/ci-tools/validate-action-pins/helpers.bats b/tests/bats/images/ci-tools/validate-action-pins/helpers.bats index c80ae33..04d6bd1 100644 --- a/tests/bats/images/ci-tools/validate-action-pins/helpers.bats +++ b/tests/bats/images/ci-tools/validate-action-pins/helpers.bats @@ -158,3 +158,56 @@ v7.0.0" assert_success assert_output "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" } + +# ── _preflight_classify_status ────────────────────────────────────── + +@test "_preflight_classify_status returns 0 on HTTP 200 with no output" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run _preflight_classify_status 200 "pin validation" + assert_success + assert_output "" +} + +@test "_preflight_classify_status warns on 401 and asks to check GITHUB_TOKEN" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run _preflight_classify_status 401 "pin validation" + assert_failure 1 + assert_output --partial "authentication failed (HTTP 401)" + assert_output --partial "check GITHUB_TOKEN" +} + +@test "_preflight_classify_status warns on 403 (same auth bucket as 401)" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run _preflight_classify_status 403 "pin validation" + assert_failure 1 + assert_output --partial "authentication failed (HTTP 403)" +} + +@test "_preflight_classify_status warns on 429 secondary rate limit" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run _preflight_classify_status 429 "update check" + assert_failure 1 + assert_output --partial "secondary rate limit hit (HTTP 429)" + assert_output --partial "update check" + assert_output --partial "Retry-After" +} + +@test "_preflight_classify_status warns on 000 transport failure" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run _preflight_classify_status 000 "pin validation" + assert_failure 1 + assert_output --partial "cannot reach GitHub API" +} + +@test "_preflight_classify_status warns on any other unexpected code" { + # shellcheck disable=SC1090 + source "${SCRIPT}" + run _preflight_classify_status 503 "pin validation" + assert_failure 1 + assert_output --partial "unexpected HTTP 503" +} From 38af306d34ae429801fe25dc1314570bc9a19add Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 17:35:47 -0600 Subject: [PATCH 27/33] Use a RETURN trap for preflight tempfile cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preflight creates a tempfile with mktemp then has to rm it before every return. Four exit points today, each needed its own `rm -f "${body_file}"` plus one at function end — five places that must stay in sync if another branch lands. Replace with a single `trap 'rm -f "${body_file:-}"' RETURN` at the top of the function. Bash's RETURN trap fires on any return from the enclosing function, including exit codes from nested calls and the implicit fall-through return. The :- guard handles the pathological case where mktemp fails before the assignment. No behavior change — tempfiles still get cleaned up on every path, and `_preflight_classify_status` can now be called with a simple `|| return 1` pattern instead of an if-then wrapper that had to manually rm before returning. Shorter preflight, one cleanup site, new branches get cleanup for free. 78/78 green. Co-Authored-By: Claude Opus 4.7 (1M context) --- images/ci-tools/bin/validate-action-pins | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/images/ci-tools/bin/validate-action-pins b/images/ci-tools/bin/validate-action-pins index bde0f5a..5c8dadc 100755 --- a/images/ci-tools/bin/validate-action-pins +++ b/images/ci-tools/bin/validate-action-pins @@ -422,6 +422,11 @@ check_api_preflight() { local body_file body_file="$(mktemp)" + # Single cleanup hook covers every return path below, including + # the ones yet to be added. RETURN trap fires when the function + # returns (any reason); the :- guards against body_file being + # unset if mktemp ever fails before assignment. + trap 'rm -f "${body_file:-}"' RETURN if [[ "${GITHUB_API_BASE}" == file://* ]]; then # file:// — test harness path. No HTTP status; curl's exit code @@ -431,7 +436,6 @@ check_api_preflight() { -H "Accept: application/vnd.github+json" \ "${GITHUB_API_BASE}/rate_limit" > "${body_file}" 2> "${_CURL_ERRFD}"; then echo "WARN: cannot reach GitHub API — skipping ${op}" - rm -f "${body_file}" return 1 fi else @@ -446,10 +450,7 @@ check_api_preflight() { "${GITHUB_API_BASE}/rate_limit" 2> "${_CURL_ERRFD}" \ || echo "000")" - if ! _preflight_classify_status "${http_code}" "${op}"; then - rm -f "${body_file}" - return 1 - fi + _preflight_classify_status "${http_code}" "${op}" || return 1 fi # Rate-limit awareness. Zero budget is a full skip — every subsequent @@ -458,7 +459,6 @@ check_api_preflight() { # a WARN only, so a short sweep can still finish. local remaining remaining="$(jq -r '.resources.core.remaining // empty' < "${body_file}" 2> "${_CURL_ERRFD}" || true)" - rm -f "${body_file}" if [[ -n "${remaining}" ]]; then if [[ "${remaining}" -le 0 ]]; then echo "WARN: GitHub API rate limit exhausted — skipping ${op}" From 8d014bc11e22448aa983f82e8b437c1e4566fb4b Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 17:37:22 -0600 Subject: [PATCH 28/33] Split main() into _short_circuit_help_version and _parse_cli_args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main() had grown to ~110 lines: the empty-arg check, the --help/ --version scan, subcommand detection, a 40-line flag loop, two validation switches, an empty-files check, and the dispatch. Each concern is load-bearing but the body read as a single long procedure. Next new flag (--max-time? --format=json?) would push it over a readability cliff. Pull the two largest concerns into named helpers: - _short_circuit_help_version — scans for --help / --version at any arg position, exits 0 after printing if found. Preserves the `check --version` and other-order cases that the existing tests pin. - _parse_cli_args — owns subcommand detection + the flag loop + the two validation switches. Publishes its results through four module-scope globals (cli_subcmd, cli_format, cli_only, cli_files) rather than threading tuples through return values. main() now reads top-to-bottom as: no-args guard → short-circuit → parse → empty-files guard → dispatch. ~18 lines. Module-scope publishing is consistent with the project's other shared state (auth_header, seen_in_file, seen_in_run) — declared once, mutated by its owning helper, read where needed. The cli_ prefix namespaces them so nothing inside cmd_check / cmd_list / cmd_updates can shadow them accidentally. No behavior change — CLI output byte-identical for --help, --version, --bogus, and no-args paths; 78/78 bats cases pass unchanged; full lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- images/ci-tools/bin/validate-action-pins | 79 ++++++++++++++---------- 1 file changed, 48 insertions(+), 31 deletions(-) diff --git a/images/ci-tools/bin/validate-action-pins b/images/ci-tools/bin/validate-action-pins index 5c8dadc..fc0f5ef 100755 --- a/images/ci-tools/bin/validate-action-pins +++ b/images/ci-tools/bin/validate-action-pins @@ -901,14 +901,18 @@ cmd_list() { # ── main ───────────────────────────────────────────────────────────── -main() { - if [[ $# -eq 0 ]]; then - usage >&2 - exit 1 - fi - - # Short-circuit --help/--version at any position, before we try to - # interpret a subcommand or flags. +# Parsed CLI state. Populated by _parse_cli_args during main(); read by +# main's dispatch. Module-scope so callers can read them without threading +# output through multiple return values. +cli_subcmd="check" +cli_format="plain" +cli_only="all" +cli_files=() + +# Short-circuit --help / --version at any arg position. Exits 0 after +# printing usage or the version line; returns normally if neither flag +# appears before a `--` terminator. +_short_circuit_help_version() { local arg for arg in "${@}"; do case "${arg}" in @@ -924,27 +928,30 @@ main() { *) ;; esac done +} + +# Parse remaining args into cli_subcmd + cli_format + cli_only + cli_files. +# Exits 2 on unknown flag or bad flag value. Unknown leading words fall +# through as positional files, preserving bare-FILE back-compat. +_parse_cli_args() { + cli_subcmd="check" + cli_format="plain" + cli_only="all" + cli_files=() - # Detect subcommand. Anything else stays as a positional file arg, - # preserving back-compat for bare-file invocations. - local subcmd="check" if [[ $# -gt 0 ]]; then case "${1}" in check | list | updates) - subcmd="${1}" + cli_subcmd="${1}" shift ;; esac fi - # Collect files and any subcommand-scoped flags. - local format="plain" - local only="all" - local files=() while [[ $# -gt 0 ]]; do case "${1}" in --format=*) - format="${1#*=}" + cli_format="${1#*=}" shift ;; --format) @@ -952,11 +959,11 @@ main() { echo "${PROGRAM}: --format requires a value" >&2 exit 2 fi - format="${2}" + cli_format="${2}" shift 2 ;; --only=*) - only="${1#*=}" + cli_only="${1#*=}" shift ;; --only) @@ -964,12 +971,12 @@ main() { echo "${PROGRAM}: --only requires a value" >&2 exit 2 fi - only="${2}" + cli_only="${2}" shift 2 ;; --) shift - files+=("${@}") + cli_files+=("${@}") break ;; -*) @@ -977,37 +984,47 @@ main() { exit 2 ;; *) - files+=("${1}") + cli_files+=("${1}") shift ;; esac done - case "${format}" in + case "${cli_format}" in plain | tsv) ;; *) - echo "${PROGRAM}: invalid --format: ${format} (expected 'plain' or 'tsv')" >&2 + echo "${PROGRAM}: invalid --format: ${cli_format} (expected 'plain' or 'tsv')" >&2 exit 2 ;; esac - case "${only}" in + case "${cli_only}" in all | tag | branch) ;; *) - echo "${PROGRAM}: invalid --only: ${only} (expected 'all', 'tag', or 'branch')" >&2 + echo "${PROGRAM}: invalid --only: ${cli_only} (expected 'all', 'tag', or 'branch')" >&2 exit 2 ;; esac +} + +main() { + if [[ $# -eq 0 ]]; then + usage >&2 + exit 1 + fi + + _short_circuit_help_version "$@" + _parse_cli_args "$@" - if [[ "${#files[@]}" -eq 0 ]]; then + if [[ "${#cli_files[@]}" -eq 0 ]]; then usage >&2 exit 1 fi - case "${subcmd}" in - check) cmd_check "${only}" "${files[@]}" ;; - list) cmd_list "${format}" "${only}" "${files[@]}" ;; - updates) cmd_updates "${format}" "${only}" "${files[@]}" ;; + case "${cli_subcmd}" in + check) cmd_check "${cli_only}" "${cli_files[@]}" ;; + list) cmd_list "${cli_format}" "${cli_only}" "${cli_files[@]}" ;; + updates) cmd_updates "${cli_format}" "${cli_only}" "${cli_files[@]}" ;; esac } From 7c5b269afcd110601f011ae0cd0e6a3b54f889d3 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 17:39:02 -0600 Subject: [PATCH 29/33] Extract preflight's transport-specific probes into named helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preflight had an `if [[ GITHUB_API_BASE == file://* ]]` branch that did a bare curl (no HTTP semantics) and an `else` branch that did curl-with-status-capture and routed through the classifier. Both branches silently converged on a shared rate-limit check after the `esac`. A reader tracing the flow saw two inline curl blocks and had to mentally match "file:// skips classifier" against "HTTP uses classifier" with no signposts. Extract each branch into a named helper: - _preflight_probe_file — file:// path. Runs curl with the failure- on-error _CURL_FLAGS, writes the body to the caller's tempfile, emits the "cannot reach" WARN on exit nonzero. - _preflight_probe_http — HTTP(s) path. Uses _CURL_PROBE_FLAGS (no --fail) to capture the status code even on errors, then delegates to _preflight_classify_status for the code-by-code WARN routing. Both have the same signature — `helper body_file op` → 0/1 — so the preflight body becomes: if [[ "${GITHUB_API_BASE}" == file://* ]]; then _preflight_probe_file "${body_file}" "${op}" || return 1 else _preflight_probe_http "${body_file}" "${op}" || return 1 fi The convergence on the rate-limit-remaining check afterwards is now obviously intentional — both probes have established the body in the same file, and the shared follow-up logic reads the same field regardless of transport. Pure refactor — 78/78 bats cases pass unchanged; full lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- images/ci-tools/bin/validate-action-pins | 79 +++++++++++++++++------- 1 file changed, 55 insertions(+), 24 deletions(-) diff --git a/images/ci-tools/bin/validate-action-pins b/images/ci-tools/bin/validate-action-pins index fc0f5ef..c7ba2bd 100755 --- a/images/ci-tools/bin/validate-action-pins +++ b/images/ci-tools/bin/validate-action-pins @@ -385,15 +385,65 @@ _preflight_classify_status() { esac } +# Probe the rate_limit endpoint through file:// (test harness). +# No HTTP status semantics — curl's exit code is the only signal. +# On success the JSON body is written to $1 for the caller's +# rate-limit-remaining check. +# +# Arguments: +# $1 - destination file for the response body +# $2 - operation name for the WARN message +# +# Returns: +# 0 - body fetched; caller may proceed +# 1 - curl failed (WARN emitted); caller should skip +_preflight_probe_file() { + local body_file="${1}" op="${2}" + if ! curl "${_CURL_FLAGS[@]}" "${auth_header[@]+"${auth_header[@]}"}" \ + -H "Accept: application/vnd.github+json" \ + "${GITHUB_API_BASE}/rate_limit" > "${body_file}" 2> "${_CURL_ERRFD}"; then + echo "WARN: cannot reach GitHub API — skipping ${op}" + return 1 + fi +} + +# Probe the rate_limit endpoint via HTTP(s). Extracts the response +# status code and routes it through _preflight_classify_status so +# transport failure, auth rejection, secondary rate limit, and any +# other unexpected response each get a distinct operator-facing +# WARN. The body is written to $1 even on non-200 responses so the +# caller can inspect it if the classifier says OK. +# +# Arguments: +# $1 - destination file for the response body +# $2 - operation name for the WARN message +# +# Returns: +# 0 - HTTP 200; body in $1, caller may proceed +# 1 - non-200 or transport failure (WARN emitted); caller skips +_preflight_probe_http() { + local body_file="${1}" op="${2}" + local http_code + http_code="$(curl "${_CURL_PROBE_FLAGS[@]}" \ + -o "${body_file}" -w '%{http_code}' \ + "${auth_header[@]+"${auth_header[@]}"}" \ + -H "Accept: application/vnd.github+json" \ + "${GITHUB_API_BASE}/rate_limit" 2> "${_CURL_ERRFD}" \ + || echo "000")" + _preflight_classify_status "${http_code}" "${op}" +} + # Preflight for API-dependent subcommands: dep check, auth init, and a # connectivity probe. Emits WARN and returns 1 when the caller should # gracefully skip its work. # -# Distinguishes four failure modes so operators can act on the WARN: +# Distinguishes these failure modes so operators can act on the WARN: # - missing curl or jq -# - cannot reach the API host (DNS/network) +# - cannot reach the API host (file:// read failure or HTTP "000") # - authentication rejected (HTTP 401/403 — bad GITHUB_TOKEN) +# - secondary rate limit (HTTP 429) # - unexpected HTTP status (generic fallback) +# - primary rate limit exhausted (remaining == 0) # # When the probe succeeds, also checks rate-limit remaining and emits # a WARN (still returning 0) if the budget looks tight. @@ -403,7 +453,7 @@ _preflight_classify_status() { # # Returns: # 0 - curl, jq, and the API are available (subcommand may proceed) -# 1 - a dependency, connectivity, or auth problem (skip the work) +# 1 - a dependency, connectivity, auth, or budget problem (skip) check_api_preflight() { local op="${1:-pin validation}" local cmd @@ -429,28 +479,9 @@ check_api_preflight() { trap 'rm -f "${body_file:-}"' RETURN if [[ "${GITHUB_API_BASE}" == file://* ]]; then - # file:// — test harness path. No HTTP status; curl's exit code - # is the only signal. Body is still captured for the rate-limit - # check below. - if ! curl "${_CURL_FLAGS[@]}" "${auth_header[@]+"${auth_header[@]}"}" \ - -H "Accept: application/vnd.github+json" \ - "${GITHUB_API_BASE}/rate_limit" > "${body_file}" 2> "${_CURL_ERRFD}"; then - echo "WARN: cannot reach GitHub API — skipping ${op}" - return 1 - fi + _preflight_probe_file "${body_file}" "${op}" || return 1 else - # HTTP(s) — extract status so we can distinguish transport failure - # (code "000") from auth rejection (401/403) from secondary rate - # limit (429) from any other unexpected response. - local http_code - http_code="$(curl "${_CURL_PROBE_FLAGS[@]}" \ - -o "${body_file}" -w '%{http_code}' \ - "${auth_header[@]+"${auth_header[@]}"}" \ - -H "Accept: application/vnd.github+json" \ - "${GITHUB_API_BASE}/rate_limit" 2> "${_CURL_ERRFD}" \ - || echo "000")" - - _preflight_classify_status "${http_code}" "${op}" || return 1 + _preflight_probe_http "${body_file}" "${op}" || return 1 fi # Rate-limit awareness. Zero budget is a full skip — every subsequent From 355c6bf9fcca01311c19f8f5811220dd98b01af4 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 17:45:49 -0600 Subject: [PATCH 30/33] Clarify _include_pin and parse_uses_line doc comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two doc-only edits to make internal helpers easier to read without having to chase behavior across functions: - _include_pin's $1 description had a parenthetical about "unknown" that actually belongs to $2. main() validates $1 as all|tag|branch only; "unknown" is a classified-kind value that the callers pass as $2 for refs they couldn't authoritatively classify. Move the parenthetical to where it applies. - parse_uses_line's regex was a wall with no legend — the body indexes BASH_REMATCH[1], [2], [4] with no in-file note of what each captures. Add a four-line capture-group map right above the regex. Reader can now trace action/ref/comment assignments without pattern-matching in their head. No behavior change — 78/78 bats cases pass unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- images/ci-tools/bin/validate-action-pins | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/images/ci-tools/bin/validate-action-pins b/images/ci-tools/bin/validate-action-pins index c7ba2bd..f9dff70 100755 --- a/images/ci-tools/bin/validate-action-pins +++ b/images/ci-tools/bin/validate-action-pins @@ -253,9 +253,10 @@ _effective_ref() { # Decide whether a pin should be included given the --only filter. # # Arguments: -# $1 - filter value: "all", "tag", or "branch" (and "unknown" for -# refs that could not be classified authoritatively) -# $2 - the pin's classified kind +# $1 - filter value: "all", "tag", or "branch" (validated by main) +# $2 - the pin's classified kind: "tag", "branch", or "unknown" +# (unknown is emitted by callers for refs that could not be +# classified authoritatively) # # Returns: # 0 - pin matches the filter (include) @@ -315,6 +316,11 @@ parse_uses_line() { if [[ "${line}" =~ ^[[:space:]]*# ]]; then return 1 fi + # Regex capture groups: + # [1] action — `owner/repo` before the `@` + # [2] ref — the `@` portion (SHA or symbolic) + # [3] — the full `# ` trailing-comment (unused; wrapper only) + # [4] comment — the first word of the trailing comment, may be empty if ! [[ "${line}" =~ uses:[[:space:]]*([^@[:space:]]+)@([^[:space:]#]+)[[:space:]]*(#[[:space:]]*([^[:space:]]+))? ]]; then return 1 fi From 72fc17ed02ef556cd89b1c024c7853c48a31cd76 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 17:46:49 -0600 Subject: [PATCH 31/33] Tighten cmd_updates output: unify TSV printf, extract _emit_update_plain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd_updates had ~35 inline lines of output formatting: two nearly- identical TSV printfs (differing only in column 3) plus a case- within-case for the plain path with four distinct printf strings. The main loop body read as "classify → fetch → then thirty-five lines of format juggling". Two structural tidies: 1. The TSV branch had an if/else that flipped column 3 between `ref` and `effective_ref` purely to handle the unknown case. Lift the choice into a `col_ref` local and call printf once: local col_ref="${effective_ref}" [[ "${effective_kind}" == "unknown" ]] && col_ref="${ref}" printf '%s\t%s\t%s\t%s\t%s\n' \ "${file}" "${action}" "${col_ref}" "${available}" "${effective_kind}" One emission, one format string, semantic difference named. 2. Extract a `_emit_update_plain` helper that owns all four plain- format shapes (unknown / tag up-to-date / tag newer / branch up-to-date / branch head-drifted). Mirrors the `_emit_check` pattern the `check` subcommand already uses — when it's time to tweak an output line later, it's a one-function change. cmd_updates' main loop body drops from ~40 lines to ~20, reading straight-through as classify → filter → emit. The output lives in two named helpers — _emit_check for check, _emit_update_plain for updates — with matching signatures for uniformity. No behavior change — 78/78 bats cases pass unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- images/ci-tools/bin/validate-action-pins | 87 +++++++++++++++--------- 1 file changed, 56 insertions(+), 31 deletions(-) diff --git a/images/ci-tools/bin/validate-action-pins b/images/ci-tools/bin/validate-action-pins index f9dff70..329aacd 100755 --- a/images/ci-tools/bin/validate-action-pins +++ b/images/ci-tools/bin/validate-action-pins @@ -597,6 +597,52 @@ _emit_check() { "${1}" "${2}" "${3}" "${4:0:12}" "${5}" } +# Emit a single line of `updates` plain-format output. One helper +# covers all four shape variants so cmd_updates' main loop stays a +# tight classify → filter → emit pipeline. +# +# Arguments: +# $1 - file basename +# $2 - action (owner/repo) +# $3 - classified kind: "tag", "branch", or "unknown" +# $4 - effective ref (comment for SHA pins, ref itself otherwise); +# for "unknown", the caller passes the original ref instead +# $5 - available upgrade (newer tag list for tag, short HEAD sha +# for branch, empty for up-to-date or unknown) +# +# Output shapes: +# : current= [unknown] +# : current= [up-to-date] () +# : current= newer= (tag) +# : current= head= (branch) +_emit_update_plain() { + local basename="${1}" action="${2}" kind="${3}" ref="${4}" avail="${5}" + case "${kind}" in + unknown) + printf '%s: %s current=%s [unknown]\n' \ + "${basename}" "${action}" "${ref}" + ;; + tag) + if [[ -z "${avail}" ]]; then + printf '%s: %s current=%s [up-to-date] (tag)\n' \ + "${basename}" "${action}" "${ref}" + else + printf '%s: %s current=%s newer=%s (tag)\n' \ + "${basename}" "${action}" "${ref}" "${avail}" + fi + ;; + branch) + if [[ -z "${avail}" ]]; then + printf '%s: %s current=%s [up-to-date] (branch)\n' \ + "${basename}" "${action}" "${ref}" + else + printf '%s: %s current=%s head=%s (branch)\n' \ + "${basename}" "${action}" "${ref}" "${avail}" + fi + ;; + esac +} + # Verify SHA pins in the given workflow files. # # Only SHA pins with an explicit `# ` comment are validated — the @@ -808,41 +854,20 @@ cmd_updates() { # Apply --only filter on the classified kind before emitting. _include_pin "${only}" "${effective_kind}" || continue + # For TSV, column 3 is "current_ref" — the classified ref hint + # except for unknown pins, where we fall back to the original + # @ref so the row still points at something recognisable. + local col_ref="${effective_ref}" + [[ "${effective_kind}" == "unknown" ]] && col_ref="${ref}" + case "${format}" in tsv) - if [[ "${effective_kind}" == "unknown" ]]; then - printf '%s\t%s\t%s\t%s\t%s\n' \ - "${file}" "${action}" "${ref}" "" "${effective_kind}" - else - printf '%s\t%s\t%s\t%s\t%s\n' \ - "${file}" "${action}" "${effective_ref}" "${available}" "${effective_kind}" - fi + printf '%s\t%s\t%s\t%s\t%s\n' \ + "${file}" "${action}" "${col_ref}" "${available}" "${effective_kind}" ;; *) - case "${effective_kind}" in - unknown) - printf '%s: %s current=%s [unknown]\n' \ - "${basename}" "${action}" "${ref}" - ;; - tag) - if [[ -z "${available}" ]]; then - printf '%s: %s current=%s [up-to-date] (tag)\n' \ - "${basename}" "${action}" "${effective_ref}" - else - printf '%s: %s current=%s newer=%s (tag)\n' \ - "${basename}" "${action}" "${effective_ref}" "${available}" - fi - ;; - branch) - if [[ -z "${available}" ]]; then - printf '%s: %s current=%s [up-to-date] (branch)\n' \ - "${basename}" "${action}" "${effective_ref}" - else - printf '%s: %s current=%s head=%s (branch)\n' \ - "${basename}" "${action}" "${effective_ref}" "${available}" - fi - ;; - esac + _emit_update_plain "${basename}" "${action}" \ + "${effective_kind}" "${col_ref}" "${available}" ;; esac done < "${file}" From bc9e7986b178bd0beae562eb1e81e53f84df8e40 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 17:48:39 -0600 Subject: [PATCH 32/33] Allow subcommand to appear anywhere in the arg list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arch-4 from the round-1 review. The subcommand word had to be $1, so `validate-action-pins --format=tsv list workflows/*.yml` didn't route correctly — `--format=tsv` was $1, no subcommand match, default `check`, and `list` got consumed as a file path. `kubectl` and `gh` accept flag-subcommand ordering either way; we do too now. Move the check|list|updates detection inside the main flag loop with a single `subcmd_seen` latch. First matching positional word becomes the subcommand; later `check|list|updates` tokens are positional files (handles the rare "a workflow literally named list" case). `--` still terminates subcommand scanning along with flag scanning, matching the existing idiom. Tradeoff accepted: a real workflow file named exactly `check`, `list`, or `updates` (no path) needs `--` to disambiguate. Users normally pass `.github/workflows/*.yml`, where the path prefix already disambiguates; the narrow edge case is documented in the man page's new SUBCOMMANDS note. 5 new bats cases cover: flag-then-subcommand, files-then- subcommand, --only-then-subcommand, second occurrence as file, and the `-- check` quoting escape hatch. Man page gains a SUBCOMMANDS-level note about the permissive ordering and the `--` workaround. 83/83 pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/man/man1/validate-action-pins.1 | 7 +++ images/ci-tools/bin/validate-action-pins | 39 ++++++++++++----- .../ci-tools/validate-action-pins/cli.bats | 43 +++++++++++++++++++ 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/docs/man/man1/validate-action-pins.1 b/docs/man/man1/validate-action-pins.1 index b526099..4d75323 100644 --- a/docs/man/man1/validate-action-pins.1 +++ b/docs/man/man1/validate-action-pins.1 @@ -61,6 +61,13 @@ Output lines are prefixed with the workflow filename: If the API is unreachable or a required dependency is missing, the check is skipped and the tool exits successfully. .Sh SUBCOMMANDS +The subcommand word may appear anywhere among the positional +arguments \(em before, between, or after files and flags \(em and +is consumed on its first occurrence. +Use +.Ic -- +to disambiguate a workflow file whose name happens to match a +subcommand. .Bl -tag -width indent .It Cm check Ar file ... Verify SHA pins in the given workflow files. diff --git a/images/ci-tools/bin/validate-action-pins b/images/ci-tools/bin/validate-action-pins index 329aacd..6e5530c 100755 --- a/images/ci-tools/bin/validate-action-pins +++ b/images/ci-tools/bin/validate-action-pins @@ -993,22 +993,27 @@ _short_circuit_help_version() { } # Parse remaining args into cli_subcmd + cli_format + cli_only + cli_files. -# Exits 2 on unknown flag or bad flag value. Unknown leading words fall -# through as positional files, preserving bare-FILE back-compat. +# Exits 2 on unknown flag or bad flag value. +# +# Subcommand detection is permissive: `check`, `list`, or `updates` +# is picked up the first time it appears as a positional word, +# regardless of what flags precede or follow it. All of these +# invocations route to the list subcommand: +# +# validate-action-pins list --format=tsv workflows/*.yml +# validate-action-pins --format=tsv list workflows/*.yml +# validate-action-pins --format=tsv workflows/*.yml list +# +# Only the first occurrence is consumed as a subcommand; a second +# `check|list|updates` word, or anything after a `--` terminator, +# is treated as a filename. Use `--` to disambiguate workflow +# files whose name matches a subcommand. _parse_cli_args() { cli_subcmd="check" cli_format="plain" cli_only="all" cli_files=() - - if [[ $# -gt 0 ]]; then - case "${1}" in - check | list | updates) - cli_subcmd="${1}" - shift - ;; - esac - fi + local subcmd_seen=false while [[ $# -gt 0 ]]; do case "${1}" in @@ -1045,6 +1050,18 @@ _parse_cli_args() { echo "${PROGRAM}: unknown flag: ${1}" >&2 exit 2 ;; + check | list | updates) + if ${subcmd_seen}; then + # Already claimed a subcommand; a second matching word is a + # filename (rare, but possible for a workflow literally named + # `list.yml` without the suffix). + cli_files+=("${1}") + else + cli_subcmd="${1}" + subcmd_seen=true + fi + shift + ;; *) cli_files+=("${1}") shift diff --git a/tests/bats/images/ci-tools/validate-action-pins/cli.bats b/tests/bats/images/ci-tools/validate-action-pins/cli.bats index 33a29bf..a94cbf6 100644 --- a/tests/bats/images/ci-tools/validate-action-pins/cli.bats +++ b/tests/bats/images/ci-tools/validate-action-pins/cli.bats @@ -76,6 +76,49 @@ setup() { assert_output --partial "OK tag-ok.yml:" } +# ── permissive subcommand placement ───────────────────────────────── + +@test "subcommand can appear after flags" { + # Flag-then-subcommand ordering: most modern CLI tools (gh, + # kubectl) accept either order; we do too. + run "${SCRIPT}" --format=tsv list "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + assert_output --regexp $'\tfoo/bar\t' +} + +@test "subcommand can appear after files" { + # Even at the end, the subcommand wins over files. + run "${SCRIPT}" "${FIXTURES_DIR}/workflows/tag-ok.yml" list + assert_success + assert_output --partial "tag-ok.yml: foo/bar@" +} + +@test "subcommand after --only flag routes correctly" { + run "${SCRIPT}" --only=tag list \ + "${FIXTURES_DIR}/workflows/tag-ok.yml" \ + "${FIXTURES_DIR}/workflows/branch-ok.yml" + assert_success + assert_output --partial "tag-ok.yml: foo/bar@" + refute_output --partial "branch-ok.yml" +} + +@test "second subcommand word becomes a file path" { + # First 'list' is the subcommand; second 'list' is a positional + # file arg — which doesn't exist, so we get a missing-file WARN. + run "${SCRIPT}" list list "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + assert_output --partial "WARN: list not found" + assert_output --partial "tag-ok.yml: foo/bar@" +} + +@test "'-- check' treats check as a file, not a subcommand" { + # After --, no word is interpreted as a subcommand. + run "${SCRIPT}" -- check "${FIXTURES_DIR}/workflows/tag-ok.yml" + assert_success + assert_output --partial "WARN: check not found" + assert_output --partial "OK tag-ok.yml:" +} + # ── flag validation ───────────────────────────────────────────────── @test "unknown flag exits 2 with a usage error" { From 41f8d0675fa2b77060868bcc96d35166cd888ac3 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sat, 18 Apr 2026 18:01:23 -0600 Subject: [PATCH 33/33] Update man page to catch up with recent script changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full pass against current behavior turned up several drifts from when the subcommand/filter/preflight work last updated the page. - Date bumped (Feb 15 → Apr 18). The whole SUBCOMMANDS + --only story landed after that date. - DESCRIPTION's "skipped successfully" paragraph listed only two skip conditions (unreachable API, missing dep). Real list after the preflight hardening is six: missing curl/jq, unreachable, auth rejection (401/403), secondary rate limit (429), exhausted primary rate limit, unexpected HTTP status. Each produces its own named WARN; document them. Also point at VALIDATE_ACTION_PINS_VERBOSE as the escape hatch. - `check` SUBCOMMANDS synopsis was missing --only (top SYNOPSIS had it); `updates` SUBCOMMANDS synopsis was missing --only too. Bring both in line with the top synopsis. - `check` SUBCOMMANDS description now notes the "SHA pin + # comment" scope rule so a reader jumping straight there knows what check operates on. - Updates plain-output shape list was three entries; add the `[unknown]` shape for SHA pins with no ref hint (shipped back in the updates-refactor commit) and explain when it's emitted. - ENVIRONMENT gained VALIDATE_ACTION_PINS_VERBOSE, a real operator knob. Test-only env vars (GITHUB_API_BASE, VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY) stay in the script header only, not the man page. Rendered output via mandoc looks clean — paragraph breaks land in the right places, no formatting artefacts. `make lint-man` clean; 83/83 bats cases unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/man/man1/validate-action-pins.1 | 42 ++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/docs/man/man1/validate-action-pins.1 b/docs/man/man1/validate-action-pins.1 index 4d75323..4b62078 100644 --- a/docs/man/man1/validate-action-pins.1 +++ b/docs/man/man1/validate-action-pins.1 @@ -1,4 +1,4 @@ -.Dd February 15, 2026 +.Dd April 18, 2026 .Dt VALIDATE-ACTION-PINS 1 .Os .Sh NAME @@ -58,8 +58,25 @@ Output lines are prefixed with the workflow filename: .Dl WARN \ ci.yml: org/action@de0fac2e4500... diverges from main HEAD (at abc123456789...) .Dl WARN \ ci.yml: org/action@de0fac2e4500... \(em could not resolve ref v6 .Pp -If the API is unreachable or a required dependency is missing, the check -is skipped and the tool exits successfully. +Validation is skipped with a clear WARN and the tool exits +successfully if any of these conditions hold: a required dependency +is missing +.Po +.Xr curl 1 +or +.Xr jq 1 +.Pc , +the API host is unreachable, authentication is rejected +.Pq HTTP 401 or 403 , +the secondary rate limit is hit +.Pq HTTP 429 , +the primary rate limit is exhausted +.Pq remaining == 0 , +or an unexpected HTTP status comes back. +Each cause names itself in the WARN so the operator can act on it. +Set +.Ev VALIDATE_ACTION_PINS_VERBOSE +to surface curl's stderr when the cause is not obvious. .Sh SUBCOMMANDS The subcommand word may appear anywhere among the positional arguments \(em before, between, or after files and flags \(em and @@ -69,12 +86,17 @@ Use to disambiguate a workflow file whose name happens to match a subcommand. .Bl -tag -width indent -.It Cm check Ar file ... +.It Cm check Oo Fl -only Ar all Ns | Ns Ar tag Ns | Ns Ar branch Oc Ar file ... Verify SHA pins in the given workflow files. This is the default when no subcommand is given; bare .Ic validate-action-pins Ar file ... is equivalent to .Ic validate-action-pins check Ar file ... . +Only SHA pins with an explicit +.Ic "# " +comment are validated; floating refs +.Pq e.g. Ic @main +and bare SHA pins with no comment are skipped. .It Cm list Oo Fl -format Ar plain Ns | Ns Ar tsv Oc Oo Fl -only Ar all Ns | Ns Ar tag Ns | Ns Ar branch Oc Ar file ... Print every .Ic uses:\& @@ -109,7 +131,7 @@ Actions pinned to a scheme or a local .Ic ./ path are skipped. -.It Cm updates Oo Fl -format Ar plain Ns | Ns Ar tsv Oc Ar file ... +.It Cm updates Oo Fl -format Ar plain Ns | Ns Ar tsv Oc Oo Fl -only Ar all Ns | Ns Ar tag Ns | Ns Ar branch Oc Ar file ... Report upgrade inventory for each unique pin. For tag pins, every tag strictly newer than the current ref is listed \(em across both minor and major bumps, since Dependabot does not open @@ -124,6 +146,11 @@ Plain output: .Dl : current= [up-to-date] () .Dl : current= newer= ... (tag) .Dl : current= head= (branch) +.Dl : current= [unknown] +.Pp +The final shape is emitted when a SHA pin carries no trailing +.Ic "# " +comment, so there's no hint to classify against. .Pp TSV output (five columns, no header): .Dl \etab\etab\etab\etab @@ -197,6 +224,11 @@ Print the program name and version, then exit. Optional GitHub personal access token or fine-grained token. When set, API requests use the token for authentication, raising the rate limit from 60 to 5\|000 requests per hour. +.It Ev VALIDATE_ACTION_PINS_VERBOSE +When set to any non-empty value, passes curl's stderr through +instead of silencing it. +Useful for diagnosing transport errors, HTTP status bodies, or +authentication failures in CI logs. .El .Sh EXIT STATUS .Bl -tag -width indent