-
-
Notifications
You must be signed in to change notification settings - Fork 0
feat(cicd): estate census over GraphQL — 4% of a quota nobody was using #583
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| #!/usr/bin/env bash | ||
| # READ ONLY. Estate CI/CD census over GraphQL: what each repository REQUIRES | ||
| # and what it actually EMITS — the pair needed to find phantom required checks. | ||
| # | ||
| # WHY GRAPHQL | ||
| # ----------- | ||
| # The REST equivalent cost ~4 calls per repo for requirements and ~20 more for | ||
| # emissions: roughly 4,000 calls for a 424-repo estate. That exhausts the | ||
| # 5,000/hour CORE budget in one pass, leaving nothing for the remediation the | ||
| # census exists to drive — and it died halfway more than once, silently | ||
| # under-reporting. | ||
| # | ||
| # Over GraphQL the same data costs ONE POINT PER 25 REPOSITORIES for | ||
| # requirements and one per repository for emissions, from a 5,000/hour budget | ||
| # that is SEPARATE from core and otherwise unused. Measured: full requirements | ||
| # census in 38 seconds for 18 points. | ||
| # | ||
| # IT IS ALSO MORE CORRECT, not merely cheaper: | ||
| # | ||
| # * Requirements live in EITHER classic branch protection OR a ruleset. The | ||
| # REST scan read those through different endpoints, so a repo using branch | ||
| # protection was reported as "no ruleset required X" and its fix silently | ||
| # skipped. One query returns both, so they cannot drift apart. | ||
| # | ||
| # * `statusCheckRollup` returns CheckRun names AND StatusContext contexts | ||
| # together. Reading only the Actions API would brand every external check | ||
| # (SonarCloud and friends) a phantom. | ||
| # | ||
| # * Ruleset TARGET is recorded. Required status checks on a ruleset targeting | ||
| # TAGS are INERT — tags have no pull request to gate. Counting them | ||
| # overstates enforcement; dropping them hides a real misconfiguration. | ||
| # | ||
| # * Rulesets with enforcement != ACTIVE are skipped: they gate nothing. | ||
| # | ||
| # KNOWN LIMIT — READ BEFORE ACTING ON A "PHANTOM" | ||
| # A context appearing only on a RARE trigger can be missed by any sample. | ||
| # Measured example: `Dependabot` shows up only on Dependabot pull requests, so | ||
| # it is absent unless one falls in the sampled window — and would be wrongly | ||
| # reported as a phantom. Widen --prs to reduce this, but treat "phantom" as a | ||
| # CANDIDATE requiring confirmation, never a verdict. That is the discipline | ||
| # docs/CICD-SIGNAL-DISCIPLINE.adoc already sets for fake gates. | ||
| # | ||
| # Output TSV: repo <TAB> kind <TAB> value | ||
| # kind = req:branch-protection | req:ruleset-BRANCH | req:ruleset-TAG | emit | ||
| set -uo pipefail | ||
|
|
||
| OUT="${1:?usage: cicd-census.sh OUT.tsv [--commits N] [--prs N]}"; shift || true | ||
| COMMITS=8 | ||
| PRS=5 | ||
| while [ $# -gt 0 ]; do | ||
|
Check failure on line 50 in scripts/cicd-census.sh
|
||
| case "$1" in | ||
| --commits) COMMITS="$2"; shift 2 ;; | ||
| --prs) PRS="$2"; shift 2 ;; | ||
| *) echo "unknown option: $1" >&2; exit 2 ;; | ||
| esac | ||
| done | ||
| : > "$OUT" | ||
| export OUT COMMITS PRS | ||
|
|
||
| REQ_QUERY=' | ||
| query($login: String!, $cursor: String) { | ||
| repositoryOwner(login: $login) { | ||
| ... on User { repositories(first: 25, ownerAffiliations: OWNER, orderBy: {field: NAME, direction: ASC}, after: $cursor) { ...P } } | ||
| ... on Organization { repositories(first: 25, ownerAffiliations: OWNER, orderBy: {field: NAME, direction: ASC}, after: $cursor) { ...P } } | ||
| } | ||
| } | ||
| fragment P on RepositoryConnection { | ||
| pageInfo { hasNextPage endCursor } | ||
| nodes { | ||
| nameWithOwner | ||
| branchProtectionRules(first: 3) { nodes { requiredStatusCheckContexts } } | ||
| rulesets(first: 5) { | ||
| nodes { | ||
| target enforcement | ||
| rules(first: 20) { | ||
| nodes { type parameters { ... on RequiredStatusChecksParameters { requiredStatusChecks { context } } } } | ||
|
Comment on lines
+71
to
+76
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
| } | ||
| } | ||
| } | ||
| }' | ||
|
|
||
| REQ_PARSE=' | ||
| import json, os, sys | ||
| d = json.load(sys.stdin) | ||
| if "errors" in d: | ||
| sys.stderr.write("GRAPHQL ERROR: " + json.dumps(d["errors"])[:300] + "\n"); print("STOP"); raise SystemExit | ||
| repos = d["data"]["repositoryOwner"]["repositories"] | ||
| rows = [] | ||
| for r in repos["nodes"]: | ||
| n = r["nameWithOwner"] | ||
| for b in r["branchProtectionRules"]["nodes"]: | ||
| for c in (b["requiredStatusCheckContexts"] or []): | ||
| rows.append(n + "\treq:branch-protection\t" + c) | ||
| for rs in r["rulesets"]["nodes"]: | ||
| if rs["enforcement"] != "ACTIVE": | ||
| continue | ||
| for rule in rs["rules"]["nodes"]: | ||
| p = rule.get("parameters") or {} | ||
| for c in (p.get("requiredStatusChecks") or []): | ||
| rows.append(n + "\treq:ruleset-" + rs["target"] + "\t" + c["context"]) | ||
| with open(os.environ["OUT"], "a") as fh: | ||
| fh.write("".join(x + "\n" for x in rows)) | ||
| pi = repos["pageInfo"] | ||
| print(pi["endCursor"] if pi["hasNextPage"] else "DONE") | ||
| ' | ||
|
|
||
| echo "== requirements ==" >&2 | ||
| for LOGIN in hyperpolymath metadatastician; do | ||
| CURSOR=""; PAGES=0 | ||
| while :; do | ||
| if [ -z "$CURSOR" ]; then | ||
|
Check failure on line 112 in scripts/cicd-census.sh
|
||
| RESP=$(gh api graphql -F login="$LOGIN" -F cursor=null -f query="$REQ_QUERY" 2>/dev/null) | ||
| else | ||
| RESP=$(gh api graphql -F login="$LOGIN" -F cursor="$CURSOR" -f query="$REQ_QUERY" 2>/dev/null) | ||
|
Comment on lines
+113
to
+115
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Bug: Cursor passed via -F may be coerced from string to non-stringThe pagination cursor is passed with Send the cursor as a raw string to avoid type coercion.: Was this helpful? React with 👍 / 👎 |
||
| fi | ||
| [ -z "$RESP" ] && break | ||
|
Check failure on line 117 in scripts/cicd-census.sh
|
||
| NEXT=$(printf '%s' "$RESP" | python3 -c "$REQ_PARSE"); PAGES=$((PAGES+1)) | ||
| case "$NEXT" in DONE|STOP|'') break ;; *) CURSOR="$NEXT" ;; esac | ||
| sleep 0.15 | ||
| done | ||
| echo " $LOGIN: $PAGES page(s)" >&2 | ||
| done | ||
|
|
||
| # Emissions only for repos that require something: a repo requiring nothing | ||
| # cannot have a phantom, so scanning it is wasted budget. | ||
| cut -f1 "$OUT" | sort -u > "$OUT.repos" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Quality: Temporary $OUT.repos file is never cleaned upThe intermediate repo list written to Was this helpful? React with 👍 / 👎 |
||
| echo "== emissions for $(wc -l < "$OUT.repos") repo(s) that require something ==" >&2 | ||
|
|
||
| EMIT_QUERY=' | ||
| query($owner: String!, $name: String!, $commits: Int!, $prs: Int!) { | ||
| repository(owner: $owner, name: $name) { | ||
| defaultBranchRef { target { ... on Commit { history(first: $commits) { nodes { ...Roll } } } } } | ||
| pullRequests(first: $prs, states: [OPEN, MERGED], orderBy: {field: UPDATED_AT, direction: DESC}) { | ||
| nodes { commits(last: 1) { nodes { commit { ...Roll } } } } | ||
| } | ||
| } | ||
| } | ||
| fragment Roll on Commit { | ||
| statusCheckRollup { contexts(first: 100) { nodes { ... on CheckRun { name } ... on StatusContext { context } } } } | ||
| }' | ||
|
|
||
| EMIT_PARSE=' | ||
| import json, os, sys | ||
| try: d = json.load(sys.stdin) | ||
| except Exception: raise SystemExit | ||
| if "errors" in d or not (d.get("data") or {}).get("repository"): raise SystemExit | ||
| ctx = set() | ||
| def take(node): | ||
| roll = (node or {}).get("statusCheckRollup") | ||
| if roll: | ||
| for n in roll["contexts"]["nodes"]: | ||
| v = n.get("name") or n.get("context") | ||
| if v: ctx.add(v) | ||
| rep = d["data"]["repository"] | ||
| tgt = (rep.get("defaultBranchRef") or {}).get("target") or {} | ||
| for c in (tgt.get("history") or {}).get("nodes", []): take(c) | ||
| for pr in (rep.get("pullRequests") or {}).get("nodes", []): | ||
| for c in pr["commits"]["nodes"]: take(c["commit"]) | ||
| with open(os.environ["OUT"], "a") as fh: | ||
| for c in sorted(ctx): fh.write(os.environ["REPO"] + "\temit\t" + c + "\n") | ||
| ' | ||
|
|
||
| while read -r R; do | ||
| [ -z "$R" ] && continue | ||
|
Check failure on line 165 in scripts/cicd-census.sh
|
||
| OWNER="${R%%/*}"; NAME="${R##*/}" | ||
| REPO="$R" gh api graphql -F owner="$OWNER" -F name="$NAME" \ | ||
| -F commits="$COMMITS" -F prs="$PRS" -f query="$EMIT_QUERY" 2>/dev/null \ | ||
| | REPO="$R" python3 -c "$EMIT_PARSE" | ||
| sleep 0.1 | ||
| done < "$OUT.repos" | ||
|
|
||
| echo "census rows: $(wc -l < "$OUT")" >&2 | ||
| gh api graphql -f query='{ rateLimit { used remaining limit } }' \ | ||
| -q '.data.rateLimit|"graphql: \(.used) used, \(.remaining)/\(.limit) left"' 2>/dev/null >&2 | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Edge Case: --commits/--prs crash on missing or non-numeric value
Under
set -u, invoking--commitsor--prsas the final argument makes$2an unbound variable, aborting the script with a cryptic error rather than a usage message. There is also no validation that the value is numeric, so--commits foois passed to theInt!GraphQL variable, causing every emission query to error out and return no data. Validate that the option has a numeric argument before assigning.Reject missing or non-numeric option values with a clear message.:
Was this helpful? React with 👍 / 👎