From 5c9a89397bf874613bd599e75567f72db81c1920 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 19 Sep 2026 19:04:51 +0200 Subject: [PATCH 1/8] feat(ci): inventory who and what can start each workflow Read-only inventory for workflow execution protections (#202): per active repository, each workflow's triggering events, an exposure class and the repository's current policy count. Fails closed (exit 2) on any row it cannot read, and ships with an offline fixture test wired into CI. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yaml | 3 + AGENTS.md | 3 +- scripts/workflow-execution-inventory.sh | 124 ++++++++++++++++++++++++ tests/workflow-execution-inventory.sh | 62 ++++++++++++ 4 files changed, 191 insertions(+), 1 deletion(-) create mode 100755 scripts/workflow-execution-inventory.sh create mode 100755 tests/workflow-execution-inventory.sh 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..fe3f5bd --- /dev/null +++ b/scripts/workflow-execution-inventory.sh @@ -0,0 +1,124 @@ +#!/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. +# +# 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; nothing on a parse failure. +events() { + yq -r '.on | ((select(tag == "!!str")), (select(tag == "!!seq") | .[]), + (select(tag == "!!map") | keys | .[]))' "$1" 2>/dev/null | grep -v '^$' | sort -u || true +} + +# classify โ€” prints "\t" or UNKNOWN. +classify() { + local file="$1" name="$2" evs display classes=() + evs="$(events "$file")" + 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 + for f in "$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 tmp policies listing name + repos="$(gh api "orgs/$org/repos" --paginate --jq '.[] | select(.archived | not) | .name')" || + { echo "workflow-execution-inventory: UNKNOWN โ€” cannot list $org repositories" >&2; exit 2; } + [ -n "$repos" ] || { echo "workflow-execution-inventory: UNKNOWN โ€” $org listed no repositories" >&2; exit 2; } + 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; } + if ! listing="$(gh api "repos/$org/$repo/contents/.github/workflows" \ + --jq '.[] | select(.type == "file") | .name' 2>"$tmp/err")"; then + if grep -q 'HTTP 404' "$tmp/err"; then + continue # no workflows directory: nothing can start + fi + 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" \ + -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..e1fedeb --- /dev/null +++ b/tests/workflow-execution-inventory.sh @@ -0,0 +1,62 @@ +#!/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" + +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 + +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" +set +e +out="$(bash "$inventory" --dir "$wf" --repo fixture 2>/dev/null)" +rc=$? +set -e +[ "$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" + +echo "workflow-execution-inventory test: ok" From a013fc60411d1b594cc1df3e648141b91a0db9eb Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 19 Sep 2026 19:06:36 +0200 Subject: [PATCH 2/8] fix(ci): keep the inventory's temp dir visible to its exit trap The EXIT trap ran after inventory_org returned, so under set -u a complete live run died on an unbound local and exited 1. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/workflow-execution-inventory.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/workflow-execution-inventory.sh b/scripts/workflow-execution-inventory.sh index fe3f5bd..22f3025 100755 --- a/scripts/workflow-execution-inventory.sh +++ b/scripts/workflow-execution-inventory.sh @@ -69,10 +69,11 @@ inventory_dir() { } inventory_org() { - local org="$1" repos repo tmp policies listing name + local org="$1" repos repo policies listing name repos="$(gh api "orgs/$org/repos" --paginate --jq '.[] | select(.archived | not) | .name')" || { echo "workflow-execution-inventory: UNKNOWN โ€” cannot list $org repositories" >&2; exit 2; } [ -n "$repos" ] || { echo "workflow-execution-inventory: UNKNOWN โ€” $org listed no repositories" >&2; exit 2; } + # 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 From e0a3abf6b8be126372e8f2de7880f46c87e79d49 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 19 Sep 2026 19:14:03 +0200 Subject: [PATCH 3/8] test(ci): capture the inventory's exit status without disabling errexit Co-Authored-By: Claude Opus 5 (1M context) --- tests/workflow-execution-inventory.sh | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/workflow-execution-inventory.sh b/tests/workflow-execution-inventory.sh index e1fedeb..8e69960 100755 --- a/tests/workflow-execution-inventory.sh +++ b/tests/workflow-execution-inventory.sh @@ -51,10 +51,8 @@ grep -q 'notes.txt' <<<"$out" && fail "a non-workflow file must not be inventori # A workflow whose triggers cannot be read makes the whole inventory UNKNOWN, never complete. printf 'jobs: {}\n' >"$wf/no-trigger.yaml" -set +e -out="$(bash "$inventory" --dir "$wf" --repo fixture 2>/dev/null)" -rc=$? -set -e +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" From 982b7570c8cd861d994ab86de699606715d7c9e9 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 19 Sep 2026 19:25:24 +0200 Subject: [PATCH 4/8] fix(ci): fail the inventory closed on hidden repositories and partial parses A 404 on the workflows directory counts as "no workflows" only when the repository root is readable, and a yq failure after partial output (a malformed later document) is UNKNOWN rather than classified from the first document. Both paths are covered, including org mode against a stub gh. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/workflow-execution-inventory.sh | 17 ++++++++----- tests/workflow-execution-inventory.sh | 32 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/scripts/workflow-execution-inventory.sh b/scripts/workflow-execution-inventory.sh index 22f3025..72ac05e 100755 --- a/scripts/workflow-execution-inventory.sh +++ b/scripts/workflow-execution-inventory.sh @@ -31,16 +31,19 @@ usage() { unknown=0 -# events โ€” one event name per line; nothing on a parse failure. +# events โ€” one event name per line. Fails when yq fails, even after partial output +# (a malformed later document in a multi-document file). events() { - yq -r '.on | ((select(tag == "!!str")), (select(tag == "!!seq") | .[]), - (select(tag == "!!map") | keys | .[]))' "$1" 2>/dev/null | grep -v '^$' | sort -u || true + local out + 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="$(events "$file")" || evs="" if [ -z "$evs" ]; then printf 'UNKNOWN\tUNKNOWN' return @@ -81,8 +84,10 @@ inventory_org() { { policies=UNKNOWN; unknown=1; } if ! listing="$(gh api "repos/$org/$repo/contents/.github/workflows" \ --jq '.[] | select(.type == "file") | .name' 2>"$tmp/err")"; then - if grep -q 'HTTP 404' "$tmp/err"; then - continue # no workflows directory: nothing can start + # A 404 also hides a repository the token cannot read. Count it as "no workflows" only + # when the same token can read the repository root. + if grep -q 'HTTP 404' "$tmp/err" && gh api "repos/$org/$repo/contents/" --jq 'length' >/dev/null 2>&1; then + continue fi printf '%s\tUNKNOWN\tUNKNOWN\tUNKNOWN\t%s\n' "$repo" "$policies" unknown=1 diff --git a/tests/workflow-execution-inventory.sh b/tests/workflow-execution-inventory.sh index 8e69960..d7390bd 100755 --- a/tests/workflow-execution-inventory.sh +++ b/tests/workflow-execution-inventory.sh @@ -57,4 +57,36 @@ out="$(bash "$inventory" --dir "$wf" --repo fixture 2>/dev/null)" || 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" + +# 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 +case "$2" in + orgs/fix/repos) printf 'readable\nnowf\nhidden\n' ;; + repos/fix/*/actions/policies) echo 0 ;; + repos/fix/readable/contents/.github/workflows) echo ci.yaml ;; + repos/fix/readable/contents/.github/workflows/ci.yaml) printf 'on: push\njobs: {}\n' ;; + repos/fix/nowf/contents/) echo 3 ;; + *) 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" + echo "workflow-execution-inventory test: ok" From 0c13d020028a56ca656064f7d2d205c1ad2021f0 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 19 Sep 2026 21:07:16 +0200 Subject: [PATCH 5/8] fix(scripts): refuse partial org listings and multi-document workflows Compare the repository listing against the organisation's own count so a restricted token is UNKNOWN, treat any file that is not exactly one YAML document as UNKNOWN, and state that the inventory covers default branches only. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/workflow-execution-inventory.sh | 16 +++++++++++++++- tests/workflow-execution-inventory.sh | 21 ++++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/scripts/workflow-execution-inventory.sh b/scripts/workflow-execution-inventory.sh index 72ac05e..1ce35b7 100755 --- a/scripts/workflow-execution-inventory.sh +++ b/scripts/workflow-execution-inventory.sh @@ -19,6 +19,8 @@ # 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. @@ -35,6 +37,8 @@ unknown=0 # (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 @@ -73,8 +77,18 @@ inventory_dir() { inventory_org() { local org="$1" repos repo policies listing name - repos="$(gh api "orgs/$org/repos" --paginate --jq '.[] | select(.archived | not) | .name')" || + 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")" [ -n "$repos" ] || { echo "workflow-execution-inventory: UNKNOWN โ€” $org listed no repositories" >&2; exit 2; } # Global, not local: the EXIT trap runs after this function has returned. tmp="$(mktemp -d)" diff --git a/tests/workflow-execution-inventory.sh b/tests/workflow-execution-inventory.sh index d7390bd..ebc0b69 100755 --- a/tests/workflow-execution-inventory.sh +++ b/tests/workflow-execution-inventory.sh @@ -66,6 +66,15 @@ out="$(bash "$inventory" --dir "$wf" --repo fixture 2>/dev/null)" || 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" + # 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" @@ -73,7 +82,8 @@ mkdir "$bin" cat >"$bin/gh" <<'STUB' #!/usr/bin/env bash case "$2" in - orgs/fix/repos) printf 'readable\nnowf\nhidden\n' ;; + orgs/fix/repos) printf 'false readable\nfalse nowf\nfalse hidden\ntrue retired\n' ;; + orgs/fix) echo "${EXPECTED-4}" ;; repos/fix/*/actions/policies) echo 0 ;; repos/fix/readable/contents/.github/workflows) echo ci.yaml ;; repos/fix/readable/contents/.github/workflows/ci.yaml) printf 'on: push\njobs: {}\n' ;; @@ -88,5 +98,14 @@ out="$(PATH="$bin:$PATH" bash "$inventory" --org fix 2>/dev/null)" || 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" + +# A token that sees only some repositories lists them successfully; the organisation count exposes it. +for expected in 5 ""; 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 echo "workflow-execution-inventory test: ok" From fcdba704acabbc69867013c23beb5652fc345116 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 19 Sep 2026 21:25:13 +0200 Subject: [PATCH 6/8] fix(scripts): report an unreadable workflow directory as UNKNOWN Co-Authored-By: Claude Opus 5 (1M context) --- scripts/workflow-execution-inventory.sh | 6 ++++++ tests/workflow-execution-inventory.sh | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/scripts/workflow-execution-inventory.sh b/scripts/workflow-execution-inventory.sh index 1ce35b7..d320dae 100755 --- a/scripts/workflow-execution-inventory.sh +++ b/scripts/workflow-execution-inventory.sh @@ -66,6 +66,12 @@ classify() { # 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 for f in "$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. diff --git a/tests/workflow-execution-inventory.sh b/tests/workflow-execution-inventory.sh index ebc0b69..3dbae4c 100755 --- a/tests/workflow-execution-inventory.sh +++ b/tests/workflow-execution-inventory.sh @@ -75,6 +75,16 @@ out="$(bash "$inventory" --dir "$wf" --repo fixture 2>/dev/null)" || 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" From 8e4fdd212f2ccf67a720e95dc41a40844cd03622 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 19 Sep 2026 21:48:21 +0200 Subject: [PATCH 7/8] fix(scripts): handle empty repositories, capped listings and dot-prefixed workflows Co-Authored-By: Claude Opus 5 (1M context) --- scripts/workflow-execution-inventory.sh | 22 +++++++++++++++++----- tests/workflow-execution-inventory.sh | 12 +++++++++--- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/scripts/workflow-execution-inventory.sh b/scripts/workflow-execution-inventory.sh index d320dae..4fd77ea 100755 --- a/scripts/workflow-execution-inventory.sh +++ b/scripts/workflow-execution-inventory.sh @@ -72,7 +72,8 @@ inventory_dir() { unknown=1 return fi - for f in "$dir"/*.yml "$dir"/*.yaml; do + # 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")")" @@ -103,16 +104,27 @@ inventory_org() { policies="$(gh api "repos/$org/$repo/actions/policies" --jq '.total_count' 2>/dev/null)" || { policies=UNKNOWN; unknown=1; } if ! listing="$(gh api "repos/$org/$repo/contents/.github/workflows" \ - --jq '.[] | select(.type == "file") | .name' 2>"$tmp/err")"; then + --jq 'if length >= 1000 then "TRUNCATED" else (.[] | select(.type == "file") | .name) end' 2>"$tmp/err")"; then # A 404 also hides a repository the token cannot read. Count it as "no workflows" only - # when the same token can read the repository root. - if grep -q 'HTTP 404' "$tmp/err" && gh api "repos/$org/$repo/contents/" --jq 'length' >/dev/null 2>&1; then - continue + # when the same token can read the repository root, or when the repository is empty: an + # empty repository has no tree, and GitHub answers its commit list with 409. + if grep -q 'HTTP 404' "$tmp/err"; then + gh api "repos/$org/$repo/contents/" --jq 'length' >/dev/null 2>&1 && continue + if ! gh api "repos/$org/$repo/commits?per_page=1" >/dev/null 2>"$tmp/err" && + grep -q 'HTTP 409' "$tmp/err"; then + continue + fi fi 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 diff --git a/tests/workflow-execution-inventory.sh b/tests/workflow-execution-inventory.sh index 3dbae4c..f59e685 100755 --- a/tests/workflow-execution-inventory.sh +++ b/tests/workflow-execution-inventory.sh @@ -24,6 +24,7 @@ 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" @@ -44,6 +45,7 @@ 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')" ] || @@ -92,8 +94,10 @@ mkdir "$bin" cat >"$bin/gh" <<'STUB' #!/usr/bin/env bash case "$2" in - orgs/fix/repos) printf 'false readable\nfalse nowf\nfalse hidden\ntrue retired\n' ;; - orgs/fix) echo "${EXPECTED-4}" ;; + orgs/fix/repos) printf 'false readable\nfalse nowf\nfalse hidden\nfalse empty\nfalse huge\ntrue retired\n' ;; + orgs/fix) echo "${EXPECTED-6}" ;; + repos/fix/empty/commits*) echo 'gh: Git Repository is empty. (HTTP 409)' >&2; exit 1 ;; + repos/fix/huge/contents/.github/workflows) echo TRUNCATED ;; repos/fix/*/actions/policies) echo 0 ;; repos/fix/readable/contents/.github/workflows) echo ci.yaml ;; repos/fix/readable/contents/.github/workflows/ci.yaml) printf 'on: push\njobs: {}\n' ;; @@ -109,9 +113,11 @@ grep -q "$(printf '^readable\tci.yaml\tpush\tci\t0$')" <<<"$out" || fail "the re 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 5 ""; do +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" From 0bee7fe77dd81808b91491ce128ca222f11cb112 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 19 Sep 2026 22:05:29 +0200 Subject: [PATCH 8/8] fix(scripts): pin each repository's reads to one commit Resolve the default-branch commit first and read the listing and every file at it, so a push mid-scan cannot split them. An empty repository answers that read with 409. An organisation whose complete listing holds no active repository is a complete, empty inventory. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/workflow-execution-inventory.sh | 29 +++++++++++++------------ tests/workflow-execution-inventory.sh | 20 ++++++++++++----- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/scripts/workflow-execution-inventory.sh b/scripts/workflow-execution-inventory.sh index 4fd77ea..eca81da 100755 --- a/scripts/workflow-execution-inventory.sh +++ b/scripts/workflow-execution-inventory.sh @@ -83,7 +83,7 @@ inventory_dir() { } inventory_org() { - local org="$1" repos repo policies listing name + 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; } @@ -96,25 +96,26 @@ inventory_org() { exit 2 fi repos="$(sed -n 's/^false //p' <<<"$listed")" - [ -n "$repos" ] || { echo "workflow-execution-inventory: UNKNOWN โ€” $org listed no repositories" >&2; exit 2; } + # 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; } - if ! listing="$(gh api "repos/$org/$repo/contents/.github/workflows" \ + # 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 - # A 404 also hides a repository the token cannot read. Count it as "no workflows" only - # when the same token can read the repository root, or when the repository is empty: an - # empty repository has no tree, and GitHub answers its commit list with 409. - if grep -q 'HTTP 404' "$tmp/err"; then - gh api "repos/$org/$repo/contents/" --jq 'length' >/dev/null 2>&1 && continue - if ! gh api "repos/$org/$repo/commits?per_page=1" >/dev/null 2>"$tmp/err" && - grep -q 'HTTP 409' "$tmp/err"; then - continue - fi - fi + # 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 @@ -128,7 +129,7 @@ inventory_org() { 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" \ + 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" diff --git a/tests/workflow-execution-inventory.sh b/tests/workflow-execution-inventory.sh index f59e685..9117acb 100755 --- a/tests/workflow-execution-inventory.sh +++ b/tests/workflow-execution-inventory.sh @@ -93,15 +93,19 @@ 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}" ;; - repos/fix/empty/commits*) echo 'gh: Git Repository is empty. (HTTP 409)' >&2; exit 1 ;; - repos/fix/huge/contents/.github/workflows) echo TRUNCATED ;; + 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) echo ci.yaml ;; - repos/fix/readable/contents/.github/workflows/ci.yaml) printf 'on: push\njobs: {}\n' ;; - repos/fix/nowf/contents/) echo 3 ;; + "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 @@ -124,4 +128,10 @@ for expected in 7 ""; do 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"