Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
5dcfaa5
fix(assail): execute declared configuration and build input adapters
hyperpolymath Nov 4, 2026
ff278a3
fix(ci): repin startup-failing reusable callers to canonical standards
hyperpolymath Nov 4, 2026
459a03d
fix(assail): close reproduced input adapter review gaps
hyperpolymath Nov 4, 2026
566adfb
fix(assail): qualify adapter detection and canonical sources
hyperpolymath Nov 4, 2026
5dfc5c7
docs: document assail input adapters, script extraction, and error ha…
coderabbitai[bot] Nov 4, 2026
2e1cdeb
fix(ci): delegate native lock integrity to published canonical tooling
hyperpolymath Nov 4, 2026
fca4977
fix(ci): reject hidden source state before native qualification
hyperpolymath Nov 4, 2026
b93461a
fix(ci): apply the official canonical lock recovery candidate
hyperpolymath Nov 4, 2026
dbc3cb4
fix(ci): initialize runner-local integrity paths inside the job
hyperpolymath Nov 4, 2026
e711e35
fix(ci): admit the existing pinned SLSA reusable workflow to tree policy
hyperpolymath Nov 4, 2026
41cc818
Extract bounded input checks and clarify CI shell cases
hyperpolymath Nov 4, 2026
00638da
Apply hosted rustfmt output for extracted input checks
hyperpolymath Nov 4, 2026
6ba1ab6
Merge branch 'main' into fix/declared-input-coverage-20261104-1f104328
hyperpolymath Nov 4, 2026
b99b9ea
fix: require Docker action digests and check HTTP hosts after userinfo
coderabbitai[bot] Nov 4, 2026
d26e722
fix: preserve inner quotes when parsing HTML attributes and CSS values
coderabbitai[bot] Nov 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
331 changes: 84 additions & 247 deletions .githooks/validate-actions-lock.sh
Original file line number Diff line number Diff line change
@@ -1,255 +1,92 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MPL-2.0
# Actions lockfile coverage: every SHA-pinned `uses:` ref in .github/workflows/
# AND in .github/actions/*/action.yml must have a matching entry in
# .github/workflows/actions.lock.
#
# WHY THIS EXISTS
#
# GitHub validates a reusable workflow against the CALLEE repo's own
# actions.lock. A lockfile that has drifted from its workflows therefore
# does not break this repo -- it breaks every repo that calls into it, with
# `failure` (not `startup_failure`), 0 jobs, and no reason in either the
# REST or the GraphQL payload; only the run page says why. actionlint
# passes either way. Dependabot bumps a `uses:` SHA without regenerating
# the lock, and Dependabot never runs pre-commit -- so CI is the only place
# this is caught before it reaches the callers.
#
# WHY NOT `gh actions-lock --verify-local`, measured 2026-09-15 on v0.1.6:
#
# * It WRITES. It rewrote `uses: ./.github/actions/signed-push` to the
# invalid `uses: $/.github/actions/signed-push` -- a ref that kills the
# workflow at startup -- while running in a mode whose own help text
# calls it read-only and "ideal for pre-commit hooks".
# --no-migrate-local-actions suppresses that, but then the tool stops
# descending into local composite actions and misreports their
# dependencies as stale. Safety and accuracy are in conflict there.
# * Its coverage is REPO-scoped, not SHA-exact. Bumping ONE of a workflow's
# two refs to the same action leaves the old lock key still referenced by
# the other: nothing reported stale, nothing reported missing, check
# green, workflow broken. That mutant survives it and dies here.
#
# SCOPE -- stated, not implied:
# * Only 40-hex SHA-pinned refs are checked. Tag refs (@v2, @main) are a
# different question, gated by validate-sha-pins.sh.
# * Reusable WORKFLOW refs (owner/repo/.github/workflows/x.yml@sha) are
# skipped: the lockfile models actions and keys no entry for them.
# * Local refs (./...) are skipped; they carry no SHA.
# * Sub-path actions normalise to their repo -- codeql-action/init@X is
# keyed once as codeql-action@X, never once per sub-path.
# * Comparison is case-insensitive: workflows say Swatinem/rust-cache while
# the lockfile stores swatinem/rust-cache.
# * Local COMPOSITE actions (.github/actions/*/action.yml) are scanned too.
# Their deps are keyed in the lock under the workflow that uses them, and
# Dependabot bumps them like any other ref; scanning only the workflow
# directory leaves that drift invisible.
# * Membership is GLOBAL, not per-workflow-section. A ref present in some
# other workflow's lock section satisfies this check. A workflow with
# SHA-pinned refs but no lock section of its own is therefore NOT
# detected here -- see the PR notes.

# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
# Native coverage plus the published canonical reusable/local-action lock-sync
# boundary. No native YAML is generated, patched, or inferred here.
set -euo pipefail

REPO_ROOT="${INPUT_PATH:-.}"
LOCKFILE="$REPO_ROOT/.github/workflows/actions.lock"
WORKFLOW_DIR="$REPO_ROOT/.github/workflows"
ACTIONS_DIR="$REPO_ROOT/.github/actions"

RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'

# Refs deliberately absent from the lockfile. Each entry is an exact
# normalised owner/repo@sha plus the reason it is absent. This is an explicit
# list, never a pattern: anything not named here fails, so the list cannot
# quietly widen to cover an accident. An entry that stops being used is
# reported as stale, so it cannot rot either.
EXPECTED_ABSENT=(
# Empty: the denoland/setup-deno exception was removed 2026-09-22 after
# the last consumer went away (the validator itself reported it stale).
# Estate doctrine is still bun-only; if a deno ref ever returns, the
# cure is to remove the consumer, not to re-add an exception.
)

if [ ! -f "$LOCKFILE" ]; then
echo -e "${RED}[validate-actions-lock] ERROR: $LOCKFILE not found${NC}" >&2
exit 1
fi

# An unreadable lockfile must abort, never silently pass.
if [ ! -r "$LOCKFILE" ]; then
echo -e "${RED}[validate-actions-lock] ERROR: $LOCKFILE unreadable${NC}" >&2
exit 1
fi

# Parse the lockfile's keys into an explicit set ONCE, rather than substring
# -matching against the whole file. A substring test over a blob fails open in
# ways that are hard to see: if the blob is empty or truncated, every ref is
# reported "missing" and the operator is told to regenerate a lockfile that
# was actually fine. An explicit set can be counted, and is counted below.
mapfile -t LOCK_KEYS < <(
grep -oE "'[A-Za-z0-9._-]+/[A-Za-z0-9._-]+@[0-9a-fA-F]{40}'" "$LOCKFILE" \
| tr -d "'" | tr '[:upper:]' '[:lower:]' | sort -u
)

# Positive control: the lockfile is known to key at least one action. If the
# parse yields nothing, the FILE FORMAT changed or the read failed -- say so,
# rather than reporting every ref in the repo as missing.
if [ "${#LOCK_KEYS[@]}" -eq 0 ]; then
echo -e "${RED}[validate-actions-lock] ERROR: parsed 0 keys from $LOCKFILE${NC}" >&2
echo " The lockfile exists but no 'owner/repo@sha' keys were found." >&2
echo " This is a parser/format failure, NOT a coverage failure." >&2
exit 1
fi

lock_has() {
local needle="$1" k
for k in "${LOCK_KEYS[@]}"; do
[ "$k" = "$needle" ] && return 0
done
return 1
}

# Leaf action entries in `dependencies:` whose `ref:` equals their own 40-hex
# SHA pin fail GitHub runner lockfile verification during `Set up job`
# ("Unable to resolve action ...: invalid lockfile pin", standards#1271).
mapfile -t SELF_SHA_KEYS < <(
awk '
/^dependencies:/ { in_dep = 1; next }
/^[a-z_]+:/ { in_dep = 0; cur = ""; next }
!in_dep { next }
/^ \x27[^\x27]+\x27:$/ {
cur = $0; sub(/^ \x27/, "", cur); sub(/\x27:$/, "", cur)
next
}
cur != "" && /^ ref: \x27[^\x27]+\x27$/ {
r = $0; sub(/^ ref: \x27/, "", r); sub(/\x27$/, "", r)
n = index(cur, "@")
pin = (n > 0) ? substr(cur, n + 1) : ""
if (length(r) == 40 && r ~ /^[0-9a-fA-F]+$/ && tolower(r) == tolower(pin)) {
print tolower(cur)
}
cur = ""
}
' "$LOCKFILE" | sort -u
)

