diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 475355db7..9a1c9e7fe 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -73,6 +73,7 @@ run_validator "SPDX headers" "validate-spdx.sh" "staged" # Workflow validation run_validator "Workflow SPDX headers" "validate-spdx-workflows.sh" "staged" run_validator "Workflow SHA-pinning" "validate-sha-pins.sh" "staged" +run_validator "Actions lockfile coverage" "validate-actions-lock.sh" "staged" run_validator "Workflow permissions" "validate-permissions.sh" "staged" run_validator "CodeQL configuration" "validate-codeql.sh" "staged" run_validator "Bot directives" "validate-bot-directives.sh" "staged" diff --git a/.githooks/validate-a2ml.sh b/.githooks/validate-a2ml.sh index fb06f810e..997f99108 100755 --- a/.githooks/validate-a2ml.sh +++ b/.githooks/validate-a2ml.sh @@ -10,7 +10,17 @@ ERRORS=0 validate_file() { local file="$1" - + + # A machine-generated manifest is the generator's responsibility, not the + # committer's. Skipping it breaks a hard deadlock in .githooks/pre-commit: + # the registry-drift gate FAILS every commit until you regenerate and stage + # .machine_readable/REGISTRY.a2ml, and this validator then REJECTED that very + # file -- so no ordering satisfied both gates and --no-verify was the only + # exit. Narrow by construction: 2 of 222 tracked .a2ml files are generated. + if head -20 "$file" | grep -qE '^#[[:space:]]*GENERATED FILE.*DO NOT EDIT BY HAND'; then + return 0 + fi + # Check required fields if ! grep -qE '^(agent-id|pedigree):' "$file"; then echo "[validate-a2ml] ERROR: $file missing agent-id or pedigree" >&2 diff --git a/.githooks/validate-actions-lock.sh b/.githooks/validate-actions-lock.sh new file mode 100755 index 000000000..d66997261 --- /dev/null +++ b/.githooks/validate-actions-lock.sh @@ -0,0 +1,200 @@ +#!/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. + +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=( + # Estate doctrine is bun-only; deno is banned. Keying it would make a + # BANNED runtime a required lockfile key for every caller of + # governance-reusable.yml. The cure is to remove the consumer -- port the + # governance scripts to bun -- not to satisfy it. + "denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed" + # A2ML is dead. security-gate-pr-target.yml still calls its + # secrets-check-action; locking it in would connect new machinery to it. + "hyperpolymath/a2ml-ecosystem@f7a40a4d5cc82b2e73f861119baa6818d77a448d" +) + +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 +} + +ERRORS=0 +CHECKED=0 +declare -a SEEN_ABSENT=() + +# Collect every SHA-pinned uses: ref across all workflow files. +mapfile -t RAW < <( + grep -rhoE '^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*[^[:space:]#]+@[0-9a-fA-F]{40}' \ + "$WORKFLOW_DIR"/*.yml "$WORKFLOW_DIR"/*.yaml \ + "$ACTIONS_DIR"/*/action.yml "$ACTIONS_DIR"/*/action.yaml 2>/dev/null \ + | sed -E 's/^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*//' \ + | sort -u +) + +# 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 + 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[@]}"; 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)) + 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[@]}"; 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 --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 diff --git a/.githooks/validate-spdx.sh b/.githooks/validate-spdx.sh index 49d0baaa0..f60df4f4f 100755 --- a/.githooks/validate-spdx.sh +++ b/.githooks/validate-spdx.sh @@ -7,11 +7,25 @@ SCAN_PATH="${INPUT_PATH:-.}" STAGED_FILES="${INPUT_STAGED_FILES:-}" ERRORS=0 -# If staged files provided, only check those +# The extension allowlist is the SINGLE source of truth for "is this a source +# file we require an SPDX header on". It MUST be applied in both modes: staged +# mode previously passed $STAGED_FILES through unfiltered, so any commit that +# touched a non-source file was judged by a rule written for source files. The +# machine-generated .github/workflows/actions.lock ("Do not edit by hand") +# carries no SPDX header and has any added header stripped on the next +# regeneration, so that omission blocked EVERY commit touching the lockfile -- +# which is why a Dependabot-caused lockfile desync could sit unrepaired. +is_source_file() { + case "$1" in + *.rs|*.res|*.js|*.ts|*.sh|*.bash|*.zig|*.ex|*.exs|*.gleam) return 0 ;; + *.ml|*.mli|*.adb|*.ads|*.ncl|*.toml|*.json|*.yaml|*.yml) return 0 ;; + *) return 1 ;; + esac +} + if [ -n "$STAGED_FILES" ]; then FILES_TO_CHECK=$STAGED_FILES else - # Check all source files FILES_TO_CHECK=$(find "$SCAN_PATH" -path '*/.git/*' -prune -o -path '*/node_modules/*' -prune -o \ -type f \( -name '*.rs' -o -name '*.res' -o -name '*.js' -o -name '*.ts' -o -name '*.sh' \ -o -name '*.bash' -o -name '*.zig' -o -name '*.ex' -o -name '*.exs' -o -name '*.gleam' \ @@ -24,6 +38,7 @@ fi for file in $FILES_TO_CHECK; do [ -f "$file" ] || continue + is_source_file "$file" || continue # Check for SPDX header in first 10 lines if ! head -10 "$file" | grep -qE '^# SPDX-License-Identifier:'; then diff --git a/.github/workflows/actions-lock-gate.yml b/.github/workflows/actions-lock-gate.yml new file mode 100644 index 000000000..ea4c64986 --- /dev/null +++ b/.github/workflows/actions-lock-gate.yml @@ -0,0 +1,35 @@ +# This workflow is managed by gh actions-lock. +# SPDX-License-Identifier: MPL-2.0 +# Fail closed when .github/workflows/actions.lock drifts from the workflows. +# +# This repo's .githooks validators ran ONLY in pre-commit, and Dependabot +# never runs pre-commit -- which is exactly how a codeql-action bump landed +# in three reusable workflows without regenerating the lockfile, poisoning +# every repo that calls them. A pre-commit hook cannot gate a bot; this can. +name: Actions Lockfile Gate + +# NO `paths:` FILTER, deliberately. This check is meant to be REQUIRED, and a +# required check whose workflow is skipped by path filtering never reports: +# the PR sits on "Expected -- waiting for status" forever. Every PR that does +# not touch .github/** would be permanently unmergeable. The job is a +# checkout plus a few seconds of bash, so it runs on everything. +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + lockfile-coverage: + name: uses ⊆ actions.lock + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: "Every SHA-pinned uses: must be in the lockfile" + run: bash .githooks/validate-actions-lock.sh diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 5a7db6cb5..5abc4ad73 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -3,6 +3,8 @@ # Docs: https://gh.io/actions-lockfile version: 'v0.0.2' workflows: + '.github/workflows/actions-lock-gate.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/affinescript-verify.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'ocaml/setup-ocaml@e89b2ded52a6e13f50162220cf5fe47290162032' @@ -20,9 +22,12 @@ workflows: '.github/workflows/changelog-reusable.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/changelog.yml': [] + '.github/workflows/check-suite-monitor.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3' '.github/workflows/codeql-reusable.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - - 'github/codeql-action@cdf488f595d80d6e07e03d4674febd5ab45fa938' + - 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63' '.github/workflows/codeql.yml': [] '.github/workflows/debt-measure.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' @@ -50,7 +55,7 @@ workflows: - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124' - - 'github/codeql-action@cdf488f595d80d6e07e03d4674febd5ab45fa938' + - 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63' '.github/workflows/hypatia-scan.yml': [] '.github/workflows/instant-sync.yml': - 'peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697' @@ -94,7 +99,7 @@ workflows: '.github/workflows/scorecard-reusable.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' - - 'github/codeql-action@cdf488f595d80d6e07e03d4674febd5ab45fa938' + - 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63' - 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc' '.github/workflows/secret-scanner-reusable.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' @@ -113,7 +118,7 @@ dependencies: owner_id: 44036562 repo_id: 215566462 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': - ref: '3d3c42e5aac5ba805825da76410c181273ba90b1' + ref: 'v7.0.1' commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' owner_id: 44036562 repo_id: 197814629 @@ -137,6 +142,11 @@ dependencies: commit: 'sha1-3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' owner_id: 44036562 repo_id: 192626254 + 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3': + ref: 'v9.0.0' + commit: 'sha1-3a2844b7e9c422d3c10d287c895573f7108da1b3' + owner_id: 44036562 + repo_id: 205262760 'actions/setup-python@v2': ref: 'v2' commit: 'sha1-e9aba2c848f5ebd159c070c61ea2c4e2b122355e' @@ -181,9 +191,9 @@ dependencies: commit: 'sha1-54075bcc5e249e4758d363f27d099f55d843f124' owner_id: 47606891 repo_id: 331103973 - 'github/codeql-action@cdf488f595d80d6e07e03d4674febd5ab45fa938': - ref: 'cdf488f595d80d6e07e03d4674febd5ab45fa938' - commit: 'sha1-cdf488f595d80d6e07e03d4674febd5ab45fa938' + 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63': + ref: 'v4.38.0' + commit: 'sha1-b96794f015dfd88f77b49b1c93e0fa7110f94c63' owner_id: 9919 repo_id: 259445878 'goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406': diff --git a/.machine_readable/REGISTRY.a2ml b/.machine_readable/REGISTRY.a2ml index 984a21716..cb39c9210 100644 --- a/.machine_readable/REGISTRY.a2ml +++ b/.machine_readable/REGISTRY.a2ml @@ -63,7 +63,7 @@ name = "META.a2ml spec" stream = "foundation" home = "meta-a2ml/" canonical_doc = "meta-a2ml/README.adoc" -source_hash = "sha256:1c5337a9782e37cf24d869109d798d0bf82c67fe62c24f94314b235ea8f173b4" +source_hash = "sha256:a058855d1c8019ccf1814a9386ba406b7e8df3698fd17c9fd342a5134f6a2eb0" route = "architecture decisions / governance metadata format" [[spec]] @@ -126,7 +126,7 @@ name = "0-AI Gatekeeper Protocol" stream = "protocol" home = "0-ai-gatekeeper-protocol/" canonical_doc = "0-ai-gatekeeper-protocol/README.adoc" -source_hash = "sha256:369bd762d903006a10f75e924be5c07d082554d88824fbf035ed32f3f67d05c2" +source_hash = "sha256:41f60acfb75bc32b0a3fc13cf3642f2e3f553bb4b7ddc9911b1e23d17e205ef3" route = "the AI-agent entry/gating protocol behind 0-AI-MANIFEST" [[spec]] @@ -207,7 +207,7 @@ name = "RSR — Rhodium Standard Repositories" stream = "governance" home = "rhodium-standard-repositories/" canonical_doc = "rhodium-standard-repositories/README.adoc" -source_hash = "sha256:dbd52c26f0ca4964683db06045feda29710e44289baf5eb573ebf195a3ed44a9" +source_hash = "sha256:35b2a2d8a9b4e33d7f73c663f0d811d054a16e8601f62a20c9ec54114cd43b54" route = "the repository-compliance standard every repo is graded against" [[spec]]