Skip to content

Instantly share code, notes, and snippets.

@schnippy
Last active July 24, 2026 17:03
Show Gist options
  • Select an option

  • Save schnippy/cf6a8bbfb7286809b69b9793363bd0fc to your computer and use it in GitHub Desktop.

Select an option

Save schnippy/cf6a8bbfb7286809b69b9793363bd0fc to your computer and use it in GitHub Desktop.
Scan all sites in Pantheon portfolio to determine last code update date
#!/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