# Check a normalized owner/repo@sha key against the parsed self-SHA entries.
# Return 0 if its dependency ref equals its own SHA pin, or 1 if absent.
lock_has_self_sha() {
local needle="$1" k
for k in ${SELF_SHA_KEYS[@]+"${SELF_SHA_KEYS[@]}"}; do
[ "$k" = "$needle" ] && return 0
done
return 1
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
assert_clean_checkout() {
local checkout=$1 git_state
# Git's normal status can hide modified consumed files behind these flags.
# NUL records keep unusual filenames from changing the flag interpretation.
git -C "$checkout" ls-files -v -z |
while IFS= read -r -d '' entry; do
case "${entry:0:1}" in
[a-z]|S)
echo "validate-actions-lock: unsafe index flags in $checkout; source bytes are unqualified" >&2
exit 2
;;
*) : ;; # Other index flags continue through the full cleanliness check.
esac
done
git_state="$(git -C "$checkout" status --porcelain --untracked-files=all --ignored=matching)" ||
{ echo "validate-actions-lock: cannot inspect source state in $checkout" >&2; return 2; }
[[ -z "$git_state" ]] ||
{ echo "validate-actions-lock: tracked, untracked or ignored changes in $checkout; use an isolated clean checkout" >&2; return 2; }
}

ERRORS=0
CHECKED=0
declare -a SEEN_ABSENT=()

