Skip to content
Merged
3 changes: 3 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <Kind>.<group>/<name>` body line, spelled the way the
Expand Down
163 changes: 163 additions & 0 deletions scripts/workflow-execution-inventory.sh
Original file line number Diff line number Diff line change
@@ -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 <org> # every active repository, live
# workflow-execution-inventory.sh --dir <workflows-dir> [--repo <name>] # 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 <file> — 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
Comment thread
devantler marked this conversation as resolved.
}

# classify <file> <workflow-name> — prints "<events>\t<exposure>" 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 <repo> <dir> <policies> — one row per workflow file in <dir>.
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
137 changes: 137 additions & 0 deletions tests/workflow-execution-inventory.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading