diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 85814c0..5e28448 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -97,6 +97,9 @@ jobs: - name: ๐Ÿงช Test retained signing-rule safety run: bash tests/signing-rule-retirement.sh + - name: ๐Ÿงช Test workflow execution inventory + run: bash tests/workflow-execution-inventory.sh + # The drift check itself reads live GitHub state and runs on a schedule # (repository-drift-check.yaml); this only pins its comparison logic, # against fixtures, so it stays offline and PR-safe. diff --git a/AGENTS.md b/AGENTS.md index 23e814a..9e68bc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,9 +106,10 @@ bash tests/signing-rule-retirement.sh # retained signing-rule identity and safe bash tests/release-contract.sh # deploy/ changes must trigger a release bash tests/deploy-deletions.sh # removed deploy/ resources must be acknowledged per resource bash tests/repository-drift.sh # declared-vs-live comparison logic +bash tests/workflow-execution-inventory.sh # who and what can start each workflow ``` -Those nine commands are the baseline checks that `ci.yaml` runs. Pull requests additionally pass +Those ten commands are the baseline checks that `ci.yaml` runs. Pull requests additionally pass their changed paths and title through `scripts/validate-release-contract.sh` and their base/head renders plus the pull-request body through `scripts/validate-deploy-deletions.sh` (every managed resource that leaves the render needs its own `Deletion-Acknowledged: ./` body line, spelled the way the diff --git a/scripts/workflow-execution-inventory.sh b/scripts/workflow-execution-inventory.sh new file mode 100755 index 0000000..eca81da --- /dev/null +++ b/scripts/workflow-execution-inventory.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# workflow-execution-inventory.sh โ€” read-only inventory of who and what can start each workflow. +# +# Lists every GitHub Actions workflow with the events that can start it, an exposure class, and the +# number of workflow execution policies set on its repository. It is the evidence a layered +# workflow execution policy is designed from: which workflows accept manual or cross-repository +# entry, which run with a privileged trigger, and which publish or deploy. +# +# Usage: +# workflow-execution-inventory.sh --org # every active repository, live +# workflow-execution-inventory.sh --dir [--repo ] # local files only +# +# Output is tab-separated: repo, workflow, events, exposure, repo_policies. +# Exposure classes (comma-separated when several apply): +# privileged-trigger pull_request_target or workflow_run: runs with base-repository privileges +# manual-entry workflow_dispatch or repository_dispatch +# release the workflow's file or display name says it publishes, releases or deploys +# reusable workflow_call +# scheduled schedule +# ci none of the above +# The release class is a naming heuristic; confirm each hit by reading the workflow. +# Scope: each repository's DEFAULT BRANCH only. A workflow that exists only on another branch or tag +# can still run there and is not listed; this is a default-branch inventory, not a complete one. +# +# Exit codes: 0 complete ยท 2 UNKNOWN โ€” at least one repository, listing or workflow could not be +# read or parsed. Those rows say UNKNOWN; a partial inventory is never reported as complete. +set -euo pipefail + +usage() { + sed -n '2,/^set -euo/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//' >&2 + exit 2 +} + +unknown=0 + +# events โ€” one event name per line. Fails when yq fails, even after partial output +# (a malformed later document in a multi-document file). +events() { + local out + # GitHub reads one workflow document per file; merging several would invent a workflow. + [ "$(yq ea '[.] | length' "$1" 2>/dev/null)" = 1 ] || return 1 + out="$(yq -r '.on | ((select(tag == "!!str")), (select(tag == "!!seq") | .[]), + (select(tag == "!!map") | keys | .[]))' "$1" 2>/dev/null)" || return 1 + grep -v '^$' <<<"$out" | sort -u || true +} + +# classify โ€” prints "\t" or UNKNOWN. +classify() { + local file="$1" name="$2" evs display classes=() + evs="$(events "$file")" || evs="" + if [ -z "$evs" ]; then + printf 'UNKNOWN\tUNKNOWN' + return + fi + display="$(yq -r '.name // ""' "$file" 2>/dev/null || true)" + grep -qxE 'pull_request_target|workflow_run' <<<"$evs" && classes+=(privileged-trigger) + grep -qxE 'workflow_dispatch|repository_dispatch' <<<"$evs" && classes+=(manual-entry) + grep -qiE '(^|[^a-z])(cd|deploy|publish|release)' <<<"$name $display" && classes+=(release) + grep -qx 'workflow_call' <<<"$evs" && classes+=(reusable) + grep -qx 'schedule' <<<"$evs" && classes+=(scheduled) + [ "${#classes[@]}" -eq 0 ] && classes=(ci) + local IFS=, + printf '%s\t%s' "$(tr '\n' ',' <<<"$evs" | sed 's/,$//')" "${classes[*]}" +} + +# inventory_dir โ€” one row per workflow file in . +inventory_dir() { + local repo="$1" dir="$2" policies="$3" f row + # An unlistable directory expands no glob, which would read as "no workflows". + if [ ! -r "$dir" ] || [ ! -x "$dir" ]; then + printf '%s\tUNKNOWN\tUNKNOWN\tUNKNOWN\t%s\n' "$repo" "$policies" + unknown=1 + return + fi + # GitHub runs dot-prefixed workflow files too, and a bare glob skips them. + for f in "$dir"/*.yml "$dir"/*.yaml "$dir"/.*.yml "$dir"/.*.yaml; do + [ -f "$f" ] || continue + # classify runs in a subshell, so its verdict is read from the row, not from a variable. + row="$(classify "$f" "$(basename "$f")")" + case "$row" in UNKNOWN*) unknown=1 ;; esac + printf '%s\t%s\t%s\t%s\n' "$repo" "$(basename "$f")" "$row" "$policies" + done +} + +inventory_org() { + local org="$1" repos repo policies listing name sha + local listed expected + listed="$(gh api "orgs/$org/repos" --paginate --jq '.[] | "\(.archived) \(.name)"')" || + { echo "workflow-execution-inventory: UNKNOWN โ€” cannot list $org repositories" >&2; exit 2; } + # A token restricted to selected repositories lists only those, and succeeds. Compare the listing + # with the organisation's own count; a token that cannot see the private count is UNKNOWN too. + expected="$(gh api "orgs/$org" --jq 'if .total_private_repos == null then "" else .public_repos + .total_private_repos end')" || + expected="" + if [ -z "$expected" ] || [ "$(grep -c . <<<"$listed")" != "$expected" ]; then + echo "workflow-execution-inventory: UNKNOWN โ€” listed $(grep -c . <<<"$listed") of ${expected:-an unknown number of} $org repositories; the token cannot see them all" >&2 + exit 2 + fi + repos="$(sed -n 's/^false //p' <<<"$listed")" + # The count check above proved the listing complete, so no active repository is a complete answer. + [ -n "$repos" ] || return 0 + # Global, not local: the EXIT trap runs after this function has returned. + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + while IFS= read -r repo; do + policies="$(gh api "repos/$org/$repo/actions/policies" --jq '.total_count' 2>/dev/null)" || + { policies=UNKNOWN; unknown=1; } + # Pin every read to one commit, so a push during the scan cannot split the listing from the files. + # An empty repository has no commit; GitHub answers with 409 and it has no workflows. + if ! sha="$(gh api "repos/$org/$repo/commits/HEAD" --jq '.sha' 2>"$tmp/err")" || [ -z "$sha" ]; then + grep -q 'HTTP 409' "$tmp/err" && continue + printf '%s\tUNKNOWN\tUNKNOWN\tUNKNOWN\t%s\n' "$repo" "$policies" + unknown=1 + continue + fi + if ! listing="$(gh api "repos/$org/$repo/contents/.github/workflows?ref=$sha" \ + --jq 'if length >= 1000 then "TRUNCATED" else (.[] | select(.type == "file") | .name) end' 2>"$tmp/err")"; then + # The commit was readable, so a 404 here means the directory does not exist at that commit. + grep -q 'HTTP 404' "$tmp/err" && continue + printf '%s\tUNKNOWN\tUNKNOWN\tUNKNOWN\t%s\n' "$repo" "$policies" + unknown=1 + continue + fi + # The contents API lists at most 1,000 entries per directory, so a full page may be partial. + if [ "$listing" = TRUNCATED ]; then + printf '%s\tUNKNOWN\tUNKNOWN\tUNKNOWN\t%s\n' "$repo" "$policies" + unknown=1 + continue + fi + rm -rf "${tmp:?}/wf" && mkdir "$tmp/wf" + while IFS= read -r name; do + case "$name" in *.yml | *.yaml) ;; *) continue ;; esac + gh api "repos/$org/$repo/contents/.github/workflows/$name?ref=$sha" \ + -H 'Accept: application/vnd.github.raw' >"$tmp/wf/$name" 2>/dev/null || + { : >"$tmp/wf/$name"; } # an empty file classifies as UNKNOWN + done <<<"$listing" + inventory_dir "$repo" "$tmp/wf" "$policies" + done <<<"$repos" +} + +mode="" target="" repo="local" +while [ $# -gt 0 ]; do + case "$1" in + --org) mode=org; target="${2:-}"; shift 2 || usage ;; + --dir) mode=dir; target="${2:-}"; shift 2 || usage ;; + --repo) repo="${2:-}"; shift 2 || usage ;; + *) usage ;; + esac +done +[ -n "$mode" ] && [ -n "$target" ] || usage + +printf 'repo\tworkflow\tevents\texposure\trepo_policies\n' +case "$mode" in + org) inventory_org "$target" ;; + dir) + [ -d "$target" ] || { echo "workflow-execution-inventory: no such directory: $target" >&2; exit 2; } + inventory_dir "$repo" "$target" "n/a" + ;; +esac + +if [ "$unknown" -ne 0 ]; then + echo "workflow-execution-inventory: UNKNOWN โ€” some rows could not be read or parsed" >&2 + exit 2 +fi diff --git a/tests/workflow-execution-inventory.sh b/tests/workflow-execution-inventory.sh new file mode 100755 index 0000000..9117acb --- /dev/null +++ b/tests/workflow-execution-inventory.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Pins the workflow execution inventory's classification against local fixtures, offline. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +inventory="$repo_root/scripts/workflow-execution-inventory.sh" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +fail() { + echo "workflow-execution-inventory test: $*" >&2 + exit 1 +} + +wf="$tmp/workflows" +mkdir "$wf" +printf 'on: push\njobs: {}\n' >"$wf/string.yaml" +printf 'on: [pull_request, merge_group]\njobs: {}\n' >"$wf/list.yml" +printf 'on:\n pull_request_target:\n types: [opened]\njobs: {}\n' >"$wf/target.yaml" +printf 'on:\n workflow_run:\n workflows: [CI]\njobs: {}\n' >"$wf/after-ci.yaml" +printf 'name: Publish Pages\non:\n workflow_dispatch: {}\n push:\n branches: [main]\njobs: {}\n' >"$wf/pages.yaml" +printf 'on:\n repository_dispatch:\n types: [sync]\njobs: {}\n' >"$wf/sync.yaml" +printf 'on:\n workflow_call: {}\njobs: {}\n' >"$wf/shared.yaml" +printf 'on:\n schedule:\n - cron: "0 0 * * *"\njobs: {}\n' >"$wf/nightly.yaml" +printf 'on:\n push:\n tags: ["v*"]\njobs: {}\n' >"$wf/cd.yaml" +printf 'on: push\njobs: {}\n' >"$wf/notes.txt" +printf 'on: push\njobs: {}\n' >"$wf/.dot.yml" + +out="$(bash "$inventory" --dir "$wf" --repo fixture)" || fail "a fully parseable directory must exit 0" + +expect() { + local workflow="$1" events="$2" exposure="$3" row + row="$(awk -F'\t' -v w="$workflow" '$2 == w' <<<"$out")" + [ -n "$row" ] || fail "no row for $workflow" + [ "$row" = "$(printf 'fixture\t%s\t%s\t%s\tn/a' "$workflow" "$events" "$exposure")" ] || + fail "$workflow: got '$row', want events='$events' exposure='$exposure'" +} + +expect string.yaml push ci +expect list.yml merge_group,pull_request ci +expect target.yaml pull_request_target privileged-trigger +expect after-ci.yaml workflow_run privileged-trigger +expect pages.yaml push,workflow_dispatch manual-entry,release +expect sync.yaml repository_dispatch manual-entry +expect shared.yaml workflow_call reusable +expect nightly.yaml schedule scheduled +expect cd.yaml push release +expect .dot.yml push ci + +grep -q 'notes.txt' <<<"$out" && fail "a non-workflow file must not be inventoried" +[ "$(head -1 <<<"$out")" = "$(printf 'repo\tworkflow\tevents\texposure\trepo_policies')" ] || + fail "missing or wrong header" + +# A workflow whose triggers cannot be read makes the whole inventory UNKNOWN, never complete. +printf 'jobs: {}\n' >"$wf/no-trigger.yaml" +rc=0 +out="$(bash "$inventory" --dir "$wf" --repo fixture 2>/dev/null)" || rc=$? +[ "$rc" -eq 2 ] || fail "an unreadable workflow must exit 2, got $rc" +grep -q "$(printf 'no-trigger.yaml\tUNKNOWN\tUNKNOWN')" <<<"$out" || + fail "the unreadable workflow must be reported as UNKNOWN" + +# A parser failure after partial output (a malformed later document) is UNKNOWN too. +rm "$wf/no-trigger.yaml" +printf 'on: push\njobs: {}\n---\non: [unclosed\n' >"$wf/multi.yaml" +rc=0 +out="$(bash "$inventory" --dir "$wf" --repo fixture 2>/dev/null)" || rc=$? +[ "$rc" -eq 2 ] || fail "a partly parsed workflow must exit 2, got $rc" +grep -q "$(printf 'multi.yaml\tUNKNOWN\tUNKNOWN')" <<<"$out" || + fail "a partly parsed workflow must be reported as UNKNOWN, not by its first document" + +# Two VALID documents are UNKNOWN as well: GitHub reads one workflow per file, so their merged +# events describe a workflow that does not exist. +printf 'on: push\njobs: {}\n---\non: workflow_dispatch\njobs: {}\n' >"$wf/multi.yaml" +rc=0 +out="$(bash "$inventory" --dir "$wf" --repo fixture 2>/dev/null)" || rc=$? +[ "$rc" -eq 2 ] || fail "a multi-document workflow must exit 2, got $rc" +grep -q "$(printf 'multi.yaml\tUNKNOWN\tUNKNOWN')" <<<"$out" || + fail "a multi-document workflow must be reported as UNKNOWN, not as a merged workflow" + +# A directory that cannot be listed is UNKNOWN, never an empty inventory. +locked="$tmp/locked" +mkdir "$locked" +chmod 000 "$locked" +rc=0 +out="$(bash "$inventory" --dir "$locked" --repo fixture 2>/dev/null)" || rc=$? +chmod 700 "$locked" +[ "$rc" -eq 2 ] || fail "an unreadable directory must exit 2, got $rc" +grep -q "$(printf '^fixture\tUNKNOWN')" <<<"$out" || fail "an unreadable directory must be reported as UNKNOWN" + +# Org mode against a stub gh: a 404 counts as "no workflows" only when the repository root is +# readable; an unreadable repository is UNKNOWN and fails the run. +bin="$tmp/bin" +mkdir "$bin" +cat >"$bin/gh" <<'STUB' +#!/usr/bin/env bash +# Content reads answer only at the pinned commit s1: an unpinned read returns 404. +case "$2" in + orgs/fix/repos) printf 'false readable\nfalse nowf\nfalse hidden\nfalse empty\nfalse huge\ntrue retired\n' ;; + orgs/fix) echo "${EXPECTED-6}" ;; + orgs/none/repos) printf 'true retired\n' ;; + orgs/none) echo 1 ;; + repos/fix/empty/commits/HEAD) echo 'gh: Git Repository is empty. (HTTP 409)' >&2; exit 1 ;; + repos/fix/hidden/commits/HEAD) echo 'gh: Not Found (HTTP 404)' >&2; exit 1 ;; + repos/fix/*/commits/HEAD) echo s1 ;; + "repos/fix/huge/contents/.github/workflows?ref=s1") echo TRUNCATED ;; + repos/fix/*/actions/policies) echo 0 ;; + "repos/fix/readable/contents/.github/workflows?ref=s1") echo ci.yaml ;; + "repos/fix/readable/contents/.github/workflows/ci.yaml?ref=s1") printf 'on: push\njobs: {}\n' ;; + *) echo 'gh: Not Found (HTTP 404)' >&2; exit 1 ;; +esac +STUB +chmod +x "$bin/gh" +rc=0 +out="$(PATH="$bin:$PATH" bash "$inventory" --org fix 2>/dev/null)" || rc=$? +[ "$rc" -eq 2 ] || fail "an unreadable repository must make the org inventory exit 2, got $rc" +grep -q "$(printf '^readable\tci.yaml\tpush\tci\t0$')" <<<"$out" || fail "the readable repository's workflow is missing" +grep -q "$(printf '^hidden\tUNKNOWN')" <<<"$out" || fail "a repository hidden behind a 404 must be UNKNOWN" +grep -q '^nowf' <<<"$out" && fail "a repository with a readable root and no workflows must be omitted" +grep -q '^retired' <<<"$out" && fail "an archived repository must not be inventoried" +grep -q '^empty' <<<"$out" && fail "an empty repository has no workflows and must be omitted" +grep -q "$(printf '^huge\tUNKNOWN')" <<<"$out" || fail "a listing at the 1,000-entry cap must be UNKNOWN" + +# A token that sees only some repositories lists them successfully; the organisation count exposes it. +for expected in 7 ""; do + rc=0 + err="$(EXPECTED="$expected" PATH="$bin:$PATH" bash "$inventory" --org fix 2>&1 >/dev/null)" || rc=$? + [ "$rc" -eq 2 ] || fail "an incomplete listing (expected='$expected') must exit 2, got $rc" + grep -q 'the token cannot see them all' <<<"$err" || fail "an incomplete listing must say why: $err" +done + +# An organisation whose complete listing holds no active repository is a complete, empty inventory. +rc=0 +out="$(PATH="$bin:$PATH" bash "$inventory" --org none 2>/dev/null)" || rc=$? +[ "$rc" -eq 0 ] || fail "an organisation with no active repositories must exit 0, got $rc" +[ "$(grep -c . <<<"$out")" -eq 1 ] || fail "an organisation with no active repositories must print only the header" + +echo "workflow-execution-inventory test: ok"