# Collect every SHA-pinned uses: ref across all workflow files.
#
# Read with yq, never grep (YAML-POLICY Y-1). A line grep for `uses:` only
# sees block style: on a KYAML file (`uses: "owner/repo@sha", # v1`) it
# captured the quote and comma into the ref and reported a pinned, locked
# action as missing. The parser returns the scalar VALUE whatever the style,
# and never matches a `uses:` that is text inside a `run:` body.
# Measured 2026-10-01: same 29-ref set as the old grep on the block tree.
if ! command -v yq >/dev/null 2>&1; then
echo -e "${RED}[validate-actions-lock] ERROR: yq not found -- it reads the workflows (YAML-POLICY Y-1)${NC}" >&2
exit 1
fi
declare -a UNPARSED=()
mapfile -t RAW < <(
for f in "$WORKFLOW_DIR"/*.yml "$WORKFLOW_DIR"/*.yaml \
"$ACTIONS_DIR"/*/action.yml "$ACTIONS_DIR"/*/action.yaml; do
[ -f "$f" ] || continue
yq -r '.. | select(tag == "!!map") | select(has("uses")) | .uses | select(tag == "!!str")' "$f" \
|| printf '\001UNPARSED\001%s\n' "$f"
done | grep -E $'@[0-9a-fA-F]{40}$|^\001UNPARSED\001' | sort -u
)
# A file yq cannot parse is a file whose refs went unchecked: fail closed.
for ref in "${RAW[@]}"; do
case "$ref" in $'\001UNPARSED\001'*) UNPARSED+=("${ref#$'\001UNPARSED\001'}") ;; esac
STANDARDS_REF=087f6bff5135ce3da6f287c83752256ce4a8efc1
NATIVE_SHA256=79ec84c191667fd935a277e6fd3662cf5d3669cccc9e99998731d8f306f373b6
: "${STANDARDS_ROOT:?checkout the pinned metadatastician/standards source; see docs/CI-CD.adoc}"
: "${GH_ACTIONS_LOCK_BIN:?path to the official gh-actions-lock v0.2.0 linux-amd64 binary}"
for tool in git jq yq sha256sum diff; do
command -v "$tool" >/dev/null || { echo "validate-actions-lock: missing $tool" >&2; exit 2; }
done
if [ "${#UNPARSED[@]}" -gt 0 ]; then
echo -e "${RED}[validate-actions-lock] ERROR: yq could not parse: ${UNPARSED[*]}${NC}" >&2
exit 1
fi

# A zero-input pass is the classic fake green: if ref extraction ever breaks,
# this script would report success having checked nothing. If the lockfile
# already names workflows, finding no refs is a contradiction, not an empty
# repo -- fail instead of passing vacuously. The success line below also
# prints the number checked, so a silent collapse to near-zero is visible.
if [ "${#RAW[@]}" -eq 0 ]; then
if grep -qE "^ '\\.github/workflows/" "$LOCKFILE"; then
echo -e "${RED}[validate-actions-lock] ERROR: no SHA-pinned refs extracted, yet the lockfile names workflows -- extraction is broken${NC}" >&2
exit 1
[[ "$GH_ACTIONS_LOCK_BIN" = /* && -f "$GH_ACTIONS_LOCK_BIN" && -x "$GH_ACTIONS_LOCK_BIN" ]] ||
{ echo 'validate-actions-lock: official native binary is unavailable' >&2; exit 2; }
printf '%s %s\n' "$NATIVE_SHA256" "$GH_ACTIONS_LOCK_BIN" | sha256sum --check --status ||
{ echo 'validate-actions-lock: native binary digest mismatch' >&2; exit 2; }
STANDARDS_ROOT="$(cd "$STANDARDS_ROOT" && pwd -P)"
[[ "$(git -C "$STANDARDS_ROOT" rev-parse --show-toplevel)" = "$STANDARDS_ROOT" ]] ||
{ echo 'validate-actions-lock: Standards path must be its repository root' >&2; exit 2; }
[[ "$(git -C "$STANDARDS_ROOT" rev-parse HEAD)" = "$STANDARDS_REF" ]] ||
{ echo 'validate-actions-lock: wrong Standards source revision' >&2; exit 2; }
assert_clean_checkout "$STANDARDS_ROOT"

cd "$ROOT"
[[ "$(git rev-parse --show-toplevel)" = "$ROOT" ]] ||
{ echo 'validate-actions-lock: consumer path must be its repository root' >&2; exit 2; }
assert_clean_checkout "$ROOT"
[[ -f .github/workflows/actions.lock ]] ||
{ echo 'validate-actions-lock: missing native lock; use canonical recovery' >&2; exit 1; }
snapshot="$(mktemp -d)"
cp -a .github/workflows "$snapshot/workflows"
cleanup() {
local status=$?
if ! diff -qr "$snapshot/workflows" .github/workflows; then
echo 'validate-actions-lock: verification changed workflow/lock bytes; refusing success' >&2
status=1
fi
echo -e "${YELLOW}[validate-actions-lock] no SHA-pinned refs found -- nothing to check${NC}"
exit 0
fi

for ref in "${RAW[@]}"; do
case "$ref" in
./*|.\\*) continue ;; # local action, carries no SHA
*/.github/workflows/*) continue ;; # reusable workflow, not keyed
esac
case "$ref" in
*.yml@*|*.yaml@*) continue ;; # reusable workflow, any path
esac

sha="${ref##*@}"
path="${ref%@*}"
owner="${path%%/*}"
rest="${path#*/}"
repo="${rest%%/*}"
[ -n "$owner" ] && [ -n "$repo" ] || continue
norm="$(printf '%s/%s@%s' "$owner" "$repo" "$sha" | tr '[:upper:]' '[:lower:]')"

skip=0
for ex in ${EXPECTED_ABSENT[@]+"${EXPECTED_ABSENT[@]}"}; do
ex_lc="$(printf '%s' "$ex" | tr '[:upper:]' '[:lower:]')"
if [ "$norm" = "$ex_lc" ]; then
SEEN_ABSENT+=("$ex_lc")
skip=1
break
fi
done
[ "$skip" -eq 1 ] && continue

CHECKED=$((CHECKED + 1))
if ! lock_has "$norm"; then
echo -e "${RED}[validate-actions-lock] ERROR: not in actions.lock: ${ref}${NC}" >&2
echo " normalised to: $norm" >&2
ERRORS=$((ERRORS + 1))
elif lock_has_self_sha "$norm"; then
echo -e "${RED}[validate-actions-lock] ERROR: self-SHA ref in actions.lock dependencies: ${norm}${NC}" >&2
echo " leaf action entries must carry a tag/branch ref, not their own commit SHA (standards#1271)" >&2
ERRORS=$((ERRORS + 1))
rm -rf "$snapshot"
if [[ "$status" -eq 0 ]]; then
echo 'validate-actions-lock: canonical native verification and lock sync passed; live GitHub admission is not established'
fi
done

