Last active
July 24, 2026 17:03
-
-
Save schnippy/cf6a8bbfb7286809b69b9793363bd0fc to your computer and use it in GitHub Desktop.
Scan all sites in Pantheon portfolio to determine last code update date
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
| #!/usr/bin/env bash | |
| # | |
| # pantheon-code-scan.sh — Scan a Pantheon organization's sites and report the | |
| # last time code was updated on each (via `terminus env:code-log`). | |
| # | |
| # Requires: terminus (authenticated), jq | |
| # | |
| # This script needs real (non-POSIX-mode) bash. If launched via `sh script.sh` | |
| # or a non-bash shell, re-exec under bash before any bash-only syntax is parsed. | |
| if [ -z "${BASH_VERSION:-}" ]; then exec /usr/bin/env bash "$0" "$@"; fi | |
| case ":${SHELLOPTS:-}:" in *:posix:*) exec /usr/bin/env bash "$0" "$@";; esac | |
| set -uo pipefail | |
| usage() { | |
| cat <<'EOF' | |
| Usage: pantheon-code-scan.sh <org-id> [options] | |
| Scan all sites in a Pantheon organization and report the most recent code | |
| commit on each site (from `terminus env:code-log`). | |
| Arguments: | |
| <org-id> Pantheon organization UUID or machine name (required) | |
| Options: | |
| --env <env> Environment to inspect (default: dev — code lands on | |
| dev first, so this reflects the latest code anywhere) | |
| --format <fmt> Output format: table (default), csv, json | |
| --parallel <n> Concurrent terminus calls (default: 8) | |
| --limit <n> Only scan the first N sites (useful for testing) | |
| -h, --help Show this help | |
| Examples: | |
| pantheon-code-scan.sh < ORG UUID > | |
| pantheon-code-scan.sh < ORG UUID > --format=csv > report.csv | |
| pantheon-code-scan.sh < ORG UUID > --format json --env live | |
| Notes: | |
| - Frozen sites are reported with status "frozen" and are not queried. | |
| - Sites whose code log cannot be read (e.g. some Node.js upstreams crash | |
| terminus) are reported with status "error". | |
| - Results are sorted oldest-first so stale sites appear at the top. | |
| EOF | |
| } | |
| err() { echo "Error: $*" >&2; } | |
| # --- argument parsing ------------------------------------------------------- | |
| ORG_ID="" | |
| SCAN_ENV="dev" | |
| FORMAT="table" | |
| PARALLEL=8 | |
| LIMIT="" | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| -h|--help) usage; exit 0 ;; | |
| --env) SCAN_ENV="${2:-}"; shift 2 ;; | |
| --env=*) SCAN_ENV="${1#*=}"; shift ;; | |
| --format) FORMAT="${2:-}"; shift 2 ;; | |
| --format=*) FORMAT="${1#*=}"; shift ;; | |
| --parallel) PARALLEL="${2:-}"; shift 2 ;; | |
| --parallel=*) PARALLEL="${1#*=}"; shift ;; | |
| --limit) LIMIT="${2:-}"; shift 2 ;; | |
| --limit=*) LIMIT="${1#*=}"; shift ;; | |
| -*) | |
| err "Unknown option: $1"; echo >&2; usage >&2; exit 1 ;; | |
| *) | |
| if [[ -n "$ORG_ID" ]]; then | |
| err "Unexpected argument: $1"; echo >&2; usage >&2; exit 1 | |
| fi | |
| ORG_ID="$1"; shift ;; | |
| esac | |
| done | |
| if [[ -z "$ORG_ID" ]]; then | |
| err "Missing required <org-id> argument." | |
| echo >&2 | |
| usage >&2 | |
| exit 1 | |
| fi | |
| case "$FORMAT" in table|csv|json) ;; *) | |
| err "Invalid --format '$FORMAT' (expected: table, csv, or json)"; exit 1 ;; | |
| esac | |
| for dep in terminus jq; do | |
| command -v "$dep" >/dev/null 2>&1 || { err "'$dep' is required but not found in PATH."; exit 1; } | |
| done | |
| # --- fetch site list -------------------------------------------------------- | |
| echo "Fetching site list for org ${ORG_ID}..." >&2 | |
| SITES_JSON="$(terminus site:list --org="$ORG_ID" --format=json 2>/dev/null)" | |
| if [[ -z "$SITES_JSON" ]] || ! echo "$SITES_JSON" | jq -e 'type == "object"' >/dev/null 2>&1; then | |
| err "Could not fetch site list. Check the org id and that you are logged in (terminus auth:whoami)." | |
| exit 1 | |
| fi | |
| TOTAL=$(echo "$SITES_JSON" | jq 'length') | |
| if [[ "$TOTAL" -eq 0 ]]; then | |
| err "No sites found in organization ${ORG_ID}." | |
| exit 1 | |
| fi | |
| WORKDIR="$(mktemp -d)" | |
| trap 'rm -rf "$WORKDIR"' EXIT | |
| # One line per site: name<TAB>framework<TAB>plan<TAB>frozen | |
| echo "$SITES_JSON" \ | |
| | jq -r 'to_entries[].value | [.name, .framework, .plan_name, (.frozen|tostring)] | @tsv' \ | |
| | sort > "$WORKDIR/sites.tsv" | |
| if [[ -n "$LIMIT" ]]; then | |
| head -n "$LIMIT" "$WORKDIR/sites.tsv" > "$WORKDIR/sites.limited.tsv" | |
| mv "$WORKDIR/sites.limited.tsv" "$WORKDIR/sites.tsv" | |
| fi | |
| COUNT=$(wc -l < "$WORKDIR/sites.tsv" | tr -d ' ') | |
| echo "Scanning ${COUNT} of ${TOTAL} sites (env: ${SCAN_ENV}, parallel: ${PARALLEL})..." >&2 | |
| # --- per-site worker -------------------------------------------------------- | |
| # Receives only the site name (safe through xargs); metadata is looked up in | |
| # the sites file, since BSD xargs does not preserve tabs in replaced lines. | |
| scan_site() { | |
| local name="$1" | |
| local meta framework plan frozen | |
| meta="$(grep -m1 "^${name}"$'\t' "$SITES_FILE")" | |
| IFS=$'\t' read -r _ framework plan frozen <<< "$meta" | |
| if [[ "$frozen" == "true" ]]; then | |
| jq -cn --arg site "$name" --arg fw "$framework" --arg plan "$plan" \ | |
| '{site:$site, framework:$fw, plan:$plan, status:"frozen", | |
| last_code_update:null, days_ago:null, author:null, message:null}' | |
| return | |
| fi | |
| local log | |
| log="$(terminus env:code-log "${name}.${SCAN_ENV}" --format=json 2>/dev/null)" | |
| if echo "$log" | jq -e 'type == "array" and length > 0' >/dev/null 2>&1; then | |
| echo "$log" | jq -c --arg site "$name" --arg fw "$framework" --arg plan "$plan" ' | |
| (.[0]) as $c | |
| | {site:$site, framework:$fw, plan:$plan, status:"ok", | |
| last_code_update:$c.datetime, | |
| days_ago:(try ((now - ($c.datetime + "Z" | fromdate)) / 86400 | floor) catch null), | |
| author:$c.author, | |
| message:($c.message | gsub("[\\n\\r\\t]"; " "))}' | |
| elif echo "$log" | jq -e 'type == "array"' >/dev/null 2>&1; then | |
| jq -cn --arg site "$name" --arg fw "$framework" --arg plan "$plan" \ | |
| '{site:$site, framework:$fw, plan:$plan, status:"no-commits", | |
| last_code_update:null, days_ago:null, author:null, message:null}' | |
| else | |
| jq -cn --arg site "$name" --arg fw "$framework" --arg plan "$plan" \ | |
| '{site:$site, framework:$fw, plan:$plan, status:"error", | |
| last_code_update:null, days_ago:null, author:null, message:null}' | |
| fi | |
| } | |
| export -f scan_site | |
| export SCAN_ENV | |
| export SITES_FILE="$WORKDIR/sites.tsv" | |
| # --- run scans in parallel with a progress counter -------------------------- | |
| cut -f1 "$WORKDIR/sites.tsv" \ | |
| | xargs -P "$PARALLEL" -I {} bash -c 'scan_site "$@"' _ {} \ | |
| | tee "$WORKDIR/results.ndjson" \ | |
| | awk -v total="$COUNT" '{ printf "\r %d/%d sites scanned", NR, total > "/dev/stderr" }' | |
| echo >&2 | |
| # Sort: sites with a date oldest-first, then no-commits/error/frozen at the end. | |
| RESULTS="$(jq -cs 'sort_by(.last_code_update // "9999-99-99")' "$WORKDIR/results.ndjson")" | |
| # --- output ----------------------------------------------------------------- | |
| case "$FORMAT" in | |
| json) | |
| echo "$RESULTS" | jq '.' | |
| ;; | |
| csv) | |
| echo "$RESULTS" | jq -r ' | |
| (["site","framework","plan","status","last_code_update","days_ago","author","message"] | @csv), | |
| (.[] | [.site, .framework, .plan, .status, .last_code_update, .days_ago, .author, .message] | @csv)' | |
| ;; | |
| table) | |
| { | |
| printf 'SITE\tFRAMEWORK\tPLAN\tSTATUS\tLAST CODE UPDATE\tDAYS AGO\tAUTHOR\tMESSAGE\n' | |
| echo "$RESULTS" | jq -r '.[] | | |
| [.site, .framework, .plan, .status, | |
| (.last_code_update // "-"), (.days_ago // "-" | tostring), | |
| (.author // "-"), ((.message // "-")[0:50])] | @tsv' | |
| } | column -t -s $'\t' | |
| ;; | |
| esac | |
| # Summary to stderr so it never pollutes csv/json output. | |
| echo "$RESULTS" | jq -r ' | |
| "Done: \(length) sites — ok: \(map(select(.status=="ok"))|length), " + | |
| "frozen: \(map(select(.status=="frozen"))|length), " + | |
| "no-commits: \(map(select(.status=="no-commits"))|length), " + | |
| "errors: \(map(select(.status=="error"))|length)"' >&2 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment