-
Notifications
You must be signed in to change notification settings - Fork 0
feat(ci): inventory who and what can start each workflow #203
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+305
−1
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
5c9a893
feat(ci): inventory who and what can start each workflow
devantler a013fc6
fix(ci): keep the inventory's temp dir visible to its exit trap
devantler e0a3abf
test(ci): capture the inventory's exit status without disabling errexit
devantler 982b757
fix(ci): fail the inventory closed on hidden repositories and partial…
devantler 0c13d02
fix(scripts): refuse partial org listings and multi-document workflows
devantler fcdba70
fix(scripts): report an unreadable workflow directory as UNKNOWN
devantler 8e4fdd2
fix(scripts): handle empty repositories, capped listings and dot-pref…
devantler 0bee7fe
fix(scripts): pin each repository's reads to one commit
devantler File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
|
|
||
| # 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.