# A doctrine exception that is no longer used must be removed, or the list
# becomes a place where real coverage gaps can hide.
for ex in ${EXPECTED_ABSENT[@]+"${EXPECTED_ABSENT[@]}"}; do
ex_lc="$(printf '%s' "$ex" | tr '[:upper:]' '[:lower:]')"
found=0
for s in ${SEEN_ABSENT[@]+"${SEEN_ABSENT[@]}"}; do
[ "$s" = "$ex_lc" ] && found=1 && break
done
if [ "$found" -eq 0 ]; then
echo -e "${YELLOW}[validate-actions-lock] WARNING: stale exception, no workflow uses ${ex} -- remove it from EXPECTED_ABSENT${NC}" >&2
fi
done

if [ "$ERRORS" -gt 0 ]; then
echo -e "${RED}[validate-actions-lock] ${ERRORS} ref(s) missing from the lockfile${NC}" >&2
echo " Regenerate with the LOCKFILE ONLY, and verify the *.yml diff is empty:" >&2
echo " gh actions-lock <workflow paths> --no-migrate-local-actions --no-narrow" >&2
echo " git diff --stat -- '.github/workflows/*.yml' # MUST be empty" >&2
exit 1
fi

echo -e "${GREEN}[validate-actions-lock] ✅ ${CHECKED} SHA-pinned ref(s) found among ${#LOCK_KEYS[@]} lockfile keys, ${#EXPECTED_ABSENT[@]} doctrine exception(s)${NC}"
exit 0
exit "$status"
}
trap cleanup EXIT
export GH_ACTIONS_LOCK_BIN
export ACTIONS_LOCK_EVIDENCE_DIR="${ACTIONS_LOCK_EVIDENCE_DIR:-$snapshot/evidence}"
mkdir -p "$ACTIONS_LOCK_EVIDENCE_DIR"
printf 'not_run\n' > "$ACTIONS_LOCK_EVIDENCE_DIR/native.exit"
jq -n \
--arg subject "$(git rev-parse HEAD)" \
--arg tree "$(git rev-parse HEAD^{tree})" \
--arg authority "$STANDARDS_REF" \
--arg native_sha256 "$NATIVE_SHA256" \
--arg run_id "${GITHUB_RUN_ID:-}" \
--arg run_attempt "${GITHUB_RUN_ATTEMPT:-}" \
'{subject_commit:$subject,subject_tree:$tree,standards_commit:$authority,
native_version:"v0.2.0",native_sha256:$native_sha256,
run_id:$run_id,run_attempt:$run_attempt}' \
> "$ACTIONS_LOCK_EVIDENCE_DIR/source.json"

GH_BIN="$ROOT/scripts/ci/actions-lock-transport.sh" \
bash "$STANDARDS_ROOT/scripts/update-actions-lock.sh" --verify-local
case "$(cat "$ACTIONS_LOCK_EVIDENCE_DIR/native.exit")" in
0|1) ;;
*)
echo 'validate-actions-lock: helper returned without a completed native verifier result' >&2
exit 1
;;
esac
bash "$STANDARDS_ROOT/scripts/check-lock-sync.sh" .github/workflows
6 changes: 6 additions & 0 deletions .github/actions-allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@
"leanprover/lean-action@*",
"ludeeus/action-shellcheck@*",
"lycheeverse/lychee-action@*",
"metadatastician/standards/.github/workflows/governance-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12",
"metadatastician/standards/.github/workflows/hypatia-scan-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12",
"metadatastician/standards/.github/workflows/mirror-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7",
"metadatastician/standards/.github/workflows/rust-ci-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12",
"metadatastician/standards/.github/workflows/scorecard-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7",
"mlugg/setup-zig@*",
"ocaml/setup-ocaml@*",
"orhun/git-cliff-action@*",
Expand All @@ -83,6 +88,7 @@
"rustsec/audit-check@*",
"shivammathur/setup-php@*",
"slsa-framework/slsa-github-generator@*",
"slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a",
"softprops/action-gh-release@*",
"SonarSource/sonarcloud-github-action@*",
"SonarSource/sonarqube-scan-action@*",
Expand Down
Loading