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 a19b8d1..4fd2f1f 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 \ @@ -59,10 +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 -e SC2154 tests/bats/*/*.bash tests/bats/*/*/*/*.bats \ && echo "OK" # Check shell script formatting @@ -106,6 +117,15 @@ man: test-package: @./tests/deb/test-all.sh +# 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: + @$(BATS_RUNNER) -r tests/bats/ + # Remove local image clean: @echo "Removing $(IMAGE_TAG) ..." @@ -135,6 +155,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/cspell.json b/cspell.json index aa3be42..53981ad 100644 --- a/cspell.json +++ b/cspell.json @@ -7,6 +7,7 @@ "busted", "chktex", "cosign", + "dedup", "devops", "extglob", "hadolint", @@ -16,7 +17,11 @@ "markdownlint", "mikefarah", "minimatch", + "nameref", "nfpm", + "nocurl", + "nojq", + "nosuch", "picomatch", "rsync", "shellcheck", @@ -24,9 +29,12 @@ "sigstore", "startswith", "stdlib", + "stubdir", "stylelint", + "subcmd", "syscall", "tinyglobby", + "tonumber", "trivy", "xmlstarlet" ] diff --git a/docs/man/man1/validate-action-pins.1 b/docs/man/man1/validate-action-pins.1 index c480661..4b62078 100644 --- a/docs/man/man1/validate-action-pins.1 +++ b/docs/man/man1/validate-action-pins.1 @@ -1,37 +1,218 @@ -.Dd February 15, 2026 +.Dd April 18, 2026 .Dt VALIDATE-ACTION-PINS 1 .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 .Op Fl -version .Ar file ... +.Nm +.Cm check +.Op Fl -only Ar all Ns | Ns Ar tag Ns | Ns Ar branch +.Ar file ... +.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 +.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 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 +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 +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 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:\& +pin found in the input files. +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 : @ (# ) +.Pp +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). +.Pp +Actions pinned to a +.Ic docker:// +scheme or a local +.Ic ./ +path are skipped. +.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 +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. .Pp -If the API is unreachable or a required dependency is missing, the check -is skipped and the tool exits successfully. +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 +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 +.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. +.Pp +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. +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 +.It Fl -format Ar plain Ns | Ns Ar tsv +Output format for +.Cm list +and +.Cm updates . +Default is +.Em plain . +.It Fl -only Ar all Ns | Ns Ar tag Ns | Ns Ar branch +Restrict 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). +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 . .It Fl -help Show a usage summary and exit. .It Fl -version @@ -43,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 @@ -53,6 +239,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: @@ -62,6 +250,25 @@ 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 +.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 \(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 '$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 da6fb16..6e5530c 100755 --- a/images/ci-tools/bin/validate-action-pins +++ b/images/ci-tools/bin/validate-action-pins @@ -9,6 +9,8 @@ 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 # @@ -16,78 +18,109 @@ 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. +# 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 # 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}" +: "${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 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; --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: +# 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() { 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. +Verify, inventory, or report on GitHub Actions pins in workflow files. -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 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: - --help Show this help message and exit - --version Print the version and exit + --format=plain|tsv Output format for `list` and `updates` (default: plain) + --only=all|tag|branch + 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 Environment: GITHUB_TOKEN Optional token for higher API rate limits 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. # @@ -95,131 +128,985 @@ fi # $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="https://api.github.com${1}" - curl -fsSL "${auth_header[@]+"${auth_header[@]}"}" \ + local url="${GITHUB_API_BASE}${1}" + 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 to its commit SHA, handling both lightweight and annotated tags. +# Resolve a tag ref (lightweight or annotated) to its commit SHA. # # Arguments: # $1 - Repository (owner/repo) # $2 - Tag name (e.g. v6) # -# Outputs: -# 40-character commit SHA, or empty string on failure -resolve_tag() { - local repo="${1}" tag="${2}" +# 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 via +# head_sha. +# +# Arguments: +# $1 - Repository (owner/repo) +# $2 - Ref name (e.g. v6, main) +# +# 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}" sha + if sha="$(_resolve_as_tag "${repo}" "${ref}")"; then + printf '%s\ttag\n' "${sha}" + return 0 + fi + if sha="$(head_sha "${repo}" "${ref}")"; then + printf '%s\tbranch\n' "${sha}" + return 0 + fi + 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}" +} + +# 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 +} + +# Decide whether a pin should be included given the --only filter. +# +# Arguments: +# $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) +# 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 +# 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=() - local ref_json - ref_json="$(gh_api "/repos/${repo}/git/ref/tags/${tag}")" - if [[ -z "${ref_json}" ]]; then +# 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 +} + +# 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 + # 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 + # 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 + 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 - 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 [[ "${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}" +} + +# 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 +} - if [[ -z "${obj_type}" || -z "${obj_sha}" ]]; then +# 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 +} - if [[ "${obj_type}" == "commit" ]]; then - echo "${obj_sha}" +# 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 these failure modes so operators can act on the WARN: +# - missing curl or jq +# - 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. +# +# Arguments: +# $1 - operation name for the WARN message (default "pin validation") +# +# Returns: +# 0 - curl, jq, and the API are available (subcommand may proceed) +# 1 - a dependency, connectivity, auth, or budget problem (skip) +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 ${op}" + return 1 + fi + done + + init_auth_header + + if [[ -n "${VALIDATE_ACTION_PINS_SKIP_CONNECTIVITY:-}" ]]; then return 0 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 + 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 + _preflight_probe_file "${body_file}" "${op}" || return 1 + else + _preflight_probe_http "${body_file}" "${op}" || return 1 + fi + + # 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)" + 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 - local commit_sha - commit_sha="$(echo "${tag_json}" | jq -r '.object.sha // empty')" - if [[ -n "${commit_sha}" ]]; then - echo "${commit_sha}" - return 0 + 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 +} - return 1 +# List all tags strictly newer than $current, sorted ascending. +# +# Arguments: +# $1 - Repository (owner/repo) +# $2 - Current ref (e.g. v6.0.0, v6, 1.2.3); parsed as v?X[.Y[.Z]] +# +# Output: +# One tag name per line, oldest → newest; empty if none. +# +# Notes: +# - 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 + # 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($re)) + | { name: ., key: tok(.) } + ] as $tags + | (tok($c)) as $ck + | $tags + | map(select(.key > $ck)) + | sort_by(.key) + | .[].name + ' <<< "${json}" } -# ── connectivity check ─────────────────────────────────────────────── +# 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}" +} -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 +# ── subcommands ────────────────────────────────────────────────────── -# ── scan and validate ──────────────────────────────────────────────── +# 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}" +} -FOUND_PINS=0 +# 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 +} -for file in "${@}"; do - [[ "${file}" == --* ]] && continue - if [[ ! -f "${file}" ]]; then - echo "WARN: ${file} not found, skipping" - continue - fi +# Verify SHA pins in the given workflow files. +# +# 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: +# $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 - basename="${file##*/}" - declare -A seen_in_file=() + local -i failures=0 + local -i found_pins=0 + local -A resolve_cache=() + local -A compare_cache=() + local file basename line parsed + local action ref kind comment cache_key cached resolved resolved_kind + local compare_key behind - # 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 + # Clean slate in case a sourced test re-enters the subcommand. + seen_in_file=() - cache_key="${action}#${claimed_tag}" + for file in "${@}"; do + if [[ ! -f "${file}" ]]; then + echo "WARN: ${file} not found, skipping" + continue + fi + basename="${file##*/}" + seen_in_file=() # per-file dedup scope - # Skip duplicates within the same file. - if [[ -n "${seen_in_file[${cache_key}]+x}" ]]; then + while IFS= read -r line; do + if ! parsed="$(parse_uses_line "${line}")"; then continue fi - seen_in_file["${cache_key}"]=1 + IFS=$'\t' read -r action ref kind comment <<< "${parsed}" + + # 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 + + cache_key="${action}#${comment}" + _once_per_file "${cache_key}" || continue - # 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}" + 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}"]="!" + resolve_cache["${cache_key}"]="NONE" fi fi + cached="${resolve_cache[${cache_key}]}" - 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 + fi + + resolved="${cached%%$'\t'*}" + resolved_kind="${cached#*$'\t'}" + + # Apply --only filter on the authoritative kind (post-API). + _include_pin "${only}" "${resolved_kind}" || continue - 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}" + if [[ "${ref}" == "${resolved}" ]]; then + _emit_check OK "${basename}" "${action}" "${ref}" "matches ${comment}" + continue + fi + + 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 + 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 + 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 + + 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" + return 1 + fi + return 0 +} + +# 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 - --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 +# 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-unique-pin API cost: +# - Tag ref: 1 call to /repos/.../tags +# - Branch ref: 1 call to /repos/.../git/ref/heads/ +# - SHA pin with no # comment: no calls, recorded as "unknown" +cmd_updates() { + local format="${1}" only="${2}" + shift 2 + + check_api_preflight "update check" || return 0 + + 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 + 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}" + + effective_ref="$(_effective_ref "${kind}" "${ref}" "${comment}")" + key="${action}@${effective_ref}" + _once_per_run "${key}" || continue + + newer_tags="" + 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}" =~ ${_SEMVER_RE} ]]; then + effective_kind="tag" + newer_tags="$(list_newer_tags "${action}" "${effective_ref}" || true)" + if [[ -n "${newer_tags}" ]]; then + available="$(tr '\n' ' ' <<< "${newer_tags}" | sed 's/ *$//')" + fi else - echo "FAIL ${basename}: ${action}@${pinned_sha:0:12}... does NOT match ${claimed_tag} (expected ${cached:0:12}...)" - FAILURES=$((FAILURES + 1)) + effective_kind="branch" + head_full="$(head_sha "${action}" "${effective_ref}" || true)" + if [[ "${kind}" == "sha" && "${ref}" != "${head_full}" && -n "${head_full}" ]]; then + available="${head_full:0:12}" + fi fi + + # 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) + printf '%s\t%s\t%s\t%s\t%s\n' \ + "${file}" "${action}" "${col_ref}" "${available}" "${effective_kind}" + ;; + *) + _emit_update_plain "${basename}" "${action}" \ + "${effective_kind}" "${col_ref}" "${available}" + ;; + esac + done < "${file}" + done +} + +# List pinned actions found in the given workflow files. +# +# 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 - --only filter: "all", "tag", or "branch" +# $3.. - workflow file paths +# +# Plain output (one line per occurrence): +# : @ (# ) +# 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() { + 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 + echo "WARN: ${file} not found, skipping" >&2 + continue fi - done < "${file}" -done + while IFS= read -r line; do + if ! parsed="$(parse_uses_line "${line}")"; then + continue + fi + IFS=$'\t' read -r action ref kind comment <<< "${parsed}" -if [[ "${FOUND_PINS}" -eq 0 ]]; then - echo "No SHA-pinned actions found in the provided files." -fi + 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 + resolve_cache["${cache_key}"]="NONE" + fi + 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 +} + +# ── main ───────────────────────────────────────────────────────────── + +# 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 + --help) + usage + exit 0 + ;; + --version) + echo "${PROGRAM} ${VERSION}" + exit 0 + ;; + --) break ;; + *) ;; + esac + done +} + +# Parse remaining args into cli_subcmd + cli_format + cli_only + cli_files. +# 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=() + local subcmd_seen=false + + while [[ $# -gt 0 ]]; do + case "${1}" in + --format=*) + cli_format="${1#*=}" + shift + ;; + --format) + if [[ $# -lt 2 ]]; then + echo "${PROGRAM}: --format requires a value" >&2 + exit 2 + fi + cli_format="${2}" + shift 2 + ;; + --only=*) + cli_only="${1#*=}" + shift + ;; + --only) + if [[ $# -lt 2 ]]; then + echo "${PROGRAM}: --only requires a value" >&2 + exit 2 + fi + cli_only="${2}" + shift 2 + ;; + --) + shift + cli_files+=("${@}") + break + ;; + -*) + 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 + ;; + esac + done + + case "${cli_format}" in + plain | tsv) ;; + *) + echo "${PROGRAM}: invalid --format: ${cli_format} (expected 'plain' or 'tsv')" >&2 + exit 2 + ;; + esac + + case "${cli_only}" in + all | tag | branch) ;; + *) + 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 [[ "${#cli_files[@]}" -eq 0 ]]; then + usage >&2 + exit 1 + fi + + 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 +} -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..4dd6ecd --- /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 normalizes 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/validate-action-pins/cli.bats b/tests/bats/images/ci-tools/validate-action-pins/cli.bats new file mode 100644 index 0000000..a94cbf6 --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/cli.bats @@ -0,0 +1,146 @@ +#!/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:" +} + +# ── 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" { + 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/validate-action-pins/fixtures/README.md b/tests/bats/images/ci-tools/validate-action-pins/fixtures/README.md new file mode 100644 index 0000000..0f32730 --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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. diff --git a/tests/bats/images/ci-tools/validate-action-pins/fixtures/api/rate_limit b/tests/bats/images/ci-tools/validate-action-pins/fixtures/api/rate_limit new file mode 100644 index 0000000..955f2b1 --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/validate-action-pins/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 new file mode 100644 index 0000000..0856809 --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/validate-action-pins/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 new file mode 100644 index 0000000..0795862 --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/validate-action-pins/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 new file mode 100644 index 0000000..3f639fb --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/validate-action-pins/fixtures/api/repos/foo/bar/tags b/tests/bats/images/ci-tools/validate-action-pins/fixtures/api/repos/foo/bar/tags new file mode 100644 index 0000000..abaf32c --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/validate-action-pins/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 new file mode 100644 index 0000000..d1bb63b --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/validate-action-pins/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 new file mode 100644 index 0000000..507723d --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/validate-action-pins/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 new file mode 100644 index 0000000..789c53e --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/validate-action-pins/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 new file mode 100644 index 0000000..4d4e652 --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/validate-action-pins/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 new file mode 100644 index 0000000..e7afca1 --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/validate-action-pins/fixtures/workflows/annotated.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/annotated.yml new file mode 100644 index 0000000..6a27f80 --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/validate-action-pins/fixtures/workflows/branch-behind.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/branch-behind.yml new file mode 100644 index 0000000..2256244 --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/validate-action-pins/fixtures/workflows/branch-diverge.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/branch-diverge.yml new file mode 100644 index 0000000..7ca41fb --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/validate-action-pins/fixtures/workflows/branch-ok.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/branch-ok.yml new file mode 100644 index 0000000..853026e --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/validate-action-pins/fixtures/workflows/commented-uses.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/commented-uses.yml new file mode 100644 index 0000000..c076a01 --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/fixtures/workflows/duplicate-branch-pins.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/duplicate-branch-pins.yml new file mode 100644 index 0000000..3f96f5f --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/validate-action-pins/fixtures/workflows/duplicate-pins.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/duplicate-pins.yml new file mode 100644 index 0000000..6c323b7 --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/validate-action-pins/fixtures/workflows/mixed.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/mixed.yml new file mode 100644 index 0000000..7ee8be4 --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/fixtures/workflows/tag-mismatch.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/tag-mismatch.yml new file mode 100644 index 0000000..224ec56 --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/validate-action-pins/fixtures/workflows/tag-ok-2.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/tag-ok-2.yml new file mode 100644 index 0000000..cf0605d --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/fixtures/workflows/tag-ok.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/tag-ok.yml new file mode 100644 index 0000000..d1af9e9 --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/fixtures/workflows/unresolvable.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/unresolvable.yml new file mode 100644 index 0000000..354802f --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/fixtures/workflows/updates-at-latest.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/updates-at-latest.yml new file mode 100644 index 0000000..9d6954f --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/validate-action-pins/fixtures/workflows/updates-major-alias.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/updates-major-alias.yml new file mode 100644 index 0000000..d2e6cee --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/fixtures/workflows/updates-mixed.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/updates-mixed.yml new file mode 100644 index 0000000..9cff22a --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/validate-action-pins/fixtures/workflows/updates-new-major.yml b/tests/bats/images/ci-tools/validate-action-pins/fixtures/workflows/updates-new-major.yml new file mode 100644 index 0000000..660f2f7 --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/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/helpers.bats b/tests/bats/images/ci-tools/validate-action-pins/helpers.bats new file mode 100644 index 0000000..04d6bd1 --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/helpers.bats @@ -0,0 +1,213 @@ +#!/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" +} + +# ── _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" +} 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..fcd7892 --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/preflight.bats @@ -0,0 +1,111 @@ +#!/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" +} + +@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" { + # 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/subcommand-check.bats b/tests/bats/images/ci-tools/validate-action-pins/subcommand-check.bats new file mode 100644 index 0000000..f154045 --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/subcommand-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/subcommand-list.bats b/tests/bats/images/ci-tools/validate-action-pins/subcommand-list.bats new file mode 100644 index 0000000..3255f80 --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/subcommand-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/subcommand-updates.bats b/tests/bats/images/ci-tools/validate-action-pins/subcommand-updates.bats new file mode 100644 index 0000000..19f786a --- /dev/null +++ b/tests/bats/images/ci-tools/validate-action-pins/subcommand-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)" +}