From dd59b59e71f3442a81000a1bed87db38a2e4e189 Mon Sep 17 00:00:00 2001 From: quality <280983584+hivecommons-hive[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:30:59 -0400 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20fix(scripts):=20action-pins?= =?UTF-8?q?=20gate=20fails=20silently=20on=20a=20tree=20with=20no=20pins;?= =?UTF-8?q?=20add=20self-test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-action-pins.sh only ever ran against this repository's own, correct workflows, so none of its failure branches had executed anywhere. Writing a hermetic self-test for it (src/scripts/test-check-action-pins.sh, fake `gh`, synthetic workflow directories) exposed two defects: - a directory with no SHA-pinned `uses:` made grep exit 1, and pipefail fed that straight into `fail=1`: "action pin check FAILED" with no finding printed above it; - NOT-PINNED printed the line number where the file name belongs (`grep -rhn` drops the name), so the message never said which workflow. The resolver now collects pins first and iterates, reporting every missing SHA instead of stopping at the first; the shape check uses `grep -rHn` so the message names the file. The self-test covers NOT-PINNED (tag, branch, short and upper-case hex), MISSING-SHA, reusable-workflow owner/repo extraction, pin de-duplication, the offline (no gh) path, EMPTY-EXPRESSION, BRACKETED-EMAIL with the numeric-noreply exclusion, combined failures and the default directory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: quality <280983584+hivecommons-hive[bot]@users.noreply.github.com> --- changelog.d/fixed-check-action-pins-gate.md | 1 + src/scripts/check-action-pins.sh | 19 +- src/scripts/test-check-action-pins.sh | 405 ++++++++++++++++++++ 3 files changed, 420 insertions(+), 5 deletions(-) create mode 100644 changelog.d/fixed-check-action-pins-gate.md create mode 100755 src/scripts/test-check-action-pins.sh diff --git a/changelog.d/fixed-check-action-pins-gate.md b/changelog.d/fixed-check-action-pins-gate.md new file mode 100644 index 0000000000..2d5b09e9b4 --- /dev/null +++ b/changelog.d/fixed-check-action-pins-gate.md @@ -0,0 +1 @@ +- The action-pins CI gate (`src/scripts/check-action-pins.sh`) no longer reports `action pin check FAILED` with no finding when a workflow directory contains no SHA-pinned `uses:` lines (pipefail turned grep's empty match into a failure), and its `NOT-PINNED` message now names the workflow file rather than the line number where the file name belonged. The resolver reports every missing SHA instead of stopping at the first, and the gate now has a hermetic self-test, `src/scripts/test-check-action-pins.sh`, covering each of its failure branches. diff --git a/src/scripts/check-action-pins.sh b/src/scripts/check-action-pins.sh index 30fcb7f217..e60dd905a0 100755 --- a/src/scripts/check-action-pins.sh +++ b/src/scripts/check-action-pins.sh @@ -30,7 +30,9 @@ while IFS= read -r line; do [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;; *) echo "NOT-PINNED $file: $ref (use a 40-hex commit SHA)"; fail=1; continue ;; esac -done < <(grep -rhnE '^\s*(-\s*)?uses:\s*[a-zA-Z0-9._-]+/' "$DIR" 2>/dev/null | grep -v '^\s*#') +# -H keeps the file name in front of the line number even for a single-file +# DIR, so `${line%%:*}` names the workflow and not its line number. +done < <(grep -rHnE '^\s*(-\s*)?uses:\s*[a-zA-Z0-9._-]+/' "$DIR" 2>/dev/null | grep -v '^\s*#') # Resolve each unique pin. Skipped without a token so the script stays usable # offline; CI always has one. @@ -38,15 +40,22 @@ if command -v gh >/dev/null 2>&1; then # Extract owner/repo from the `uses:` VALUE, taking only the first two path # segments: a reusable workflow is `owner/repo/.github/workflows/x.yml@SHA`, # so naively splitting on the last '/' asks the API about a path, not a repo. - grep -rhoE 'uses:[[:space:]]*[a-zA-Z0-9._-]+/[a-zA-Z0-9._/-]+@[0-9a-f]{40}' "$DIR" 2>/dev/null \ - | sed -E 's/^uses:[[:space:]]*//' | sort -u | while read -r ref; do + # + # Collected first, then iterated: when the pipeline's status fed `|| fail=1` + # directly, a tree with no SHA-pinned `uses:` at all made grep exit 1 and + # pipefail turned that into a FAILED verdict with no finding printed. + # Nothing to resolve is a pass, not a failure. + pins="$(grep -rhoE 'uses:[[:space:]]*[a-zA-Z0-9._-]+/[a-zA-Z0-9._/-]+@[0-9a-f]{40}' "$DIR" 2>/dev/null \ + | sed -E 's/^uses:[[:space:]]*//' | sort -u)" + while read -r ref; do + [ -z "$ref" ] && continue sha="${ref#*@}"; path="${ref%@*}" repo="$(printf '%s' "$path" | cut -d/ -f1,2)" if ! gh api "repos/$repo/commits/$sha" --jq '.sha' >/dev/null 2>&1; then echo "MISSING-SHA $repo@$sha does not exist in that repository" - exit 1 + fail=1 fi - done || fail=1 + done <<<"$pins" fi # An EMPTY workflow-expression delimiter pair anywhere in a workflow file is a diff --git a/src/scripts/test-check-action-pins.sh b/src/scripts/test-check-action-pins.sh new file mode 100755 index 0000000000..c796f1384b --- /dev/null +++ b/src/scripts/test-check-action-pins.sh @@ -0,0 +1,405 @@ +#!/usr/bin/env bash +# Exercises check-action-pins.sh with a hermetic `gh` test double: no network +# and no real workflow files are used. Each case writes a synthetic workflow +# directory, runs the check against it with a fake `gh` first on PATH that +# records every API call and can be told which SHA to report missing, then +# asserts the exit status and the verdict printed. +# +# The check's job is to fail closed. In CI it only ever sees this repository's +# own (correct) workflows, so every failure branch (NOT-PINNED, MISSING-SHA, +# EMPTY-EXPRESSION, BRACKETED-EMAIL), the numeric-noreply exclusion and the +# reusable-workflow owner/repo extraction would otherwise run for the first +# time on the day they matter. This test runs them on every change to the +# script. +# +# Usage: bash src/scripts/test-check-action-pins.sh (from the repository root) +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT="${ROOT}/src/scripts/check-action-pins.sh" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/check-action-pins-test.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT + +failures=0 +pass() { printf ' ok: %s\n' "$*"; } +bad() { + printf ' FAIL: %s\n' "$*" + failures=$((failures + 1)) +} +# Here-strings, not pipes, so a grep that stops reading early cannot turn +# pipefail into a false FAIL. +assert_contains() { + local haystack="$1" needle="$2" msg="$3" + if grep -qF -- "$needle" <<<"$haystack"; then pass "$msg"; else bad "$msg (missing: $needle)"; fi +} +assert_not_contains() { + local haystack="$1" needle="$2" msg="$3" + if grep -qF -- "$needle" <<<"$haystack"; then bad "$msg (unexpected: $needle)"; else pass "$msg"; fi +} +assert_exit() { + local want="$1" got="$2" msg="$3" + if [ "$want" = "$got" ]; then pass "$msg (exit $got)"; else bad "$msg (want exit $want, got $got)"; fi +} +assert_eq() { + local want="$1" got="$2" msg="$3" + if [ "$want" = "$got" ]; then pass "$msg ($got)"; else bad "$msg (want $want, got $got)"; fi +} + +# Fake `gh`: only `gh api repos///commits/ --jq .sha` is +# ever called by the check. Every call is appended to $FAKE_GH_LOG; a call +# whose SHA equals $FAKE_GH_MISSING fails like the real API does for a commit +# that does not exist in the repository. +BIN="$TMP/bin" +mkdir -p "$BIN" +cat >"$BIN/gh" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"${FAKE_GH_LOG:?}" +case "$2" in + *"/commits/${FAKE_GH_MISSING:-no-such-sha}") exit 1 ;; +esac +printf '%s\n' "${2##*/}" +exit 0 +SH +chmod +x "$BIN/gh" + +# A PATH with every external tool the check uses except gh, so the "no token, +# stay usable offline" branch can be exercised without uninstalling anything. +NOGH="$TMP/nogh" +mkdir -p "$NOGH" +for tool in grep sed sort cut head; do + ln -s "$(command -v "$tool")" "$NOGH/$tool" +done + +SHA_A=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +SHA_B=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +SHA_C=cccccccccccccccccccccccccccccccccccccccc + +# workflow DIR NAME <"$1/$2"; } + +# run_check CASE [env VAR=VALUE ...] -- ARGS... +# Runs the check with the fake gh first on PATH. Leaves stdout+stderr in +# $TMP/.out, the exit status in RC, the output in OUT and the fake gh's +# call log in GH_CALLS. +run_check() { + local name="$1"; shift + local envs=() + while [ "$#" -gt 0 ] && [ "$1" != "--" ]; do envs+=("$1"); shift; done + shift + : >"$TMP/$name.gh" + ( + env FAKE_GH_LOG="$TMP/$name.gh" "${envs[@]}" PATH="$BIN:$PATH" bash "$SCRIPT" "$@" + ) >"$TMP/$name.out" 2>&1 + RC=$? + OUT="$(cat "$TMP/$name.out")" + GH_CALLS="$(cat "$TMP/$name.gh")" +} + +echo "case: every action pinned to a resolvable SHA" +D="$TMP/green" +workflow "$D" ci.yml <"$BIN/gh" <>"\${FAKE_GH_LOG:?}" +case "\$2" in + */commits/\${FAKE_GH_MISSING:-no-such-sha}|*/commits/$FAKE_SECOND_MISSING) exit 1 ;; +esac +printf '%s\n' "\${2##*/}" +exit 0 +SH +run_check missing-two FAKE_GH_MISSING="$SHA_C" -- "$D" +assert_exit 1 "$RC" "two fabricated SHAs fail" +assert_contains "$OUT" "MISSING-SHA anchore/sbom-action@$SHA_C" "first missing SHA reported" +assert_contains "$OUT" "MISSING-SHA actions/checkout@$SHA_A" "second missing SHA reported too" +assert_contains "$GH_CALLS" "api repos/actions/setup-go/commits/$SHA_B" "resolution continues past a missing SHA" +assert_eq 3 "$(grep -c . <<<"$GH_CALLS")" "every unique pin is still resolved" +# Restore the single-miss double for the remaining cases. +cat >"$BIN/gh" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"${FAKE_GH_LOG:?}" +case "$2" in + *"/commits/${FAKE_GH_MISSING:-no-such-sha}") exit 1 ;; +esac +printf '%s\n' "${2##*/}" +exit 0 +SH + +echo "case: a directory with no third-party actions passes with gh present" +D="$TMP/nopins" +workflow "$D" ci.yml <"$TMP/nogh.gh" +OUT="$( env FAKE_GH_LOG="$TMP/nogh.gh" PATH="$NOGH" "$(command -v bash)" "$SCRIPT" "$D" 2>&1 )" +RC=$? +assert_exit 0 "$RC" "a pinned workflow passes without gh" +assert_contains "$OUT" "action pin check OK" "prints the pass verdict offline" +assert_eq 0 "$(grep -c . <"$TMP/nogh.gh")" "no API calls attempted without gh" +workflow "$D" bad.yml <&1 )" +RC=$? +assert_exit 1 "$RC" "the shape check still fails without gh" +assert_contains "$OUT" "NOT-PINNED" "NOT-PINNED reported offline" + +echo "case: an empty expression delimiter pair" +D="$TMP/emptyexpr" +workflow "$D" ci.yml <"$TMP/default.gh" +OUT="$( cd "$D" && env FAKE_GH_LOG="$TMP/default.gh" PATH="$BIN:$PATH" bash "$SCRIPT" 2>&1 )" +RC=$? +assert_exit 1 "$RC" "the default directory is scanned when no argument is given" +assert_contains "$OUT" "NOT-PINNED .github/workflows/ci.yml: actions/checkout@v4" "reports the path relative to the default directory" + +echo +if [ "$failures" -eq 0 ]; then + echo "check-action-pins tests: all passed" + exit 0 +fi +echo "check-action-pins tests: $failures failure(s)" +exit 1 From 3d88954bdd5c7e5c65bc9e9cf142fd373fd40457 Mon Sep 17 00:00:00 2001 From: quality <280983584+hivecommons-hive[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:20:11 -0400 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=94=A7=20chore(deps):=20bump=20golang?= =?UTF-8?q?.org/x/net=20to=20v0.62.0=20(GO-2026-6860)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns go.mod/go.sum with v5 so govulncheck passes on this branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: quality <280983584+hivecommons-hive[bot]@users.noreply.github.com> --- src/go.mod | 4 ++-- src/go.sum | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/go.mod b/src/go.mod index d91689a7d1..a787500400 100644 --- a/src/go.mod +++ b/src/go.mod @@ -5,6 +5,7 @@ go 1.26.10 require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/ansi v0.10.1 github.com/charmbracelet/x/exp/teatest v0.0.0-20260823001701-96af6d2cb5f6 github.com/charmbracelet/x/term v0.2.2 github.com/fsnotify/fsnotify v1.10.1 @@ -34,7 +35,6 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-udiff v0.3.1 // indirect github.com/charmbracelet/colorprofile v0.3.2 // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect @@ -67,7 +67,7 @@ require ( go.uber.org/automaxprocs v1.6.0 go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect - golang.org/x/net v0.61.0 + golang.org/x/net v0.62.0 golang.org/x/sys v0.49.0 golang.org/x/text v0.43.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect diff --git a/src/go.sum b/src/go.sum index ac522ab39e..7214b2e5f6 100644 --- a/src/go.sum +++ b/src/go.sum @@ -135,8 +135,8 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= -golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= -golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=