|
| 1 | +#!/usr/bin/env bash |
| 2 | +# SPDX-License-Identifier: MPL-2.0 |
| 3 | +# Actions lockfile coverage: every SHA-pinned `uses:` ref in .github/workflows/ |
| 4 | +# AND in .github/actions/*/action.yml must have a matching entry in |
| 5 | +# .github/workflows/actions.lock. |
| 6 | +# |
| 7 | +# WHY THIS EXISTS |
| 8 | +# |
| 9 | +# GitHub validates a reusable workflow against the CALLEE repo's own |
| 10 | +# actions.lock. A lockfile that has drifted from its workflows therefore |
| 11 | +# does not break this repo -- it breaks every repo that calls into it, with |
| 12 | +# `failure` (not `startup_failure`), 0 jobs, and no reason in either the |
| 13 | +# REST or the GraphQL payload; only the run page says why. actionlint |
| 14 | +# passes either way. Dependabot bumps a `uses:` SHA without regenerating |
| 15 | +# the lock, and Dependabot never runs pre-commit -- so CI is the only place |
| 16 | +# this is caught before it reaches the callers. |
| 17 | +# |
| 18 | +# WHY NOT `gh actions-lock --verify-local`, measured 2026-09-15 on v0.1.6: |
| 19 | +# |
| 20 | +# * It WRITES. It rewrote `uses: ./.github/actions/signed-push` to the |
| 21 | +# invalid `uses: $/.github/actions/signed-push` -- a ref that kills the |
| 22 | +# workflow at startup -- while running in a mode whose own help text |
| 23 | +# calls it read-only and "ideal for pre-commit hooks". |
| 24 | +# --no-migrate-local-actions suppresses that, but then the tool stops |
| 25 | +# descending into local composite actions and misreports their |
| 26 | +# dependencies as stale. Safety and accuracy are in conflict there. |
| 27 | +# * Its coverage is REPO-scoped, not SHA-exact. Bumping ONE of a workflow's |
| 28 | +# two refs to the same action leaves the old lock key still referenced by |
| 29 | +# the other: nothing reported stale, nothing reported missing, check |
| 30 | +# green, workflow broken. That mutant survives it and dies here. |
| 31 | +# |
| 32 | +# SCOPE -- stated, not implied: |
| 33 | +# * Only 40-hex SHA-pinned refs are checked. Tag refs (@v2, @main) are a |
| 34 | +# different question, gated by validate-sha-pins.sh. |
| 35 | +# * Reusable WORKFLOW refs (owner/repo/.github/workflows/x.yml@sha) are |
| 36 | +# skipped: the lockfile models actions and keys no entry for them. |
| 37 | +# * Local refs (./...) are skipped; they carry no SHA. |
| 38 | +# * Sub-path actions normalise to their repo -- codeql-action/init@X is |
| 39 | +# keyed once as codeql-action@X, never once per sub-path. |
| 40 | +# * Comparison is case-insensitive: workflows say Swatinem/rust-cache while |
| 41 | +# the lockfile stores swatinem/rust-cache. |
| 42 | +# * Local COMPOSITE actions (.github/actions/*/action.yml) are scanned too. |
| 43 | +# Their deps are keyed in the lock under the workflow that uses them, and |
| 44 | +# Dependabot bumps them like any other ref; scanning only the workflow |
| 45 | +# directory leaves that drift invisible. |
| 46 | +# * Membership is GLOBAL, not per-workflow-section. A ref present in some |
| 47 | +# other workflow's lock section satisfies this check. A workflow with |
| 48 | +# SHA-pinned refs but no lock section of its own is therefore NOT |
| 49 | +# detected here -- see the PR notes. |
| 50 | + |
| 51 | +set -euo pipefail |
| 52 | + |
| 53 | +REPO_ROOT="${INPUT_PATH:-.}" |
| 54 | +LOCKFILE="$REPO_ROOT/.github/workflows/actions.lock" |
| 55 | +WORKFLOW_DIR="$REPO_ROOT/.github/workflows" |
| 56 | +ACTIONS_DIR="$REPO_ROOT/.github/actions" |
| 57 | + |
| 58 | +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' |
| 59 | + |
| 60 | +# Refs deliberately absent from the lockfile. Each entry is an exact |
| 61 | +# normalised owner/repo@sha plus the reason it is absent. This is an explicit |
| 62 | +# list, never a pattern: anything not named here fails, so the list cannot |
| 63 | +# quietly widen to cover an accident. An entry that stops being used is |
| 64 | +# reported as stale, so it cannot rot either. |
| 65 | +EXPECTED_ABSENT=( |
| 66 | + # Empty: the denoland/setup-deno exception was removed 2026-09-22 after |
| 67 | + # the last consumer went away (the validator itself reported it stale). |
| 68 | + # Estate doctrine is still bun-only; if a deno ref ever returns, the |
| 69 | + # cure is to remove the consumer, not to re-add an exception. |
| 70 | +) |
| 71 | + |
| 72 | +if [ ! -f "$LOCKFILE" ]; then |
| 73 | + echo -e "${RED}[validate-actions-lock] ERROR: $LOCKFILE not found${NC}" >&2 |
| 74 | + exit 1 |
| 75 | +fi |
| 76 | + |
| 77 | +# An unreadable lockfile must abort, never silently pass. |
| 78 | +if [ ! -r "$LOCKFILE" ]; then |
| 79 | + echo -e "${RED}[validate-actions-lock] ERROR: $LOCKFILE unreadable${NC}" >&2 |
| 80 | + exit 1 |
| 81 | +fi |
| 82 | + |
| 83 | +# Parse the lockfile's keys into an explicit set ONCE, rather than substring |
| 84 | +# -matching against the whole file. A substring test over a blob fails open in |
| 85 | +# ways that are hard to see: if the blob is empty or truncated, every ref is |
| 86 | +# reported "missing" and the operator is told to regenerate a lockfile that |
| 87 | +# was actually fine. An explicit set can be counted, and is counted below. |
| 88 | +mapfile -t LOCK_KEYS < <( |
| 89 | + grep -oE "'[A-Za-z0-9._-]+/[A-Za-z0-9._-]+@[0-9a-fA-F]{40}'" "$LOCKFILE" \ |
| 90 | + | tr -d "'" | tr '[:upper:]' '[:lower:]' | sort -u |
| 91 | +) |
| 92 | + |
| 93 | +# Positive control: the lockfile is known to key at least one action. If the |
| 94 | +# parse yields nothing, the FILE FORMAT changed or the read failed -- say so, |
| 95 | +# rather than reporting every ref in the repo as missing. |
| 96 | +if [ "${#LOCK_KEYS[@]}" -eq 0 ]; then |
| 97 | + echo -e "${RED}[validate-actions-lock] ERROR: parsed 0 keys from $LOCKFILE${NC}" >&2 |
| 98 | + echo " The lockfile exists but no 'owner/repo@sha' keys were found." >&2 |
| 99 | + echo " This is a parser/format failure, NOT a coverage failure." >&2 |
| 100 | + exit 1 |
| 101 | +fi |
| 102 | + |
| 103 | +lock_has() { |
| 104 | + local needle="$1" k |
| 105 | + for k in "${LOCK_KEYS[@]}"; do |
| 106 | + [ "$k" = "$needle" ] && return 0 |
| 107 | + done |
| 108 | + return 1 |
| 109 | +} |
| 110 | + |
| 111 | +# Leaf action entries in `dependencies:` whose `ref:` equals their own 40-hex |
| 112 | +# SHA pin fail GitHub runner lockfile verification during `Set up job` |
| 113 | +# ("Unable to resolve action ...: invalid lockfile pin", standards#1271). |
| 114 | +mapfile -t SELF_SHA_KEYS < <( |
| 115 | + awk ' |
| 116 | + /^dependencies:/ { in_dep = 1; next } |
| 117 | + /^[a-z_]+:/ { in_dep = 0; cur = ""; next } |
| 118 | + !in_dep { next } |
| 119 | + /^ \x27[^\x27]+\x27:$/ { |
| 120 | + cur = $0; sub(/^ \x27/, "", cur); sub(/\x27:$/, "", cur) |
| 121 | + next |
| 122 | + } |
| 123 | + cur != "" && /^ ref: \x27[^\x27]+\x27$/ { |
| 124 | + r = $0; sub(/^ ref: \x27/, "", r); sub(/\x27$/, "", r) |
| 125 | + n = index(cur, "@") |
| 126 | + pin = (n > 0) ? substr(cur, n + 1) : "" |
| 127 | + if (length(r) == 40 && r ~ /^[0-9a-fA-F]+$/ && tolower(r) == tolower(pin)) { |
| 128 | + print tolower(cur) |
| 129 | + } |
| 130 | + cur = "" |
| 131 | + } |
| 132 | + ' "$LOCKFILE" | sort -u |
| 133 | +) |
| 134 | + |
| 135 | +# Check a normalized owner/repo@sha key against the parsed self-SHA entries. |
| 136 | +# Return 0 if its dependency ref equals its own SHA pin, or 1 if absent. |
| 137 | +lock_has_self_sha() { |
| 138 | + local needle="$1" k |
| 139 | + for k in ${SELF_SHA_KEYS[@]+"${SELF_SHA_KEYS[@]}"}; do |
| 140 | + [ "$k" = "$needle" ] && return 0 |
| 141 | + done |
| 142 | + return 1 |
| 143 | +} |
| 144 | + |
| 145 | +ERRORS=0 |
| 146 | +CHECKED=0 |
| 147 | +declare -a SEEN_ABSENT=() |
| 148 | + |
| 149 | +# Collect every SHA-pinned uses: ref across all workflow files. |
| 150 | +# |
| 151 | +# Read with yq, never grep (YAML-POLICY Y-1). A line grep for `uses:` only |
| 152 | +# sees block style: on a KYAML file (`uses: "owner/repo@sha", # v1`) it |
| 153 | +# captured the quote and comma into the ref and reported a pinned, locked |
| 154 | +# action as missing. The parser returns the scalar VALUE whatever the style, |
| 155 | +# and never matches a `uses:` that is text inside a `run:` body. |
| 156 | +# Measured 2026-10-01: same 29-ref set as the old grep on the block tree. |
| 157 | +if ! command -v yq >/dev/null 2>&1; then |
| 158 | + echo -e "${RED}[validate-actions-lock] ERROR: yq not found -- it reads the workflows (YAML-POLICY Y-1)${NC}" >&2 |
| 159 | + exit 1 |
| 160 | +fi |
| 161 | +declare -a UNPARSED=() |
| 162 | +mapfile -t RAW < <( |
| 163 | + for f in "$WORKFLOW_DIR"/*.yml "$WORKFLOW_DIR"/*.yaml \ |
| 164 | + "$ACTIONS_DIR"/*/action.yml "$ACTIONS_DIR"/*/action.yaml; do |
| 165 | + [ -f "$f" ] || continue |
| 166 | + yq -r '.. | select(tag == "!!map") | select(has("uses")) | .uses | select(tag == "!!str")' "$f" \ |
| 167 | + || printf '\001UNPARSED\001%s\n' "$f" |
| 168 | + done | grep -E $'@[0-9a-fA-F]{40}$|^\001UNPARSED\001' | sort -u |
| 169 | +) |
| 170 | +# A file yq cannot parse is a file whose refs went unchecked: fail closed. |
| 171 | +for ref in "${RAW[@]}"; do |
| 172 | + case "$ref" in $'\001UNPARSED\001'*) UNPARSED+=("${ref#$'\001UNPARSED\001'}") ;; esac |
| 173 | +done |
| 174 | +if [ "${#UNPARSED[@]}" -gt 0 ]; then |
| 175 | + echo -e "${RED}[validate-actions-lock] ERROR: yq could not parse: ${UNPARSED[*]}${NC}" >&2 |
| 176 | + exit 1 |
| 177 | +fi |
| 178 | + |
| 179 | +# A zero-input pass is the classic fake green: if ref extraction ever breaks, |
| 180 | +# this script would report success having checked nothing. If the lockfile |
| 181 | +# already names workflows, finding no refs is a contradiction, not an empty |
| 182 | +# repo -- fail instead of passing vacuously. The success line below also |
| 183 | +# prints the number checked, so a silent collapse to near-zero is visible. |
| 184 | +if [ "${#RAW[@]}" -eq 0 ]; then |
| 185 | + if grep -qE "^ '\\.github/workflows/" "$LOCKFILE"; then |
| 186 | + echo -e "${RED}[validate-actions-lock] ERROR: no SHA-pinned refs extracted, yet the lockfile names workflows -- extraction is broken${NC}" >&2 |
| 187 | + exit 1 |
| 188 | + fi |
| 189 | + echo -e "${YELLOW}[validate-actions-lock] no SHA-pinned refs found -- nothing to check${NC}" |
| 190 | + exit 0 |
| 191 | +fi |
| 192 | + |
| 193 | +for ref in "${RAW[@]}"; do |
| 194 | + case "$ref" in |
| 195 | + ./*|.\\*) continue ;; # local action, carries no SHA |
| 196 | + */.github/workflows/*) continue ;; # reusable workflow, not keyed |
| 197 | + esac |
| 198 | + case "$ref" in |
| 199 | + *.yml@*|*.yaml@*) continue ;; # reusable workflow, any path |
| 200 | + esac |
| 201 | + |
| 202 | + sha="${ref##*@}" |
| 203 | + path="${ref%@*}" |
| 204 | + owner="${path%%/*}" |
| 205 | + rest="${path#*/}" |
| 206 | + repo="${rest%%/*}" |
| 207 | + [ -n "$owner" ] && [ -n "$repo" ] || continue |
| 208 | + norm="$(printf '%s/%s@%s' "$owner" "$repo" "$sha" | tr '[:upper:]' '[:lower:]')" |
| 209 | + |
| 210 | + skip=0 |
| 211 | + for ex in ${EXPECTED_ABSENT[@]+"${EXPECTED_ABSENT[@]}"}; do |
| 212 | + ex_lc="$(printf '%s' "$ex" | tr '[:upper:]' '[:lower:]')" |
| 213 | + if [ "$norm" = "$ex_lc" ]; then |
| 214 | + SEEN_ABSENT+=("$ex_lc") |
| 215 | + skip=1 |
| 216 | + break |
| 217 | + fi |
| 218 | + done |
| 219 | + [ "$skip" -eq 1 ] && continue |
| 220 | + |
| 221 | + CHECKED=$((CHECKED + 1)) |
| 222 | + if ! lock_has "$norm"; then |
| 223 | + echo -e "${RED}[validate-actions-lock] ERROR: not in actions.lock: ${ref}${NC}" >&2 |
| 224 | + echo " normalised to: $norm" >&2 |
| 225 | + ERRORS=$((ERRORS + 1)) |
| 226 | + elif lock_has_self_sha "$norm"; then |
| 227 | + echo -e "${RED}[validate-actions-lock] ERROR: self-SHA ref in actions.lock dependencies: ${norm}${NC}" >&2 |
| 228 | + echo " leaf action entries must carry a tag/branch ref, not their own commit SHA (standards#1271)" >&2 |
| 229 | + ERRORS=$((ERRORS + 1)) |
| 230 | + fi |
| 231 | +done |
| 232 | + |
| 233 | +# A doctrine exception that is no longer used must be removed, or the list |
| 234 | +# becomes a place where real coverage gaps can hide. |
| 235 | +for ex in ${EXPECTED_ABSENT[@]+"${EXPECTED_ABSENT[@]}"}; do |
| 236 | + ex_lc="$(printf '%s' "$ex" | tr '[:upper:]' '[:lower:]')" |
| 237 | + found=0 |
| 238 | + for s in ${SEEN_ABSENT[@]+"${SEEN_ABSENT[@]}"}; do |
| 239 | + [ "$s" = "$ex_lc" ] && found=1 && break |
| 240 | + done |
| 241 | + if [ "$found" -eq 0 ]; then |
| 242 | + echo -e "${YELLOW}[validate-actions-lock] WARNING: stale exception, no workflow uses ${ex} -- remove it from EXPECTED_ABSENT${NC}" >&2 |
| 243 | + fi |
| 244 | +done |
| 245 | + |
| 246 | +if [ "$ERRORS" -gt 0 ]; then |
| 247 | + echo -e "${RED}[validate-actions-lock] ${ERRORS} ref(s) missing from the lockfile${NC}" >&2 |
| 248 | + echo " Regenerate with the LOCKFILE ONLY, and verify the *.yml diff is empty:" >&2 |
| 249 | + echo " gh actions-lock <workflow paths> --no-migrate-local-actions --no-narrow" >&2 |
| 250 | + echo " git diff --stat -- '.github/workflows/*.yml' # MUST be empty" >&2 |
| 251 | + exit 1 |
| 252 | +fi |
| 253 | + |
| 254 | +echo -e "${GREEN}[validate-actions-lock] ✅ ${CHECKED} SHA-pinned ref(s) found among ${#LOCK_KEYS[@]} lockfile keys, ${#EXPECTED_ABSENT[@]} doctrine exception(s)${NC}" |
| 255 | +exit 0 |
0 commit comments