diff --git a/scripts/org-workflows-on-main.sh b/scripts/org-workflows-on-main.sh new file mode 100755 index 0000000..ddd127e --- /dev/null +++ b/scripts/org-workflows-on-main.sh @@ -0,0 +1,585 @@ +#!/bin/bash +# +# Report CI health of every repository in a GitHub organisation, on its +# default branch. +# +# The checks are deliberately strict: a repository is only reported green when +# we could actually verify that it is green. Anything unverifiable -- an API +# error, a truncated response, an unknown check conclusion, a commit that no +# workflow ever ran on -- is reported as such instead of being rounded up to a +# pass. +# +# Every line also reports when the default branch was last pushed to, read +# from the repository activity log, which records the moment the branch head +# actually moved. A timestamp suffixed with "~" is a fallback to the head +# commit's own commit date, used only when the activity log is unavailable; +# it can be far older than the push that delivered it (a rebase, a merge of a +# stale branch, an imported commit). The repository-level `pushed_at` is not +# used at all: it counts pushes to *any* branch. +# +# ORG=webpack ./org-workflows-on-main.sh +# +# Environment: +# ORG organisation to scan (default: webpack) +# INCLUDE_ARCHIVED also scan archived repositories (default: 0) +# INCLUDE_FORKS also scan forks (default: 0) +# CANCELLED_IS_FAIL count cancelled checks as failures (default: 0) +# INCLUDE_DYNAMIC_RUNS count Dependabot "dynamic/" runs (default: 0) +# JOBS repositories checked in parallel (default: 8) +# REPO_LIMIT max repositories to list (default: 1000) +# +# Exit status is 0 only when every scanned repository was verified green. + +set -o pipefail + +ORG="${ORG:-webpack}" +INCLUDE_ARCHIVED="${INCLUDE_ARCHIVED:-0}" +INCLUDE_FORKS="${INCLUDE_FORKS:-0}" +CANCELLED_IS_FAIL="${CANCELLED_IS_FAIL:-0}" +INCLUDE_DYNAMIC_RUNS="${INCLUDE_DYNAMIC_RUNS:-0}" +JOBS="${JOBS:-8}" +REPO_LIMIT="${REPO_LIMIT:-1000}" + +# Absolute path to self, so the xargs workers can re-enter this script +# regardless of the caller's working directory. +SELF=$(cd "$(dirname "$0")" && pwd)/$(basename "$0") + +# Conclusions that mean the check genuinely failed. "cancelled" is handled +# separately because it usually means "superseded", not "broken". +FAIL_CONCLUSIONS='"failure","timed_out","startup_failure","action_required","stale"' +PASS_CONCLUSIONS='"success","neutral","skipped"' + +# Colors (disabled when not writing to a terminal) +if [ -t 1 ]; then + BOLD=$'\033[1m'; DIM=$'\033[2m'; RESET=$'\033[0m' + GREEN=$'\033[32m'; RED=$'\033[31m'; YELLOW=$'\033[33m'; CYAN=$'\033[36m' +else + BOLD=""; DIM=""; RESET=""; GREEN=""; RED=""; YELLOW=""; CYAN="" +fi + +# --------------------------------------------------------------------------- +# Timestamps +# --------------------------------------------------------------------------- + +# BSD date (macOS) and GNU date parse ISO-8601 with different flags; probe once +# rather than guessing from `uname`. +if date -u -j -f '%Y-%m-%dT%H:%M:%SZ' '2000-01-01T00:00:00Z' +%s >/dev/null 2>&1; then + DATE_STYLE=bsd +else + DATE_STYLE=gnu +fi + +epoch_of() { + case "$DATE_STYLE" in + bsd) date -u -j -f '%Y-%m-%dT%H:%M:%SZ' "$1" +%s 2>/dev/null ;; + *) date -u -d "$1" +%s 2>/dev/null ;; + esac +} + +# fmt_age -> "5d4h". Two units, so the number keeps its meaning as +# it grows: "5d" alone cannot distinguish five days from nearly six. +fmt_age() { + local d="$1" + [ "$d" -lt 0 ] && d=0 + if [ "$d" -lt 60 ]; then printf '%ds' "$d" + elif [ "$d" -lt 3600 ]; then printf '%dm%ds' $(( d / 60 )) $(( d % 60 )) + elif [ "$d" -lt 86400 ]; then printf '%dh%dm' $(( d / 3600 )) $(( d % 3600 / 60 )) + elif [ "$d" -lt 2592000 ]; then printf '%dd%dh' $(( d / 86400 )) $(( d % 86400 / 3600 )) + elif [ "$d" -lt 31536000 ]; then printf '%dmo%dd' $(( d / 2592000 )) $(( d % 2592000 / 86400 )) + else printf '%dy%dmo' $(( d / 31536000 )) $(( d % 31536000 / 2592000 )) + fi +} + +# fmt_pushed [~] -> "2026-08-25T09:26:47Z (5d4h)". The full UTC +# timestamp is reported verbatim so two repositories pushed minutes apart can +# still be told apart; the age beside it is only a scanning aid. A trailing +# "~" survives into the output to mark a commit-date fallback. +fmt_pushed() { + local ts="$1" mark="" t0 now + if [ -z "$ts" ] || [ "$ts" = "-" ]; then printf '%s' "-"; return; fi + + case "$ts" in *'~') mark="~"; ts="${ts%\~}" ;; esac + + t0=$(epoch_of "$ts") + if [ -z "$t0" ]; then printf '%s%s' "$ts" "$mark"; return; fi + + now=$(date -u +%s) + printf '%s (%s)%s' "$ts" "$(fmt_age $(( now - t0 )))" "$mark" +} + +# --------------------------------------------------------------------------- +# API helper +# --------------------------------------------------------------------------- + +# gh_api [extra gh args...] +# +# Prints the response body and returns 0 only when the request really +# succeeded, 2 when the endpoint answered 404, 1 for anything else. gh writes the error body to stdout on a 4xx/5xx, so a plain +# `gh api ... 2>/dev/null` leaves jq looking at an error object and reading it +# as "zero checks, therefore fine" -- which is how unverifiable repositories +# used to be reported green. Transient failures are retried. +gh_api() { + local path="$1"; shift + local attempt out rc + + for attempt in 1 2 3; do + out=$(gh api "$path" "$@" 2>/dev/null) + rc=$? + + if [ $rc -eq 0 ] && [ -n "$out" ] && jq -e . >/dev/null 2>&1 <<< "$out"; then + # An error object gh happened to exit 0 on is still an error. + if jq -e '(if type == "array" then (.[0] // {}) else . end) + | type == "object" and has("message") and has("documentation_url")' \ + >/dev/null 2>&1 <<< "$out"; then + printf '%s' "$out" + api_is_404 "$out" && return 2 + return 1 + fi + printf '%s' "$out" + return 0 + fi + + # 4xx will not fix itself; only back off for empty or 5xx responses. + case "$out" in + *'"status": "4'*|*'"status":"4'*) break ;; + esac + sleep $(( attempt * 2 )) + done + + printf '%s' "$out" + api_is_404 "$out" && return 2 + return 1 +} + +# A 404 on an /actions/ endpoint means Actions is switched off for the +# repository, not that the repository is unverifiable. +api_is_404() { + case "$1" in + *'"status": "404"'*|*'"status":"404"'*|*'Not Found'*) return 0 ;; + esac + return 1 +} + +# Condense an error body into one line of explanation. +api_error() { + local msg + msg=$(jq -r '(if type == "array" then (.[0] // {}) else . end) | .message? // empty' \ + 2>/dev/null <<< "$1" | head -1) + printf '%s' "${msg:-unreachable}" +} + +# --------------------------------------------------------------------------- +# Per-repository check. Emits a single +# "\t