check-action-refs.sh 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. #!/usr/bin/env bash
  2. # Staleness verifier for GitHub Actions `uses:` references in workflow YAML.
  3. #
  4. # Lints every `uses: owner/repo@ref` line in one or more workflow files. General-
  5. # purpose: not terraform-specific — point it at any GitHub Actions workflow. Two
  6. # modes per the staleness-verifier pattern (SKILL-RESOURCE-PROTOCOL §7):
  7. # --offline (default): structural-only, NO network. Asserts every `uses:` is
  8. # well-formed; floating @main/@master refs are a WARNING.
  9. # --live: resolves every owner/repo@ref against the GitHub API.
  10. # A 404 (ref does not exist) is DRIFT; rate-limit/offline
  11. # is UNAVAILABLE (advisory, never a build failure).
  12. #
  13. # Usage: check-action-refs.sh [--offline|--live] [--strict] [--json] [-q] [FILE ...]
  14. # Input: workflow YAML paths as positionals (default: the skill's own
  15. # assets/github-actions-terraform.yml). Pure grep/sed extraction — no
  16. # YAML library dependency.
  17. # Output: stdout = data only (findings list, or JSON envelope with --json)
  18. # Stderr: headers, progress, warnings, errors
  19. # Exit: 0 ok, 2 usage, 3 not-found, 4 malformed-uses, 5 missing-dep,
  20. # 7 api-unavailable (live), 10 drift (live: a ref does not resolve)
  21. #
  22. # Examples:
  23. # check-action-refs.sh --offline
  24. # check-action-refs.sh --offline .github/workflows/ci.yml
  25. # GITHUB_TOKEN=ghp_xxx check-action-refs.sh --live ci.yml deploy.yml
  26. # check-action-refs.sh --json --offline | jq '.data[] | select(.status!="ok")'
  27. set -uo pipefail
  28. EXIT_OK=0; EXIT_USAGE=2; EXIT_NOT_FOUND=3; EXIT_MALFORMED=4
  29. EXIT_MISSING_DEP=5; EXIT_UNAVAILABLE=7; EXIT_DRIFT=10
  30. # Terminal design system (skills/_lib/term.sh). Framing rides stderr (term_init 2);
  31. # the findings list / --json stay plain on stdout. Degrade if the lib is gone.
  32. __lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../_lib" 2>/dev/null && pwd || true)"
  33. if [ -n "${__lib:-}" ] && [ -f "$__lib/term.sh" ]; then . "$__lib/term.sh"; term_init 2; __HAVE_TERM=1
  34. else __HAVE_TERM=0; TERM_DOT="|"; fi
  35. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  36. DEFAULT_FILE="${SCRIPT_DIR}/../assets/github-actions-terraform.yml"
  37. MODE="offline"; STRICT=0; JSON=0; QUIET=0; FILES=()
  38. while [[ $# -gt 0 ]]; do
  39. case "$1" in
  40. --offline) MODE="offline" ;;
  41. --live) MODE="live" ;;
  42. --strict) STRICT=1 ;;
  43. --json) JSON=1 ;;
  44. -q|--quiet) QUIET=1 ;;
  45. -h|--help) sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; exit "$EXIT_OK" ;;
  46. -*) echo "ERROR: unknown flag: $1 (try --help)" >&2; exit "$EXIT_USAGE" ;;
  47. *) FILES+=("$1") ;;
  48. esac
  49. shift
  50. done
  51. [[ ${#FILES[@]} -eq 0 ]] && FILES=("$DEFAULT_FILE")
  52. command -v grep >/dev/null 2>&1 || { echo "ERROR: grep required" >&2; exit "$EXIT_MISSING_DEP"; }
  53. HAS_JQ=0; command -v jq >/dev/null 2>&1 && HAS_JQ=1
  54. [[ "$JSON" -eq 1 && "$HAS_JQ" -eq 0 ]] && {
  55. echo '{"error":{"code":"PRECONDITION","message":"jq required for --json"}}'
  56. echo "ERROR: jq required for --json" >&2; exit "$EXIT_MISSING_DEP"; }
  57. if [[ "$MODE" == "live" ]]; then
  58. command -v curl >/dev/null 2>&1 || { echo "ERROR: curl required for --live" >&2; exit "$EXIT_MISSING_DEP"; }
  59. fi
  60. emit() { [[ "$QUIET" -eq 1 ]] && return; printf '%s\n' "$1" >&2; }
  61. # Panel framing applies to the human stderr stream when it's a TTY (or FORCE_COLOR
  62. # forces a render); piped/quiet consumers keep the legacy "=== / [TAG]" lines.
  63. PANEL=0
  64. if [[ "$__HAVE_TERM" -eq 1 && "$QUIET" -eq 0 ]] && { [ -t 2 ] || [ -n "${FORCE_COLOR:-}" ]; }; then PANEL=1; fi
  65. __PANEL_OPEN=0
  66. popen() {
  67. [[ "$PANEL" -eq 1 && "$__PANEL_OPEN" -eq 0 ]] || return 0
  68. { term_panel_open terraform "action-refs ${TERM_DOT} ${MODE}"; term_panel_vert; } >&2
  69. __PANEL_OPEN=1
  70. }
  71. # prow <mark> <legacy-prefix> <text> — panel status row, or the legacy tagged line.
  72. prow() {
  73. if [[ "$PANEL" -eq 1 ]]; then popen; term_status_row "$1" "$3" >&2
  74. else emit " $2 $3"; fi
  75. }
  76. # State accumulators
  77. malformed=0; drift=0; unavailable=0; warned=0
  78. declare -a JSON_OBJS=()
  79. declare -a TEXT_ROWS=()
  80. # --- classify a `uses:` value -------------------------------------------------
  81. # Sets globals: C_STATUS (ok|warn|malformed), C_OWNER, C_REPO, C_REF, C_KIND
  82. classify_uses() {
  83. local v=$1
  84. C_OWNER=""; C_REPO=""; C_REF=""; C_KIND=""; C_STATUS="ok"
  85. # Local action: ./path — always valid, no network
  86. if [[ "$v" == ./* ]]; then C_KIND="local"; return; fi
  87. # Docker action: docker://image[:tag] — out of scope for ref resolution
  88. if [[ "$v" == docker://* ]]; then C_KIND="docker"; return; fi
  89. # Must contain an @ separating owner/repo[/path] from ref
  90. if [[ "$v" != *"@"* ]]; then C_STATUS="malformed"; C_KIND="action"; return; fi
  91. local path="${v%@*}" ref="${v#*@}"
  92. C_REF="$ref"
  93. # Empty ref, or empty path
  94. if [[ -z "$ref" || -z "$path" ]]; then C_STATUS="malformed"; C_KIND="action"; return; fi
  95. # path must be owner/repo[/subpath...]
  96. if [[ "$path" != */* ]]; then C_STATUS="malformed"; C_KIND="action"; return; fi
  97. C_OWNER="${path%%/*}"
  98. local rest="${path#*/}"
  99. C_REPO="${rest%%/*}"
  100. C_KIND="action"
  101. if [[ -z "$C_OWNER" || -z "$C_REPO" ]]; then C_STATUS="malformed"; return; fi
  102. # Validate ref shape: tag (vN / vN.N.N...), 40-hex SHA, or branch name.
  103. if [[ "$ref" =~ ^[0-9a-f]{40}$ ]]; then
  104. C_STATUS="ok" # pinned SHA — best practice
  105. elif [[ "$ref" == "main" || "$ref" == "master" ]]; then
  106. C_STATUS="warn" # floating default branch — advisory
  107. elif [[ "$ref" =~ ^v[0-9]+([._][0-9]+)*$ ]]; then
  108. C_STATUS="ok" # version tag
  109. elif [[ "$ref" =~ ^[A-Za-z0-9._/-]+$ ]]; then
  110. C_STATUS="ok" # other tag/branch name — structurally fine
  111. else
  112. C_STATUS="malformed"
  113. fi
  114. }
  115. # --- live resolution: does owner/repo@ref exist on GitHub? --------------------
  116. # Echoes one of: resolved | notfound | unavailable
  117. resolve_ref() {
  118. local owner=$1 repo=$2 ref=$3
  119. local -a auth=()
  120. [[ -n "${GITHUB_TOKEN:-}" ]] && auth=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
  121. local base="https://api.github.com/repos/${owner}/${repo}"
  122. local code body url
  123. try() {
  124. local u=$1 out
  125. out=$(curl -sS -w $'\n%{http_code}' \
  126. -H "Accept: application/vnd.github+json" \
  127. -H "X-GitHub-Api-Version: 2022-11-28" \
  128. "${auth[@]}" "$u" 2>/dev/null)
  129. [[ -z "$out" ]] && { echo "000"; return; }
  130. code="${out##*$'\n'}"; body="${out%$'\n'*}"
  131. echo "$code"
  132. }
  133. if [[ "$ref" =~ ^[0-9a-f]{40}$ ]]; then
  134. url="${base}/commits/${ref}"
  135. else
  136. url="${base}/git/ref/tags/${ref}"
  137. fi
  138. code=$(try "$url")
  139. # Network failure
  140. [[ "$code" == "000" ]] && { echo "unavailable"; return; }
  141. # Rate limit — advisory, never drift
  142. if [[ "$code" == "403" || "$code" == "429" ]]; then echo "unavailable"; return; fi
  143. if [[ "$code" == "200" ]]; then echo "resolved"; return; fi
  144. # Tag lookup 404'd — fall back to a commit/branch lookup before declaring drift.
  145. # A non-SHA ref can still be a branch name; the commits endpoint resolves those.
  146. if [[ "$code" == "404" && ! "$ref" =~ ^[0-9a-f]{40}$ ]]; then
  147. code=$(try "${base}/commits/${ref}")
  148. [[ "$code" == "000" ]] && { echo "unavailable"; return; }
  149. if [[ "$code" == "403" || "$code" == "429" ]]; then echo "unavailable"; return; fi
  150. [[ "$code" == "200" ]] && { echo "resolved"; return; }
  151. # 404 (no such branch) or 422 (unparseable commit-ish) — the ref does not exist
  152. [[ "$code" == "404" || "$code" == "422" ]] && { echo "notfound"; return; }
  153. echo "unavailable"; return
  154. fi
  155. # Direct SHA lookup that 404/422'd, or a tag 404 with no branch fallback path
  156. [[ "$code" == "404" || "$code" == "422" ]] && { echo "notfound"; return; }
  157. echo "unavailable"
  158. }
  159. add_json() { # file line ref status
  160. [[ "$HAS_JQ" -eq 1 ]] || return
  161. JSON_OBJS+=("$(jq -cn --arg f "$1" --argjson l "$2" --arg r "$3" --arg s "$4" \
  162. '{file:$f, line:$l, ref:$r, status:$s}')")
  163. }
  164. if [[ "$PANEL" -eq 1 ]]; then popen; else emit "=== check-action-refs (${MODE}) ==="; fi
  165. for f in "${FILES[@]}"; do
  166. if [[ ! -f "$f" ]]; then
  167. prow bad "ERROR:" "file not found: $f"
  168. # In JSON mode still report a structured error per §5
  169. if [[ "$JSON" -eq 1 ]]; then
  170. echo "{\"error\":{\"code\":\"NOT_FOUND\",\"message\":\"file not found: $f\"}}"
  171. fi
  172. exit "$EXIT_NOT_FOUND"
  173. fi
  174. # Extract every `uses:` value with its 1-based line number. Strip inline
  175. # comments and surrounding quotes. grep -n gives "LINE:content".
  176. while IFS= read -r entry; do
  177. [[ -z "$entry" ]] && continue
  178. lineno="${entry%%:*}"
  179. rawline="${entry#*:}"
  180. # value after `uses:` — drop leading `- ` list dash if present
  181. val="${rawline#*uses:}"
  182. val="${val%%#*}" # strip trailing comment
  183. # trim whitespace
  184. val="${val#"${val%%[![:space:]]*}"}"
  185. val="${val%"${val##*[![:space:]]}"}"
  186. # strip surrounding quotes
  187. val="${val%\"}"; val="${val#\"}"
  188. val="${val%\'}"; val="${val#\'}"
  189. [[ -z "$val" ]] && continue
  190. classify_uses "$val"
  191. case "$C_STATUS" in
  192. malformed)
  193. malformed=1
  194. prow bad "[MALFORMED]" "${f}:${lineno} uses: ${val}"
  195. TEXT_ROWS+=("${f}:${lineno} ${val} malformed")
  196. add_json "$f" "$lineno" "$val" "malformed"
  197. ;;
  198. warn)
  199. warned=1
  200. prow warn "[WARN floating]" "${f}:${lineno} ${val} (prefer SHA pin)"
  201. TEXT_ROWS+=("${f}:${lineno} ${val} warn")
  202. add_json "$f" "$lineno" "$val" "warn"
  203. ;;
  204. ok)
  205. if [[ "$MODE" == "live" && "$C_KIND" == "action" ]]; then
  206. res=$(resolve_ref "$C_OWNER" "$C_REPO" "$C_REF")
  207. case "$res" in
  208. resolved)
  209. prow ok "[ok]" "${f}:${lineno} ${C_OWNER}/${C_REPO}@${C_REF}"
  210. TEXT_ROWS+=("${f}:${lineno} ${val} ok")
  211. add_json "$f" "$lineno" "$val" "ok" ;;
  212. notfound)
  213. drift=1
  214. prow bad "[DRIFT 404]" "${f}:${lineno} ${C_OWNER}/${C_REPO}@${C_REF}"
  215. TEXT_ROWS+=("${f}:${lineno} ${val} drift")
  216. add_json "$f" "$lineno" "$val" "drift" ;;
  217. unavailable)
  218. unavailable=1
  219. prow warn "[unavailable]" "${f}:${lineno} ${C_OWNER}/${C_REPO}@${C_REF} (API unreachable/rate-limited)"
  220. TEXT_ROWS+=("${f}:${lineno} ${val} unavailable")
  221. add_json "$f" "$lineno" "$val" "unavailable" ;;
  222. esac
  223. else
  224. TEXT_ROWS+=("${f}:${lineno} ${val} ok")
  225. add_json "$f" "$lineno" "$val" "ok"
  226. fi
  227. ;;
  228. esac
  229. done < <(grep -nE '^[[:space:]]*-?[[:space:]]*uses:[[:space:]]*' "$f" 2>/dev/null)
  230. done
  231. # --- panel footer (stderr framing only) ---------------------------------------
  232. if [[ "$PANEL" -eq 1 && "$__PANEL_OPEN" -eq 1 ]]; then
  233. ph_state="healthy"; ph_text="refs well-formed"
  234. if [[ "$malformed" -eq 1 || "$drift" -eq 1 ]]; then ph_state="critical"; ph_text="findings present"
  235. elif [[ "$unavailable" -eq 1 ]]; then ph_state="warning"; ph_text="api unavailable"
  236. elif [[ "$warned" -eq 1 ]]; then ph_state="warning"; ph_text="floating refs"; fi
  237. { term_panel_vert
  238. term_panel_close "--live to resolve ${TERM_DOT} --json for data" "$(term_health "$ph_state" "$ph_text")"
  239. } >&2
  240. fi
  241. # --- output -------------------------------------------------------------------
  242. if [[ "$JSON" -eq 1 ]]; then
  243. printf '%s\n' "${JSON_OBJS[@]:-}" | jq -s \
  244. --arg mode "$MODE" \
  245. '{data: map(select(length>0)),
  246. meta: {mode:$mode,
  247. count:(map(select(length>0))|length),
  248. schema:"claude-mods.terraform-ops.action-refs/v1"}}'
  249. else
  250. # plain text: data rows to stdout (only the non-ok findings are interesting,
  251. # but emit all rows so the agent sees the full inventory)
  252. for row in "${TEXT_ROWS[@]:-}"; do
  253. [[ -n "$row" ]] && printf '%s\n' "$row"
  254. done
  255. fi
  256. # --- exit ---------------------------------------------------------------------
  257. [[ "$malformed" -eq 1 ]] && exit "$EXIT_MALFORMED"
  258. [[ "$drift" -eq 1 ]] && exit "$EXIT_DRIFT"
  259. [[ "$unavailable" -eq 1 ]] && exit "$EXIT_UNAVAILABLE"
  260. if [[ "$warned" -eq 1 && "$STRICT" -eq 1 ]]; then exit "$EXIT_MALFORMED"; fi
  261. exit "$EXIT_OK"