← all scripts

.github/scripts/lib_dependency_scan.sh

444 of 444 statements covered (100.00%).

coveredmissednever traced by Bash (not counted)A line ending in … continues the statement above it and shares its fate.

1#!/usr/bin/env bash
2# =============================================================================
3# lib_dependency_scan.sh — sourceable functions for dependency-scan.sh
4# =============================================================================
5# Extracted from dependency-scan.sh so BATS tests can exercise the real logic
6# without running the full scan (which needs pip, skopeo, helm, AWS creds).
7#
8# Usage:
9# source .github/scripts/lib_dependency_scan.sh
10# =============================================================================
11
12# extract_direct_python_deps [pyproject_path]
13#
14# Reads the ``project.dependencies`` list and every list under
15# ``project.optional-dependencies`` from ``pyproject.toml`` and prints
16# one normalized package name per line (lowercased, ``_`` → ``-`` per
17# PEP 503). These are the packages we pin *directly* — everything else
18# is a transitive dependency whose version is controlled by something
19# we pin, and bumping it ourselves either does nothing (pip resolves
20# back to the same version) or breaks the resolver.
21#
22# Used by the python-drift path in ``dependency-scan.sh`` to filter
23# ``pip list --outdated`` down to names the operator can actually act
24# on, so the monthly report doesn't flag (for example) ``cattrs`` as
25# "outdated" when it's a jsii transitive we have no input on.
26#
27# Falls back silently to an empty list (prints nothing) if the file
28# isn't present or can't be parsed — the caller treats an empty list
29# as "no filter applied" rather than "no direct deps" so the scan
30# never silently hides genuine drift if the TOML parse breaks.
31#
32# Requires Python 3.11+ for ``tomllib`` (stdlib). The deps-scan
33# workflow already runs on 3.14.
34extract_direct_python_deps() {
3541 local pyproject="${1:-pyproject.toml}"
3643 [ -f "$pyproject" ] || return 0
3739 python3 -c "
38import re, sys, tomllib
39try:
40 with open(sys.argv[1], 'rb') as f:
41 data = tomllib.load(f)
42except Exception:
43 sys.exit(0)
44
45project = data.get('project', {}) or {}
46deps = list(project.get('dependencies', []) or [])
47for group in (project.get('optional-dependencies', {}) or {}).values():
48 deps.extend(group or [])
49
50# Drop the project self-reference (``gco-cli[dev]`` etc.) before
51# normalising — pip doesn't report it in ``list --outdated`` anyway
52# but we also don't want to match on it.
53seen = set()
54for spec in deps:
55 if not isinstance(spec, str):
56 continue
57 name = re.split(r'[\\[=!<>;~ ]', spec, 1)[0].strip()
58 if not name or name.lower() == 'gco-cli':
59 continue
60 # PEP 503 normalisation: lowercase, ``_`` + ``.`` → ``-``.
61 name = re.sub(r'[-_.]+', '-', name).lower()
62 if name not in seen:
63 seen.add(name)
64 print(name)
65" "$pyproject" 2>/dev/null
66}
67
68# =============================================================================
69# extract_python_extras [pyproject_path]
70#
71# Prints every ``[project.optional-dependencies]`` group name from
72# ``pyproject.toml``, one per line, in declaration order. The python-drift
73# path in ``dependency-scan.sh`` joins these into a single
74# ``pip install -e ".[group1,group2,...]"`` so that packages pinned *only*
75# inside an optional group (``aws-cdk-lib`` in ``cdk``, ``playwright`` in
76# ``diagrams``, ``mypy`` in ``typecheck``, ...) are actually present in the
77# scan venv. ``pip list --outdated`` can only report drift for installed
78# packages, so the previous base-only install silently dropped every
79# extras-only pin from the report even though
80# ``extract_direct_python_deps`` already includes those names in the
81# direct-pin filter.
82#
83# Falls back silently to an empty list (prints nothing) if the file is
84# missing or unparsable — the caller then falls back to a plain ``-e .``
85# install, which is exactly the old behaviour, rather than dropping the
86# report section.
87extract_python_extras() {
8840 local pyproject="${1:-pyproject.toml}"
8942 [ -f "$pyproject" ] || return 0
9038 python3 -c "
91import sys, tomllib
92try:
93 with open(sys.argv[1], 'rb') as f:
94 data = tomllib.load(f)
95except Exception:
96 sys.exit(0)
97groups = (data.get('project', {}) or {}).get('optional-dependencies', {}) or {}
98for group in groups:
99 print(group)
100" "$pyproject" 2>/dev/null
101}
102
103# extract_build_system_pins [pyproject_path]
104#
105# Reads ``[build-system].requires`` and emits one ``name|version|raw``
106# line per entry. ``version`` is filled only when the entry is a single
107# exact ``==X[.Y[.Z…]]`` pin with no extras, ranges, or markers;
108# otherwise it is left empty so the consistency check can flag the
109# entry while the PyPI drift check skips it.
110#
111# The build backend is a Python dependency too, but it is invisible to
112# ``pip list --outdated``: pip resolves it inside build isolation, not
113# in the scan venv (a Python 3.14 venv does not even ship setuptools).
114# Before this extractor existed the backend floated on a ``>=`` range
115# with nothing watching it — the exact failure mode the rest of this
116# library exists to catch.
117#
118# Prints nothing for a missing or unparseable file; the consistency
119# check treats that as a finding (fail-visible) rather than a pass.
12024extract_build_system_pins() {
12188 local pyproject="${1:-pyproject.toml}"
12293 [ -f "$pyproject" ] || return 0
12383 python3 -c "
124import re, sys, tomllib
125try:
126 with open(sys.argv[1], 'rb') as f:
127 data = tomllib.load(f)
128except Exception:
129 sys.exit(0)
130requires = (data.get('build-system') or {}).get('requires') or []
131for raw in requires:
132 if not isinstance(raw, str) or not raw.strip():
133 continue
134 entry = raw.strip()
135 exact = re.fullmatch(r'([A-Za-z0-9][A-Za-z0-9._-]*)==([0-9]+(?:\.[0-9]+)*)', entry)
136 name_match = re.match(r'[A-Za-z0-9][A-Za-z0-9._-]*', entry)
137 # PEP 503 normalisation: lowercase, ``_`` + ``.`` → ``-``.
138 name = re.sub(r'[-_.]+', '-', name_match.group(0)).lower() if name_match else ''
139 version = exact.group(2) if exact else ''
140 print(f'{name}|{version}|{entry}')
141" "$pyproject" 2>/dev/null
142}
143
144# parse_image_registry <image>
145#
146# Given a Docker image name (without tag), prints "registry|repo" where
147# registry is the domain and repo is the path within that registry.
148#
149# Examples:
150# parse_image_registry "nvcr.io/nvidia/cuda" → "nvcr.io|nvidia/cuda"
151# parse_image_registry "pytorch/pytorch" → "docker.io|pytorch/pytorch"
152# parse_image_registry "python" → "docker.io|library/python"
153# parse_image_registry "public.ecr.aws/eks/coredns" → "public.ecr.aws|eks/coredns"
154# parse_image_registry "docker.io/library/busybox" → "docker.io|library/busybox"
155# parse_image_registry "docker.io/python" → "docker.io|library/python"
156#
157# A first path component containing a dot, a port colon, or the literal
158# ``localhost`` is treated as a registry domain — the same heuristic container
159# runtimes apply — so a newly referenced registry needs no code change here.
160# The previous enumerated-registry list silently misparsed fully-qualified
161# Docker Hub references (``docker.io/library/busybox`` became repository
162# ``docker.io/library/busybox`` under a second ``docker.io``), which made
163# their tag lookups fail every month.
164254parse_image_registry() {
1651343 local image="$1"
1661343 local registry="" repo="" first=""
1671343 case "$image" in
168 */*)
169973 first="${image%%/*}"
170973 case "$first" in
171 *.*|*:*|localhost)
172620 registry="$first"
173620 repo="${image#*/}"
174 # Docker Hub keeps official images under the implicit library/
175 # namespace; restore it for a fully-qualified single-segment repo.
176655 if [ "$registry" = "docker.io" ] && [ "${repo#*/}" = "$repo" ]; then
1773 repo="library/$repo"
178 fi
179 ;;
180 *)
181353 registry="docker.io"
182353 repo="$image"
183 ;;
184 esac
185 ;;
186 *)
187370 registry="docker.io"
188370 repo="library/$image"
189 ;;
190 esac
1911343 echo "${registry}|${repo}"
192}
193
194# dedupe_image_refs
195#
196# Read ``image:tag`` lines on stdin and print each distinct image once, keyed
197# on the canonical ``registry/repo:tag`` from parse_image_registry, so every
198# spelling of one Docker Hub image (``python:3.14.8-slim``,
199# ``docker.io/library/python:3.14.8-slim``) is looked up and reported once.
200# The shortest spelling is kept for display (ties broken by sort order), and
201# the output is sorted. Blank lines are dropped; a line without a tag is kept
202# as-is (check_image skips it as a non-semver tag).
203dedupe_image_refs() {
204190 _image_ref_keys | sort -t "$(printf '\t')" -k1,1 -k2,2n -k3,3 | awk -F '\t' '$1 != previous { print $3; previous = $1 }' | sort
205}
206
207# _image_ref_keys — dedupe_image_refs' first stage: one
208# ``<canonical ref>\t<length>\t<original ref>`` line per non-blank stdin line.
209_image_ref_keys() {
21038 local ref image tag parsed
2111126 while IFS= read -r ref; do
212526 [ -n "$ref" ] || continue
213524 image="${ref%%:*}"
214524 tag="${ref#*:}"
215524 [ "$tag" = "$ref" ] && tag=""
2161048 parsed="$(parse_image_registry "$image")"
217524 printf '%s/%s:%s\t%d\t%s\n' "${parsed%%|*}" "${parsed#*|}" "$tag" "${#ref}" "$ref"
218 done
219}
220
221# is_semver_tag <tag>
222#
223# Returns 0 (true) if the tag looks like a semver version (v1.2.3, 1.2, etc).
224# Returns 1 (false) otherwise.
22510is_semver_tag() {
226790 echo "$1" | grep -qE "^v?[0-9]+\.[0-9]+(\.[0-9]+)?"
227}
228
229# is_project_image <image>
230#
231# Returns 0 (true) if the image is built by this project (gco/*).
2325is_project_image() {
233706 echo "$1" | grep -q "^gco/"
234}
235
236# compare_semver <current> <candidate>
237#
238# Prints "newer" if candidate is strictly newer than current (by sort -V),
239# "same" if they're equal, "older" otherwise.
2408compare_semver() {
241660 local current="${1#v}"
242660 local candidate="${2#v}"
243660 if [ "$current" = "$candidate" ]; then
244350 echo "same"
245350 return
246 fi
247310 local newest
2481240 newest="$(printf '%s\n%s' "$current" "$candidate" | sort -V | tail -1)"
249310 if [ "$newest" = "$candidate" ]; then
250277 echo "newer"
251 else
25233 echo "older"
253 fi
254}
255
256# newer_same_variant_tag <current_tag>
257#
258# Reads a raw registry tag list on stdin (one tag per line) and prints the
259# newest tag in the *same variant family* as <current_tag> that is strictly
260# newer than it — or nothing when the pin is already the family's newest.
261#
262# A variant family shares the exact literal suffix after the leading numeric
263# version: ``24.01-py3`` compares only against ``NN[.NN…]-py3`` tags,
264# ``2.6.0-cuda12.6-cudnn9-runtime`` only against its identical variant
265# suffix, ``3.14.6-slim`` only against ``-slim`` tags, and a bare ``1.38.0``
266# only against bare ``X.Y[.Z]`` tags. This mirrors the same-family scoping
267# the Bedrock model check uses: moving to a different variant (another CUDA
268# line, another base distro, dropping ``-slim``) is a human decision, not
269# drift, so it is never suggested. A leading ``v`` is accepted on either
270# side and ignored for comparison.
271#
272# The strict ``^v?X.Y.Z$`` filter this replaces matched nothing for every
273# suffix-tagged repository (NGC, GHCR Slurm, CUDA, PyTorch), which — under
274# ``pipefail`` — surfaced as a permanent "tag lookup failed" every month.
275#
276# Numeric components of five or more digits are treated as build/date
277# identifiers, not release numbers, and disqualify a candidate unless the
278# current pin itself carries one (a CalVer pin keeps comparing against
279# CalVer tags). Without this, ``alpine:3.21`` was "upgraded" to the
280# ``20260805`` date tag, ``kuberay/operator:v1.6.2`` to a ``9831375``
281# commit-numbered tag, and ``ray:2.56.1`` to the ``2.57.0.397131`` nightly.
28210newer_same_variant_tag() {
283307 python3 -c "
284import re, sys
285
286WIDE = 10000 # five digits: build number, date stamp, or commit counter
287
288current = sys.argv[1]
289match = re.match(r'^v?(\d+(?:\.\d+)*)(.*)\$', current)
290if not match:
291 raise SystemExit(0)
292suffix = match.group(2)
293current_key = [int(part) for part in match.group(1).split('.')]
294current_is_calver = any(part >= WIDE for part in current_key)
295
296pattern = re.compile(r'^v?(\d+(?:\.\d+)*)' + re.escape(suffix) + r'\$')
297best_key = None
298best_tag = None
299for line in sys.stdin:
300 tag = line.strip()
301 if not tag:
302 continue
303 candidate = pattern.match(tag)
304 if candidate is None:
305 continue
306 key = [int(part) for part in candidate.group(1).split('.')]
307 if not current_is_calver and any(part >= WIDE for part in key):
308 continue
309 if key <= current_key:
310 continue
311 if best_key is None or key > best_key:
312 best_key, best_tag = key, tag
313if best_tag:
314 print(best_tag)
315" "$1" 2>/dev/null
316}
317
318# tag_listed <tag> <raw_tags>
319#
320# Whether <raw_tags> (a newline-separated registry tag list passed as one
321# argument, not on stdin) lists <tag> exactly, accepting a leading ``v`` on
322# either side. Exists because the obvious spelling —
323# ``printf '%s\n' "$raw_tags" | grep -qxF …`` — is wrong under ``pipefail``
324# for large repositories: ``grep -q`` exits at the first match and closes
325# the pipe, ``printf`` takes SIGPIPE/EPIPE while still writing tag lists
326# bigger than the pipe buffer (python has ~3900 tags, tritonserver ~2600),
327# and the pipeline reports failure for a tag that is in fact listed. That
328# inverted into false "pinned tag is no longer listed" INCOMPLETE findings
329# in the 2026-09 scan. A herestring keeps grep's stdin writer-free, so
330# early exit has nothing to signal.
3315tag_listed() {
332232 local tag="$1" raw_tags="$2"
333232 grep -qxF -e "$tag" -e "v${tag#v}" -e "${tag#v}" <<< "$raw_tags"
334}
335
336# split_pinned_image_ref <repo:tag@sha256:digest>
337#
338# Validate and decompose a digest-pinned image reference, printing
339# ``repository|tag|digest`` on one line. Returns non-zero with no output for
340# anything that is not exactly the ``repo:tag@sha256:<64 hex>`` shape —
341# tag-only references, digest-only references, and malformed digests all
342# fail, so callers can gate the digest-freshness check on the return code.
3432split_pinned_image_ref() {
344218 local ref="$1"
345218 if ! [[ "$ref" =~ ^([^@:]+(:[0-9]+)?(/[^@:]+)*):([^@]+)@(sha256:[0-9a-f]{64})$ ]]; then
3467 return 1
347 fi
348211 printf '%s|%s|%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[4]}" "${BASH_REMATCH[5]}"
349}
350
351# Transient-failure retries for the scan's registry and HTTP lookups. A single
352# dropped connection used to mark the whole monthly scan incomplete (and fail
353# its job): 2026-10-25, one public.ecr.aws manifest read for a tag that
354# resolved fine minutes later. Every lookup now retries before it counts as a
355# failure; a lookup that still fails is reported incomplete exactly as before.
356#
357# SCAN_CURL_RETRY: curl's own retry on timeouts, refused connections, 408, 429
358# and 5xx (never on a 404 or another definite answer), bounded in total time
359# so a dead host still fails inside the job's budget.
360852SCAN_CURL_RETRY=(--retry 2 --retry-connrefused --retry-max-time 30)
361# SCAN_SKOPEO_RETRY: skopeo's built-in retry for transient registry errors.
362852SCAN_SKOPEO_RETRY=(--retry-times 3)
363
364# with_retries <attempts> <command...>
365#
366# Run <command> up to <attempts> times, stopping at the first success, with
367# DEPS_SCAN_RETRY_DELAY_SECONDS (default 3) between attempts. For tools that
368# have no retry option of their own (helm). The command's stdout passes
369# through only from the attempt that succeeded; a failed attempt's partial
370# output is discarded so a caller parsing it never sees a mix. Returns the
371# last attempt's exit status.
372with_retries() {
373184 local attempts="$1" attempt=1 status=0 output
374184 shift
375197 while :; do
376394 if output="$("$@")"; then
377300 [ -n "$output" ] && printf '%s\n' "$output"
378178 return 0
379 else
38019 status=$?
381 fi
38225 [ "$attempt" -ge "$attempts" ] && return "$status"
38313 attempt=$((attempt + 1))
38413 sleep "${DEPS_SCAN_RETRY_DELAY_SECONDS:-3}"
385 done
386}
387
388# published_manifest_digest <repository:tag>
389#
390# Print the tag's current top-level manifest digest (``sha256:<64 hex>``) as
391# published by the registry. Hashes the raw manifest bytes rather than
392# selecting a platform child, because committed pins are multi-architecture
393# manifest-list digests. Returns non-zero with no output on transport
394# failure or an implausible hash, so callers distinguish "registry
395# unreachable" from "digest moved".
396published_manifest_digest() {
397143 local tagged_image="$1" manifest digest
398286 manifest="$(mktemp)"
399143 if ! skopeo inspect "${SCAN_SKOPEO_RETRY[@]}" --raw "docker://${tagged_image}" > "$manifest" 2>/dev/null; then
4006 rm -f "$manifest"
4016 return 1
402 fi
403411 digest="sha256:$(sha256sum "$manifest" | awk '{print $1}')"
404137 rm -f "$manifest"
405137 if ! [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then
4061 return 1
407 fi
408136 printf '%s\n' "$digest"
409}
410
411# parse_accelerator_drift_count <json-summary-file>
412#
413# Validates the machine-readable output from
414# ``scripts/accelerator_catalog.py check-online --json-summary`` and prints
415# its exact non-negative ``drift_count``. The status/count relationship is
416# checked as well: ``current`` means zero and ``drift`` means one or more.
417#
418# Returns non-zero with no output for a missing file, malformed JSON, a
419# missing/invalid count, or an inconsistent status. The dependency-scan
420# driver turns any such parser failure into one operational finding so a
421# broken online scan can never be reported as clean.
422parse_accelerator_drift_count() {
42334 local summary_file="$1"
42434 [ -f "$summary_file" ] || return 1
42534 python3 -c "
426import json, sys
427try:
428 with open(sys.argv[1]) as handle:
429 summary = json.load(handle)
430except (OSError, json.JSONDecodeError):
431 raise SystemExit(1)
432if not isinstance(summary, dict):
433 raise SystemExit(1)
434count = summary.get('drift_count')
435status = summary.get('status')
436if isinstance(count, bool) or not isinstance(count, int) or count < 0:
437 raise SystemExit(1)
438if status not in {'current', 'drift'}:
439 raise SystemExit(1)
440if (status == 'current') != (count == 0):
441 raise SystemExit(1)
442print(count)
443" "$summary_file" 2>/dev/null
444}
445
446# extract_aurora_versions <file>
447#
448# Prints the pinned Aurora PostgreSQL engine version (``major.minor``) from
449# the constants module, importing it when possible and falling back to a
450# direct regex over ``constants.py``. The pin is a plain version string
451# applied through ``rds.AuroraPostgresEngineVersion.of()`` — there is no CDK
452# enum member to scan for. Output is validated to ``X.Y[.Z]`` shape so a
453# refactor of the constant can never leak a non-version into the RDS query.
45423extract_aurora_versions() {
45582 local file="${1:-gco/stacks/regional_stack.py}"
45682 python3 -c "
457import re, sys
458value = None
459try:
460 from gco.stacks.constants import AURORA_POSTGRES_VERSION
461 value = AURORA_POSTGRES_VERSION
462except ImportError:
463 import os
464 constants_path = os.path.join(os.path.dirname(sys.argv[1]), 'constants.py')
465 if os.path.exists(constants_path):
466 with open(constants_path) as f:
467 text = f.read()
468 m = re.search(r'^AURORA_POSTGRES_VERSION\s*=\s*\"([^\"]+)\"', text, re.M)
469 if m:
470 value = m.group(1)
471if value and re.fullmatch(r'\d+\.\d+(?:\.\d+)?', value):
472 print(value)
473" "$file" 2>/dev/null | sort -V
474}
475
476# extract_eks_addons <file>
477#
478# Extracts EKS addon name|version pairs from the constants module.
479# Falls back to reading constants.py directly if the module can't be imported.
480# Prints one "addon_name|addon_version" per line.
48123extract_eks_addons() {
48280 local file="${1:-gco/stacks/regional_stack.py}"
48382 python3 -c "
484import sys
485try:
486 from gco.stacks.constants import (
487 EKS_ADDON_POD_IDENTITY_AGENT,
488 EKS_ADDON_METRICS_SERVER,
489 EKS_ADDON_EFS_CSI_DRIVER,
490 EKS_ADDON_CLOUDWATCH_OBSERVABILITY,
491 EKS_ADDON_FSX_CSI_DRIVER,
492 )
493 addons = [
494 ('eks-pod-identity-agent', EKS_ADDON_POD_IDENTITY_AGENT),
495 ('metrics-server', EKS_ADDON_METRICS_SERVER),
496 ('aws-efs-csi-driver', EKS_ADDON_EFS_CSI_DRIVER),
497 ('amazon-cloudwatch-observability', EKS_ADDON_CLOUDWATCH_OBSERVABILITY),
498 ('aws-fsx-csi-driver', EKS_ADDON_FSX_CSI_DRIVER),
499 ]
500 for name, version in addons:
501 print(f'{name}|{version}')
502except ImportError:
503 # Fallback: read constants.py directly
504 import re, os
505 constants_path = os.path.join(os.path.dirname(sys.argv[1]), 'constants.py')
506 if os.path.exists(constants_path):
507 with open(constants_path) as f:
508 text = f.read()
509 # Map constant names to addon names
510 mapping = {
511 'EKS_ADDON_POD_IDENTITY_AGENT': 'eks-pod-identity-agent',
512 'EKS_ADDON_METRICS_SERVER': 'metrics-server',
513 'EKS_ADDON_EFS_CSI_DRIVER': 'aws-efs-csi-driver',
514 'EKS_ADDON_CLOUDWATCH_OBSERVABILITY': 'amazon-cloudwatch-observability',
515 'EKS_ADDON_FSX_CSI_DRIVER': 'aws-fsx-csi-driver',
516 }
517 for const_name, addon_name in mapping.items():
518 m = re.search(const_name + r'\s*=\s*\"([^\"]+)\"', text)
519 if m:
520 print(f'{addon_name}|{m.group(1)}')
521 else:
522 # Last resort: scan the file for inline addon_name/addon_version pairs
523 with open(sys.argv[1]) as f:
524 text = f.read()
525 for m in re.finditer(r'addon_name=\"([^\"]+)\".*?addon_version=\"([^\"]+)\"', text, re.DOTALL):
526 print(f'{m.group(1)}|{m.group(2)}')
527" "$file" 2>/dev/null
528}
529
530# extract_helm_charts [charts_yaml_path]
531#
532# Reads ``lambda/helm-installer/charts.yaml`` and prints one JSON object per
533# chart entry: ``{name, repo_url, chart, version, use_oci}``. Extracted from
534# dependency-scan.sh so BATS can exercise the real charts.yaml parse — the
535# driver sources this helper and pipes its output into the version-drift loop
536# (``helm search repo`` / ``helm show chart`` per entry). Because it iterates
537# every entry, a newly-added chart (e.g. kube-prometheus-stack) is picked up
538# automatically with no change here.
539#
540# Prints nothing (exit 0) when the file is missing or unparseable, matching the
541# other extractors in this file so the caller treats empty as "skip".
54223extract_helm_charts() {
54386 local file="${1:-lambda/helm-installer/charts.yaml}"
54489 [ -f "$file" ] || return 0
54583 python3 -c "
546import json, sys, yaml
547try:
548 with open(sys.argv[1]) as f:
549 data = yaml.safe_load(f)
550except Exception:
551 sys.exit(0)
552for name, cfg in (data or {}).get('charts', {}).items():
553 cfg = cfg or {}
554 print(json.dumps({
555 'name': name,
556 'repo_url': cfg.get('repo_url', ''),
557 'chart': cfg.get('chart', ''),
558 'version': cfg.get('version', ''),
559 'use_oci': cfg.get('use_oci', False),
560 }))
561" "$file" 2>/dev/null
562}
563
564# extract_k8s_version [cdk_json_path]
565#
566# Reads the kubernetes_version from cdk.json. Falls back to "1.37".
56724extract_k8s_version() {
568182 local cdk="${1:-cdk.json}"
569188 python3 -c "import json; print(json.load(open('$cdk'))['context']['kubernetes_version'])" 2>/dev/null || echo "1.37"
570}
571
572# extract_dockerfile_pins <dockerfile>
573#
574# Parses ``ARG <NAME>=<VALUE>`` lines from the given Dockerfile and emits
575# ``NAME|VALUE`` for each pin we care about. The allowlist below is
576# intentional — random build-time ARGs (e.g. ``BUILD_DATE``) would add
577# noise to the drift report.
578#
579# The line-anchor (``^ARG``) and single-line Python regex avoid matching
580# ``ARG`` appearing inside a comment or a RUN heredoc. Leading whitespace
581# is permitted so a future ``RUN --mount=…`` or multi-stage FROM line
582# doesn't break the scan silently.
583#
584# Example output for Dockerfile.dev:
585#
586# NODE_VERSION|v24.18.0
587# NPM_VERSION|11.14.1
588# CDK_VERSION|2.1120.0
589# KUBECTL_VERSION|v1.37.1
590# AWSCLI_VERSION|2.34.42
591# DOCKER_VERSION|29.4.2
592# BUILDX_VERSION|v0.35.0
593# SESSION_MANAGER_PLUGIN_VERSION|1.2.896.0
59424extract_dockerfile_pins() {
595128 local file="${1:-Dockerfile.dev}"
596131 [ -f "$file" ] || return 0
597125 python3 -c "
598import re, sys
599allowlist = {
600 'NODE_VERSION',
601 'NPM_VERSION',
602 'CDK_VERSION',
603 'KUBECTL_VERSION',
604 'AWSCLI_VERSION',
605 'DOCKER_VERSION',
606 'BUILDX_VERSION',
607 'UV_VERSION',
608 'SESSION_MANAGER_PLUGIN_VERSION',
609}
610with open(sys.argv[1]) as f:
611 for line in f:
612 # Strip trailing inline comments but keep the ARG value itself.
613 stripped = line.split('#', 1)[0]
614 m = re.match(r'^\s*ARG\s+([A-Z_][A-Z0-9_]*)=(\S+)\s*$', stripped)
615 if not m:
616 continue
617 name, value = m.group(1), m.group(2)
618 if name in allowlist:
619 print(f'{name}|{value}')
620" "$file" 2>/dev/null
621}
622
623# extract_npm_direct_pins [package_json_path]
624#
625# Prints ``name|version`` for every exactly-pinned direct dependency in one
626# ``package.json`` — ``dependencies`` and ``devDependencies`` both count, since
627# every repository-owned graph pins tooling through devDependencies. Range
628# specifiers (``^``, ``~``, ``>=``, ``*``, tags) are skipped: the npm package
629# management check already fails graphs that carry them, and a range cannot
630# drift in the "pinned copy is behind latest" sense this scan reports.
631# Missing or malformed files print nothing, matching extract_dockerfile_pins.
63224extract_npm_direct_pins() {
63392 local file="${1:-package.json}"
63493 [ -f "$file" ] || return 0
63591 python3 -c "
636import json, re, sys
637
638try:
639 with open(sys.argv[1]) as f:
640 manifest = json.load(f)
641except (OSError, json.JSONDecodeError):
642 sys.exit(0)
643
644exact = re.compile(r'^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$')
645for section in ('dependencies', 'devDependencies'):
646 entries = manifest.get(section)
647 if not isinstance(entries, dict):
648 continue
649 for name, version in sorted(entries.items()):
650 if isinstance(version, str) and exact.match(version):
651 print(f'{name}|{version}')
652" "$file" 2>/dev/null
653}
654
655# extract_precommit_hooks [config_path]
656#
657# Parses ``.pre-commit-config.yaml`` and emits one ``repo|rev`` pair per
658# hook ``repo:`` block. The repo URL is left intact (it's needed to
659# resolve the upstream releases endpoint), and ``rev`` is the literal
660# string committed to the config — usually a tag like ``v0.15.7`` or
661# ``v1.19.1`` but pre-commit also tolerates plain semver and full SHAs.
662# Local hook stanzas (``repo: local``) and the pre-commit hook
663# meta-stanza (``repo: meta``) are skipped: there's no upstream release
664# to compare against.
665#
666# Falls back silently to an empty list if the file is missing or the
667# YAML can't be parsed — the caller treats that as "skip" rather than
668# "no drift", same pattern as the other extractors in this file.
66923extract_precommit_hooks() {
67086 local file="${1:-.pre-commit-config.yaml}"
67189 [ -f "$file" ] || return 0
67283 python3 -c "
673import sys, yaml
674try:
675 with open(sys.argv[1]) as f:
676 data = yaml.safe_load(f)
677except Exception:
678 sys.exit(0)
679for entry in (data or {}).get('repos', []) or []:
680 repo = (entry or {}).get('repo', '') or ''
681 rev = (entry or {}).get('rev', '') or ''
682 # ``local`` and ``meta`` are pre-commit conventions for hooks
683 # that aren't backed by an upstream repo; skip them.
684 if not repo or repo in ('local', 'meta'):
685 continue
686 if not rev:
687 continue
688 print(f'{repo}|{rev}')
689" "$file" 2>/dev/null
690}
691
692# is_full_git_commit_sha <revision>
693#
694# Returns success only for a complete SHA-1 or SHA-256 object id. Pre-commit
695# accepts branch names and floating labels in ``rev`` too, so merely being
696# non-semver is not enough to call a ref immutable.
6976is_full_git_commit_sha() {
698162 [[ "$1" =~ ^[0-9a-fA-F]{40}$ || "$1" =~ ^[0-9a-fA-F]{64}$ ]]
699}
700
701# extract_emr_versions <file>
702#
703# Extracts the pinned EMR Serverless release label from the constants module.
704# Prints the label (e.g. ``emr-7.13.0``) on a single line. Falls back to
705# reading constants.py directly if the module can't be imported.
70623extract_emr_versions() {
70780 local file="${1:-gco/stacks/constants.py}"
70880 python3 -c "
709import sys
710try:
711 from gco.stacks.constants import EMR_SERVERLESS_RELEASE_LABEL
712 print(EMR_SERVERLESS_RELEASE_LABEL)
713except ImportError:
714 import re, os
715 constants_path = os.path.join(os.path.dirname(sys.argv[1]), 'constants.py') if 'constants.py' not in sys.argv[1] else sys.argv[1]
716 if os.path.exists(constants_path):
717 with open(constants_path) as f:
718 text = f.read()
719 m = re.search(r'EMR_SERVERLESS_RELEASE_LABEL\s*=\s*\"([^\"]+)\"', text)
720 if m:
721 print(m.group(1))
722" "$file" 2>/dev/null
723}
724
725# list_emr_release_labels <region>
726#
727# Prints every release label ``emr list-release-labels`` knows in <region>,
728# one per line, following ``NextToken`` through every page. Fails (non-zero,
729# nothing printed) when a page cannot be fetched or parsed.
730#
731# Two things about this API decide the shape of the call. It pages at 100
732# labels and the AWS CLI does not paginate it on its own, so a single call
733# sees the first page only. And EMR Serverless' current major line is not
734# named ``emr-8.x.y``: it is the "AWS runtime for Apache Spark" line,
735# ``emr-spark-8.x.y`` (``emr-spark-8.0.0`` GA, then ``emr-spark-8.1.0``),
736# and the service lists those labels *after* every classic ``emr-N.x.y``
737# label — on the last page. The one-page, ``^emr-N.N.N$`` scan that this
738# replaces therefore reported the ``emr-7.14.0`` pin as up to date while a
739# newer major had been generally available for months. The application GCO
740# creates is ``type: SPARK``, for which both label families are valid.
741list_emr_release_labels() {
74235 local region="$1" token="" page
74366 while :; do
74466 if [ -n "$token" ]; then
74563 page="$(aws emr list-release-labels --region "$region" --next-token "$token" \
746 --output json 2>/dev/null)" || return 1
747 else
74870 page="$(aws emr list-release-labels --region "$region" --output json 2>/dev/null)" \
7491 || return 1
750 fi
75165 jq -r '.ReleaseLabels[]?' <<< "$page" 2>/dev/null || return 1
752126 token="$(jq -r '.NextToken // empty' <<< "$page" 2>/dev/null)" || return 1
75395 [ -n "$token" ] || break
754 done
755}
756
757#: A GA EMR release label in either family: ``emr-7.14.0`` or ``emr-spark-8.1.0``.
758#: Preview, beta and release-candidate labels carry a suffix and do not match.
759852EMR_RELEASE_LABEL_RE='^emr-(spark-)?[0-9]+\.[0-9]+\.[0-9]+$'
760
761# emr_label_version <label>
762#
763# The numeric version of a release label in either family:
764# ``emr-7.14.0`` -> ``7.14.0``, ``emr-spark-8.1.0`` -> ``8.1.0``.
76527emr_label_version() {
766233 local version="${1#emr-}"
767233 echo "${version#spark-}"
768}
769
770# newest_emr_label [major]
771#
772# Reads release labels on stdin (one per line) and prints the one with the
773# highest version among the GA labels of both families — restricted to the
774# given major line when <major> is given. Prints nothing when no label
775# qualifies. Ordering is by version, not by label text, so
776# ``emr-spark-8.1.0`` ranks above ``emr-7.14.0`` even though ``sort -V`` on
777# the labels themselves would put every ``emr-spark-`` label first.
7789newest_emr_label() {
77978 local major="${1:-}" label version
780663 while IFS= read -r label; do
781362 [[ "$label" =~ $EMR_RELEASE_LABEL_RE ]] || continue
782380 version="$(emr_label_version "$label")"
783350 [ -z "$major" ] || [ "${version%%.*}" = "$major" ] || continue
784132 printf '%s %s\n' "$version" "$label"
785234 done | sort -V | tail -1 | cut -d' ' -f2-
786234}
787234
788234# extract_constant_value <name> [constants_path]
789234#
790234# Reads a single string-valued top-level constant from the constants
791234# module by regex (does *not* import ``gco.stacks``, which would pull
792234# in the full CDK stack package). Used by the CDK-enum drift checks
793234# below to look up ``LAMBDA_PYTHON_RUNTIME`` and ``AURORA_POSTGRES_VERSION``
794234# without assuming the rest of the project is installable.
795234#
796234# Example:
797234# extract_constant_value LAMBDA_PYTHON_RUNTIME
798234# # → PYTHON_3_14
79969extract_constant_value() {
800215 local name="$1"
801215 local file="${2:-gco/stacks/constants.py}"
802226 [ -f "$file" ] || return 0
803234 python3 -c "
804import re, sys
805name = sys.argv[1]
806with open(sys.argv[2]) as f:
807 text = f.read()
808m = re.search(r'^' + re.escape(name) + r'\s*=\s*\"([^\"]+)\"', text, re.MULTILINE)
809if m:
810 print(m.group(1))
811" "$name" "$file" 2>/dev/null
812}
813
814# extract_python_string_constant <name> <python_path>
815#
816# Tokenizes the source without importing or executing it, then literal-evaluates
817# only the requested top-level assignment. Parsing only that expression keeps
818# extraction compatible when the rest of the module uses syntax newer than the
819# scanner's Python interpreter. Adjacent/parenthesized string literals remain
820# supported, while names, calls, and other executable expressions are rejected.
82170extract_python_string_constant() {
822216 local name="$1"
823216 local file="$2"
824225 [ -f "$file" ] || return 0
825207 python3 -c "
826import ast, io, sys, tokenize
827try:
828 with open(sys.argv[2], encoding='utf-8') as handle:
829 source = handle.read()
830except OSError:
831 raise SystemExit(0)
832tokens = tokenize.generate_tokens(io.StringIO(source).readline)
833try:
834 for token in tokens:
835 if not (
836 token.type == tokenize.NAME
837 and token.string == sys.argv[1]
838 and token.start[1] == 0
839 ):
840 continue
841 for token in tokens:
842 if token.type not in (tokenize.NL, tokenize.COMMENT):
843 break
844 if token.type != tokenize.OP or token.string != '=':
845 continue
846 expression = []
847 depth = 0
848 for token in tokens:
849 if token.type == tokenize.OP:
850 if token.string in '([{':
851 depth += 1
852 elif token.string in ')]}':
853 depth -= 1
854 if token.type in (tokenize.NEWLINE, tokenize.ENDMARKER) and depth == 0:
855 break
856 expression.append((token.type, token.string))
857 try:
858 value = ast.literal_eval(tokenize.untokenize(expression))
859 except (SyntaxError, ValueError):
860 break
861 if isinstance(value, str):
862 print(value)
863 break
864except tokenize.TokenError:
865 pass
866" "$name" "$file" 2>/dev/null
867}
868
869# get_latest_lambda_python_runtime
870#
871# Imports ``aws_cdk.aws_lambda`` and prints the highest ``PYTHON_X_Y``
872# enum member of ``Runtime`` (e.g. ``PYTHON_3_14``). Empty output when
873# aws-cdk-lib isn't importable — callers treat this as "skip".
874#
875# Used by the CDK-enum drift check in ``dependency-scan.sh`` to compare
876# the ``LAMBDA_PYTHON_RUNTIME`` constant against the newest Lambda
877# Python runtime the installed CDK can construct. The dep-scan workflow
878# installs the latest ``aws-cdk-lib`` for this; locally the helper just
879# reflects whatever is on the active interpreter.
880#
881# Suffixed members (``PYTHON_3_14_PROVIDED`` if it ever exists) are
882# ignored — the regex anchor on ``$`` keeps the result aligned with the
883# canonical "X.Y" runtime CDK exposes today.
884get_latest_lambda_python_runtime() {
88534 python3 -c "
886import re
887try:
888 from aws_cdk import aws_lambda
889except Exception:
890 raise SystemExit(0)
891versions = []
892for name in dir(aws_lambda.Runtime):
893 m = re.match(r'^PYTHON_(\d+)_(\d+)$', name)
894 if m:
895 versions.append((int(m.group(1)), int(m.group(2)), name))
896if versions:
897 print(max(versions)[2])
898" 2>/dev/null
899}
900
901# get_latest_lambda_nodejs_runtime
902#
903# Prints the highest canonical ``NODEJS_<major>_X`` member exposed by the
904# installed aws-cdk-lib. This is the Node equivalent of the Python helper
905# above and feeds the managed Lambda runtime drift check.
906get_latest_lambda_nodejs_runtime() {
90734 python3 -c "
908import re
909try:
910 from aws_cdk import aws_lambda
911except Exception:
912 raise SystemExit(0)
913versions = []
914for name in dir(aws_lambda.Runtime):
915 m = re.match(r'^NODEJS_(\d+)_X$', name)
916 if m:
917 versions.append((int(m.group(1)), name))
918if versions:
919 print(max(versions)[1])
920" 2>/dev/null
921}
922
923# get_latest_endoflife_cycle <product>
924#
925# Queries https://endoflife.date/api/<product>.json and prints the
926# highest ``cycle`` (e.g. ``3.14``) that's already shipped and still
927# under standard support. Empty output on network failure or schema
928# change — callers treat this as "skip" rather than as drift.
929#
930# We pick endoflife.date because it's a clean, unauthenticated JSON
931# endpoint that already filters out prerelease/EOL cycles via its
932# ``releaseDate`` and ``eol`` fields. Going through python.org, ruby-lang.org
933# or the upstream GitHub APIs would either rate-limit (no token) or
934# require us to hand-roll prerelease-tag filtering.
935#
936# Interpreters are pinned by *series* here (``3.14``, ``4.0``), not by patch,
937# because that's how .python-version and .ruby-version pin them: the patch is
938# whatever the runner or the prebuilt toolchain resolves to.
939get_latest_endoflife_cycle() {
94075 local product="$1"
94175 curl -fsSL --max-time 15 "${SCAN_CURL_RETRY[@]}" "https://endoflife.date/api/${product}.json" 2>/dev/null \
94275 | python3 -c "
943import datetime, json, sys
944try:
945 data = json.load(sys.stdin)
946except Exception:
947 sys.exit(0)
948today = datetime.date.today().isoformat()
949candidates = []
950for entry in data:
951 cycle = entry.get('cycle', '')
952 release = entry.get('releaseDate', '') or ''
953 eol = entry.get('eol', '')
954 if not cycle or '.' not in cycle:
955 continue
956 # Skip prereleases (release date in the future).
957 if isinstance(release, str) and release > today:
958 continue
959 # Skip end-of-life cycles. ``eol`` may be a string date or False
960 # when EOL hasn't been announced yet — treat False/empty as still
961 # supported.
962 if isinstance(eol, str) and eol and eol < today:
963 continue
964 try:
965 parts = tuple(int(p) for p in cycle.split('.'))
966 except ValueError:
967 continue
968 candidates.append((parts, cycle))
969if candidates:
970 print(max(candidates)[1])
971" 2>/dev/null
972}
973
974# get_latest_python_release
975#
976# The supported Python series, for the Lambda runtime comparison.
977get_latest_python_release() {
97836 get_latest_endoflife_cycle python
979}
980
981# get_latest_ruby_release
982#
983# The supported Ruby series, for the .ruby-version comparison. Ruby is a
984# CI-only dependency (bashcov measures BATS shell coverage — see the Gemfile),
985# but it is pinned like every other toolchain, so it gets the same monthly
986# "is the pin still the current series?" check .python-version gets.
987get_latest_ruby_release() {
98836 get_latest_endoflife_cycle ruby
989}
990
991# ubuntu_variant_release <image_tag>
992#
993# Prints the Ubuntu release an ``-ubuntuXX.YY`` image-tag variant names
994# (``26.05.5-ubuntu24.04`` → ``24.04``), nothing for any other tag. Slinky
995# publishes every Slurm image per base distribution, so the Ubuntu release is
996# the part of ``SLURM_IMAGE_TAG`` the LTS check in dependency-scan.sh reasons
997# about; a Rocky-based or bare tag is "not an Ubuntu variant", not an error.
99823ubuntu_variant_release() {
999178 printf '%s\n' "${1:-}" | sed -nE 's/^.*-ubuntu([0-9]{2}\.[0-9]{2})$/\1/p'
1000}
1001
1002# get_latest_ubuntu_lts
1003#
1004# Prints the newest Ubuntu LTS cycle (``26.04``) that has shipped its first
1005# point release and is still supported, from endoflife.date. Interim
1006# (non-LTS) releases are never candidates, and a brand-new LTS stays out
1007# until its ``.1`` point release: that is the conventional "ready to adopt"
1008# marker (it is also when ``do-release-upgrade`` starts offering LTS→LTS),
1009# so the scan does not nag about a base the ecosystem has not settled on.
1010# Empty output on network failure or schema change — the caller treats that
1011# as a skip, not drift.
1012get_latest_ubuntu_lts() {
101338 curl -fsSL --max-time 15 "${SCAN_CURL_RETRY[@]}" "https://endoflife.date/api/ubuntu.json" 2>/dev/null \
101438 | python3 -c "
1015import datetime, json, sys
1016try:
1017 data = json.load(sys.stdin)
1018except Exception:
1019 sys.exit(0)
1020today = datetime.date.today().isoformat()
1021candidates = []
1022for entry in data:
1023 cycle = str(entry.get('cycle', '') or '')
1024 latest = str(entry.get('latest', '') or '')
1025 release = entry.get('releaseDate', '') or ''
1026 eol = entry.get('eol', '')
1027 if entry.get('lts') is not True or '.' not in cycle:
1028 continue
1029 if isinstance(release, str) and release > today:
1030 continue
1031 if isinstance(eol, str) and eol and eol < today:
1032 continue
1033 # First point release out: ``latest`` reads ``<cycle>.N``.
1034 if not latest.startswith(cycle + '.'):
1035 continue
1036 try:
1037 parts = tuple(int(p) for p in cycle.split('.'))
1038 except ValueError:
1039 continue
1040 candidates.append((parts, cycle))
1041if candidates:
1042 print(max(candidates)[1])
1043" 2>/dev/null
1044}
1045
1046# read_ruby_version_pin [version_file]
1047#
1048# Prints the committed Ruby series from .ruby-version (comments and blank
1049# lines ignored), or nothing when the file is missing or holds no version.
1050# Empty output makes the caller skip rather than report false drift.
105123read_ruby_version_pin() {
105286 local version_file="${1:-.ruby-version}"
105390 [ -f "$version_file" ] || return 0
105482 grep -vE '^[[:space:]]*(#|$)' "$version_file" 2>/dev/null \
105582 | tr -d '[:space:]' \
105682 | grep -oE '^(ruby-)?[0-9]+\.[0-9]+(\.[0-9]+)?$' \
105782 | sed -E 's/^ruby-//' \
105882 | head -1
1059}
1060
1061# get_latest_precommit_hook_release <repo_url>
1062#
1063# Given the ``repo:`` URL committed to ``.pre-commit-config.yaml``,
1064# prints the latest semver-shaped tag from the upstream Git host so
1065# the dep-scan can compare it against the pinned ``rev:``. Empty
1066# output on network failure, an unsupported host, or when no tag
1067# matches — callers treat that as "skip" rather than as drift.
1068#
1069# Today only GitHub repos are supported. Every hook in the project's
1070# ``.pre-commit-config.yaml`` is hosted there, and the pre-commit
1071# ecosystem is overwhelmingly GitHub-based. If a future hook lives
1072# elsewhere (GitLab, Codeberg) the helper will return empty and the
1073# scan logs a one-line skip note for that hook — no false drift.
1074#
1075# We use ``GET /repos/{owner}/{repo}/tags`` rather than
1076# ``releases/latest`` because pre-commit pins ``rev:`` to a Git tag,
1077# not a GitHub Release — and several hooks (yamllint, mirrors-mypy,
1078# markdownlint-cli2) tag without ever cutting a Release. The tags
1079# endpoint returns newest-first, so we filter to ``vX.Y.Z`` /
1080# ``X.Y.Z`` / ``X.Y`` shapes, drop pre-release suffixes (``-rc1``,
1081# ``-beta``), and take the highest by semver.
1082#
1083# Goes through ``github_api_get``, so the call is authenticated whenever
1084# ``GITHUB_TOKEN`` is set. The scan only makes a handful of these calls,
1085# but the anonymous 60 req/h bucket is keyed on the runner's *shared*
1086# egress IP, and a run that starts with that bucket empty loses every
1087# GitHub lookup at once (see github_api_get).
1088get_latest_precommit_hook_release() {
108980 local repo_url="$1"
109081 [ -n "$repo_url" ] || return 0
1091
1092 # Only GitHub is supported today. Strip any trailing ``.git`` or
1093 # ``/`` so the owner/repo extraction works for both forms commonly
1094 # seen in pre-commit configs.
109579 local cleaned="${repo_url%.git}"
109679 cleaned="${cleaned%/}"
109779 case "$cleaned" in
1098 https://github.com/*) ;;
10993 *) return 0 ;;
1100 esac
1101
110276 local owner_repo="${cleaned#https://github.com/}"
1103 # Reject anything that isn't ``owner/repo`` (no extra path segments).
1104224 case "$owner_repo" in
11051 */*/*) return 0 ;;
1106 */*) ;;
11071 *) return 0 ;;
1108 esac
1109
111074 github_api_get "https://api.github.com/repos/${owner_repo}/tags?per_page=100" \
111174 | python3 -c "
1112import json, re, sys
1113try:
1114 data = json.load(sys.stdin)
1115except Exception:
1116 sys.exit(0)
1117# pre-commit's ``rev:`` accepts ``vX.Y[.Z]``, ``X.Y[.Z]``, or full
1118# SHAs. We compare on the semver-shaped ones; SHA-pinned hooks are
1119# left alone (the helper returns empty and the caller skips them).
1120pat = re.compile(r'^v?\d+\.\d+(?:\.\d+)?$')
1121candidates = []
1122for entry in data or []:
1123 name = (entry or {}).get('name', '')
1124 if not pat.match(name):
1125 continue
1126 stripped = name.lstrip('v')
1127 parts = stripped.split('.')
1128 try:
1129 nums = tuple(int(p) for p in parts)
1130 except ValueError:
1131 continue
1132 candidates.append((nums, name))
1133if candidates:
1134 print(max(candidates)[1])
1135" 2>/dev/null
1136}
1137
1138
1139# extract_mooncake_default_image [images_py_path]
1140#
1141# Prints the default upstream Mooncake vLLM image reference (``repo:tag``)
1142# pinned in ``cli/images.py`` as ``_DISAGGREGATED_DEFAULT_IMAGE`` — the image
1143# GCO's disaggregated/store/both inference deploys pull when the operator
1144# passes no ``--image``.
1145#
1146# This image lives in a Python constant, not a Dockerfile ``FROM`` or a K8s
1147# manifest, so neither Dependabot (docker ecosystem) nor the manifest/workflow
1148# image sweep in dependency-scan.sh sees it. This extractor feeds it into the
1149# Docker-image drift check so a newer vLLM release is surfaced in the monthly
1150# report — the cue to validate and bump the pin (the ``mooncake-image``
1151# workflow re-runs the image contract tests against the new tag).
1152#
1153# Prints nothing if the file or constant is absent — the caller treats an
1154# empty result as "skip", same as the other extractors here.
115523extract_mooncake_default_image() {
115685 local file="${1:-cli/images.py}"
115789 [ -f "$file" ] || return 0
115881 python3 -c "
1159import re, sys
1160with open(sys.argv[1]) as f:
1161 text = f.read()
1162m = re.search(r'^_DISAGGREGATED_DEFAULT_IMAGE\s*=\s*\"([^\"]+)\"', text, re.MULTILINE)
1163if m:
1164 print(m.group(1))
1165" "$file" 2>/dev/null
1166}
1167
1168# extract_chart_value_images [charts_yaml_path]
1169#
1170# Walks every ``values:`` block in charts.yaml and prints one fully-qualified
1171# ``image:tag`` per line for every mapping that pins BOTH a repository and a
1172# tag. Handles the two image-pin shapes the file uses:
1173#
1174# image: image:
1175# repository: example/app registry: ghcr.io
1176# tag: "1.2.3" repository: org/sub/app
1177# tag: "v1.2.3"
1178#
1179# The ``registry`` sibling must join the emitted reference: without it a
1180# ghcr-hosted repository is emitted bare and the tag sweep resolves it
1181# against docker.io — for multi-segment repositories that cannot exist
1182# there, turning every monthly scan into a false INCOMPLETE (first hit by
1183# kubeflow-trainer's controller pin, the first registry-split image with an
1184# explicit tag; KEDA's registry-split blocks are tag-less and never emit).
1185#
1186# Tag-less pins stay un-emitted on purpose: their images follow the chart's
1187# appVersion, which the Helm chart version sweep already reports. Registry-
1188# less single-segment repositories are skipped as before (ambiguous
1189# namespace). Prints nothing on a missing or unparseable file — the caller
1190# treats empty output as a broken parse, matching the smoke-manifest
1191# precedent (charts.yaml always carries pinned values images).
119223extract_chart_value_images() {
119385 local file="${1:-lambda/helm-installer/charts.yaml}"
119489 [ -f "$file" ] || return 0
119581 python3 -c "
1196import sys
1197import yaml
1198try:
1199 with open(sys.argv[1]) as handle:
1200 data = yaml.safe_load(handle)
1201except Exception:
1202 sys.exit(0)
1203
1204
1205def find_images(node):
1206 if isinstance(node, dict):
1207 registry = node.get('registry', '')
1208 repository = node.get('repository', '')
1209 tag = node.get('tag', '')
1210 if repository and tag:
1211 if registry:
1212 print(f'{registry}/{repository}:{tag}')
1213 elif '/' in repository:
1214 print(f'{repository}:{tag}')
1215 for value in node.values():
1216 find_images(value)
1217 elif isinstance(node, list):
1218 for item in node:
1219 find_images(item)
1220
1221
1222for _name, cfg in ((data or {}).get('charts') or {}).items():
1223 if isinstance(cfg, dict):
1224 find_images(cfg.get('values') or {})
1225" "$file" 2>/dev/null
1226}
1227
1228# extract_default_bedrock_model [cdk_json_path] [leaf_key]
1229#
1230# Prints a configured Bedrock model id from ``cdk.json``
1231# ``context.bedrock.<leaf_key>`` (default leaf: ``mission_default_model_id``).
1232# The managed generation leaves are ``mission_default_model_id`` — what
1233# Mission sampling resolves through ``gco.bedrock`` when no explicit override
1234# is supplied — ``capacity_advisor_default_model_id`` — the capacity
1235# advisor's equivalent — and ``claude_code_default_model_id``, the session
1236# model ``gco autopilot`` hands to Claude Code. The keys are deliberately
1237# independent knobs.
1238#
1239# These values feed the Bedrock-model drift check in dependency-scan.sh, which
1240# compares each against the newest profile in the same model family
1241# (get_latest_bedrock_model). A newer release is the cue to update cdk.json
1242# and, for the Mission key, re-capture the scaffold fixture under
1243# tests/fixtures/scaffold_responses/.
1244#
1245# Prints nothing if the file is absent, malformed, or does not contain a
1246# non-empty string at the expected path. The caller treats empty output as a
1247# skip, matching the other extractors in this library.
1248161extract_default_bedrock_model() {
1249563 local file="${1:-cdk.json}"
1250563 local leaf="${2:-mission_default_model_id}"
1251 # Optional third argument selects the context block (default: bedrock).
1252 # The vector-store feature keeps its own independent embedding model at
1253 # ``context.vector_store.embedding_model_id``; passing ``vector_store``
1254 # here lets the same extractor and drift plumbing manage it.
1255563 local block="${3:-bedrock}"
1256585 [ -f "$file" ] || return 0
1257541 python3 -c "
1258import json, sys
1259try:
1260 with open(sys.argv[1]) as handle:
1261 data = json.load(handle)
1262 value = data.get('context', {}).get(sys.argv[3], {}).get(sys.argv[2])
1263except Exception:
1264 value = None
1265if isinstance(value, str) and value.strip():
1266 print(value.strip())
1267" "$file" "$leaf" "$block" 2>/dev/null
1268}
1269
1270# bedrock_model_family <inference_profile_id>
1271#
1272# Prints the "model family" key for a Bedrock system-defined inference
1273# profile id so two releases of the same model line compare equal on
1274# family and differ only on version. The family is the geography +
1275# provider + the non-version tokens of the model name, with numeric model
1276# version/generation/date tokens dropped. Numeric tokens may be integers or
1277# dotted versions embedded between hyphens, or a single-letter generation
1278# marker such as Moonshot's ``k2.5`` / ``k3`` (a letter followed by the
1279# digits) when it follows the model's name word:
1280#
1281# us.amazon.nova-pro-v1:0 -> us.amazon.nova-pro
1282# global.amazon.nova-3-lite-v1:0 -> global.amazon.nova-lite
1283# us.anthropic.claude-sonnet-4-5-20250929-v1:0 -> us.anthropic.claude-sonnet
1284# global.anthropic.claude-opus-4-6-v1 -> global.anthropic.claude-opus
1285# global.anthropic.claude-opus-9 -> global.anthropic.claude-opus
1286# global.openai.gpt-5.7-sol -> global.openai.gpt-sol
1287# global.moonshotai.kimi-k4 -> global.moonshotai.kimi
1288#
1289# The trailing revision appears in three shapes across live profiles:
1290# ``-vMAJOR:MINOR``, ``-vMAJOR`` alone (newer Anthropic profiles), and
1291# absent entirely. All three are stripped, so one model line stays one
1292# family; matching only the ``:MINOR`` form would file
1293# ``claude-opus-4-6-v1`` under a phantom ``claude-opus-v1`` family and
1294# silently stop reporting drift against ``claude-opus-5``.
1295#
1296# Folding the numeric generation token into the version key (rather
1297# than the family) is deliberate: it keeps "Nova 1 Pro" and a future
1298# "Nova 2 Pro" in the same family so a generation bump is reported as
1299# drift, while different tiers (nova-pro vs nova-lite) and providers
1300# stay in separate families and are never cross-suggested. The
1301# single-letter marker rule exists for the same reason: without it
1302# ``kimi-k3`` would sit in its own ``kimi-k3`` family and a ``kimi-k4``
1303# release would never be reported. The marker is only folded when it
1304# follows a name word, so ids whose entire name IS the marker
1305# (``deepseek.r1`` vs ``deepseek.v3``) keep separate families instead of
1306# collapsing into a provider-wide one.
130723bedrock_model_family() {
130846 python3 -c "
1309import re, sys
1310mid = sys.argv[1]
1311core = re.sub(r'-v\d+(?::\d+)?\Z', '', mid)
1312parts = core.split('.')
1313if len(parts) >= 3:
1314 geo, provider, name = parts[0], parts[1], '.'.join(parts[2:])
1315elif len(parts) == 2:
1316 geo, provider, name = '', parts[0], parts[1]
1317else:
1318 geo, provider, name = '', '', core
1319words = [t for t in name.split('-') if t]
1320tokens = [
1321 t for i, t in enumerate(words)
1322 if not re.fullmatch(r'\d+(?:\.\d+)*', t)
1323 and not (i > 0 and re.fullmatch(r'[A-Za-z]\d+(?:\.\d+)*', t))
1324]
1325prefix = '.'.join([p for p in (geo, provider) if p])
1326print(prefix + ('.' + '-'.join(tokens) if tokens else ''))
1327" "$1" 2>/dev/null
1328}
1329
1330# compare_bedrock_model <current_id> <candidate_id>
1331#
1332# Prints "newer" when candidate is a newer release than current,
1333# "same" when equal, "older" otherwise — mirroring compare_semver's
1334# contract so the drift check reads the same way. The comparison key
1335# is the tuple of every integer in the id, left to right (model
1336# version, generation, date, and the trailing ``vMAJOR:MINOR``), so
1337# nova-pro-v1:0 (1,0) is older than a hypothetical nova-pro-v2:0 (2,0)
1338# and claude-sonnet-4-5-...-v1:0 (4,5,...) is older than a
1339# claude-sonnet-4-6-...-v1:0. Callers scope to one family first (see
1340# bedrock_model_family); this helper only looks at the integer key.
134112compare_bedrock_model() {
134273 python3 -c "
1343import re, sys
1344def key(mid):
1345 return [int(n) for n in re.findall(r'\d+', mid)]
1346a, b = key(sys.argv[1]), key(sys.argv[2])
1347print('same' if a == b else ('newer' if b > a else 'older'))
1348" "$1" "$2" 2>/dev/null
1349}
1350
1351# get_latest_bedrock_model <current_id> [region]
1352#
1353# Prints the newest system-defined inference-profile id in the same
1354# model family as <current_id> (see bedrock_model_family), as reported
1355# by ``aws bedrock list-inference-profiles --type-equals SYSTEM_DEFINED``.
1356# Used by the Bedrock-model drift check to tell whether a pinned
1357# generation-model default has a newer release available.
1358#
1359# Family scoping keeps the comparison apples-to-apples: a newer Nova
1360# Pro is reported against a pinned Nova Pro, but a different tier (Nova
1361# Lite, a Claude model, ...) is never suggested as a "newer" default —
1362# switching tier/provider is a human decision, not drift.
1363#
1364# Region defaults to us-east-1 (matches the advisor + Mission sampling
1365# default region) regardless of the workflow's configured region, so both
1366# global.* and geography-scoped cross-Region profiles resolve consistently.
1367# Profiles without a numeric version key are ignored because they cannot be
1368# ordered safely. Empty output on any failure (no creds, throttling, schema
1369# change, or no comparable profile) tells the caller to mark the check skipped.
1370#
1371# IAM action: bedrock:ListInferenceProfiles.
1372get_latest_bedrock_model() {
1373165 local current="$1"
1374165 local region="${2:-us-east-1}"
1375329 if [ -z "$current" ] || ! [[ "$current" =~ [0-9] ]]; then
13761 return 0
1377 fi
1378164 aws bedrock list-inference-profiles \
1379 --type-equals SYSTEM_DEFINED \
1380 --region "$region" \
1381 --output json 2>/dev/null \
1382164 | python3 -c "
1383import json, re, sys
1384current = sys.argv[1]
1385def family(mid):
1386 # Keep in lockstep with bedrock_model_family above: the revision
1387 # suffix is optional and its ``:MINOR`` half is too, and a
1388 # single-letter generation marker (kimi-k3) folds like a number.
1389 core = re.sub(r'-v\d+(?::\d+)?\Z', '', mid)
1390 parts = core.split('.')
1391 if len(parts) >= 3:
1392 geo, provider, name = parts[0], parts[1], '.'.join(parts[2:])
1393 elif len(parts) == 2:
1394 geo, provider, name = '', parts[0], parts[1]
1395 else:
1396 geo, provider, name = '', '', core
1397 words = [t for t in name.split('-') if t]
1398 tokens = [
1399 t for i, t in enumerate(words)
1400 if not re.fullmatch(r'\d+(?:\.\d+)*', t)
1401 and not (i > 0 and re.fullmatch(r'[A-Za-z]\d+(?:\.\d+)*', t))
1402 ]
1403 prefix = '.'.join([p for p in (geo, provider) if p])
1404 return prefix + ('.' + '-'.join(tokens) if tokens else '')
1405def key(mid):
1406 return [int(n) for n in re.findall(r'\d+', mid)]
1407try:
1408 data = json.load(sys.stdin)
1409except Exception:
1410 sys.exit(0)
1411target = family(current)
1412cands = []
1413for prof in data.get('inferenceProfileSummaries', []) or []:
1414 pid = prof.get('inferenceProfileId', '') or ''
1415 if (prof.get('status') or 'ACTIVE') != 'ACTIVE':
1416 continue
1417 if pid and key(pid) and family(pid) == target:
1418 cands.append(pid)
1419if cands:
1420 cands.sort(key=key)
1421 print(cands[-1])
1422" "$current" 2>/dev/null
1423}
1424
1425# get_latest_bedrock_embedding_model <current_id> [region]
1426#
1427# Prints the newest ACTIVE text-embedding foundation model in the same
1428# model family as <current_id> (see bedrock_model_family), as reported by
1429# ``aws bedrock list-foundation-models --by-output-modality EMBEDDING``.
1430# Used by the Bedrock-model drift check for
1431# ``context.bedrock.embedding_model_id`` — Mission memory's embedding
1432# default, resolved at runtime through
1433# ``gco.bedrock.get_default_embedding_model_id()``.
1434#
1435# Embedding models are plain foundation models, not system-defined
1436# inference profiles, so this is a separate lookup from
1437# get_latest_bedrock_model; the family scoping and integer-tuple version
1438# ranking are deliberately identical. A newer same-family release (e.g. a
1439# Titan Text Embeddings v3) is reported as drift; a different provider or
1440# model line never is. Embedding drift is advisory-with-a-caveat: stored
1441# vectors are only comparable to vectors from the same model, so adopting
1442# a newer embedding model means re-embedding or segregating existing
1443# data, not just bumping the pin (the drift report's remediation text
1444# says so).
1445#
1446# Region defaults to us-east-1 (the Mission memory default region).
1447# Models without a numeric version key are ignored because they cannot be
1448# ordered safely; non-ACTIVE lifecycles (e.g. LEGACY) are skipped. Empty
1449# output on any failure tells the caller to mark the check skipped.
1450#
1451# IAM action: bedrock:ListFoundationModels (already granted to the scan
1452# role alongside bedrock:ListInferenceProfiles).
1453get_latest_bedrock_embedding_model() {
145467 local current="$1"
145567 local region="${2:-us-east-1}"
1456133 if [ -z "$current" ] || ! [[ "$current" =~ [0-9] ]]; then
14571 return 0
1458 fi
145966 aws bedrock list-foundation-models \
1460 --by-output-modality EMBEDDING \
1461 --region "$region" \
1462 --output json 2>/dev/null \
146366 | python3 -c "
1464import json, re, sys
1465current = sys.argv[1]
1466def family(mid):
1467 # Keep in lockstep with bedrock_model_family above: the revision
1468 # suffix is optional and its ``:MINOR`` half is too, and a
1469 # single-letter generation marker (kimi-k3) folds like a number.
1470 core = re.sub(r'-v\d+(?::\d+)?\Z', '', mid)
1471 parts = core.split('.')
1472 if len(parts) >= 3:
1473 geo, provider, name = parts[0], parts[1], '.'.join(parts[2:])
1474 elif len(parts) == 2:
1475 geo, provider, name = '', parts[0], parts[1]
1476 else:
1477 geo, provider, name = '', '', core
1478 words = [t for t in name.split('-') if t]
1479 tokens = [
1480 t for i, t in enumerate(words)
1481 if not re.fullmatch(r'\d+(?:\.\d+)*', t)
1482 and not (i > 0 and re.fullmatch(r'[A-Za-z]\d+(?:\.\d+)*', t))
1483 ]
1484 prefix = '.'.join([p for p in (geo, provider) if p])
1485 return prefix + ('.' + '-'.join(tokens) if tokens else '')
1486def key(mid):
1487 return [int(n) for n in re.findall(r'\d+', mid)]
1488try:
1489 data = json.load(sys.stdin)
1490except Exception:
1491 sys.exit(0)
1492target = family(current)
1493cands = []
1494for model in data.get('modelSummaries', []) or []:
1495 mid = model.get('modelId', '') or ''
1496 lifecycle = (model.get('modelLifecycle') or {}).get('status') or 'ACTIVE'
1497 if lifecycle != 'ACTIVE':
1498 continue
1499 if mid and key(mid) and family(mid) == target:
1500 cands.append(mid)
1501if cands:
1502 cands.sort(key=key)
1503 print(cands[-1])
1504" "$current" 2>/dev/null
1505}
1506
1507# =============================================================================
1508# Expanded coverage helpers
1509# =============================================================================
1510# The functions below extend the scan to surfaces that live outside the
1511# original twelve: CI tooling pins the workflows install by hand, version
1512# pins that must move in lockstep across several files, and recurring
1513# hygiene checks (suppression expiry, lockfile freshness, base-image
1514# security epochs).
1515#
1516# Every one keeps the same contract as the extractors above: print one
1517# record per line to stdout, and print nothing (exit 0) on missing/malformed
1518# input so the caller treats an empty result as "skip", never as drift.
1519# =============================================================================
1520
1521# github_api_get <url>
1522#
1523# The one code path for every ``api.github.com`` read the scan makes
1524# (release and tag lookups for the CI tooling, the pre-commit hooks, and the
1525# Dockerfile.dev pins). Prints the response body on success and nothing on
1526# failure, like the ``curl -fsSL`` calls it replaces, so callers keep treating
1527# empty as "skip".
1528#
1529# Authentication is the point. Unauthenticated requests draw from a 60 req/h
1530# bucket keyed on the *client IP*, and on a GitHub-hosted runner that IP is
1531# shared with every other tenant's job on the same host — so a scan can start
1532# with the bucket already empty and every GitHub lookup fails at once while
1533# PyPI, npm and the OCI registries (different hosts, different limits) all
1534# succeed. That is exactly the failure shape of a partial monthly report. When
1535# ``GITHUB_TOKEN`` (the workflow passes the job's own token) or ``GH_TOKEN``
1536# (what a developer running ``gco deps scan`` has for the gh CLI) is set, the
1537# call is sent with a bearer header and lands in the 5 000 req/h per-token
1538# bucket instead; ``contents: read`` is enough because everything read here is
1539# public. With neither set the call stays anonymous, so the scan still works
1540# from a laptop with no token at all.
1541#
1542# A refused token falls back to an anonymous read. Some organisations block
1543# the GitHub Actions app, and then a workflow's token gets 401/403 on their
1544# *public* repositories while an anonymous request to the same URL returns
1545# 200 — aquasecurity, whose Trivy releases this scan reads, behaves this way
1546# (``verify_action_pins.py`` carries the same fallback for the same reason).
1547# A refusal is told apart from a rate limit by the quota headers: a limit
1548# leaves ``x-ratelimit-remaining: 0`` (or answers 429, or sets
1549# ``retry-after``); a refusal leaves quota on the table.
1550#
1551# Failure diagnostics: the scan used to hide *why* a GitHub call failed
1552# (``-f ... 2>/dev/null``), which made a rate-limited run indistinguishable
1553# from an upstream outage in the report. The cause of the last failed call is
1554# written to the file named by ``GITHUB_API_FAILURE_FILE`` so
1555# ``github_api_failure_hint`` can put it in the incomplete-lookup line. A
1556# file rather than a variable because every caller runs this helper inside a
1557# ``$(...)`` capture, where a shell variable would die with the subshell (the
1558# same reason ``mark_scan_incomplete`` appends to a file). A successful call
1559# clears it and the hint consumes it, so it can only ever describe the call
1560# that just failed.
15611704GITHUB_API_FAILURE_FILE="${GITHUB_API_FAILURE_FILE:-$(mktemp)}"
1562
1563_github_api_record_failure() {
156476 printf '%s\n' "$1" > "$GITHUB_API_FAILURE_FILE"
1565}
1566
1567# _github_api_request <url> <token>
1568#
1569# One request. Prints the body and returns 0 on HTTP 200; otherwise records
1570# the failure and returns 1 (no response), 2 (rate limited), 3 (the token was
1571# refused with quota left, so an anonymous retry is worth making), or 4 (any
1572# other status). ``<token>`` may be empty for an anonymous request.
1573_github_api_request() {
1574548 local url="$1" token="$2"
1575548 local headers body code remaining retry_after
15761096 headers="$(mktemp)"
15771096 local -a auth=()
1578548 if [ -n "$token" ]; then
157952 auth=(-H "Authorization: Bearer ${token}")
1580 fi
1581 # ``-f`` is deliberately absent: the headers of a failed call are what tell
1582 # a rate limit apart from an outage. ``--retry`` covers transport flakes
1583 # (timeouts, resets) with curl's own backoff, capped so a ``retry-after``
1584 # of a minute cannot stall the scan; a 403 is a policy answer and is not
1585 # retried. The empty-array expansion is written the bash 3.2 way because
1586 # ``gco deps scan`` runs this under whatever ``bash`` is on PATH, which on
1587 # macOS is 3.2 with ``set -u`` active.
15881096 if ! body="$(curl -sSL --max-time 15 --retry 2 --retry-connrefused --retry-max-time 30 \
1589 -D "$headers" \
1590 -H "Accept: application/vnd.github+json" \
1591 -H "X-GitHub-Api-Version: 2022-11-28" \
1592 ${auth[@]+"${auth[@]}"} \
1593 "$url" 2>/dev/null)"; then
159417 rm -f "$headers"
159517 _github_api_record_failure "transport error"
159617 return 1
1597 fi
1598 # With ``-L`` the header dump holds one block per hop; the final status is
1599 # the last one. Header names are case-insensitive and lines end in CRLF.
16001062 code="$(awk 'toupper($1) ~ /^HTTP\// { code = $2 } END { print code }' "$headers")"
16011062 remaining="$(awk 'tolower($1) == "x-ratelimit-remaining:" { v = $2 } END { gsub(/\r/, "", v); print v }' "$headers")"
16021062 retry_after="$(awk 'tolower($1) == "retry-after:" { v = $2 } END { gsub(/\r/, "", v); print v }' "$headers")"
1603531 rm -f "$headers"
1604531 if [ "$code" = "200" ]; then
1605474 : > "$GITHUB_API_FAILURE_FILE"
1606474 printf '%s' "$body"
1607474 return 0
1608 fi
160957 if [ "$code" = "429" ] \
1610129 || { [ "$code" = "403" ] && { [ "${remaining:-}" = "0" ] || [ -n "$retry_after" ]; }; }; then
161135 if [ -n "$token" ]; then
161216 _github_api_record_failure "rate limit exceeded for the authenticated token"
1613 else
161419 _github_api_record_failure "rate limit exceeded on the anonymous 60 req/h bucket, set GITHUB_TOKEN"
1615 fi
161635 return 2
1617 fi
161859 if [ -n "$token" ] && { [ "$code" = "401" ] || [ "$code" = "403" ]; }; then
161919 _github_api_record_failure "HTTP ${code}, token refused"
162019 return 3
1621 fi
16223 _github_api_record_failure "HTTP ${code:-000}"
16233 return 4
1624}
1625
1626github_api_get() {
1627530 local url="$1"
1628531 [ -n "$url" ] || return 1
1629529 local token="${GITHUB_TOKEN:-${GH_TOKEN:-}}"
1630529 local rc=0 refused
1631601 _github_api_request "$url" "$token" || rc=$?
1632529 if [ "$rc" -ne 3 ]; then
1633510 return "$rc"
1634 fi
163519 refused="$(< "$GITHUB_API_FAILURE_FILE")"
163619 rc=0
163721 _github_api_request "$url" "" || rc=$?
163819 if [ "$rc" -ne 0 ]; then
16392 _github_api_record_failure "${refused}, then anonymous retry: $(< "$GITHUB_API_FAILURE_FILE")"
1640 fi
164119 return "$rc"
1642}
1643
1644# github_api_failure_hint
1645#
1646# Prints a parenthesised suffix for an incomplete-lookup reason describing
1647# the last ``github_api_get`` failure, or nothing when the last call
1648# succeeded or none was made, and clears the record so a later reason for an
1649# unrelated lookup can never inherit it. The rate-limit text says which
1650# bucket ran dry, which is the one-line remediation (export ``GITHUB_TOKEN``).
1651github_api_failure_hint() {
165259 local failure=""
1653113 [ -s "$GITHUB_API_FAILURE_FILE" ] && failure="$(< "$GITHUB_API_FAILURE_FILE")"
165459 : > "$GITHUB_API_FAILURE_FILE"
165564 [ -n "$failure" ] || return 0
165654 printf ' (GitHub API: %s)' "$failure"
1657}
1658
1659# get_latest_github_release_tag <owner/repo>
1660#
1661# Prints the ``tag_name`` of the latest non-prerelease GitHub Release for
1662# ``<owner/repo>`` (e.g. ``v0.70.0``). Generalises the inline release lookups
1663# the Dockerfile.dev section already does for moby/moby and docker/buildx so
1664# the CI-tooling drift check (Trivy, Helm, kind) can share one code path.
1665#
1666# Empty output on network failure, a non ``owner/repo`` argument, or a repo
1667# with no published Release — callers treat empty as "skip", same as the
1668# other lookups here. Goes through ``github_api_get`` so the call is
1669# authenticated whenever ``GITHUB_TOKEN`` is set (see that helper for why the
1670# anonymous bucket is not enough on a shared runner).
1671get_latest_github_release_tag() {
1672276 local owner_repo="$1"
1673277 [ -n "$owner_repo" ] || return 0
1674 # Reject anything that isn't exactly ``owner/repo`` (mirrors the guard in
1675 # get_latest_precommit_hook_release) so a stray URL or path can't 404.
1676821 case "$owner_repo" in
16771 */*/*) return 0 ;;
1678 */*) ;;
16791 *) return 0 ;;
1680 esac
1681273 github_api_get "https://api.github.com/repos/${owner_repo}/releases/latest" \
1682273 | jq -r '.tag_name // empty' 2>/dev/null
1683}
1684
1685# extract_workflow_env_pin <VAR_NAME> [workflows_dir]
1686#
1687# Prints the unique value(s) of a ``<VAR_NAME>: "<value>"`` env assignment
1688# found across the workflow YAML under <workflows_dir> (default
1689# ``.github/workflows``). Used by the CI-tooling drift check to read the
1690# pinned ``KUBECONFORM_VERSION`` / ``CALICO_VERSION`` / ``METRICS_SERVER_VERSION`` the
1691# workflows install their own tooling from — pins Dependabot doesn't watch
1692# (they're plain env strings, not ``uses:`` refs or Dockerfile ``FROM``
1693# lines).
1694#
1695# Prints one value per line, de-duplicated and sorted. More than one line
1696# means the same tool is pinned to *different* values across files — a
1697# lockstep-drift bug the Version Consistency section reports. Empty output
1698# when the dir is absent or the var is unset anywhere.
169992extract_workflow_env_pin() {
1700543 local var="$1"
1701543 local dir="${2:-.github/workflows}"
1702543 [ -n "$var" ] || return 0
1703560 [ -d "$dir" ] || return 0
1704526 grep -rhoE "^[[:space:]]*${var}:[[:space:]]*\"?[A-Za-z0-9._+-]+\"?" "$dir" 2>/dev/null \
1705526 | sed -E "s/^[[:space:]]*${var}:[[:space:]]*//" \
1706526 | tr -d '"' \
1707526 | sort -u
1708}
1709
1710# extract_crossplane_function_packages <manifest> <function-name>
1711#
1712# Prints every Crossplane package reference (``spec.package``) in <manifest>
1713# whose repository is ``<registry>/<org>/<function-name>``, exactly as written,
1714# one per line, de-duplicated and sorted. post-helm-crossplane.yaml pins
1715# function-go-templating as ``<repo>:<tag>@sha256:<index digest>``. The
1716# reference is an xpkg OCI package, not a container ``image:``, so neither the
1717# manifest image sweep nor Dependabot sees it. The manifest is parsed as YAML,
1718# not grepped, because the digest-pinned reference is too long for the
1719# ``package:`` line and sits on the next one. GCO's ``{{TOKEN}}`` placeholders
1720# are neutralised first, so an unrendered template still parses. Empty output
1721# when the manifest is absent or unparseable, the name is blank, or no such
1722# package is named.
172346extract_crossplane_function_packages() {
1724175 local manifest="$1" name="$2"
1725177 [ -n "$name" ] || return 0
1726181 [ -f "$manifest" ] || return 0
1727165 python3 -c "
1728import re, sys, yaml
1729try:
1730 with open(sys.argv[1], encoding='utf-8') as f:
1731 text = re.sub(r'\{\{[A-Za-z0-9_]+\}\}', 'placeholder', f.read())
1732 documents = list(yaml.safe_load_all(text))
1733except Exception:
1734 sys.exit(0)
1735found = set()
1736for document in documents:
1737 spec = document.get('spec') if isinstance(document, dict) else None
1738 package = spec.get('package') if isinstance(spec, dict) else None
1739 if not isinstance(package, str):
1740 continue
1741 last = package.strip().split('@', 1)[0].rsplit('/', 1)[-1]
1742 if last.split(':', 1)[0] == sys.argv[2]:
1743 found.add(package.strip())
1744for package in sorted(found):
1745 print(package)
1746" "$manifest" "$name" 2>/dev/null
1747}
1748
1749# extract_crossplane_function_pin <manifest> <function-name>
1750#
1751# Prints the release tag of every package reference
1752# extract_crossplane_function_packages finds, one per line, de-duplicated and
1753# sorted. The ``@sha256:`` digest is dropped, and a digest-only reference has
1754# no tag, so it prints nothing. More than one line means two tags drifted
1755# apart. Empty output under the same conditions as the package extractor.
175623extract_crossplane_function_pin() {
175788 extract_crossplane_function_packages "$1" "$2" \
175888 | sed -nE 's/@sha256:[0-9a-f]{64}$//; s#^.*/[^/:@]+:([A-Za-z0-9._+-]+)$#\1#p' \
175988 | sort -u
1760}
1761
1762# extract_install_trivy_pin [action_yml]
1763#
1764# Prints the Trivy version pinned as the ``version`` input default of the
1765# install-trivy composite action — the one place the Trivy pin lives, now
1766# that security.yml and cve-scan.yml carry no copies of their own. Empty
1767# output when the file, the input, or the default is absent.
176823extract_install_trivy_pin() {
176984 local file="${1:-.github/actions/install-trivy/action.yml}"
177088 [ -f "$file" ] || return 0
177180 python3 -c "
1772import sys, yaml
1773try:
1774 with open(sys.argv[1]) as f:
1775 data = yaml.safe_load(f)
1776except Exception:
1777 sys.exit(0)
1778value = ((((data or {}).get('inputs') or {}).get('version') or {}).get('default') or '')
1779if value:
1780 print(value)
1781" "$file" 2>/dev/null
1782}
1783
1784# extract_kind_pins [workflow_file]
1785#
1786# Prints the kind pins configured on the ``helm/kind-action`` step:
1787# kind|<version> e.g. kind|v0.32.0 (the kind binary)
1788# kind-node|<image ref> e.g. kind-node|kindest/node:v1.37.0@sha256:<64 hex>
1789#
1790# The node image is passed through exactly as written. The committed pin is
1791# tag AND digest (the form kind's release notes publish); callers split it
1792# with split_pinned_image_ref and treat a tag-only value as a finding.
1793#
1794# These live in the action's ``with:`` block, not a top-level ``image:`` or
1795# a Dockerfile ``FROM``, so neither the workflow image sweep nor Dependabot's
1796# docker ecosystem sees them. The caller checks the kind binary against
1797# kubernetes-sigs/kind releases, the node image's tag against its own
1798# registry tags within the pinned K8s minor, and its digest against what the
1799# tag currently resolves to.
1800#
1801# Empty output if the file or the kind-action step is absent.
180269extract_kind_pins() {
1803323 local file="${1:-.github/workflows/integration-tests.yml}"
1804335 [ -f "$file" ] || return 0
1805311 python3 -c "
1806import re, sys, yaml
1807try:
1808 with open(sys.argv[1]) as f:
1809 data = yaml.safe_load(f)
1810except Exception:
1811 sys.exit(0)
1812
1813# The kind-action steps reference the single workflow-level declarations as
1814# \${{ env.KIND_VERSION }} / \${{ env.KIND_NODE_IMAGE }}; resolve those here
1815# so callers keep seeing concrete values. Literal with: values (the
1816# pre-hoist shape) still pass through unchanged. An env reference that does
1817# not resolve prints nothing — the caller's presence checks report the pin
1818# as missing rather than passing a template string to a release lookup.
1819workflow_env = {
1820 str(k): str(v) for k, v in ((data or {}).get('env') or {}).items()
1821}
1822_REF = re.compile(r'^\\\$\\{\\{\\s*env\\.([A-Za-z_][A-Za-z0-9_]*)\\s*\\}\\}\$')
1823
1824def resolve(value, job_env):
1825 value = str(value or '')
1826 match = _REF.match(value.strip())
1827 if not match:
1828 return value
1829 name = match.group(1)
1830 if name in job_env:
1831 return str(job_env[name])
1832 return workflow_env.get(name, '')
1833
1834seen = []
1835for job in (data or {}).get('jobs', {}).values():
1836 job_env = {str(k): str(v) for k, v in ((job or {}).get('env') or {}).items()}
1837 for step in (job or {}).get('steps', []) or []:
1838 uses = (step or {}).get('uses', '') or ''
1839 if uses.startswith('helm/kind-action'):
1840 with_ = (step or {}).get('with', {}) or {}
1841 ver = resolve(with_.get('version', ''), job_env)
1842 node = resolve(with_.get('node_image', ''), job_env)
1843 if ver and ('kind', ver) not in seen:
1844 seen.append(('kind', ver))
1845 if node and ('kind-node', node) not in seen:
1846 seen.append(('kind-node', node))
1847# De-duplicated: multiple kind-action steps (cluster-e2e + examples-smoke)
1848# resolving to the SAME versions print once. A key appearing twice therefore
1849# always means the steps drifted apart — the caller's consistency check
1850# reports exactly that.
1851for key, value in seen:
1852 print(f'{key}|{value}')
1853" "$file" 2>/dev/null
1854}
1855
1856# extract_ruff_pins [pyproject] [precommit] [lint_workflow]
1857#
1858# Prints the ruff version pinned in each place the project keeps it, one
1859# ``source|version`` per line (version normalised without a leading ``v``):
1860# Example output shape (the values differ when local pins drift):
1861# pyproject|1.2.3 ruff==X in [project.optional-dependencies]
1862# precommit|1.2.4 astral-sh/ruff-pre-commit rev in .pre-commit-config.yaml
1863# lint-action|1.2.3 astral-sh/ruff-action version input in lint.yml
1864#
1865# Ruff is pinned in three spots that must move together (developer install,
1866# the pre-commit hook, and the prebuilt-binary CI lint job). The base Python
1867# deps check already flags ruff drift vs PyPI, but nothing catches the three
1868# local pins silently disagreeing — which is exactly what the Version
1869# Consistency section reports.
1870extract_ruff_pins() {
187137 local pyproject="${1:-pyproject.toml}"
187237 local precommit="${2:-.pre-commit-config.yaml}"
187337 local lintwf="${3:-.github/workflows/lint.yml}"
187437 python3 -c "
1875import re, sys
1876pyproject, precommit, lintwf = sys.argv[1], sys.argv[2], sys.argv[3]
1877
1878def norm(v):
1879 return v.lstrip('v').strip()
1880
1881# pyproject: first ruff==X.Y.Z anywhere (the lint + diagrams extras pin the
1882# same value; report it once).
1883try:
1884 with open(pyproject) as f:
1885 m = re.search(r'ruff==([0-9.]+)', f.read())
1886 if m:
1887 print(f'pyproject|{norm(m.group(1))}')
1888except OSError:
1889 pass
1890
1891# pre-commit: rev of the astral-sh/ruff-pre-commit repo block.
1892try:
1893 import yaml
1894 with open(precommit) as f:
1895 data = yaml.safe_load(f)
1896 for entry in (data or {}).get('repos', []) or []:
1897 if 'astral-sh/ruff-pre-commit' in ((entry or {}).get('repo', '') or ''):
1898 rev = (entry or {}).get('rev', '')
1899 if rev:
1900 print(f'precommit|{norm(rev)}')
1901 break
1902except Exception:
1903 pass
1904
1905# lint workflow: version input on the first astral-sh/ruff-action step (the
1906# two steps pin the same value). Parse the YAML rather than regex the raw
1907# text so a nearby ``python-version:`` can't be mistaken for the action's
1908# own ``version:`` input.
1909try:
1910 import yaml
1911 with open(lintwf) as f:
1912 wf = yaml.safe_load(f)
1913 found = ''
1914 for job in (wf or {}).get('jobs', {}).values():
1915 for step in (job or {}).get('steps', []) or []:
1916 uses = (step or {}).get('uses', '') or ''
1917 if uses.startswith('astral-sh/ruff-action'):
1918 found = str(((step or {}).get('with', {}) or {}).get('version', '') or '')
1919 if found:
1920 break
1921 if found:
1922 break
1923 if found:
1924 print(f'lint-action|{norm(found)}')
1925except Exception:
1926 pass
1927" "$pyproject" "$precommit" "$lintwf" 2>/dev/null
1928}
1929
1930# extract_python_version_pins [workflows_dir]
1931#
1932# Prints one line per ``python-version: "X.Y"`` occurrence across the
1933# workflow YAML (value only, e.g. ``3.14``). The caller collapses these to
1934# unique values: more than one distinct value, or a value that disagrees
1935# with the project's canonical Python (derived from LAMBDA_PYTHON_RUNTIME),
1936# means the CI matrix drifted from the runtime the Lambdas actually ship on.
1937extract_python_version_pins() {
193839 local dir="${1:-.github/workflows}"
193939 local version_file="${2:-.python-version}"
194041 [ -d "$dir" ] || return 0
1941 # The CI Python lives once, in .python-version (every setup-python step
1942 # uses python-version-file). Emit that single pin, PLUS any literal
1943 # python-version: leftovers in the workflows — a stray literal is
1944 # exactly the drift the consistency check should surface.
194537 if [ -f "$version_file" ]; then
194672 grep -oE "^[0-9]+\.[0-9]+" "$version_file" | head -1
1947 fi
194837 grep -rhoE "python-version:[[:space:]]*\"?[0-9]+\.[0-9]+\"?" "$dir" 2>/dev/null \
194937 | sed -E "s/python-version:[[:space:]]*//" \
195037 | tr -d '"'
1951}
1952
1953# list_npm_package_dirs [root]
1954#
1955# Prints every repository-owned npm package directory. Generated, vendored,
1956# virtual-environment, and CDK assembly trees are excluded so a copied
1957# ``package.json`` never becomes a false dependency surface.
195825list_npm_package_dirs() {
1959121 local root="${1:-.}"
1960121 python3 -c "
1961from pathlib import Path
1962import sys
1963
1964root = Path(sys.argv[1]).resolve()
1965excluded = {
1966 '.git', '.kiro', '.mypy_cache', '.pytest_cache', '.ruff_cache',
1967 'build', 'cdk.out', 'dist', 'node_modules', '__pycache__',
1968}
1969
1970def owned(path):
1971 parts = path.relative_to(root).parts[:-1]
1972 return not any(
1973 part in excluded or part.startswith('.venv') or part.endswith('-build')
1974 for part in parts
1975 )
1976
1977for manifest in sorted(root.rglob('package.json')):
1978 if not owned(manifest):
1979 continue
1980 relative = manifest.parent.relative_to(root)
1981 print('.' if relative == Path('.') else relative.as_posix())
1982" "$root" 2>/dev/null
1983}
1984
1985# check_npm_package_management [root] [dependabot_config]
1986#
1987# Emits ``package.json|problem`` for every repository-owned npm graph that is
1988# not fully reproducible and managed. A graph must have a package-lock.json,
1989# an exact npm packageManager pin, exact direct dependency pins, and a matching
1990# Dependabot npm directory entry. The all-package npm-audit CI job treats any
1991# output as a hard failure; the monthly scan also reports it as consistency
1992# drift.
1993check_npm_package_management() {
199438 local root="${1:-.}"
199538 local dependabot="${2:-.github/dependabot.yml}"
199638 python3 -c "
1997import json, re, sys
1998from pathlib import Path
1999
2000root = Path(sys.argv[1]).resolve()
2001dependabot_path = Path(sys.argv[2])
2002if not dependabot_path.is_absolute():
2003 dependabot_path = root / dependabot_path
2004excluded = {
2005 '.git', '.kiro', '.mypy_cache', '.pytest_cache', '.ruff_cache',
2006 'build', 'cdk.out', 'dist', 'node_modules', '__pycache__',
2007}
2008exact_version = re.compile(r'^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$')
2009
2010def owned(path):
2011 parts = path.relative_to(root).parts[:-1]
2012 return not any(
2013 part in excluded or part.startswith('.venv') or part.endswith('-build')
2014 for part in parts
2015 )
2016
2017dependabot_dirs = set()
2018try:
2019 text = dependabot_path.read_text(encoding='utf-8')
2020except OSError:
2021 text = ''
2022for block in re.split(r'(?m)(?=^\s*-\s+package-ecosystem:)', text):
2023 if not re.search(r'(?m)^\s*-\s+package-ecosystem:\s*[\"\']?npm[\"\']?\s*$', block):
2024 continue
2025 match = re.search(r'(?m)^\s+directory:\s*[\"\']?([^\"\'\s]+)', block)
2026 if match:
2027 dependabot_dirs.add('/' + match.group(1).strip('/'))
2028
2029for manifest in sorted(root.rglob('package.json')):
2030 if not owned(manifest):
2031 continue
2032 rel_manifest = manifest.relative_to(root).as_posix()
2033 rel_dir = manifest.parent.relative_to(root)
2034 dependabot_dir = '/' if rel_dir == Path('.') else '/' + rel_dir.as_posix()
2035 try:
2036 package = json.loads(manifest.read_text(encoding='utf-8'))
2037 except (OSError, json.JSONDecodeError) as exc:
2038 print(f'{rel_manifest}|invalid JSON: {exc}')
2039 continue
2040 if not manifest.with_name('package-lock.json').is_file():
2041 print(f'{rel_manifest}|missing package-lock.json')
2042 manager = package.get('packageManager', '')
2043 if not re.fullmatch(r'npm@\d+\.\d+\.\d+', manager):
2044 print(f'{rel_manifest}|packageManager must be an exact npm@X.Y.Z pin')
2045 for section in ('dependencies', 'devDependencies', 'optionalDependencies'):
2046 for name, version in (package.get(section) or {}).items():
2047 if not isinstance(version, str) or not exact_version.fullmatch(version):
2048 print(f'{rel_manifest}|{section}.{name} must use an exact version pin')
2049 if dependabot_dir not in dependabot_dirs:
2050 print(f'{rel_manifest}|missing Dependabot npm entry for {dependabot_dir}')
2051" "$root" "$dependabot" 2>/dev/null
2052}
2053
2054# extract_node_major_pins [root] [constants] [nvmrc] [dockerfile]
2055#
2056# Emits ``source|major`` for every place that intentionally mirrors the
2057# repository Node major: the Lambda runtime constant, .nvmrc, every owned npm
2058# graph's engine, and Dockerfile.dev. The driver reports missing sources and
2059# disagreement; the CDK enum check separately detects a newer Lambda runtime.
2060extract_node_major_pins() {
206138 local root="${1:-.}"
206238 local constants="${2:-gco/stacks/constants.py}"
206338 local nvmrc="${3:-.nvmrc}"
206438 local dockerfile="${4:-Dockerfile.dev}"
206538 python3 -c "
2066import json, re, sys
2067from pathlib import Path
2068
2069root = Path(sys.argv[1]).resolve()
2070excluded = {
2071 '.git', '.kiro', '.mypy_cache', '.pytest_cache', '.ruff_cache',
2072 'build', 'cdk.out', 'dist', 'node_modules', '__pycache__',
2073}
2074
2075def resolve(path):
2076 candidate = Path(path)
2077 return candidate if candidate.is_absolute() else root / candidate
2078
2079def emit(source, value):
2080 match = re.search(r'\d+', str(value))
2081 if match:
2082 print(f'{source}|{int(match.group())}')
2083
2084def owned(path):
2085 parts = path.relative_to(root).parts[:-1]
2086 return not any(
2087 part in excluded or part.startswith('.venv') or part.endswith('-build')
2088 for part in parts
2089 )
2090
2091try:
2092 text = resolve(sys.argv[2]).read_text(encoding='utf-8')
2093 match = re.search(r'^LAMBDA_NODEJS_RUNTIME\s*=\s*\"NODEJS_(\d+)_X\"', text, re.MULTILINE)
2094 if match:
2095 emit('gco/stacks/constants.py', match.group(1))
2096except OSError:
2097 pass
2098try:
2099 emit('.nvmrc', resolve(sys.argv[3]).read_text(encoding='utf-8').strip())
2100except OSError:
2101 pass
2102try:
2103 text = resolve(sys.argv[4]).read_text(encoding='utf-8')
2104 # NODE_VERSION pins the exact release (e.g. ``v24.18.0``); emit()
2105 # reduces it to the leading major for the cross-source comparison.
2106 match = re.search(r'^\s*ARG\s+NODE_VERSION=(v?\d+(?:\.\d+)*)\s*$', text, re.MULTILINE)
2107 if match:
2108 emit('Dockerfile.dev', match.group(1))
2109except OSError:
2110 pass
2111for manifest in sorted(root.rglob('package.json')):
2112 if not owned(manifest):
2113 continue
2114 try:
2115 package = json.loads(manifest.read_text(encoding='utf-8'))
2116 except (OSError, json.JSONDecodeError):
2117 continue
2118 emit(manifest.relative_to(root).as_posix(), (package.get('engines') or {}).get('node', ''))
2119" "$root" "$constants" "$nvmrc" "$dockerfile" 2>/dev/null
2120}
2121
2122# extract_npm_version_pins [root] [dockerfile]
2123#
2124# Emits every package.json packageManager npm version plus Dockerfile.dev's
2125# NPM_VERSION so Dependabot/tooling updates cannot leave contributor and CI
2126# npm versions disagreeing.
2127extract_npm_version_pins() {
212837 local root="${1:-.}"
212937 local dockerfile="${2:-Dockerfile.dev}"
213037 python3 -c "
2131import json, re, sys
2132from pathlib import Path
2133
2134root = Path(sys.argv[1]).resolve()
2135excluded = {
2136 '.git', '.kiro', '.mypy_cache', '.pytest_cache', '.ruff_cache',
2137 'build', 'cdk.out', 'dist', 'node_modules', '__pycache__',
2138}
2139
2140def owned(path):
2141 parts = path.relative_to(root).parts[:-1]
2142 return not any(
2143 part in excluded or part.startswith('.venv') or part.endswith('-build')
2144 for part in parts
2145 )
2146
2147for manifest in sorted(root.rglob('package.json')):
2148 if not owned(manifest):
2149 continue
2150 try:
2151 package = json.loads(manifest.read_text(encoding='utf-8'))
2152 except (OSError, json.JSONDecodeError):
2153 continue
2154 match = re.fullmatch(r'npm@(\d+\.\d+\.\d+)', package.get('packageManager', ''))
2155 if match:
2156 print(f'{manifest.relative_to(root).as_posix()}|{match.group(1)}')
2157docker = Path(sys.argv[2])
2158if not docker.is_absolute():
2159 docker = root / docker
2160try:
2161 text = docker.read_text(encoding='utf-8')
2162 match = re.search(r'^\s*ARG\s+NPM_VERSION=(\d+\.\d+\.\d+)\s*$', text, re.MULTILINE)
2163 if match:
2164 print(f'Dockerfile.dev|{match.group(1)}')
2165except OSError:
2166 pass
2167" "$root" "$dockerfile" 2>/dev/null
2168}
2169
2170# extract_cdk_cli_pins [root] [dockerfile]
2171#
2172# Emits the root tooling graph's aws-cdk version and Dockerfile.dev's global
2173# CDK CLI pin. Both execution paths must synthesize with the same CLI release.
2174extract_cdk_cli_pins() {
217537 local root="${1:-.}"
217637 local dockerfile="${2:-Dockerfile.dev}"
217737 python3 -c "
2178import json, re, sys
2179from pathlib import Path
2180
2181root = Path(sys.argv[1]).resolve()
2182try:
2183 package = json.loads((root / 'package.json').read_text(encoding='utf-8'))
2184 version = (package.get('devDependencies') or {}).get('aws-cdk', '')
2185 if re.fullmatch(r'\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?', version):
2186 print(f'package.json|{version}')
2187except (OSError, json.JSONDecodeError):
2188 pass
2189docker = Path(sys.argv[2])
2190if not docker.is_absolute():
2191 docker = root / docker
2192try:
2193 text = docker.read_text(encoding='utf-8')
2194 match = re.search(r'^\s*ARG\s+CDK_VERSION=(\S+)\s*$', text, re.MULTILINE)
2195 if match:
2196 print(f'Dockerfile.dev|{match.group(1)}')
2197except OSError:
2198 pass
2199" "$root" "$dockerfile" 2>/dev/null
2200}
2201
2202# parse_suppression_expiries <file>
2203#
2204# Prints ``ID|YYYY-MM-DD`` for every dated suppression entry in a
2205# ``.trivyignore`` / ``.pip-audit-ignore`` / ``.npm-audit-ignore`` file (any
2206# non-comment line carrying an ``exp:YYYY-MM-DD`` marker). For the
2207# whitespace-delimited trivy/pip format the ID is the first token; for the
2208# npm-audit pipe format (``package-dir|package|advisory|node-path|exp:…``)
2209# the ID is the advisory field, so the report names the GHSA rather than the
2210# whole entry line. The caller computes days-to-expiry and surfaces entries
2211# expiring soon so they get renewed *before* the CI expiry validator hard-
2212# fails a PR — the report is the early warning, the validator is the gate.
2213#
2214# Empty output when the file is absent or has no dated entries.
2215parse_suppression_expiries() {
2216105 local file="$1"
2217106 [ -f "$file" ] || return 0
2218104 python3 -c "
2219import re, sys
2220with open(sys.argv[1]) as f:
2221 for line in f:
2222 s = line.strip()
2223 if not s or s.startswith('#'):
2224 continue
2225 m = re.search(r'exp:(\d{4}-\d{2}-\d{2})', s)
2226 if not m:
2227 continue
2228 fields = s.split('|')
2229 if len(fields) == 5:
2230 # .npm-audit-ignore pipe format — the advisory is field 3.
2231 ident = fields[2]
2232 else:
2233 ident = s.split()[0]
2234 print(f'{ident}|{m.group(1)}')
2235" "$file" 2>/dev/null
2236}
2237
2238# extract_helm_installer_pins [dockerfile]
2239#
2240# Prints the tool versions hardcoded in the helm-installer Lambda's
2241# Dockerfile RUN lines:
2242# HELM_VERSION|vX.Y.Z from the get.helm.sh download URL
2243# KUBECTL_VERSION|vX.Y.Z from the dl.k8s.io download URL
2244#
2245# These pins are RUN-line URL literals — not ARGs, ``FROM`` lines, or
2246# workflow env — so Dependabot, the Dockerfile.dev ARG sweep, and the
2247# workflow-env consistency check were all blind to them. The consistency
2248# section compares them against the HELM_VERSION / KUBECTL_VERSION workflow
2249# env pins (and Dockerfile.dev's KUBECTL_VERSION ARG) so the Lambda image,
2250# the CI installs, and the dev container can't silently disagree about
2251# which helm/kubectl they ship.
2252#
2253# Empty output when the file is absent or the URLs aren't found — callers
2254# treat that as "skip", matching every other extractor here.
225546extract_helm_installer_pins() {
2256201 local file="${1:-lambda/helm-installer/Dockerfile}"
2257209 [ -f "$file" ] || return 0
2258193 python3 -c "
2259import re, sys
2260with open(sys.argv[1]) as f:
2261 text = f.read()
2262# Versions from the download URLs; checksums from the sha256sum trust
2263# anchor bound to each download's output path. These four lines are the
2264# single Helm/kubectl source the CI jobs load into GITHUB_ENV — the
2265# workflows deliberately carry no literal copies of their own.
2266m = re.search(r'get\.helm\.sh/helm-(v\d+\.\d+\.\d+)-', text)
2267if m:
2268 print(f'HELM_VERSION|{m.group(1)}')
2269m = re.search(r'([0-9a-f]{64})\s+/tmp/helm\.tar\.gz', text)
2270if m:
2271 print(f'HELM_SHA256|{m.group(1)}')
2272m = re.search(r'dl\.k8s\.io/release/(v\d+\.\d+\.\d+)/', text)
2273if m:
2274 print(f'KUBECTL_VERSION|{m.group(1)}')
2275m = re.search(r'([0-9a-f]{64})\s+/tmp/kubectl', text)
2276if m:
2277 print(f'KUBECTL_SHA256|{m.group(1)}')
2278" "$file" 2>/dev/null
2279}
2280
2281# extract_kubernetes_client_pin [pyproject_path]
2282#
2283# Prints the exact ``kubernetes==X.Y.Z`` version pinned in
2284# ``[project.dependencies]`` of pyproject.toml (``36.0.3``), or nothing when
2285# the file is missing, unparseable, or carries no exact pin. Callers treat
2286# empty output as "skip", matching the other extractors here.
2287#
2288# kubernetes-client/python numbers its major after the Kubernetes minor it
2289# was generated from, so the Version Consistency section compares this major
2290# with cdk.json's ``kubernetes_version`` minor (see check_kubernetes_client_skew).
2291extract_kubernetes_client_pin() {
229241 local pyproject="${1:-pyproject.toml}"
229343 [ -f "$pyproject" ] || return 0
229439 python3 -c "
2295import re, sys, tomllib
2296try:
2297 with open(sys.argv[1], 'rb') as f:
2298 data = tomllib.load(f)
2299except Exception:
2300 sys.exit(0)
2301for spec in (data.get('project', {}) or {}).get('dependencies', []) or []:
2302 m = re.fullmatch(r'kubernetes==(\d+\.\d+\.\d+)', str(spec).strip())
2303 if m:
2304 print(m.group(1))
2305 break
2306" "$pyproject" 2>/dev/null
2307}
2308
2309# check_kubernetes_client_skew <client-version> <k8s-version>
2310#
2311# Applies the client/server rule the PR-time guard
2312# (tests/test_integration.py::TestDependencyVersionConsistency::
2313# test_kubernetes_python_client_matches_eks_version) enforces: the client's
2314# major must equal the cluster minor, or trail it by exactly one. Trailing
2315# by one is allowed because upstream publishes the new client weeks after
2316# the Kubernetes release; this check is the monthly reminder that the one
2317# allowed minor of lag is being used, so the report nags until the client is
2318# bumped. Prints one ``problem`` line when the client trails the cluster
2319# (``kubernetes==36.0.3 is one minor behind EKS 1.37; pin 37.x once
2320# released``); prints nothing when they agree. A client *ahead* of the
2321# cluster, or two or more minors behind, is also reported — the PR-time
2322# guard rejects both, so seeing either here means the guard was bypassed.
2323# Empty input prints nothing (the caller already skipped).
2324check_kubernetes_client_skew() {
232543 local client="$1" k8s="$2"
232687 [ -n "$client" ] && [ -n "$k8s" ] || return 0
23279 local client_major k8s_minor
23289 client_major="${client%%.*}"
23299 k8s_minor="${k8s#*.}"
23309 k8s_minor="${k8s_minor%%.*}"
233119 [[ "$client_major" =~ ^[0-9]+$ && "$k8s_minor" =~ ^[0-9]+$ ]] || return 0
23327 if [ "$client_major" -eq "$k8s_minor" ]; then
23331 return 0
23346 elif [ "$client_major" -eq $((k8s_minor - 1)) ]; then
23354 echo "kubernetes==${client} is one minor behind EKS ${k8s}; pin kubernetes==${k8s_minor}.x once a stable release is on PyPI"
23362 elif [ "$client_major" -gt "$k8s_minor" ]; then
23371 echo "kubernetes==${client} is ahead of EKS ${k8s}; the client must not lead the cluster"
2338 else
23391 echo "kubernetes==${client} is $((k8s_minor - client_major)) minors behind EKS ${k8s}; outside the one-minor skew the guard test allows"
2340 fi
2341}
2342
2343# ---------------------------------------------------------------------------
2344# Machine-readable findings
2345#
2346# The Markdown report is for people. Anything that acts on the findings (a
2347# script, an agent session, ``gco deps scan -o json``) must not parse prose,
2348# so every surface is also recorded as JSON: the rows the Markdown tables
2349# render, keyed by column name, plus the surface's urgency and skip reason.
2350# The scan emits facts only; which findings to act on is the consumer's
2351# policy.
2352# ---------------------------------------------------------------------------
2353
2354#: Schema identifier written into every findings document. Bump the suffix
2355#: when a field changes meaning, so a consumer can refuse a shape it does not
2356#: know.
2357852FINDINGS_SCHEMA="gco.dependency-scan.findings/1"
2358
2359#: Markers around the findings JSON embedded in the Markdown report, so a
2360#: consumer can lift it out of the rolling issue body without parsing the
2361#: rest. Both lines are HTML comments and render as nothing.
2362852FINDINGS_BEGIN_MARKER="<!-- gco-deps-findings:begin -->"
2363852FINDINGS_END_MARKER="<!-- gco-deps-findings:end -->"
2364
2365#: An issue body is capped at 65,536 characters. The report itself runs to
2366#: about 15,000, so a findings document above this size is left to the
2367#: workflow artifact and the report says so instead of risking a failed
2368#: ``gh issue edit``.
2369852FINDINGS_EMBED_MAX_BYTES="${FINDINGS_EMBED_MAX_BYTES:-40000}"
2370
2371# findings_rows <keys> <results-file>
2372#
2373# Turns a pipe-delimited results file into a JSON array of objects. <keys>
2374# names the columns, pipe-separated, in the order the scan writes them
2375# (``image|current|latest``). A row with more cells than keys keeps the
2376# overflow in its last field, joined back with ``|``; a row with fewer
2377# cells fills the missing fields with "". An empty or missing file is ``[]``.
23786findings_rows() {
2379748 local keys="$1" file="${2:-}"
23801490 if [ -z "$file" ] || [ ! -s "$file" ]; then
2381599 echo "[]"
2382599 return
2383 fi
2384149 jq -R -s --arg keys "$keys" '
2385 ($keys | split("|")) as $cols
2386 | ($cols | length) as $n
2387 | split("\n")
2388 | map(select(length > 0))
2389 | map(
2390 split("|") as $cells
2391 | ($cells[0:($n - 1)] + [($cells[($n - 1):] | join("|"))]) as $fitted
2392 | [range(0; $n)] | map({key: $cols[.], value: ($fitted[.] // "")}) | from_entries
2393 )' "$file"
2394}
2395
2396# findings_surface <title> <urgency> <count> <skip-reason> <keys> [results-file]
2397#
2398# Appends one surface record to the file named by ``FINDINGS_SURFACES``:
2399# the Markdown section title, the urgency the summary table shows, the row
2400# count, the skip reason (null when the surface ran) and the rows as
2401# objects. Surfaces whose findings are not rows (the Python packages' JSON,
2402# the accelerator catalog's Markdown) append their own record with
2403# ``findings_surface_json``.
24043findings_surface() {
2405741 local title="$1" urgency="$2" count="$3" skip="$4" keys="$5" file="${6:-}"
2406741 local rows
24071482 rows="$(findings_rows "$keys" "$file")"
2408741 findings_surface_json "$title" "$urgency" "$count" "$skip" "$rows"
2409}
2410
2411# findings_surface_json <title> <urgency> <count> <skip-reason> <findings-json> [extra-json]
2412#
2413# The general form of ``findings_surface``: <findings-json> is the ready
2414# JSON array of findings and the optional <extra-json> object is merged
2415# into the record (the accelerator surface carries its reports this way).
24165findings_surface_json() {
2417815 local title="$1" urgency="$2" count="$3" skip="$4" rows="$5" extra="${6:-{\}}"
2418815 jq -cn --arg title "$title" --arg urgency "$urgency" --argjson count "$count" \
2419 --arg skip "$skip" --argjson rows "$rows" --argjson extra "$extra" '
2420 {surface: $title, urgency: $urgency, count: $count,
2421 skipped: (if $skip == "" then null else $skip end), findings: $rows} + $extra' \
2422 >> "$FINDINGS_SURFACES"
2423}
2424
2425# write_findings_document <output-file> <scan-complete> <has-drift> <incomplete-reasons-file>
2426#
2427# Assembles the findings document from the surface records accumulated in
2428# ``FINDINGS_SURFACES`` and writes it to <output-file>. Fails when a record
2429# is not valid JSON, so a broken emitter can never publish a half document.
24302write_findings_document() {
243140 local output="$1" complete="$2" drift="$3" reasons_file="$4"
243240 local reasons='[]'
243340 if [ -s "$reasons_file" ]; then
243451 reasons="$(sort -u "$reasons_file" | jq -R -s 'split("\n") | map(select(length > 0))')"
2435 fi
243680 jq -s --arg schema "$FINDINGS_SCHEMA" \
2437 --arg generated "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
2438 --argjson complete "$complete" --argjson drift "$drift" --argjson reasons "$reasons" '
2439 {schema: $schema, generated_at: $generated, scan_complete: $complete, has_drift: $drift,
2440 incomplete_reasons: $reasons, surfaces: .}' "$FINDINGS_SURFACES" > "$output"
2441}
2442
2443# emit_findings_embed <findings-file>
2444#
2445# Prints the Markdown that carries the findings document inside the report:
2446# a collapsed block between the two markers, or a pointer at the workflow
2447# artifact when the document is too large to embed.
2448emit_findings_embed() {
244925 local file="$1" size
245075 size="$(wc -c < "$file" | tr -d ' ')"
245125 echo "<details>"
245225 echo "<summary>Machine-readable findings (the same report as JSON)</summary>"
245325 echo ""
245425 if [ "$size" -gt "$FINDINGS_EMBED_MAX_BYTES" ]; then
24551 echo "_The findings document (${size} bytes) is too large to embed here; download the"
24561 echo "\`dependency-scan-findings\` artifact of the workflow run, or run \`gco deps scan\`._"
2457 else
245824 echo "$FINDINGS_BEGIN_MARKER"
245924 echo '```json'
246024 cat "$file"
246124 echo '```'
246224 echo "$FINDINGS_END_MARKER"
2463 fi
246425 echo ""
246525 echo "</details>"
2466}
2467
2468# dependency_scan_is_complete <incomplete-reasons-file> [skip-reason ...]
2469#
2470# Returns success only when the durable incomplete-reason channel exists and
2471# is empty and every section-specific skip reason is empty. The driver uses
2472# this single predicate for its ``scan_complete`` output, so a failed lookup,
2473# parser, or explicit skip can never close the rolling dependency issue.
2474dependency_scan_is_complete() {
247539 local incomplete_reasons_file="$1"
247639 shift
2477
247840 [ -f "$incomplete_reasons_file" ] || return 1
247954 [ ! -s "$incomplete_reasons_file" ] || return 1
2480
248122 local skip_reason
2482205 for skip_reason in "$@"; do
2483211 [ -z "$skip_reason" ] || return 1
2484 done
2485}
2486
2487# check_lockfile_freshness [pyproject] [lockfile]
2488#
2489# Prints ``normalised-name[;marker]|expected-version|locked-version`` for each
2490# exact direct dependency whose compiled lock entry is missing or has a
2491# different version. ``locked-version`` is ``<missing>`` when no matching
2492# name-and-marker entry exists. Both base and optional dependency groups are
2493# checked; self-referential ``gco-cli`` extras are intentionally excluded.
2494#
2495# Whitespace around ``==`` and marker operators is insignificant. Marker text
2496# remains part of the comparison identity so mutually exclusive platform pins
2497# can carry different exact versions without being treated as conflicts.
2498# Returns nonzero for missing/malformed input, non-exact direct dependencies,
2499# conflicting pins with the same marker, or unparseable lock records.
2500check_lockfile_freshness() {
250152 local pyproject="${1:-pyproject.toml}"
250252 local lockfile="${2:-requirements-lock.txt}"
250352 python3 -c "
2504import re, sys, tomllib
2505
2506pyproject, lockfile = sys.argv[1], sys.argv[2]
2507
2508def fail(message):
2509 print(f'lockfile freshness error: {message}', file=sys.stderr)
2510 raise SystemExit(2)
2511
2512try:
2513 with open(pyproject, 'rb') as handle:
2514 data = tomllib.load(handle)
2515except (OSError, tomllib.TOMLDecodeError) as exc:
2516 fail(f'cannot read {pyproject}: {exc}')
2517
2518project = data.get('project')
2519if not isinstance(project, dict):
2520 fail(f'{pyproject} has no valid [project] table')
2521base_dependencies = project.get('dependencies', [])
2522optional_dependencies = project.get('optional-dependencies', {})
2523if not isinstance(base_dependencies, list) or not isinstance(optional_dependencies, dict):
2524 fail(f'{pyproject} dependency tables have invalid types')
2525
2526def norm(name):
2527 return re.sub(r'[-_.]+', '-', name).lower()
2528
2529def canonical_marker(marker, source):
2530 marker = (marker or '').strip()
2531 if not marker:
2532 return ''
2533 if '|' in marker:
2534 fail(f'{source} marker contains an unsupported pipe character')
2535 result = []
2536 quote = None
2537 escaped = False
2538 for character in marker:
2539 if quote is not None:
2540 result.append(character)
2541 if escaped:
2542 escaped = False
2543 elif character == '\\\\':
2544 escaped = True
2545 elif character == quote:
2546 # PEP 508 treats single- and double-quoted marker values as
2547 # equivalent. Emit one quote style for identity comparison.
2548 result[-1] = '\"'
2549 quote = None
2550 elif character in ('\"', \"'\"):
2551 quote = character
2552 result.append('\"')
2553 elif not character.isspace():
2554 result.append(character)
2555 if quote is not None:
2556 fail(f'{source} marker has an unterminated quoted value')
2557 return ''.join(result)
2558
2559exact_requirement = re.compile(
2560 r'\\s*(?P<name>[A-Za-z0-9][A-Za-z0-9._-]*)\\s*'
2561 r'(?:\\[\\s*[A-Za-z0-9._-]+(?:\\s*,\\s*[A-Za-z0-9._-]+)*\\s*\\])?\\s*'
2562 r'==\\s*(?P<version>[^\\s;]+)\\s*'
2563 r'(?:;\\s*(?P<marker>.+?))?\\s*'
2564)
2565# A concrete PEP-440-shaped literal may carry an epoch, pre/post/dev suffix,
2566# or local version, but never a wildcard, another comparator, or a comma-
2567# separated clause. This deliberately rejects ``===`` arbitrary equality.
2568concrete_version = re.compile(r'v?(?:[0-9]+!)?[0-9]+[A-Za-z0-9._+-]*')
2569
2570def parse_exact(spec, source):
2571 if not isinstance(spec, str):
2572 fail(f'{source} contains a non-string dependency')
2573 name_match = re.match(r'^\\s*([A-Za-z0-9][A-Za-z0-9._-]*)', spec)
2574 if not name_match:
2575 fail(f'{source} contains an invalid dependency: {spec!r}')
2576 name = norm(name_match.group(1))
2577 if name == 'gco-cli':
2578 return None
2579 match = exact_requirement.fullmatch(spec)
2580 if not match:
2581 fail(f'{source} dependency is not an exact == pin: {spec!r}')
2582 version = match.group('version')
2583 if not concrete_version.fullmatch(version):
2584 fail(f'{source} dependency is not a concrete exact == pin: {spec!r}')
2585 marker = canonical_marker(match.group('marker'), source)
2586 return (name, marker), version
2587
2588def add_pin(pins, parsed, source):
2589 if parsed is None:
2590 return
2591 identity, version = parsed
2592 previous = pins.get(identity)
2593 if previous is not None and previous != version:
2594 display = identity[0] + (f';{identity[1]}' if identity[1] else '')
2595 fail(f'{display} has conflicting pins in {source}: {previous} and {version}')
2596 pins[identity] = version
2597
2598expected = {}
2599for dependency in base_dependencies:
2600 add_pin(
2601 expected,
2602 parse_exact(dependency, '[project].dependencies'),
2603 '[project].dependencies',
2604 )
2605for group, dependencies in optional_dependencies.items():
2606 if not isinstance(dependencies, list):
2607 fail(f'[project.optional-dependencies].{group} is not an array')
2608 source = f'[project.optional-dependencies].{group}'
2609 for dependency in dependencies:
2610 add_pin(expected, parse_exact(dependency, source), source)
2611if not expected:
2612 fail(f'{pyproject} contains no exact direct dependency pins')
2613
2614locked = {}
2615try:
2616 with open(lockfile, encoding='utf-8') as handle:
2617 for line_number, raw_line in enumerate(handle, 1):
2618 candidate = raw_line.strip()
2619 if not candidate or candidate.startswith(('#', '-')):
2620 continue
2621 candidate = candidate.split(' #', 1)[0].rstrip()
2622 if candidate.endswith('\\\\'):
2623 candidate = candidate[:-1].rstrip()
2624 source = f'{lockfile}:{line_number}'
2625 add_pin(locked, parse_exact(candidate, source), source)
2626except OSError as exc:
2627 fail(f'cannot read {lockfile}: {exc}')
2628if not locked:
2629 fail(f'{lockfile} contains no pinned requirements')
2630
2631for identity, expected_version in sorted(expected.items()):
2632 locked_version = locked.get(identity)
2633 if locked_version != expected_version:
2634 name, marker = identity
2635 display = name + (f';{marker}' if marker else '')
2636 print(f'{display}|{expected_version}|{locked_version or \"<missing>\"}')
2637" "$pyproject" "$lockfile"
2638}
2639
2640# extract_security_epochs <dockerfile>
2641#
2642# Prints ``ARGNAME|YYYY-MM-DD`` for each build-time security-refresh epoch
2643# ARG in the given Dockerfile (``APT_SECURITY_EPOCH`` for the Debian service
2644# images / dev image, ``DNF_SECURITY_EPOCH`` for the AL2023 helm-installer
2645# Lambda). These dates are bumped by hand to bust the CI layer cache and pull
2646# freshly-published OS security patches; nothing else reminds anyone to move
2647# them, so the report flags an epoch older than the freshness window. Trivy's
2648# container scan is the backstop; this is the proactive nudge.
2649#
2650# Empty output when the file is absent or pins no epoch ARG.
2651extract_security_epochs() {
2652116 local file="$1"
2653117 [ -f "$file" ] || return 0
2654115 python3 -c "
2655import re, sys
2656with open(sys.argv[1]) as f:
2657 for line in f:
2658 stripped = line.split('#', 1)[0]
2659 m = re.match(r'^\s*ARG\s+((?:APT|DNF)_SECURITY_EPOCH)=(\d{4}-\d{2}-\d{2})\s*$', stripped)
2660 if m:
2661 print(f'{m.group(1)}|{m.group(2)}')
2662" "$file" 2>/dev/null
2663}
2664
2665# extract_claude_code_pin [autopilot_py]
2666#
2667# Prints the exact Claude Code release ``gco autopilot`` installs, read from
2668# the ``CLAUDE_CODE_VERSION`` constant in ``cli/autopilot.py``.
2669#
2670# Like the Mooncake image above, this pin lives in a Python constant, so
2671# neither Dependabot nor the npm-graph sweep sees it — autopilot installs
2672# Claude Code lazily via ``npm install -g`` rather than declaring it in any
2673# package.json. This extractor feeds the autopilot-pins drift check so a
2674# newer npm release is surfaced in the monthly report.
2675#
2676# The textual contract (a single-line, double-quoted assignment) is locked
2677# by tests/test_cli_autopilot.py, so this regex and the constant cannot
2678# silently drift apart. Prints nothing if the file or constant is absent —
2679# the caller treats an empty result as "skip".
268024extract_claude_code_pin() {
268186 local file="${1:-cli/autopilot.py}"
268291 [ -f "$file" ] || return 0
268381 python3 -c "
2684import re, sys
2685with open(sys.argv[1]) as f:
2686 text = f.read()
2687m = re.search(r'^CLAUDE_CODE_VERSION = \"([^\"]+)\"', text, re.MULTILINE)
2688if m:
2689 print(m.group(1))
2690" "$file" 2>/dev/null
2691}
2692
2693# extract_codex_pin [autopilot_py]
2694#
2695# Prints the exact OpenAI Codex CLI release the Codex Autopilot engine installs,
2696# read from the scanner-stable ``CODEX_VERSION`` literal in cli/autopilot.py.
2697# Codex is another lazy global npm dependency, so it must flow through the same
2698# monthly registry-drift system as Claude Code rather than package.json.
269924extract_codex_pin() {
270086 local file="${1:-cli/autopilot.py}"
270191 [ -f "$file" ] || return 0
270281 python3 -c "
2703import re, sys
2704with open(sys.argv[1]) as f:
2705 text = f.read()
2706m = re.search(r'^CODEX_VERSION = \"([^\"]+)\"', text, re.MULTILINE)
2707if m:
2708 print(m.group(1))
2709" "$file" 2>/dev/null
2710}
2711
2712# extract_opencode_pin [autopilot_py]
2713#
2714# Prints the exact OpenCode CLI release the OpenCode Autopilot engine
2715# installs, read from the scanner-stable ``OPENCODE_VERSION`` literal in
2716# cli/autopilot.py. OpenCode (npm ``opencode-ai``) is the third lazy global
2717# npm dependency, so it flows through the same monthly registry-drift
2718# system as Claude Code and Codex rather than package.json.
271924extract_opencode_pin() {
272086 local file="${1:-cli/autopilot.py}"
272191 [ -f "$file" ] || return 0
272281 python3 -c "
2723import re, sys
2724with open(sys.argv[1]) as f:
2725 text = f.read()
2726m = re.search(r'^OPENCODE_VERSION = \"([^\"]+)\"', text, re.MULTILINE)
2727if m:
2728 print(m.group(1))
2729" "$file" 2>/dev/null
2730}
2731
2732# extract_companion_mcp_packages [autopilot_py]
2733#
2734# Prints one ``name|registry|package`` line per companion MCP server in the
2735# ``COMPANION_MCP_SERVERS`` registry of ``cli/autopilot.py`` — the servers
2736# ``gco autopilot`` wires into every session. ``registry`` is ``npm`` or
2737# ``pypi``.
2738#
2739# These packages are launch-time dependencies fetched by npx/uvx, so they
2740# appear in no lockfile and no manifest Dependabot watches. The autopilot
2741# liveness check uses this list to verify each package is still published
2742# (and not deprecated/yanked) on its registry — exactly the failure mode
2743# that got mcp-server-fetch and mcp-server-calculator pruned in 2026-08.
2744#
2745# Parses the ``name=`` / ``registry=`` / ``package=`` keywords inside each
2746# ``CompanionServer(`` block textually (no imports), so BATS can exercise it
2747# against fixtures; tests/test_cli_autopilot.py locks the source formatting.
2748# Prints nothing for a missing or unparseable file.
2749extract_companion_mcp_packages() {
275037 local file="${1:-cli/autopilot.py}"
275138 [ -f "$file" ] || return 0
275236 python3 -c "
2753import re, sys
2754with open(sys.argv[1]) as f:
2755 text = f.read()
2756pattern = re.compile(
2757 r'CompanionServer\(\s*name=\"([^\"]+)\",\s*registry=\"([^\"]+)\",\s*package=\"([^\"]+)\",'
2758)
2759for name, registry, package in pattern.findall(text):
2760 print(f'{name}|{registry}|{package}')
2761" "$file" 2>/dev/null
2762}
2763
2764# get_registry_package_status <registry> <package>
2765#
2766# Resolves a package's publication status on its public registry and prints
2767# exactly one of:
2768#
2769# ok|<latest-version> published and healthy
2770# missing| registry answers 404 — unpublished/renamed
2771# deprecated|<message> npm ``deprecated`` flag on the latest release
2772# yanked|<latest-version> PyPI ``yanked`` flag on the latest release
2773#
2774# Prints nothing on a network/transport failure or an unknown registry so
2775# the caller marks the check skipped instead of reporting phantom drift —
2776# the same fail-quiet contract as the other ``get_latest_*`` helpers here.
2777# Both endpoints are public; no credentials needed.
2778get_registry_package_status() {
2779191 local registry="$1" package="$2"
2780191 local url="" body="" code=""
2781191 case "$registry" in
2782 npm)
2783 # Scoped packages need the slash percent-encoded on the
2784 # per-version route (@scope%2Fname/latest).
2785147 url="https://registry.npmjs.org/${package//\//%2F}/latest"
2786 ;;
2787 pypi)
278843 url="https://pypi.org/pypi/${package}/json"
2789 ;;
2790 *)
27911 return 0
2792 ;;
2793 esac
2794
2795380 body="$(mktemp)"
2796387 code="$(curl -sSL --max-time 15 "${SCAN_CURL_RETRY[@]}" -o "$body" -w '%{http_code}' "$url" 2>/dev/null)" || code=""
2797190 if [ "$code" = "404" ]; then
27983 rm -f "$body"
27993 echo "missing|"
28003 return 0
2801 fi
2802187 if [ "$code" != "200" ]; then
28038 rm -f "$body"
28048 return 0
2805 fi
2806
2807179 python3 -c "
2808import json, sys
2809registry, path = sys.argv[1], sys.argv[2]
2810try:
2811 with open(path) as handle:
2812 data = json.load(handle)
2813except Exception:
2814 sys.exit(0)
2815if registry == 'npm':
2816 deprecated = data.get('deprecated')
2817 if deprecated:
2818 print('deprecated|' + ' '.join(str(deprecated).split())[:120])
2819 else:
2820 version = str(data.get('version', '') or '')
2821 if version:
2822 print('ok|' + version)
2823else:
2824 info = data.get('info') or {}
2825 urls = data.get('urls') or []
2826 yanked = bool(urls and urls[0].get('yanked'))
2827 version = str(info.get('version', '') or '')
2828 if version:
2829 print(('yanked|' if yanked else 'ok|') + version)
2830" "$registry" "$body" 2>/dev/null
2831179 rm -f "$body"
2832}
2833
2834# check_lambda_requirements_pins [root] [pyproject] [lockfile]
2835#
2836# Emits ``requirements-path|problem`` for every pin in a per-Lambda
2837# ``requirements.txt`` that disagrees with the version this repository
2838# actually resolves for that package.
2839#
2840# Each Lambda carries its own ``requirements.txt`` because it is packaged and
2841# deployed independently, so the same library is pinned in up to seven places:
2842# ``pyproject.toml`` (what the test suite and the CLI resolve),
2843# ``requirements-lock.txt`` (what pip-compile pins the whole graph to), and one
2844# copy per Lambda that declares it. Nothing watched the Lambda copies. A bump
2845# applied centrally therefore left them behind silently, and the handlers
2846# shipped a different boto3 than anything CI ever exercised — the exact drift
2847# class the rest of this library exists to catch, on the one surface that
2848# reaches production directly.
2849#
2850# Resolution order for the authoritative version, most specific first:
2851# 1. ``[project].dependencies`` — the packages we pin deliberately
2852# 2. ``[project.optional-dependencies]`` — group-only pins
2853# 3. ``requirements-lock.txt`` — transitives (cryptography, …) that
2854# no pyproject entry names but the repository still resolves exactly
2855# A package found in none of the three is a Lambda-only dependency with no
2856# central copy to disagree with, so it is skipped rather than reported.
2857#
2858# Only ``lambda/<name>/requirements.txt`` is read. The generated ``*-build``
2859# staging bundles copy their requirements from the source directory, so
2860# including them would double-report every finding when they happen to exist
2861# locally and report nothing in CI, where they do not.
2862#
2863# A requirements file that pins nothing (three of them only document that the
2864# Lambda runtime supplies boto3) is a skip, not a finding. A missing or
2865# unparseable ``pyproject.toml`` is reported, because it always exists here and
2866# a parse break must not silently downgrade this to a pass.
2867#
2868# Example output:
2869#
2870# lambda/secret-rotation/requirements.txt|boto3==1.43.74 must match pyproject.toml 1.43.85
2871#
2872# The PR-time half of this contract lives in
2873# tests/test_integration.py::TestDependencyVersionConsistency::test_lambda_requirements_match_pyproject.
2874check_lambda_requirements_pins() {
287544 local root="${1:-.}"
287644 local pyproject="${2:-pyproject.toml}"
287744 local lockfile="${3:-requirements-lock.txt}"
287844 python3 -c "
2879import re, sys, tomllib
2880from pathlib import Path
2881
2882root = Path(sys.argv[1]).resolve()
2883
2884def resolve(path):
2885 candidate = Path(path)
2886 return candidate if candidate.is_absolute() else root / candidate
2887
2888def normalize(name):
2889 # PEP 503 normalisation: lowercase, ``_`` + ``.`` -> ``-``.
2890 return re.sub(r'[-_.]+', '-', name).lower()
2891
2892pin = re.compile(r'([A-Za-z0-9][A-Za-z0-9._-]*)\s*==\s*([^\s;]+)')
2893
2894try:
2895 with open(resolve(sys.argv[2]), 'rb') as handle:
2896 data = tomllib.load(handle)
2897except Exception:
2898 print('pyproject.toml|missing or unparseable, cannot verify Lambda pins')
2899 sys.exit(0)
2900
2901project = data.get('project', {}) or {}
2902
2903def collect(specs):
2904 found = {}
2905 for spec in specs or []:
2906 if not isinstance(spec, str):
2907 continue
2908 match = pin.fullmatch(spec.strip())
2909 if match:
2910 found.setdefault(normalize(match.group(1)), set()).add(match.group(2))
2911 return found
2912
2913direct = collect(project.get('dependencies'))
2914optional = {}
2915for group in (project.get('optional-dependencies', {}) or {}).values():
2916 for name, versions in collect(group).items():
2917 optional.setdefault(name, set()).update(versions)
2918
2919locked = {}
2920try:
2921 for line in resolve(sys.argv[3]).read_text(encoding='utf-8').splitlines():
2922 # Lock annotations are indented ('' # via boto3''); pins are not.
2923 if not line or line[:1].isspace() or line.lstrip().startswith('#'):
2924 continue
2925 match = pin.match(line.split('#', 1)[0].strip())
2926 if match:
2927 locked.setdefault(normalize(match.group(1)), set()).add(match.group(2))
2928except OSError:
2929 pass
2930
2931def authority(name):
2932 for label, table in (
2933 ('pyproject.toml', direct),
2934 ('pyproject.toml optional groups', optional),
2935 ('requirements-lock.txt', locked),
2936 ):
2937 if name in table:
2938 return label, table[name]
2939 return None, None
2940
2941def owned(path):
2942 return not any(part.endswith('-build') for part in path.relative_to(root).parts[:-1])
2943
2944for requirements in sorted((root / 'lambda').glob('*/requirements.txt')):
2945 if not owned(requirements):
2946 continue
2947 relative = requirements.relative_to(root).as_posix()
2948 try:
2949 lines = requirements.read_text(encoding='utf-8').splitlines()
2950 except OSError:
2951 print(relative + '|unreadable, cannot verify Lambda pins')
2952 continue
2953 for line in lines:
2954 entry = line.split('#', 1)[0].strip()
2955 if not entry:
2956 continue
2957 match = pin.fullmatch(entry)
2958 if not match:
2959 continue
2960 name, version = normalize(match.group(1)), match.group(2)
2961 label, expected = authority(name)
2962 if not expected or version in expected:
2963 continue
2964 want = ','.join(sorted(expected))
2965 print(f'{relative}|{name}=={version} must match {label} {want}')
2966" "$root" "$pyproject" "$lockfile" 2>/dev/null
2967}
2968
2969# check_image_digest_consistency [root]
2970#
2971# Emits ``image|problem`` for every ``repo:tag`` this repository pins to two
2972# different ``@sha256:`` digests — the tag was re-pushed upstream and only some
2973# of the copies were refreshed.
2974#
2975# This exists because the drift sections answer "is this pin behind upstream?"
2976# and the version-consistency checks answer "do the copies of a *named* pin
2977# agree?", but neither could see a pin restated as a bare literal elsewhere in
2978# the tree. That is the class that keeps escaping: the #317 sweep refreshed the
2979# python-slim digest in a smoke manifest while a test kept the previous one, and
2980# it surfaced only when the CI shard holding that test happened to run.
2981#
2982# Deliberately narrow. Two broader variants were written and measured against
2983# this tree before being cut, because both were pure noise here:
2984#
2985# * "same repo, different tags" reported python at 3.14, 3.14.6-slim and
2986# 3.14.7-slim for three legitimate purposes, busybox pinned in examples
2987# versus ``:latest`` in property-test fixtures, and several synthetic
2988# Volcano tags — about fifteen rows, none of them drift.
2989# * "digest-pinned here but named at a bare tag there" reported only fixture
2990# image strings and the quoted registry-timeout error messages in the
2991# ``build-image-with-retry`` docs.
2992#
2993# A section that is mostly noise trains readers to skip it. Two digests under
2994# one tag, by contrast, cannot be anything but a copy someone missed: a fixture
2995# does not invent a real 64-hex digest. Images that need tag-level coverage get
2996# a dedicated guard instead — see ``tests/test_pinned_floci_version.py``.
2997#
2998# Excludes generated trees, sibling git worktrees, recorded terminal casts, and
2999# the generated flowchart HTML: all of them hold point-in-time copies of source
3000# that are not pins.
3001#
3002# Example output:
3003#
3004# docker.io/library/python|tag 3.14.7-slim has 2 digests: 656d12e7… in a.yaml, ce407646… in b.py
3005#
3006# Prints nothing when every digest-pinned image agrees with itself.
3007check_image_digest_consistency() {
300841 local root="${1:-.}"
300941 python3 -c "
3010import re, sys
3011from pathlib import Path
3012from collections import defaultdict
3013
3014root = Path(sys.argv[1]).resolve()
3015excluded = {
3016 '.git', '.kiro', '.mypy_cache', '.pytest_cache', '.ruff_cache', '.worktrees',
3017 'build', 'cdk.out', 'dist', 'node_modules', '__pycache__', 'site',
3018 '.example-job-validation',
3019}
3020suffixes = {'.py', '.yaml', '.yml', '.md', '.sh', '.txt', '.toml'}
3021
3022def owned(path):
3023 parts = path.relative_to(root).parts
3024 if any(p in excluded or p.startswith('.venv') or p.endswith('-build') for p in parts[:-1]):
3025 return False
3026 if parts and parts[0] in excluded:
3027 return False
3028 # Generated flowchart HTML embeds a copy of the source it charts.
3029 if parts and parts[0] == 'diagrams' and path.suffix != '.py':
3030 return False
3031 return path.suffix in suffixes or 'ockerfile' in path.name
3032
3033# repo:tag[@sha256:digest]. The tag must look like a version so ordinary
3034# ''key: value'' text and ''host:port'' pairs are never read as images.
3035reference = re.compile(
3036 r'(?<![\w./:-])'
3037 r'([a-z0-9][a-z0-9._-]*(?:/[a-z0-9][a-z0-9._-]*)+)'
3038 r':(v?[0-9][A-Za-z0-9._-]*)'
3039 r'(?:@sha256:([0-9a-f]{64}))?'
3040)
3041
3042pinned = defaultdict(lambda: defaultdict(set)) # repo -> tag -> {(digest, file)}
3043
3044for path in sorted(root.rglob('*')):
3045 if not path.is_file() or not owned(path):
3046 continue
3047 try:
3048 text = path.read_text(encoding='utf-8')
3049 except (OSError, UnicodeDecodeError):
3050 continue
3051 relative = path.relative_to(root).as_posix()
3052 # Rejoin references split across adjacent string literals. The stale copy
3053 # that caused the incident was written exactly this way —
3054 # ''docker.io/library/python:3.14.7-slim@'' on one line and
3055 # ''sha256:656d…'' on the next — so a line-at-a-time matcher saw no pin at
3056 # all and reported nothing. Only the seam around the digest is rejoined, so
3057 # unrelated adjacent literals cannot be welded into a false reference.
3058 text = re.sub(r'@[\"\x27][\s,]*[\"\x27]sha256:', '@sha256:', text)
3059 text = re.sub(r'[\"\x27][\s,]*[\"\x27]@sha256:', '@sha256:', text)
3060 for line in text.splitlines():
3061 for match in reference.finditer(line):
3062 repo, tag, digest = match.group(1), match.group(2), match.group(3)
3063 prefix = line[: match.start()]
3064 # ''https://host/path:1.2'' is a URL, not an image reference.
3065 if '://' in prefix[-12:] or prefix.endswith('//'):
3066 continue
3067 if digest:
3068 pinned[repo][tag].add((digest, relative))
3069
3070def listing(items):
3071 return ', '.join(sorted(items))
3072
3073for repo in sorted(pinned):
3074 for tag in sorted(pinned[repo]):
3075 seen = pinned[repo][tag]
3076 distinct = sorted({digest for digest, _ in seen})
3077 if len(distinct) > 1:
3078 detail = ', '.join(
3079 d[:8] + '… in ' + listing(f for g, f in seen if g == d) for d in distinct
3080 )
3081 print(f'{repo}|tag {tag} has {len(distinct)} digests: {detail}')
3082
3083" "$root" 2>/dev/null
3084}