← all scripts

.github/scripts/lib_dependency_scan.sh

266 of 266 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() {
3532 local pyproject="${1:-pyproject.toml}"
3634 [ -f "$pyproject" ] || return 0
3730 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() {
8831 local pyproject="${1:-pyproject.toml}"
8933 [ -f "$pyproject" ] || return 0
9029 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.
12017extract_build_system_pins() {
12165 local pyproject="${1:-pyproject.toml}"
12270 [ -f "$pyproject" ] || return 0
12360 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.
164169parse_image_registry() {
165554 local image="$1"
166554 local registry="" repo="" first=""
167554 case "$image" in
168 */*)
169406 first="${image%%/*}"
170406 case "$first" in
171 *.*|*:*|localhost)
172238 registry="$first"
173238 repo="${image#*/}"
174 # Docker Hub keeps official images under the implicit library/
175 # namespace; restore it for a fully-qualified single-segment repo.
176252 if [ "$registry" = "docker.io" ] && [ "${repo#*/}" = "$repo" ]; then
1772 repo="library/$repo"
178 fi
179 ;;
180 *)
181168 registry="docker.io"
182168 repo="$image"
183 ;;
184 esac
185 ;;
186 *)
187148 registry="docker.io"
188148 repo="library/$image"
189 ;;
190 esac
191554 echo "${registry}|${repo}"
192}
193
194# is_semver_tag <tag>
195#
196# Returns 0 (true) if the tag looks like a semver version (v1.2.3, 1.2, etc).
197# Returns 1 (false) otherwise.
1988is_semver_tag() {
199556 echo "$1" | grep -qE "^v?[0-9]+\.[0-9]+(\.[0-9]+)?"
200}
201
202# is_project_image <image>
203#
204# Returns 0 (true) if the image is built by this project (gco/*).
2055is_project_image() {
206498 echo "$1" | grep -q "^gco/"
207}
208
209# compare_semver <current> <candidate>
210#
211# Prints "newer" if candidate is strictly newer than current (by sort -V),
212# "same" if they're equal, "older" otherwise.
2138compare_semver() {
214388 local current="${1#v}"
215388 local candidate="${2#v}"
216388 if [ "$current" = "$candidate" ]; then
217231 echo "same"
218231 return
219 fi
220157 local newest
221628 newest="$(printf '%s\n%s' "$current" "$candidate" | sort -V | tail -1)"
222157 if [ "$newest" = "$candidate" ]; then
223131 echo "newer"
224 else
22526 echo "older"
226 fi
227}
228
229# newer_same_variant_tag <current_tag>
230#
231# Reads a raw registry tag list on stdin (one tag per line) and prints the
232# newest tag in the *same variant family* as <current_tag> that is strictly
233# newer than it — or nothing when the pin is already the family's newest.
234#
235# A variant family shares the exact literal suffix after the leading numeric
236# version: ``24.01-py3`` compares only against ``NN[.NN…]-py3`` tags,
237# ``2.6.0-cuda12.6-cudnn9-runtime`` only against its identical variant
238# suffix, ``3.14.6-slim`` only against ``-slim`` tags, and a bare ``1.38.0``
239# only against bare ``X.Y[.Z]`` tags. This mirrors the same-family scoping
240# the Bedrock model check uses: moving to a different variant (another CUDA
241# line, another base distro, dropping ``-slim``) is a human decision, not
242# drift, so it is never suggested. A leading ``v`` is accepted on either
243# side and ignored for comparison.
244#
245# The strict ``^v?X.Y.Z$`` filter this replaces matched nothing for every
246# suffix-tagged repository (NGC, GHCR Slurm, CUDA, PyTorch), which — under
247# ``pipefail`` — surfaced as a permanent "tag lookup failed" every month.
248#
249# Numeric components of five or more digits are treated as build/date
250# identifiers, not release numbers, and disqualify a candidate unless the
251# current pin itself carries one (a CalVer pin keeps comparing against
252# CalVer tags). Without this, ``alpine:3.21`` was "upgraded" to the
253# ``20260805`` date tag, ``kuberay/operator:v1.6.2`` to a ``9831375``
254# commit-numbered tag, and ``ray:2.56.1`` to the ``2.57.0.397131`` nightly.
25510newer_same_variant_tag() {
256222 python3 -c "
257import re, sys
258
259WIDE = 10000 # five digits: build number, date stamp, or commit counter
260
261current = sys.argv[1]
262match = re.match(r'^v?(\d+(?:\.\d+)*)(.*)\$', current)
263if not match:
264 raise SystemExit(0)
265suffix = match.group(2)
266current_key = [int(part) for part in match.group(1).split('.')]
267current_is_calver = any(part >= WIDE for part in current_key)
268
269pattern = re.compile(r'^v?(\d+(?:\.\d+)*)' + re.escape(suffix) + r'\$')
270best_key = None
271best_tag = None
272for line in sys.stdin:
273 tag = line.strip()
274 if not tag:
275 continue
276 candidate = pattern.match(tag)
277 if candidate is None:
278 continue
279 key = [int(part) for part in candidate.group(1).split('.')]
280 if not current_is_calver and any(part >= WIDE for part in key):
281 continue
282 if key <= current_key:
283 continue
284 if best_key is None or key > best_key:
285 best_key, best_tag = key, tag
286if best_tag:
287 print(best_tag)
288" "$1" 2>/dev/null
289}
290
291# tag_listed <tag> <raw_tags>
292#
293# Whether <raw_tags> (a newline-separated registry tag list passed as one
294# argument, not on stdin) lists <tag> exactly, accepting a leading ``v`` on
295# either side. Exists because the obvious spelling —
296# ``printf '%s\n' "$raw_tags" | grep -qxF …`` — is wrong under ``pipefail``
297# for large repositories: ``grep -q`` exits at the first match and closes
298# the pipe, ``printf`` takes SIGPIPE/EPIPE while still writing tag lists
299# bigger than the pipe buffer (python has ~3900 tags, tritonserver ~2600),
300# and the pipeline reports failure for a tag that is in fact listed. That
301# inverted into false "pinned tag is no longer listed" INCOMPLETE findings
302# in the 2026-09 scan. A herestring keeps grep's stdin writer-free, so
303# early exit has nothing to signal.
3045tag_listed() {
305162 local tag="$1" raw_tags="$2"
306162 grep -qxF -e "$tag" -e "v${tag#v}" -e "${tag#v}" <<< "$raw_tags"
307}
308
309# split_pinned_image_ref <repo:tag@sha256:digest>
310#
311# Validate and decompose a digest-pinned image reference, printing
312# ``repository|tag|digest`` on one line. Returns non-zero with no output for
313# anything that is not exactly the ``repo:tag@sha256:<64 hex>`` shape —
314# tag-only references, digest-only references, and malformed digests all
315# fail, so callers can gate the digest-freshness check on the return code.
316split_pinned_image_ref() {
31759 local ref="$1"
31859 if ! [[ "$ref" =~ ^([^@:]+(:[0-9]+)?(/[^@:]+)*):([^@]+)@(sha256:[0-9a-f]{64})$ ]]; then
3194 return 1
320 fi
32155 printf '%s|%s|%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[4]}" "${BASH_REMATCH[5]}"
322}
323
324# published_manifest_digest <repository:tag>
325#
326# Print the tag's current top-level manifest digest (``sha256:<64 hex>``) as
327# published by the registry. Hashes the raw manifest bytes rather than
328# selecting a platform child, because committed pins are multi-architecture
329# manifest-list digests. Returns non-zero with no output on transport
330# failure or an implausible hash, so callers distinguish "registry
331# unreachable" from "digest moved".
332published_manifest_digest() {
33357 local tagged_image="$1" manifest digest
334114 manifest="$(mktemp)"
33557 if ! skopeo inspect --raw "docker://${tagged_image}" > "$manifest" 2>/dev/null; then
3364 rm -f "$manifest"
3374 return 1
338 fi
339159 digest="sha256:$(sha256sum "$manifest" | awk '{print $1}')"
34053 rm -f "$manifest"
34153 if ! [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then
3421 return 1
343 fi
34452 printf '%s\n' "$digest"
345}
346
347# parse_accelerator_drift_count <json-summary-file>
348#
349# Validates the machine-readable output from
350# ``scripts/accelerator_catalog.py check-online --json-summary`` and prints
351# its exact non-negative ``drift_count``. The status/count relationship is
352# checked as well: ``current`` means zero and ``drift`` means one or more.
353#
354# Returns non-zero with no output for a missing file, malformed JSON, a
355# missing/invalid count, or an inconsistent status. The dependency-scan
356# driver turns any such parser failure into one operational finding so a
357# broken online scan can never be reported as clean.
358parse_accelerator_drift_count() {
35925 local summary_file="$1"
36025 [ -f "$summary_file" ] || return 1
36125 python3 -c "
362import json, sys
363try:
364 with open(sys.argv[1]) as handle:
365 summary = json.load(handle)
366except (OSError, json.JSONDecodeError):
367 raise SystemExit(1)
368if not isinstance(summary, dict):
369 raise SystemExit(1)
370count = summary.get('drift_count')
371status = summary.get('status')
372if isinstance(count, bool) or not isinstance(count, int) or count < 0:
373 raise SystemExit(1)
374if status not in {'current', 'drift'}:
375 raise SystemExit(1)
376if (status == 'current') != (count == 0):
377 raise SystemExit(1)
378print(count)
379" "$summary_file" 2>/dev/null
380}
381
382# extract_aurora_versions <file>
383#
384# Prints the pinned Aurora PostgreSQL engine version (``major.minor``) from
385# the constants module, importing it when possible and falling back to a
386# direct regex over ``constants.py``. The pin is a plain version string
387# applied through ``rds.AuroraPostgresEngineVersion.of()`` — there is no CDK
388# enum member to scan for. Output is validated to ``X.Y[.Z]`` shape so a
389# refactor of the constant can never leak a non-version into the RDS query.
39016extract_aurora_versions() {
39159 local file="${1:-gco/stacks/regional_stack.py}"
39259 python3 -c "
393import re, sys
394value = None
395try:
396 from gco.stacks.constants import AURORA_POSTGRES_VERSION
397 value = AURORA_POSTGRES_VERSION
398except ImportError:
399 import os
400 constants_path = os.path.join(os.path.dirname(sys.argv[1]), 'constants.py')
401 if os.path.exists(constants_path):
402 with open(constants_path) as f:
403 text = f.read()
404 m = re.search(r'^AURORA_POSTGRES_VERSION\s*=\s*\"([^\"]+)\"', text, re.M)
405 if m:
406 value = m.group(1)
407if value and re.fullmatch(r'\d+\.\d+(?:\.\d+)?', value):
408 print(value)
409" "$file" 2>/dev/null | sort -V
410}
411
412# extract_eks_addons <file>
413#
414# Extracts EKS addon name|version pairs from the constants module.
415# Falls back to reading constants.py directly if the module can't be imported.
416# Prints one "addon_name|addon_version" per line.
41716extract_eks_addons() {
41857 local file="${1:-gco/stacks/regional_stack.py}"
41959 python3 -c "
420import sys
421try:
422 from gco.stacks.constants import (
423 EKS_ADDON_POD_IDENTITY_AGENT,
424 EKS_ADDON_METRICS_SERVER,
425 EKS_ADDON_EFS_CSI_DRIVER,
426 EKS_ADDON_CLOUDWATCH_OBSERVABILITY,
427 EKS_ADDON_FSX_CSI_DRIVER,
428 )
429 addons = [
430 ('eks-pod-identity-agent', EKS_ADDON_POD_IDENTITY_AGENT),
431 ('metrics-server', EKS_ADDON_METRICS_SERVER),
432 ('aws-efs-csi-driver', EKS_ADDON_EFS_CSI_DRIVER),
433 ('amazon-cloudwatch-observability', EKS_ADDON_CLOUDWATCH_OBSERVABILITY),
434 ('aws-fsx-csi-driver', EKS_ADDON_FSX_CSI_DRIVER),
435 ]
436 for name, version in addons:
437 print(f'{name}|{version}')
438except ImportError:
439 # Fallback: read constants.py directly
440 import re, os
441 constants_path = os.path.join(os.path.dirname(sys.argv[1]), 'constants.py')
442 if os.path.exists(constants_path):
443 with open(constants_path) as f:
444 text = f.read()
445 # Map constant names to addon names
446 mapping = {
447 'EKS_ADDON_POD_IDENTITY_AGENT': 'eks-pod-identity-agent',
448 'EKS_ADDON_METRICS_SERVER': 'metrics-server',
449 'EKS_ADDON_EFS_CSI_DRIVER': 'aws-efs-csi-driver',
450 'EKS_ADDON_CLOUDWATCH_OBSERVABILITY': 'amazon-cloudwatch-observability',
451 'EKS_ADDON_FSX_CSI_DRIVER': 'aws-fsx-csi-driver',
452 }
453 for const_name, addon_name in mapping.items():
454 m = re.search(const_name + r'\s*=\s*\"([^\"]+)\"', text)
455 if m:
456 print(f'{addon_name}|{m.group(1)}')
457 else:
458 # Last resort: scan the file for inline addon_name/addon_version pairs
459 with open(sys.argv[1]) as f:
460 text = f.read()
461 for m in re.finditer(r'addon_name=\"([^\"]+)\".*?addon_version=\"([^\"]+)\"', text, re.DOTALL):
462 print(f'{m.group(1)}|{m.group(2)}')
463" "$file" 2>/dev/null
464}
465
466# extract_helm_charts [charts_yaml_path]
467#
468# Reads ``lambda/helm-installer/charts.yaml`` and prints one JSON object per
469# chart entry: ``{name, repo_url, chart, version, use_oci}``. Extracted from
470# dependency-scan.sh so BATS can exercise the real charts.yaml parse — the
471# driver sources this helper and pipes its output into the version-drift loop
472# (``helm search repo`` / ``helm show chart`` per entry). Because it iterates
473# every entry, a newly-added chart (e.g. kube-prometheus-stack) is picked up
474# automatically with no change here.
475#
476# Prints nothing (exit 0) when the file is missing or unparseable, matching the
477# other extractors in this file so the caller treats empty as "skip".
47816extract_helm_charts() {
47963 local file="${1:-lambda/helm-installer/charts.yaml}"
48066 [ -f "$file" ] || return 0
48160 python3 -c "
482import json, sys, yaml
483try:
484 with open(sys.argv[1]) as f:
485 data = yaml.safe_load(f)
486except Exception:
487 sys.exit(0)
488for name, cfg in (data or {}).get('charts', {}).items():
489 cfg = cfg or {}
490 print(json.dumps({
491 'name': name,
492 'repo_url': cfg.get('repo_url', ''),
493 'chart': cfg.get('chart', ''),
494 'version': cfg.get('version', ''),
495 'use_oci': cfg.get('use_oci', False),
496 }))
497" "$file" 2>/dev/null
498}
499
500# extract_k8s_version [cdk_json_path]
501#
502# Reads the kubernetes_version from cdk.json. Falls back to "1.36".
50316extract_k8s_version() {
504104 local cdk="${1:-cdk.json}"
505109 python3 -c "import json; print(json.load(open('$cdk'))['context']['kubernetes_version'])" 2>/dev/null || echo "1.36"
506}
507
508# extract_dockerfile_pins <dockerfile>
509#
510# Parses ``ARG <NAME>=<VALUE>`` lines from the given Dockerfile and emits
511# ``NAME|VALUE`` for each pin we care about. The allowlist below is
512# intentional — random build-time ARGs (e.g. ``BUILD_DATE``) would add
513# noise to the drift report.
514#
515# The line-anchor (``^ARG``) and single-line Python regex avoid matching
516# ``ARG`` appearing inside a comment or a RUN heredoc. Leading whitespace
517# is permitted so a future ``RUN --mount=…`` or multi-stage FROM line
518# doesn't break the scan silently.
519#
520# Example output for Dockerfile.dev:
521#
522# NODE_VERSION|v24.18.0
523# NPM_VERSION|11.14.1
524# CDK_VERSION|2.1120.0
525# KUBECTL_VERSION|v1.36.1
526# AWSCLI_VERSION|2.34.42
527# DOCKER_VERSION|29.4.2
528# BUILDX_VERSION|v0.35.0
52917extract_dockerfile_pins() {
53096 local file="${1:-Dockerfile.dev}"
53199 [ -f "$file" ] || return 0
53293 python3 -c "
533import re, sys
534allowlist = {
535 'NODE_VERSION',
536 'NPM_VERSION',
537 'CDK_VERSION',
538 'KUBECTL_VERSION',
539 'AWSCLI_VERSION',
540 'DOCKER_VERSION',
541 'BUILDX_VERSION',
542 'UV_VERSION',
543}
544with open(sys.argv[1]) as f:
545 for line in f:
546 # Strip trailing inline comments but keep the ARG value itself.
547 stripped = line.split('#', 1)[0]
548 m = re.match(r'^\s*ARG\s+([A-Z_][A-Z0-9_]*)=(\S+)\s*$', stripped)
549 if not m:
550 continue
551 name, value = m.group(1), m.group(2)
552 if name in allowlist:
553 print(f'{name}|{value}')
554" "$file" 2>/dev/null
555}
556
557# extract_npm_direct_pins [package_json_path]
558#
559# Prints ``name|version`` for every exactly-pinned direct dependency in one
560# ``package.json`` — ``dependencies`` and ``devDependencies`` both count, since
561# every repository-owned graph pins tooling through devDependencies. Range
562# specifiers (``^``, ``~``, ``>=``, ``*``, tags) are skipped: the npm package
563# management check already fails graphs that carry them, and a range cannot
564# drift in the "pinned copy is behind latest" sense this scan reports.
565# Missing or malformed files print nothing, matching extract_dockerfile_pins.
56617extract_npm_direct_pins() {
56769 local file="${1:-package.json}"
56870 [ -f "$file" ] || return 0
56968 python3 -c "
570import json, re, sys
571
572try:
573 with open(sys.argv[1]) as f:
574 manifest = json.load(f)
575except (OSError, json.JSONDecodeError):
576 sys.exit(0)
577
578exact = re.compile(r'^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$')
579for section in ('dependencies', 'devDependencies'):
580 entries = manifest.get(section)
581 if not isinstance(entries, dict):
582 continue
583 for name, version in sorted(entries.items()):
584 if isinstance(version, str) and exact.match(version):
585 print(f'{name}|{version}')
586" "$file" 2>/dev/null
587}
588
589# extract_precommit_hooks [config_path]
590#
591# Parses ``.pre-commit-config.yaml`` and emits one ``repo|rev`` pair per
592# hook ``repo:`` block. The repo URL is left intact (it's needed to
593# resolve the upstream releases endpoint), and ``rev`` is the literal
594# string committed to the config — usually a tag like ``v0.15.7`` or
595# ``v1.19.1`` but pre-commit also tolerates plain semver and full SHAs.
596# Local hook stanzas (``repo: local``) and the pre-commit hook
597# meta-stanza (``repo: meta``) are skipped: there's no upstream release
598# to compare against.
599#
600# Falls back silently to an empty list if the file is missing or the
601# YAML can't be parsed — the caller treats that as "skip" rather than
602# "no drift", same pattern as the other extractors in this file.
60316extract_precommit_hooks() {
60463 local file="${1:-.pre-commit-config.yaml}"
60566 [ -f "$file" ] || return 0
60660 python3 -c "
607import sys, yaml
608try:
609 with open(sys.argv[1]) as f:
610 data = yaml.safe_load(f)
611except Exception:
612 sys.exit(0)
613for entry in (data or {}).get('repos', []) or []:
614 repo = (entry or {}).get('repo', '') or ''
615 rev = (entry or {}).get('rev', '') or ''
616 # ``local`` and ``meta`` are pre-commit conventions for hooks
617 # that aren't backed by an upstream repo; skip them.
618 if not repo or repo in ('local', 'meta'):
619 continue
620 if not rev:
621 continue
622 print(f'{repo}|{rev}')
623" "$file" 2>/dev/null
624}
625
626# is_full_git_commit_sha <revision>
627#
628# Returns success only for a complete SHA-1 or SHA-256 object id. Pre-commit
629# accepts branch names and floating labels in ``rev`` too, so merely being
630# non-semver is not enough to call a ref immutable.
6316is_full_git_commit_sha() {
632126 [[ "$1" =~ ^[0-9a-fA-F]{40}$ || "$1" =~ ^[0-9a-fA-F]{64}$ ]]
633}
634
635# extract_emr_versions <file>
636#
637# Extracts the pinned EMR Serverless release label from the constants module.
638# Prints the label (e.g. ``emr-7.13.0``) on a single line. Falls back to
639# reading constants.py directly if the module can't be imported.
64016extract_emr_versions() {
64157 local file="${1:-gco/stacks/constants.py}"
64257 python3 -c "
643import sys
644try:
645 from gco.stacks.constants import EMR_SERVERLESS_RELEASE_LABEL
646 print(EMR_SERVERLESS_RELEASE_LABEL)
647except ImportError:
648 import re, os
649 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]
650 if os.path.exists(constants_path):
651 with open(constants_path) as f:
652 text = f.read()
653 m = re.search(r'EMR_SERVERLESS_RELEASE_LABEL\s*=\s*\"([^\"]+)\"', text)
654 if m:
655 print(m.group(1))
656" "$file" 2>/dev/null
657}
658
659# extract_constant_value <name> [constants_path]
660#
661# Reads a single string-valued top-level constant from the constants
662# module by regex (does *not* import ``gco.stacks``, which would pull
663# in the full CDK stack package). Used by the CDK-enum drift checks
664# below to look up ``LAMBDA_PYTHON_RUNTIME`` and ``AURORA_POSTGRES_VERSION``
665# without assuming the rest of the project is installable.
666#
667# Example:
668# extract_constant_value LAMBDA_PYTHON_RUNTIME
669# # → PYTHON_3_14
67048extract_constant_value() {
671155 local name="$1"
672155 local file="${2:-gco/stacks/constants.py}"
673166 [ -f "$file" ] || return 0
674144 python3 -c "
675import re, sys
676name = sys.argv[1]
677with open(sys.argv[2]) as f:
678 text = f.read()
679m = re.search(r'^' + re.escape(name) + r'\s*=\s*\"([^\"]+)\"', text, re.MULTILINE)
680if m:
681 print(m.group(1))
682" "$name" "$file" 2>/dev/null
683}
684
685# extract_python_string_constant <name> <python_path>
686#
687# Tokenizes the source without importing or executing it, then literal-evaluates
688# only the requested top-level assignment. Parsing only that expression keeps
689# extraction compatible when the rest of the module uses syntax newer than the
690# scanner's Python interpreter. Adjacent/parenthesized string literals remain
691# supported, while names, calls, and other executable expressions are rejected.
69232extract_python_string_constant() {
69396 local name="$1"
69496 local file="$2"
695102 [ -f "$file" ] || return 0
69690 python3 -c "
697import ast, io, sys, tokenize
698try:
699 with open(sys.argv[2], encoding='utf-8') as handle:
700 source = handle.read()
701except OSError:
702 raise SystemExit(0)
703tokens = tokenize.generate_tokens(io.StringIO(source).readline)
704try:
705 for token in tokens:
706 if not (
707 token.type == tokenize.NAME
708 and token.string == sys.argv[1]
709 and token.start[1] == 0
710 ):
711 continue
712 for token in tokens:
713 if token.type not in (tokenize.NL, tokenize.COMMENT):
714 break
715 if token.type != tokenize.OP or token.string != '=':
716 continue
717 expression = []
718 depth = 0
719 for token in tokens:
720 if token.type == tokenize.OP:
721 if token.string in '([{':
722 depth += 1
723 elif token.string in ')]}':
724 depth -= 1
725 if token.type in (tokenize.NEWLINE, tokenize.ENDMARKER) and depth == 0:
726 break
727 expression.append((token.type, token.string))
728 try:
729 value = ast.literal_eval(tokenize.untokenize(expression))
730 except (SyntaxError, ValueError):
731 break
732 if isinstance(value, str):
733 print(value)
734 break
735except tokenize.TokenError:
736 pass
737" "$name" "$file" 2>/dev/null
738}
739
740# get_latest_lambda_python_runtime
741#
742# Imports ``aws_cdk.aws_lambda`` and prints the highest ``PYTHON_X_Y``
743# enum member of ``Runtime`` (e.g. ``PYTHON_3_14``). Empty output when
744# aws-cdk-lib isn't importable — callers treat this as "skip".
745#
746# Used by the CDK-enum drift check in ``dependency-scan.sh`` to compare
747# the ``LAMBDA_PYTHON_RUNTIME`` constant against the newest Lambda
748# Python runtime the installed CDK can construct. The dep-scan workflow
749# installs the latest ``aws-cdk-lib`` for this; locally the helper just
750# reflects whatever is on the active interpreter.
751#
752# Suffixed members (``PYTHON_3_14_PROVIDED`` if it ever exists) are
753# ignored — the regex anchor on ``$`` keeps the result aligned with the
754# canonical "X.Y" runtime CDK exposes today.
755get_latest_lambda_python_runtime() {
75625 python3 -c "
757import re
758try:
759 from aws_cdk import aws_lambda
760except Exception:
761 raise SystemExit(0)
762versions = []
763for name in dir(aws_lambda.Runtime):
764 m = re.match(r'^PYTHON_(\d+)_(\d+)$', name)
765 if m:
766 versions.append((int(m.group(1)), int(m.group(2)), name))
767if versions:
768 print(max(versions)[2])
769" 2>/dev/null
770}
771
772# get_latest_lambda_nodejs_runtime
773#
774# Prints the highest canonical ``NODEJS_<major>_X`` member exposed by the
775# installed aws-cdk-lib. This is the Node equivalent of the Python helper
776# above and feeds the managed Lambda runtime drift check.
777get_latest_lambda_nodejs_runtime() {
77825 python3 -c "
779import re
780try:
781 from aws_cdk import aws_lambda
782except Exception:
783 raise SystemExit(0)
784versions = []
785for name in dir(aws_lambda.Runtime):
786 m = re.match(r'^NODEJS_(\d+)_X$', name)
787 if m:
788 versions.append((int(m.group(1)), name))
789if versions:
790 print(max(versions)[1])
791" 2>/dev/null
792}
793
794# get_latest_endoflife_cycle <product>
795#
796# Queries https://endoflife.date/api/<product>.json and prints the
797# highest ``cycle`` (e.g. ``3.14``) that's already shipped and still
798# under standard support. Empty output on network failure or schema
799# change — callers treat this as "skip" rather than as drift.
800#
801# We pick endoflife.date because it's a clean, unauthenticated JSON
802# endpoint that already filters out prerelease/EOL cycles via its
803# ``releaseDate`` and ``eol`` fields. Going through python.org, ruby-lang.org
804# or the upstream GitHub APIs would either rate-limit (no token) or
805# require us to hand-roll prerelease-tag filtering.
806#
807# Interpreters are pinned by *series* here (``3.14``, ``4.0``), not by patch,
808# because that's how .python-version and .ruby-version pin them: the patch is
809# whatever the runner or the prebuilt toolchain resolves to.
810get_latest_endoflife_cycle() {
81157 local product="$1"
81257 curl -fsSL --max-time 15 "https://endoflife.date/api/${product}.json" 2>/dev/null \
81357 | python3 -c "
814import datetime, json, sys
815try:
816 data = json.load(sys.stdin)
817except Exception:
818 sys.exit(0)
819today = datetime.date.today().isoformat()
820candidates = []
821for entry in data:
822 cycle = entry.get('cycle', '')
823 release = entry.get('releaseDate', '') or ''
824 eol = entry.get('eol', '')
825 if not cycle or '.' not in cycle:
826 continue
827 # Skip prereleases (release date in the future).
828 if isinstance(release, str) and release > today:
829 continue
830 # Skip end-of-life cycles. ``eol`` may be a string date or False
831 # when EOL hasn't been announced yet — treat False/empty as still
832 # supported.
833 if isinstance(eol, str) and eol and eol < today:
834 continue
835 try:
836 parts = tuple(int(p) for p in cycle.split('.'))
837 except ValueError:
838 continue
839 candidates.append((parts, cycle))
840if candidates:
841 print(max(candidates)[1])
842" 2>/dev/null
843}
844
845# get_latest_python_release
846#
847# The supported Python series, for the Lambda runtime comparison.
848get_latest_python_release() {
84927 get_latest_endoflife_cycle python
850}
851
852# get_latest_ruby_release
853#
854# The supported Ruby series, for the .ruby-version comparison. Ruby is a
855# CI-only dependency (bashcov measures BATS shell coverage — see the Gemfile),
856# but it is pinned like every other toolchain, so it gets the same monthly
857# "is the pin still the current series?" check .python-version gets.
858get_latest_ruby_release() {
85927 get_latest_endoflife_cycle ruby
860}
861
862# read_ruby_version_pin [version_file]
863#
864# Prints the committed Ruby series from .ruby-version (comments and blank
865# lines ignored), or nothing when the file is missing or holds no version.
866# Empty output makes the caller skip rather than report false drift.
86716read_ruby_version_pin() {
86863 local version_file="${1:-.ruby-version}"
86967 [ -f "$version_file" ] || return 0
87059 grep -vE '^[[:space:]]*(#|$)' "$version_file" 2>/dev/null \
87159 | tr -d '[:space:]' \
87259 | grep -oE '^(ruby-)?[0-9]+\.[0-9]+(\.[0-9]+)?$' \
87359 | sed -E 's/^ruby-//' \
87459 | head -1
875}
876
877# get_latest_precommit_hook_release <repo_url>
878#
879# Given the ``repo:`` URL committed to ``.pre-commit-config.yaml``,
880# prints the latest semver-shaped tag from the upstream Git host so
881# the dep-scan can compare it against the pinned ``rev:``. Empty
882# output on network failure, an unsupported host, or when no tag
883# matches — callers treat that as "skip" rather than as drift.
884#
885# Today only GitHub repos are supported. Every hook in the project's
886# ``.pre-commit-config.yaml`` is hosted there, and the pre-commit
887# ecosystem is overwhelmingly GitHub-based. If a future hook lives
888# elsewhere (GitLab, Codeberg) the helper will return empty and the
889# scan logs a one-line skip note for that hook — no false drift.
890#
891# We use ``GET /repos/{owner}/{repo}/tags`` rather than
892# ``releases/latest`` because pre-commit pins ``rev:`` to a Git tag,
893# not a GitHub Release — and several hooks (yamllint, mirrors-mypy,
894# markdownlint-cli2) tag without ever cutting a Release. The tags
895# endpoint returns newest-first, so we filter to ``vX.Y.Z`` /
896# ``X.Y.Z`` / ``X.Y`` shapes, drop pre-release suffixes (``-rc1``,
897# ``-beta``), and take the highest by semver.
898#
899# Unauthenticated. The monthly scan calls this once per hook (four
900# times against today's config) — the unauthenticated GitHub API
901# limit is 60 req/h per IP, so a per-PAT/GITHUB_TOKEN bump to the
902# 5000 req/h authenticated bucket isn't worth the extra coupling.
903get_latest_precommit_hook_release() {
90462 local repo_url="$1"
90563 [ -n "$repo_url" ] || return 0
906
907 # Only GitHub is supported today. Strip any trailing ``.git`` or
908 # ``/`` so the owner/repo extraction works for both forms commonly
909 # seen in pre-commit configs.
91061 local cleaned="${repo_url%.git}"
91161 cleaned="${cleaned%/}"
91261 case "$cleaned" in
913 https://github.com/*) ;;
9143 *) return 0 ;;
915 esac
916
91758 local owner_repo="${cleaned#https://github.com/}"
918 # Reject anything that isn't ``owner/repo`` (no extra path segments).
919170 case "$owner_repo" in
9201 */*/*) return 0 ;;
921 */*) ;;
9221 *) return 0 ;;
923 esac
924
92556 curl -fsSL --max-time 15 \
926 -H "Accept: application/vnd.github+json" \
927 -H "X-GitHub-Api-Version: 2022-11-28" \
928 "https://api.github.com/repos/${owner_repo}/tags?per_page=100" 2>/dev/null \
92956 | python3 -c "
930import json, re, sys
931try:
932 data = json.load(sys.stdin)
933except Exception:
934 sys.exit(0)
935# pre-commit's ``rev:`` accepts ``vX.Y[.Z]``, ``X.Y[.Z]``, or full
936# SHAs. We compare on the semver-shaped ones; SHA-pinned hooks are
937# left alone (the helper returns empty and the caller skips them).
938pat = re.compile(r'^v?\d+\.\d+(?:\.\d+)?$')
939candidates = []
940for entry in data or []:
941 name = (entry or {}).get('name', '')
942 if not pat.match(name):
943 continue
944 stripped = name.lstrip('v')
945 parts = stripped.split('.')
946 try:
947 nums = tuple(int(p) for p in parts)
948 except ValueError:
949 continue
950 candidates.append((nums, name))
951if candidates:
952 print(max(candidates)[1])
953" 2>/dev/null
954}
955
956
957# extract_mooncake_default_image [images_py_path]
958#
959# Prints the default upstream Mooncake vLLM image reference (``repo:tag``)
960# pinned in ``cli/images.py`` as ``_DISAGGREGATED_DEFAULT_IMAGE`` — the image
961# GCO's disaggregated/store/both inference deploys pull when the operator
962# passes no ``--image``.
963#
964# This image lives in a Python constant, not a Dockerfile ``FROM`` or a K8s
965# manifest, so neither Dependabot (docker ecosystem) nor the manifest/workflow
966# image sweep in dependency-scan.sh sees it. This extractor feeds it into the
967# Docker-image drift check so a newer vLLM release is surfaced in the monthly
968# report — the cue to validate and bump the pin (the ``mooncake-image``
969# workflow re-runs the image contract tests against the new tag).
970#
971# Prints nothing if the file or constant is absent — the caller treats an
972# empty result as "skip", same as the other extractors here.
97316extract_mooncake_default_image() {
97462 local file="${1:-cli/images.py}"
97566 [ -f "$file" ] || return 0
97658 python3 -c "
977import re, sys
978with open(sys.argv[1]) as f:
979 text = f.read()
980m = re.search(r'^_DISAGGREGATED_DEFAULT_IMAGE\s*=\s*\"([^\"]+)\"', text, re.MULTILINE)
981if m:
982 print(m.group(1))
983" "$file" 2>/dev/null
984}
985
986# extract_chart_value_images [charts_yaml_path]
987#
988# Walks every ``values:`` block in charts.yaml and prints one fully-qualified
989# ``image:tag`` per line for every mapping that pins BOTH a repository and a
990# tag. Handles the two image-pin shapes the file uses:
991#
992# image: image:
993# repository: example/app registry: ghcr.io
994# tag: "1.2.3" repository: org/sub/app
995# tag: "v1.2.3"
996#
997# The ``registry`` sibling must join the emitted reference: without it a
998# ghcr-hosted repository is emitted bare and the tag sweep resolves it
999# against docker.io — for multi-segment repositories that cannot exist
1000# there, turning every monthly scan into a false INCOMPLETE (first hit by
1001# kubeflow-trainer's controller pin, the first registry-split image with an
1002# explicit tag; KEDA's registry-split blocks are tag-less and never emit).
1003#
1004# Tag-less pins stay un-emitted on purpose: their images follow the chart's
1005# appVersion, which the Helm chart version sweep already reports. Registry-
1006# less single-segment repositories are skipped as before (ambiguous
1007# namespace). Prints nothing on a missing or unparseable file — the caller
1008# treats empty output as a broken parse, matching the smoke-manifest
1009# precedent (charts.yaml always carries pinned values images).
101016extract_chart_value_images() {
101161 local file="${1:-lambda/helm-installer/charts.yaml}"
101265 [ -f "$file" ] || return 0
101357 python3 -c "
1014import sys
1015import yaml
1016try:
1017 with open(sys.argv[1]) as handle:
1018 data = yaml.safe_load(handle)
1019except Exception:
1020 sys.exit(0)
1021
1022
1023def find_images(node):
1024 if isinstance(node, dict):
1025 registry = node.get('registry', '')
1026 repository = node.get('repository', '')
1027 tag = node.get('tag', '')
1028 if repository and tag:
1029 if registry:
1030 print(f'{registry}/{repository}:{tag}')
1031 elif '/' in repository:
1032 print(f'{repository}:{tag}')
1033 for value in node.values():
1034 find_images(value)
1035 elif isinstance(node, list):
1036 for item in node:
1037 find_images(item)
1038
1039
1040for _name, cfg in ((data or {}).get('charts') or {}).items():
1041 if isinstance(cfg, dict):
1042 find_images(cfg.get('values') or {})
1043" "$file" 2>/dev/null
1044}
1045
1046# extract_default_bedrock_model [cdk_json_path] [leaf_key]
1047#
1048# Prints a configured Bedrock model id from ``cdk.json``
1049# ``context.bedrock.<leaf_key>`` (default leaf: ``mission_default_model_id``).
1050# The managed generation leaves are ``mission_default_model_id`` — what
1051# Mission sampling resolves through ``gco.bedrock`` when no explicit override
1052# is supplied — ``capacity_advisor_default_model_id`` — the capacity
1053# advisor's equivalent — and ``claude_code_default_model_id``, the session
1054# model ``gco autopilot`` hands to Claude Code. The keys are deliberately
1055# independent knobs.
1056#
1057# These values feed the Bedrock-model drift check in dependency-scan.sh, which
1058# compares each against the newest profile in the same model family
1059# (get_latest_bedrock_model). A newer release is the cue to update cdk.json
1060# and, for the Mission key, re-capture the scaffold fixture under
1061# tests/fixtures/scaffold_responses/.
1062#
1063# Prints nothing if the file is absent, malformed, or does not contain a
1064# non-empty string at the expected path. The caller treats empty output as a
1065# skip, matching the other extractors in this library.
106696extract_default_bedrock_model() {
1067347 local file="${1:-cdk.json}"
1068347 local leaf="${2:-mission_default_model_id}"
1069 # Optional third argument selects the context block (default: bedrock).
1070 # The vector-store feature keeps its own independent embedding model at
1071 # ``context.vector_store.embedding_model_id``; passing ``vector_store``
1072 # here lets the same extractor and drift plumbing manage it.
1073347 local block="${3:-bedrock}"
1074366 [ -f "$file" ] || return 0
1075328 python3 -c "
1076import json, sys
1077try:
1078 with open(sys.argv[1]) as handle:
1079 data = json.load(handle)
1080 value = data.get('context', {}).get(sys.argv[3], {}).get(sys.argv[2])
1081except Exception:
1082 value = None
1083if isinstance(value, str) and value.strip():
1084 print(value.strip())
1085" "$file" "$leaf" "$block" 2>/dev/null
1086}
1087
1088# bedrock_model_family <inference_profile_id>
1089#
1090# Prints the "model family" key for a Bedrock system-defined inference
1091# profile id so two releases of the same model line compare equal on
1092# family and differ only on version. The family is the geography +
1093# provider + the non-version tokens of the model name, with numeric model
1094# version/generation/date tokens dropped. Numeric tokens may be integers or
1095# dotted versions embedded between hyphens:
1096#
1097# us.amazon.nova-pro-v1:0 -> us.amazon.nova-pro
1098# global.amazon.nova-3-lite-v1:0 -> global.amazon.nova-lite
1099# us.anthropic.claude-sonnet-4-5-20250929-v1:0 -> us.anthropic.claude-sonnet
1100# global.anthropic.claude-opus-4-6-v1 -> global.anthropic.claude-opus
1101# global.anthropic.claude-opus-9 -> global.anthropic.claude-opus
1102# global.openai.gpt-5.7-sol -> global.openai.gpt-sol
1103#
1104# The trailing revision appears in three shapes across live profiles:
1105# ``-vMAJOR:MINOR``, ``-vMAJOR`` alone (newer Anthropic profiles), and
1106# absent entirely. All three are stripped, so one model line stays one
1107# family; matching only the ``:MINOR`` form would file
1108# ``claude-opus-4-6-v1`` under a phantom ``claude-opus-v1`` family and
1109# silently stop reporting drift against ``claude-opus-5``.
1110#
1111# Folding the numeric generation token into the version key (rather
1112# than the family) is deliberate: it keeps "Nova 1 Pro" and a future
1113# "Nova 2 Pro" in the same family so a generation bump is reported as
1114# drift, while different tiers (nova-pro vs nova-lite) and providers
1115# stay in separate families and are never cross-suggested.
111615bedrock_model_family() {
111730 python3 -c "
1118import re, sys
1119mid = sys.argv[1]
1120core = re.sub(r'-v\d+(?::\d+)?\Z', '', mid)
1121parts = core.split('.')
1122if len(parts) >= 3:
1123 geo, provider, name = parts[0], parts[1], '.'.join(parts[2:])
1124elif len(parts) == 2:
1125 geo, provider, name = '', parts[0], parts[1]
1126else:
1127 geo, provider, name = '', '', core
1128tokens = [
1129 t for t in name.split('-') if t and not re.fullmatch(r'\d+(?:\.\d+)*', t)
1130]
1131prefix = '.'.join([p for p in (geo, provider) if p])
1132print(prefix + ('.' + '-'.join(tokens) if tokens else ''))
1133" "$1" 2>/dev/null
1134}
1135
1136# compare_bedrock_model <current_id> <candidate_id>
1137#
1138# Prints "newer" when candidate is a newer release than current,
1139# "same" when equal, "older" otherwise — mirroring compare_semver's
1140# contract so the drift check reads the same way. The comparison key
1141# is the tuple of every integer in the id, left to right (model
1142# version, generation, date, and the trailing ``vMAJOR:MINOR``), so
1143# nova-pro-v1:0 (1,0) is older than a hypothetical nova-pro-v2:0 (2,0)
1144# and claude-sonnet-4-5-...-v1:0 (4,5,...) is older than a
1145# claude-sonnet-4-6-...-v1:0. Callers scope to one family first (see
1146# bedrock_model_family); this helper only looks at the integer key.
11479compare_bedrock_model() {
114836 python3 -c "
1149import re, sys
1150def key(mid):
1151 return [int(n) for n in re.findall(r'\d+', mid)]
1152a, b = key(sys.argv[1]), key(sys.argv[2])
1153print('same' if a == b else ('newer' if b > a else 'older'))
1154" "$1" "$2" 2>/dev/null
1155}
1156
1157# get_latest_bedrock_model <current_id> [region]
1158#
1159# Prints the newest system-defined inference-profile id in the same
1160# model family as <current_id> (see bedrock_model_family), as reported
1161# by ``aws bedrock list-inference-profiles --type-equals SYSTEM_DEFINED``.
1162# Used by the Bedrock-model drift check to tell whether a pinned
1163# generation-model default has a newer release available.
1164#
1165# Family scoping keeps the comparison apples-to-apples: a newer Nova
1166# Pro is reported against a pinned Nova Pro, but a different tier (Nova
1167# Lite, a Claude model, ...) is never suggested as a "newer" default —
1168# switching tier/provider is a human decision, not drift.
1169#
1170# Region defaults to us-east-1 (matches the advisor + Mission sampling
1171# default region) regardless of the workflow's configured region, so both
1172# global.* and geography-scoped cross-Region profiles resolve consistently.
1173# Profiles without a numeric version key are ignored because they cannot be
1174# ordered safely. Empty output on any failure (no creds, throttling, schema
1175# change, or no comparable profile) tells the caller to mark the check skipped.
1176#
1177# IAM action: bedrock:ListInferenceProfiles.
1178get_latest_bedrock_model() {
117996 local current="$1"
118096 local region="${2:-us-east-1}"
1181191 if [ -z "$current" ] || ! [[ "$current" =~ [0-9] ]]; then
11821 return 0
1183 fi
118495 aws bedrock list-inference-profiles \
1185 --type-equals SYSTEM_DEFINED \
1186 --region "$region" \
1187 --output json 2>/dev/null \
118895 | python3 -c "
1189import json, re, sys
1190current = sys.argv[1]
1191def family(mid):
1192 # Keep in lockstep with bedrock_model_family above: the revision
1193 # suffix is optional and its ``:MINOR`` half is too.
1194 core = re.sub(r'-v\d+(?::\d+)?\Z', '', mid)
1195 parts = core.split('.')
1196 if len(parts) >= 3:
1197 geo, provider, name = parts[0], parts[1], '.'.join(parts[2:])
1198 elif len(parts) == 2:
1199 geo, provider, name = '', parts[0], parts[1]
1200 else:
1201 geo, provider, name = '', '', core
1202 tokens = [
1203 t for t in name.split('-') if t and not re.fullmatch(r'\d+(?:\.\d+)*', t)
1204]
1205 prefix = '.'.join([p for p in (geo, provider) if p])
1206 return prefix + ('.' + '-'.join(tokens) if tokens else '')
1207def key(mid):
1208 return [int(n) for n in re.findall(r'\d+', mid)]
1209try:
1210 data = json.load(sys.stdin)
1211except Exception:
1212 sys.exit(0)
1213target = family(current)
1214cands = []
1215for prof in data.get('inferenceProfileSummaries', []) or []:
1216 pid = prof.get('inferenceProfileId', '') or ''
1217 if (prof.get('status') or 'ACTIVE') != 'ACTIVE':
1218 continue
1219 if pid and key(pid) and family(pid) == target:
1220 cands.append(pid)
1221if cands:
1222 cands.sort(key=key)
1223 print(cands[-1])
1224" "$current" 2>/dev/null
1225}
1226
1227# get_latest_bedrock_embedding_model <current_id> [region]
1228#
1229# Prints the newest ACTIVE text-embedding foundation model in the same
1230# model family as <current_id> (see bedrock_model_family), as reported by
1231# ``aws bedrock list-foundation-models --by-output-modality EMBEDDING``.
1232# Used by the Bedrock-model drift check for
1233# ``context.bedrock.embedding_model_id`` — Mission memory's embedding
1234# default, resolved at runtime through
1235# ``gco.bedrock.get_default_embedding_model_id()``.
1236#
1237# Embedding models are plain foundation models, not system-defined
1238# inference profiles, so this is a separate lookup from
1239# get_latest_bedrock_model; the family scoping and integer-tuple version
1240# ranking are deliberately identical. A newer same-family release (e.g. a
1241# Titan Text Embeddings v3) is reported as drift; a different provider or
1242# model line never is. Embedding drift is advisory-with-a-caveat: stored
1243# vectors are only comparable to vectors from the same model, so adopting
1244# a newer embedding model means re-embedding or segregating existing
1245# data, not just bumping the pin (the drift report's remediation text
1246# says so).
1247#
1248# Region defaults to us-east-1 (the Mission memory default region).
1249# Models without a numeric version key are ignored because they cannot be
1250# ordered safely; non-ACTIVE lifecycles (e.g. LEGACY) are skipped. Empty
1251# output on any failure tells the caller to mark the check skipped.
1252#
1253# IAM action: bedrock:ListFoundationModels (already granted to the scan
1254# role alongside bedrock:ListInferenceProfiles).
1255get_latest_bedrock_embedding_model() {
125649 local current="$1"
125749 local region="${2:-us-east-1}"
125897 if [ -z "$current" ] || ! [[ "$current" =~ [0-9] ]]; then
12591 return 0
1260 fi
126148 aws bedrock list-foundation-models \
1262 --by-output-modality EMBEDDING \
1263 --region "$region" \
1264 --output json 2>/dev/null \
126548 | python3 -c "
1266import json, re, sys
1267current = sys.argv[1]
1268def family(mid):
1269 # Keep in lockstep with bedrock_model_family above: the revision
1270 # suffix is optional and its ``:MINOR`` half is too.
1271 core = re.sub(r'-v\d+(?::\d+)?\Z', '', mid)
1272 parts = core.split('.')
1273 if len(parts) >= 3:
1274 geo, provider, name = parts[0], parts[1], '.'.join(parts[2:])
1275 elif len(parts) == 2:
1276 geo, provider, name = '', parts[0], parts[1]
1277 else:
1278 geo, provider, name = '', '', core
1279 tokens = [
1280 t for t in name.split('-') if t and not re.fullmatch(r'\d+(?:\.\d+)*', t)
1281]
1282 prefix = '.'.join([p for p in (geo, provider) if p])
1283 return prefix + ('.' + '-'.join(tokens) if tokens else '')
1284def key(mid):
1285 return [int(n) for n in re.findall(r'\d+', mid)]
1286try:
1287 data = json.load(sys.stdin)
1288except Exception:
1289 sys.exit(0)
1290target = family(current)
1291cands = []
1292for model in data.get('modelSummaries', []) or []:
1293 mid = model.get('modelId', '') or ''
1294 lifecycle = (model.get('modelLifecycle') or {}).get('status') or 'ACTIVE'
1295 if lifecycle != 'ACTIVE':
1296 continue
1297 if mid and key(mid) and family(mid) == target:
1298 cands.append(mid)
1299if cands:
1300 cands.sort(key=key)
1301 print(cands[-1])
1302" "$current" 2>/dev/null
1303}
1304
1305# =============================================================================
1306# Expanded coverage helpers
1307# =============================================================================
1308# The functions below extend the scan to surfaces that live outside the
1309# original twelve: CI tooling pins the workflows install by hand, version
1310# pins that must move in lockstep across several files, and recurring
1311# hygiene checks (suppression expiry, lockfile freshness, base-image
1312# security epochs).
1313#
1314# Every one keeps the same contract as the extractors above: print one
1315# record per line to stdout, and print nothing (exit 0) on missing/malformed
1316# input so the caller treats an empty result as "skip", never as drift.
1317# =============================================================================
1318
1319# get_latest_github_release_tag <owner/repo>
1320#
1321# Prints the ``tag_name`` of the latest non-prerelease GitHub Release for
1322# ``<owner/repo>`` (e.g. ``v0.70.0``). Generalises the inline release lookups
1323# the Dockerfile.dev section already does for moby/moby and docker/buildx so
1324# the CI-tooling drift check (Trivy, Helm, kind) can share one code path.
1325#
1326# Empty output on network failure, a non ``owner/repo`` argument, or a repo
1327# with no published Release — callers treat empty as "skip", same as the
1328# other lookups here. Unauthenticated: the monthly scan makes a handful of
1329# these calls, well under the 60 req/h anonymous GitHub limit.
1330get_latest_github_release_tag() {
1331179 local owner_repo="$1"
1332180 [ -n "$owner_repo" ] || return 0
1333 # Reject anything that isn't exactly ``owner/repo`` (mirrors the guard in
1334 # get_latest_precommit_hook_release) so a stray URL or path can't 404.
1335530 case "$owner_repo" in
13361 */*/*) return 0 ;;
1337 */*) ;;
13381 *) return 0 ;;
1339 esac
1340176 curl -fsSL --max-time 15 \
1341 -H "Accept: application/vnd.github+json" \
1342 -H "X-GitHub-Api-Version: 2022-11-28" \
1343 "https://api.github.com/repos/${owner_repo}/releases/latest" 2>/dev/null \
1344176 | jq -r '.tag_name // empty' 2>/dev/null
1345}
1346
1347# extract_workflow_env_pin <VAR_NAME> [workflows_dir]
1348#
1349# Prints the unique value(s) of a ``<VAR_NAME>: "<value>"`` env assignment
1350# found across the workflow YAML under <workflows_dir> (default
1351# ``.github/workflows``). Used by the CI-tooling drift check to read the
1352# pinned ``HELM_VERSION`` / ``KUBECTL_VERSION`` / ``CALICO_VERSION`` the
1353# workflows install their own tooling from — pins Dependabot doesn't watch
1354# (they're plain env strings, not ``uses:`` refs or Dockerfile ``FROM``
1355# lines).
1356#
1357# Prints one value per line, de-duplicated and sorted. More than one line
1358# means the same tool is pinned to *different* values across files — a
1359# lockstep-drift bug the Version Consistency section reports. Empty output
1360# when the dir is absent or the var is unset anywhere.
136164extract_workflow_env_pin() {
1362396 local var="$1"
1363396 local dir="${2:-.github/workflows}"
1364396 [ -n "$var" ] || return 0
1365413 [ -d "$dir" ] || return 0
1366379 grep -rhoE "^[[:space:]]*${var}:[[:space:]]*\"?[A-Za-z0-9._+-]+\"?" "$dir" 2>/dev/null \
1367379 | sed -E "s/^[[:space:]]*${var}:[[:space:]]*//" \
1368379 | tr -d '"' \
1369379 | sort -u
1370}
1371
1372# extract_install_trivy_pin [action_yml]
1373#
1374# Prints the Trivy version pinned as the ``version`` input default of the
1375# install-trivy composite action — the one place the Trivy pin lives, now
1376# that security.yml and cve-scan.yml carry no copies of their own. Empty
1377# output when the file, the input, or the default is absent.
137816extract_install_trivy_pin() {
137961 local file="${1:-.github/actions/install-trivy/action.yml}"
138065 [ -f "$file" ] || return 0
138157 python3 -c "
1382import sys, yaml
1383try:
1384 with open(sys.argv[1]) as f:
1385 data = yaml.safe_load(f)
1386except Exception:
1387 sys.exit(0)
1388value = ((((data or {}).get('inputs') or {}).get('version') or {}).get('default') or '')
1389if value:
1390 print(value)
1391" "$file" 2>/dev/null
1392}
1393
1394# extract_kind_pins [workflow_file]
1395#
1396# Prints the kind pins configured on the ``helm/kind-action`` step:
1397# kind|<version> e.g. kind|v0.32.0 (the kind binary)
1398# kind-node|<image:tag> e.g. kind-node|kindest/node:v1.36.1
1399#
1400# These live in the action's ``with:`` block, not a top-level ``image:`` or
1401# a Dockerfile ``FROM``, so neither the workflow image sweep nor Dependabot's
1402# docker ecosystem sees them. The caller checks the kind binary against
1403# kubernetes-sigs/kind releases and the node image against its own registry
1404# tags within the pinned K8s minor.
1405#
1406# Empty output if the file or the kind-action step is absent.
140732extract_kind_pins() {
1408177 local file="${1:-.github/workflows/integration-tests.yml}"
1409186 [ -f "$file" ] || return 0
1410168 python3 -c "
1411import re, sys, yaml
1412try:
1413 with open(sys.argv[1]) as f:
1414 data = yaml.safe_load(f)
1415except Exception:
1416 sys.exit(0)
1417
1418# The kind-action steps reference the single workflow-level declarations as
1419# \${{ env.KIND_VERSION }} / \${{ env.KIND_NODE_IMAGE }}; resolve those here
1420# so callers keep seeing concrete values. Literal with: values (the
1421# pre-hoist shape) still pass through unchanged. An env reference that does
1422# not resolve prints nothing — the caller's presence checks report the pin
1423# as missing rather than passing a template string to a release lookup.
1424workflow_env = {
1425 str(k): str(v) for k, v in ((data or {}).get('env') or {}).items()
1426}
1427_REF = re.compile(r'^\\\$\\{\\{\\s*env\\.([A-Za-z_][A-Za-z0-9_]*)\\s*\\}\\}\$')
1428
1429def resolve(value, job_env):
1430 value = str(value or '')
1431 match = _REF.match(value.strip())
1432 if not match:
1433 return value
1434 name = match.group(1)
1435 if name in job_env:
1436 return str(job_env[name])
1437 return workflow_env.get(name, '')
1438
1439seen = []
1440for job in (data or {}).get('jobs', {}).values():
1441 job_env = {str(k): str(v) for k, v in ((job or {}).get('env') or {}).items()}
1442 for step in (job or {}).get('steps', []) or []:
1443 uses = (step or {}).get('uses', '') or ''
1444 if uses.startswith('helm/kind-action'):
1445 with_ = (step or {}).get('with', {}) or {}
1446 ver = resolve(with_.get('version', ''), job_env)
1447 node = resolve(with_.get('node_image', ''), job_env)
1448 if ver and ('kind', ver) not in seen:
1449 seen.append(('kind', ver))
1450 if node and ('kind-node', node) not in seen:
1451 seen.append(('kind-node', node))
1452# De-duplicated: multiple kind-action steps (cluster-e2e + examples-smoke)
1453# resolving to the SAME versions print once. A key appearing twice therefore
1454# always means the steps drifted apart — the caller's consistency check
1455# reports exactly that.
1456for key, value in seen:
1457 print(f'{key}|{value}')
1458" "$file" 2>/dev/null
1459}
1460
1461# extract_ruff_pins [pyproject] [precommit] [lint_workflow]
1462#
1463# Prints the ruff version pinned in each place the project keeps it, one
1464# ``source|version`` per line (version normalised without a leading ``v``):
1465# Example output shape (the values differ when local pins drift):
1466# pyproject|1.2.3 ruff==X in [project.optional-dependencies]
1467# precommit|1.2.4 astral-sh/ruff-pre-commit rev in .pre-commit-config.yaml
1468# lint-action|1.2.3 astral-sh/ruff-action version input in lint.yml
1469#
1470# Ruff is pinned in three spots that must move together (developer install,
1471# the pre-commit hook, and the prebuilt-binary CI lint job). The base Python
1472# deps check already flags ruff drift vs PyPI, but nothing catches the three
1473# local pins silently disagreeing — which is exactly what the Version
1474# Consistency section reports.
1475extract_ruff_pins() {
147628 local pyproject="${1:-pyproject.toml}"
147728 local precommit="${2:-.pre-commit-config.yaml}"
147828 local lintwf="${3:-.github/workflows/lint.yml}"
147928 python3 -c "
1480import re, sys
1481pyproject, precommit, lintwf = sys.argv[1], sys.argv[2], sys.argv[3]
1482
1483def norm(v):
1484 return v.lstrip('v').strip()
1485
1486# pyproject: first ruff==X.Y.Z anywhere (the lint + diagrams extras pin the
1487# same value; report it once).
1488try:
1489 with open(pyproject) as f:
1490 m = re.search(r'ruff==([0-9.]+)', f.read())
1491 if m:
1492 print(f'pyproject|{norm(m.group(1))}')
1493except OSError:
1494 pass
1495
1496# pre-commit: rev of the astral-sh/ruff-pre-commit repo block.
1497try:
1498 import yaml
1499 with open(precommit) as f:
1500 data = yaml.safe_load(f)
1501 for entry in (data or {}).get('repos', []) or []:
1502 if 'astral-sh/ruff-pre-commit' in ((entry or {}).get('repo', '') or ''):
1503 rev = (entry or {}).get('rev', '')
1504 if rev:
1505 print(f'precommit|{norm(rev)}')
1506 break
1507except Exception:
1508 pass
1509
1510# lint workflow: version input on the first astral-sh/ruff-action step (the
1511# two steps pin the same value). Parse the YAML rather than regex the raw
1512# text so a nearby ``python-version:`` can't be mistaken for the action's
1513# own ``version:`` input.
1514try:
1515 import yaml
1516 with open(lintwf) as f:
1517 wf = yaml.safe_load(f)
1518 found = ''
1519 for job in (wf or {}).get('jobs', {}).values():
1520 for step in (job or {}).get('steps', []) or []:
1521 uses = (step or {}).get('uses', '') or ''
1522 if uses.startswith('astral-sh/ruff-action'):
1523 found = str(((step or {}).get('with', {}) or {}).get('version', '') or '')
1524 if found:
1525 break
1526 if found:
1527 break
1528 if found:
1529 print(f'lint-action|{norm(found)}')
1530except Exception:
1531 pass
1532" "$pyproject" "$precommit" "$lintwf" 2>/dev/null
1533}
1534
1535# extract_python_version_pins [workflows_dir]
1536#
1537# Prints one line per ``python-version: "X.Y"`` occurrence across the
1538# workflow YAML (value only, e.g. ``3.14``). The caller collapses these to
1539# unique values: more than one distinct value, or a value that disagrees
1540# with the project's canonical Python (derived from LAMBDA_PYTHON_RUNTIME),
1541# means the CI matrix drifted from the runtime the Lambdas actually ship on.
1542extract_python_version_pins() {
154330 local dir="${1:-.github/workflows}"
154430 local version_file="${2:-.python-version}"
154532 [ -d "$dir" ] || return 0
1546 # The CI Python lives once, in .python-version (every setup-python step
1547 # uses python-version-file). Emit that single pin, PLUS any literal
1548 # python-version: leftovers in the workflows — a stray literal is
1549 # exactly the drift the consistency check should surface.
155028 if [ -f "$version_file" ]; then
155154 grep -oE "^[0-9]+\.[0-9]+" "$version_file" | head -1
1552 fi
155328 grep -rhoE "python-version:[[:space:]]*\"?[0-9]+\.[0-9]+\"?" "$dir" 2>/dev/null \
155428 | sed -E "s/python-version:[[:space:]]*//" \
155528 | tr -d '"'
1556}
1557
1558# list_npm_package_dirs [root]
1559#
1560# Prints every repository-owned npm package directory. Generated, vendored,
1561# virtual-environment, and CDK assembly trees are excluded so a copied
1562# ``package.json`` never becomes a false dependency surface.
156318list_npm_package_dirs() {
156489 local root="${1:-.}"
156589 python3 -c "
1566from pathlib import Path
1567import sys
1568
1569root = Path(sys.argv[1]).resolve()
1570excluded = {
1571 '.git', '.kiro', '.mypy_cache', '.pytest_cache', '.ruff_cache',
1572 'build', 'cdk.out', 'dist', 'node_modules', '__pycache__',
1573}
1574
1575def owned(path):
1576 parts = path.relative_to(root).parts[:-1]
1577 return not any(
1578 part in excluded or part.startswith('.venv') or part.endswith('-build')
1579 for part in parts
1580 )
1581
1582for manifest in sorted(root.rglob('package.json')):
1583 if not owned(manifest):
1584 continue
1585 relative = manifest.parent.relative_to(root)
1586 print('.' if relative == Path('.') else relative.as_posix())
1587" "$root" 2>/dev/null
1588}
1589
1590# check_npm_package_management [root] [dependabot_config]
1591#
1592# Emits ``package.json|problem`` for every repository-owned npm graph that is
1593# not fully reproducible and managed. A graph must have a package-lock.json,
1594# an exact npm packageManager pin, exact direct dependency pins, and a matching
1595# Dependabot npm directory entry. The all-package npm-audit CI job treats any
1596# output as a hard failure; the monthly scan also reports it as consistency
1597# drift.
1598check_npm_package_management() {
159929 local root="${1:-.}"
160029 local dependabot="${2:-.github/dependabot.yml}"
160129 python3 -c "
1602import json, re, sys
1603from pathlib import Path
1604
1605root = Path(sys.argv[1]).resolve()
1606dependabot_path = Path(sys.argv[2])
1607if not dependabot_path.is_absolute():
1608 dependabot_path = root / dependabot_path
1609excluded = {
1610 '.git', '.kiro', '.mypy_cache', '.pytest_cache', '.ruff_cache',
1611 'build', 'cdk.out', 'dist', 'node_modules', '__pycache__',
1612}
1613exact_version = re.compile(r'^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$')
1614
1615def owned(path):
1616 parts = path.relative_to(root).parts[:-1]
1617 return not any(
1618 part in excluded or part.startswith('.venv') or part.endswith('-build')
1619 for part in parts
1620 )
1621
1622dependabot_dirs = set()
1623try:
1624 text = dependabot_path.read_text(encoding='utf-8')
1625except OSError:
1626 text = ''
1627for block in re.split(r'(?m)(?=^\s*-\s+package-ecosystem:)', text):
1628 if not re.search(r'(?m)^\s*-\s+package-ecosystem:\s*[\"\']?npm[\"\']?\s*$', block):
1629 continue
1630 match = re.search(r'(?m)^\s+directory:\s*[\"\']?([^\"\'\s]+)', block)
1631 if match:
1632 dependabot_dirs.add('/' + match.group(1).strip('/'))
1633
1634for manifest in sorted(root.rglob('package.json')):
1635 if not owned(manifest):
1636 continue
1637 rel_manifest = manifest.relative_to(root).as_posix()
1638 rel_dir = manifest.parent.relative_to(root)
1639 dependabot_dir = '/' if rel_dir == Path('.') else '/' + rel_dir.as_posix()
1640 try:
1641 package = json.loads(manifest.read_text(encoding='utf-8'))
1642 except (OSError, json.JSONDecodeError) as exc:
1643 print(f'{rel_manifest}|invalid JSON: {exc}')
1644 continue
1645 if not manifest.with_name('package-lock.json').is_file():
1646 print(f'{rel_manifest}|missing package-lock.json')
1647 manager = package.get('packageManager', '')
1648 if not re.fullmatch(r'npm@\d+\.\d+\.\d+', manager):
1649 print(f'{rel_manifest}|packageManager must be an exact npm@X.Y.Z pin')
1650 for section in ('dependencies', 'devDependencies', 'optionalDependencies'):
1651 for name, version in (package.get(section) or {}).items():
1652 if not isinstance(version, str) or not exact_version.fullmatch(version):
1653 print(f'{rel_manifest}|{section}.{name} must use an exact version pin')
1654 if dependabot_dir not in dependabot_dirs:
1655 print(f'{rel_manifest}|missing Dependabot npm entry for {dependabot_dir}')
1656" "$root" "$dependabot" 2>/dev/null
1657}
1658
1659# extract_node_major_pins [root] [constants] [nvmrc] [dockerfile]
1660#
1661# Emits ``source|major`` for every place that intentionally mirrors the
1662# repository Node major: the Lambda runtime constant, .nvmrc, every owned npm
1663# graph's engine, and Dockerfile.dev. The driver reports missing sources and
1664# disagreement; the CDK enum check separately detects a newer Lambda runtime.
1665extract_node_major_pins() {
166629 local root="${1:-.}"
166729 local constants="${2:-gco/stacks/constants.py}"
166829 local nvmrc="${3:-.nvmrc}"
166929 local dockerfile="${4:-Dockerfile.dev}"
167029 python3 -c "
1671import json, re, sys
1672from pathlib import Path
1673
1674root = Path(sys.argv[1]).resolve()
1675excluded = {
1676 '.git', '.kiro', '.mypy_cache', '.pytest_cache', '.ruff_cache',
1677 'build', 'cdk.out', 'dist', 'node_modules', '__pycache__',
1678}
1679
1680def resolve(path):
1681 candidate = Path(path)
1682 return candidate if candidate.is_absolute() else root / candidate
1683
1684def emit(source, value):
1685 match = re.search(r'\d+', str(value))
1686 if match:
1687 print(f'{source}|{int(match.group())}')
1688
1689def owned(path):
1690 parts = path.relative_to(root).parts[:-1]
1691 return not any(
1692 part in excluded or part.startswith('.venv') or part.endswith('-build')
1693 for part in parts
1694 )
1695
1696try:
1697 text = resolve(sys.argv[2]).read_text(encoding='utf-8')
1698 match = re.search(r'^LAMBDA_NODEJS_RUNTIME\s*=\s*\"NODEJS_(\d+)_X\"', text, re.MULTILINE)
1699 if match:
1700 emit('gco/stacks/constants.py', match.group(1))
1701except OSError:
1702 pass
1703try:
1704 emit('.nvmrc', resolve(sys.argv[3]).read_text(encoding='utf-8').strip())
1705except OSError:
1706 pass
1707try:
1708 text = resolve(sys.argv[4]).read_text(encoding='utf-8')
1709 # NODE_VERSION pins the exact release (e.g. ``v24.18.0``); emit()
1710 # reduces it to the leading major for the cross-source comparison.
1711 match = re.search(r'^\s*ARG\s+NODE_VERSION=(v?\d+(?:\.\d+)*)\s*$', text, re.MULTILINE)
1712 if match:
1713 emit('Dockerfile.dev', match.group(1))
1714except OSError:
1715 pass
1716for manifest in sorted(root.rglob('package.json')):
1717 if not owned(manifest):
1718 continue
1719 try:
1720 package = json.loads(manifest.read_text(encoding='utf-8'))
1721 except (OSError, json.JSONDecodeError):
1722 continue
1723 emit(manifest.relative_to(root).as_posix(), (package.get('engines') or {}).get('node', ''))
1724" "$root" "$constants" "$nvmrc" "$dockerfile" 2>/dev/null
1725}
1726
1727# extract_npm_version_pins [root] [dockerfile]
1728#
1729# Emits every package.json packageManager npm version plus Dockerfile.dev's
1730# NPM_VERSION so Dependabot/tooling updates cannot leave contributor and CI
1731# npm versions disagreeing.
1732extract_npm_version_pins() {
173328 local root="${1:-.}"
173428 local dockerfile="${2:-Dockerfile.dev}"
173528 python3 -c "
1736import json, re, sys
1737from pathlib import Path
1738
1739root = Path(sys.argv[1]).resolve()
1740excluded = {
1741 '.git', '.kiro', '.mypy_cache', '.pytest_cache', '.ruff_cache',
1742 'build', 'cdk.out', 'dist', 'node_modules', '__pycache__',
1743}
1744
1745def owned(path):
1746 parts = path.relative_to(root).parts[:-1]
1747 return not any(
1748 part in excluded or part.startswith('.venv') or part.endswith('-build')
1749 for part in parts
1750 )
1751
1752for manifest in sorted(root.rglob('package.json')):
1753 if not owned(manifest):
1754 continue
1755 try:
1756 package = json.loads(manifest.read_text(encoding='utf-8'))
1757 except (OSError, json.JSONDecodeError):
1758 continue
1759 match = re.fullmatch(r'npm@(\d+\.\d+\.\d+)', package.get('packageManager', ''))
1760 if match:
1761 print(f'{manifest.relative_to(root).as_posix()}|{match.group(1)}')
1762docker = Path(sys.argv[2])
1763if not docker.is_absolute():
1764 docker = root / docker
1765try:
1766 text = docker.read_text(encoding='utf-8')
1767 match = re.search(r'^\s*ARG\s+NPM_VERSION=(\d+\.\d+\.\d+)\s*$', text, re.MULTILINE)
1768 if match:
1769 print(f'Dockerfile.dev|{match.group(1)}')
1770except OSError:
1771 pass
1772" "$root" "$dockerfile" 2>/dev/null
1773}
1774
1775# extract_cdk_cli_pins [root] [dockerfile]
1776#
1777# Emits the root tooling graph's aws-cdk version and Dockerfile.dev's global
1778# CDK CLI pin. Both execution paths must synthesize with the same CLI release.
1779extract_cdk_cli_pins() {
178028 local root="${1:-.}"
178128 local dockerfile="${2:-Dockerfile.dev}"
178228 python3 -c "
1783import json, re, sys
1784from pathlib import Path
1785
1786root = Path(sys.argv[1]).resolve()
1787try:
1788 package = json.loads((root / 'package.json').read_text(encoding='utf-8'))
1789 version = (package.get('devDependencies') or {}).get('aws-cdk', '')
1790 if re.fullmatch(r'\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?', version):
1791 print(f'package.json|{version}')
1792except (OSError, json.JSONDecodeError):
1793 pass
1794docker = Path(sys.argv[2])
1795if not docker.is_absolute():
1796 docker = root / docker
1797try:
1798 text = docker.read_text(encoding='utf-8')
1799 match = re.search(r'^\s*ARG\s+CDK_VERSION=(\S+)\s*$', text, re.MULTILINE)
1800 if match:
1801 print(f'Dockerfile.dev|{match.group(1)}')
1802except OSError:
1803 pass
1804" "$root" "$dockerfile" 2>/dev/null
1805}
1806
1807# parse_suppression_expiries <file>
1808#
1809# Prints ``ID|YYYY-MM-DD`` for every dated suppression entry in a
1810# ``.trivyignore`` / ``.pip-audit-ignore`` / ``.npm-audit-ignore`` file (any
1811# non-comment line carrying an ``exp:YYYY-MM-DD`` marker). For the
1812# whitespace-delimited trivy/pip format the ID is the first token; for the
1813# npm-audit pipe format (``package-dir|package|advisory|node-path|exp:…``)
1814# the ID is the advisory field, so the report names the GHSA rather than the
1815# whole entry line. The caller computes days-to-expiry and surfaces entries
1816# expiring soon so they get renewed *before* the CI expiry validator hard-
1817# fails a PR — the report is the early warning, the validator is the gate.
1818#
1819# Empty output when the file is absent or has no dated entries.
1820parse_suppression_expiries() {
182178 local file="$1"
182279 [ -f "$file" ] || return 0
182377 python3 -c "
1824import re, sys
1825with open(sys.argv[1]) as f:
1826 for line in f:
1827 s = line.strip()
1828 if not s or s.startswith('#'):
1829 continue
1830 m = re.search(r'exp:(\d{4}-\d{2}-\d{2})', s)
1831 if not m:
1832 continue
1833 fields = s.split('|')
1834 if len(fields) == 5:
1835 # .npm-audit-ignore pipe format — the advisory is field 3.
1836 ident = fields[2]
1837 else:
1838 ident = s.split()[0]
1839 print(f'{ident}|{m.group(1)}')
1840" "$file" 2>/dev/null
1841}
1842
1843# extract_helm_installer_pins [dockerfile]
1844#
1845# Prints the tool versions hardcoded in the helm-installer Lambda's
1846# Dockerfile RUN lines:
1847# HELM_VERSION|vX.Y.Z from the get.helm.sh download URL
1848# KUBECTL_VERSION|vX.Y.Z from the dl.k8s.io download URL
1849#
1850# These pins are RUN-line URL literals — not ARGs, ``FROM`` lines, or
1851# workflow env — so Dependabot, the Dockerfile.dev ARG sweep, and the
1852# workflow-env consistency check were all blind to them. The consistency
1853# section compares them against the HELM_VERSION / KUBECTL_VERSION workflow
1854# env pins (and Dockerfile.dev's KUBECTL_VERSION ARG) so the Lambda image,
1855# the CI installs, and the dev container can't silently disagree about
1856# which helm/kubectl they ship.
1857#
1858# Empty output when the file is absent or the URLs aren't found — callers
1859# treat that as "skip", matching every other extractor here.
186032extract_helm_installer_pins() {
1861146 local file="${1:-lambda/helm-installer/Dockerfile}"
1862154 [ -f "$file" ] || return 0
1863138 python3 -c "
1864import re, sys
1865with open(sys.argv[1]) as f:
1866 text = f.read()
1867# Versions from the download URLs; checksums from the sha256sum trust
1868# anchor bound to each download's output path. These four lines are the
1869# single Helm/kubectl source the CI jobs load into GITHUB_ENV — the
1870# workflows deliberately carry no literal copies of their own.
1871m = re.search(r'get\.helm\.sh/helm-(v\d+\.\d+\.\d+)-', text)
1872if m:
1873 print(f'HELM_VERSION|{m.group(1)}')
1874m = re.search(r'([0-9a-f]{64})\s+/tmp/helm\.tar\.gz', text)
1875if m:
1876 print(f'HELM_SHA256|{m.group(1)}')
1877m = re.search(r'dl\.k8s\.io/release/(v\d+\.\d+\.\d+)/', text)
1878if m:
1879 print(f'KUBECTL_VERSION|{m.group(1)}')
1880m = re.search(r'([0-9a-f]{64})\s+/tmp/kubectl', text)
1881if m:
1882 print(f'KUBECTL_SHA256|{m.group(1)}')
1883" "$file" 2>/dev/null
1884}
1885
1886# dependency_scan_is_complete <incomplete-reasons-file> [skip-reason ...]
1887#
1888# Returns success only when the durable incomplete-reason channel exists and
1889# is empty and every section-specific skip reason is empty. The driver uses
1890# this single predicate for its ``scan_complete`` output, so a failed lookup,
1891# parser, or explicit skip can never close the rolling dependency issue.
1892dependency_scan_is_complete() {
189330 local incomplete_reasons_file="$1"
189430 shift
1895
189631 [ -f "$incomplete_reasons_file" ] || return 1
189741 [ ! -s "$incomplete_reasons_file" ] || return 1
1898
189917 local skip_reason
1900135 for skip_reason in "$@"; do
1901140 [ -z "$skip_reason" ] || return 1
1902 done
1903}
1904
1905# check_lockfile_freshness [pyproject] [lockfile]
1906#
1907# Prints ``normalised-name[;marker]|expected-version|locked-version`` for each
1908# exact direct dependency whose compiled lock entry is missing or has a
1909# different version. ``locked-version`` is ``<missing>`` when no matching
1910# name-and-marker entry exists. Both base and optional dependency groups are
1911# checked; self-referential ``gco-cli`` extras are intentionally excluded.
1912#
1913# Whitespace around ``==`` and marker operators is insignificant. Marker text
1914# remains part of the comparison identity so mutually exclusive platform pins
1915# can carry different exact versions without being treated as conflicts.
1916# Returns nonzero for missing/malformed input, non-exact direct dependencies,
1917# conflicting pins with the same marker, or unparseable lock records.
1918check_lockfile_freshness() {
191943 local pyproject="${1:-pyproject.toml}"
192043 local lockfile="${2:-requirements-lock.txt}"
192143 python3 -c "
1922import re, sys, tomllib
1923
1924pyproject, lockfile = sys.argv[1], sys.argv[2]
1925
1926def fail(message):
1927 print(f'lockfile freshness error: {message}', file=sys.stderr)
1928 raise SystemExit(2)
1929
1930try:
1931 with open(pyproject, 'rb') as handle:
1932 data = tomllib.load(handle)
1933except (OSError, tomllib.TOMLDecodeError) as exc:
1934 fail(f'cannot read {pyproject}: {exc}')
1935
1936project = data.get('project')
1937if not isinstance(project, dict):
1938 fail(f'{pyproject} has no valid [project] table')
1939base_dependencies = project.get('dependencies', [])
1940optional_dependencies = project.get('optional-dependencies', {})
1941if not isinstance(base_dependencies, list) or not isinstance(optional_dependencies, dict):
1942 fail(f'{pyproject} dependency tables have invalid types')
1943
1944def norm(name):
1945 return re.sub(r'[-_.]+', '-', name).lower()
1946
1947def canonical_marker(marker, source):
1948 marker = (marker or '').strip()
1949 if not marker:
1950 return ''
1951 if '|' in marker:
1952 fail(f'{source} marker contains an unsupported pipe character')
1953 result = []
1954 quote = None
1955 escaped = False
1956 for character in marker:
1957 if quote is not None:
1958 result.append(character)
1959 if escaped:
1960 escaped = False
1961 elif character == '\\\\':
1962 escaped = True
1963 elif character == quote:
1964 # PEP 508 treats single- and double-quoted marker values as
1965 # equivalent. Emit one quote style for identity comparison.
1966 result[-1] = '\"'
1967 quote = None
1968 elif character in ('\"', \"'\"):
1969 quote = character
1970 result.append('\"')
1971 elif not character.isspace():
1972 result.append(character)
1973 if quote is not None:
1974 fail(f'{source} marker has an unterminated quoted value')
1975 return ''.join(result)
1976
1977exact_requirement = re.compile(
1978 r'\\s*(?P<name>[A-Za-z0-9][A-Za-z0-9._-]*)\\s*'
1979 r'(?:\\[\\s*[A-Za-z0-9._-]+(?:\\s*,\\s*[A-Za-z0-9._-]+)*\\s*\\])?\\s*'
1980 r'==\\s*(?P<version>[^\\s;]+)\\s*'
1981 r'(?:;\\s*(?P<marker>.+?))?\\s*'
1982)
1983# A concrete PEP-440-shaped literal may carry an epoch, pre/post/dev suffix,
1984# or local version, but never a wildcard, another comparator, or a comma-
1985# separated clause. This deliberately rejects ``===`` arbitrary equality.
1986concrete_version = re.compile(r'v?(?:[0-9]+!)?[0-9]+[A-Za-z0-9._+-]*')
1987
1988def parse_exact(spec, source):
1989 if not isinstance(spec, str):
1990 fail(f'{source} contains a non-string dependency')
1991 name_match = re.match(r'^\\s*([A-Za-z0-9][A-Za-z0-9._-]*)', spec)
1992 if not name_match:
1993 fail(f'{source} contains an invalid dependency: {spec!r}')
1994 name = norm(name_match.group(1))
1995 if name == 'gco-cli':
1996 return None
1997 match = exact_requirement.fullmatch(spec)
1998 if not match:
1999 fail(f'{source} dependency is not an exact == pin: {spec!r}')
2000 version = match.group('version')
2001 if not concrete_version.fullmatch(version):
2002 fail(f'{source} dependency is not a concrete exact == pin: {spec!r}')
2003 marker = canonical_marker(match.group('marker'), source)
2004 return (name, marker), version
2005
2006def add_pin(pins, parsed, source):
2007 if parsed is None:
2008 return
2009 identity, version = parsed
2010 previous = pins.get(identity)
2011 if previous is not None and previous != version:
2012 display = identity[0] + (f';{identity[1]}' if identity[1] else '')
2013 fail(f'{display} has conflicting pins in {source}: {previous} and {version}')
2014 pins[identity] = version
2015
2016expected = {}
2017for dependency in base_dependencies:
2018 add_pin(
2019 expected,
2020 parse_exact(dependency, '[project].dependencies'),
2021 '[project].dependencies',
2022 )
2023for group, dependencies in optional_dependencies.items():
2024 if not isinstance(dependencies, list):
2025 fail(f'[project.optional-dependencies].{group} is not an array')
2026 source = f'[project.optional-dependencies].{group}'
2027 for dependency in dependencies:
2028 add_pin(expected, parse_exact(dependency, source), source)
2029if not expected:
2030 fail(f'{pyproject} contains no exact direct dependency pins')
2031
2032locked = {}
2033try:
2034 with open(lockfile, encoding='utf-8') as handle:
2035 for line_number, raw_line in enumerate(handle, 1):
2036 candidate = raw_line.strip()
2037 if not candidate or candidate.startswith(('#', '-')):
2038 continue
2039 candidate = candidate.split(' #', 1)[0].rstrip()
2040 if candidate.endswith('\\\\'):
2041 candidate = candidate[:-1].rstrip()
2042 source = f'{lockfile}:{line_number}'
2043 add_pin(locked, parse_exact(candidate, source), source)
2044except OSError as exc:
2045 fail(f'cannot read {lockfile}: {exc}')
2046if not locked:
2047 fail(f'{lockfile} contains no pinned requirements')
2048
2049for identity, expected_version in sorted(expected.items()):
2050 locked_version = locked.get(identity)
2051 if locked_version != expected_version:
2052 name, marker = identity
2053 display = name + (f';{marker}' if marker else '')
2054 print(f'{display}|{expected_version}|{locked_version or \"<missing>\"}')
2055" "$pyproject" "$lockfile"
2056}
2057
2058# extract_security_epochs <dockerfile>
2059#
2060# Prints ``ARGNAME|YYYY-MM-DD`` for each build-time security-refresh epoch
2061# ARG in the given Dockerfile (``APT_SECURITY_EPOCH`` for the Debian service
2062# images / dev image, ``DNF_SECURITY_EPOCH`` for the AL2023 helm-installer
2063# Lambda). These dates are bumped by hand to bust the CI layer cache and pull
2064# freshly-published OS security patches; nothing else reminds anyone to move
2065# them, so the report flags an epoch older than the freshness window. Trivy's
2066# container scan is the backstop; this is the proactive nudge.
2067#
2068# Empty output when the file is absent or pins no epoch ARG.
2069extract_security_epochs() {
207089 local file="$1"
207190 [ -f "$file" ] || return 0
207288 python3 -c "
2073import re, sys
2074with open(sys.argv[1]) as f:
2075 for line in f:
2076 stripped = line.split('#', 1)[0]
2077 m = re.match(r'^\s*ARG\s+((?:APT|DNF)_SECURITY_EPOCH)=(\d{4}-\d{2}-\d{2})\s*$', stripped)
2078 if m:
2079 print(f'{m.group(1)}|{m.group(2)}')
2080" "$file" 2>/dev/null
2081}
2082
2083# extract_claude_code_pin [autopilot_py]
2084#
2085# Prints the exact Claude Code release ``gco autopilot`` installs, read from
2086# the ``CLAUDE_CODE_VERSION`` constant in ``cli/autopilot.py``.
2087#
2088# Like the Mooncake image above, this pin lives in a Python constant, so
2089# neither Dependabot nor the npm-graph sweep sees it — autopilot installs
2090# Claude Code lazily via ``npm install -g`` rather than declaring it in any
2091# package.json. This extractor feeds the autopilot-pins drift check so a
2092# newer npm release is surfaced in the monthly report.
2093#
2094# The textual contract (a single-line, double-quoted assignment) is locked
2095# by tests/test_cli_autopilot.py, so this regex and the constant cannot
2096# silently drift apart. Prints nothing if the file or constant is absent —
2097# the caller treats an empty result as "skip".
209817extract_claude_code_pin() {
209962 local file="${1:-cli/autopilot.py}"
210067 [ -f "$file" ] || return 0
210157 python3 -c "
2102import re, sys
2103with open(sys.argv[1]) as f:
2104 text = f.read()
2105m = re.search(r'^CLAUDE_CODE_VERSION = \"([^\"]+)\"', text, re.MULTILINE)
2106if m:
2107 print(m.group(1))
2108" "$file" 2>/dev/null
2109}
2110
2111# extract_codex_pin [autopilot_py]
2112#
2113# Prints the exact OpenAI Codex CLI release the Codex Autopilot engine installs,
2114# read from the scanner-stable ``CODEX_VERSION`` literal in cli/autopilot.py.
2115# Codex is another lazy global npm dependency, so it must flow through the same
2116# monthly registry-drift system as Claude Code rather than package.json.
211717extract_codex_pin() {
211862 local file="${1:-cli/autopilot.py}"
211967 [ -f "$file" ] || return 0
212057 python3 -c "
2121import re, sys
2122with open(sys.argv[1]) as f:
2123 text = f.read()
2124m = re.search(r'^CODEX_VERSION = \"([^\"]+)\"', text, re.MULTILINE)
2125if m:
2126 print(m.group(1))
2127" "$file" 2>/dev/null
2128}
2129
2130# extract_companion_mcp_packages [autopilot_py]
2131#
2132# Prints one ``name|registry|package`` line per companion MCP server in the
2133# ``COMPANION_MCP_SERVERS`` registry of ``cli/autopilot.py`` — the servers
2134# ``gco autopilot`` wires into every session. ``registry`` is ``npm`` or
2135# ``pypi``.
2136#
2137# These packages are launch-time dependencies fetched by npx/uvx, so they
2138# appear in no lockfile and no manifest Dependabot watches. The autopilot
2139# liveness check uses this list to verify each package is still published
2140# (and not deprecated/yanked) on its registry — exactly the failure mode
2141# that got mcp-server-fetch and mcp-server-calculator pruned in 2026-08.
2142#
2143# Parses the ``name=`` / ``registry=`` / ``package=`` keywords inside each
2144# ``CompanionServer(`` block textually (no imports), so BATS can exercise it
2145# against fixtures; tests/test_cli_autopilot.py locks the source formatting.
2146# Prints nothing for a missing or unparseable file.
2147extract_companion_mcp_packages() {
214828 local file="${1:-cli/autopilot.py}"
214929 [ -f "$file" ] || return 0
215027 python3 -c "
2151import re, sys
2152with open(sys.argv[1]) as f:
2153 text = f.read()
2154pattern = re.compile(
2155 r'CompanionServer\(\s*name=\"([^\"]+)\",\s*registry=\"([^\"]+)\",\s*package=\"([^\"]+)\",'
2156)
2157for name, registry, package in pattern.findall(text):
2158 print(f'{name}|{registry}|{package}')
2159" "$file" 2>/dev/null
2160}
2161
2162# get_registry_package_status <registry> <package>
2163#
2164# Resolves a package's publication status on its public registry and prints
2165# exactly one of:
2166#
2167# ok|<latest-version> published and healthy
2168# missing| registry answers 404 — unpublished/renamed
2169# deprecated|<message> npm ``deprecated`` flag on the latest release
2170# yanked|<latest-version> PyPI ``yanked`` flag on the latest release
2171#
2172# Prints nothing on a network/transport failure or an unknown registry so
2173# the caller marks the check skipped instead of reporting phantom drift —
2174# the same fail-quiet contract as the other ``get_latest_*`` helpers here.
2175# Both endpoints are public; no credentials needed.
2176get_registry_package_status() {
2177123 local registry="$1" package="$2"
2178123 local url="" body="" code=""
2179123 case "$registry" in
2180 npm)
2181 # Scoped packages need the slash percent-encoded on the
2182 # per-version route (@scope%2Fname/latest).
218388 url="https://registry.npmjs.org/${package//\//%2F}/latest"
2184 ;;
2185 pypi)
218634 url="https://pypi.org/pypi/${package}/json"
2187 ;;
2188 *)
21891 return 0
2190 ;;
2191 esac
2192
2193244 body="$(mktemp)"
2194250 code="$(curl -sSL --max-time 15 -o "$body" -w '%{http_code}' "$url" 2>/dev/null)" || code=""
2195122 if [ "$code" = "404" ]; then
21963 rm -f "$body"
21973 echo "missing|"
21983 return 0
2199 fi
2200119 if [ "$code" != "200" ]; then
22017 rm -f "$body"
22027 return 0
2203 fi
2204
2205112 python3 -c "
2206import json, sys
2207registry, path = sys.argv[1], sys.argv[2]
2208try:
2209 with open(path) as handle:
2210 data = json.load(handle)
2211except Exception:
2212 sys.exit(0)
2213if registry == 'npm':
2214 deprecated = data.get('deprecated')
2215 if deprecated:
2216 print('deprecated|' + ' '.join(str(deprecated).split())[:120])
2217 else:
2218 version = str(data.get('version', '') or '')
2219 if version:
2220 print('ok|' + version)
2221else:
2222 info = data.get('info') or {}
2223 urls = data.get('urls') or []
2224 yanked = bool(urls and urls[0].get('yanked'))
2225 version = str(info.get('version', '') or '')
2226 if version:
2227 print(('yanked|' if yanked else 'ok|') + version)
2228" "$registry" "$body" 2>/dev/null
2229112 rm -f "$body"
2230}
2231
2232# check_lambda_requirements_pins [root] [pyproject] [lockfile]
2233#
2234# Emits ``requirements-path|problem`` for every pin in a per-Lambda
2235# ``requirements.txt`` that disagrees with the version this repository
2236# actually resolves for that package.
2237#
2238# Each Lambda carries its own ``requirements.txt`` because it is packaged and
2239# deployed independently, so the same library is pinned in up to seven places:
2240# ``pyproject.toml`` (what the test suite and the CLI resolve),
2241# ``requirements-lock.txt`` (what pip-compile pins the whole graph to), and one
2242# copy per Lambda that declares it. Nothing watched the Lambda copies. A bump
2243# applied centrally therefore left them behind silently, and the handlers
2244# shipped a different boto3 than anything CI ever exercised — the exact drift
2245# class the rest of this library exists to catch, on the one surface that
2246# reaches production directly.
2247#
2248# Resolution order for the authoritative version, most specific first:
2249# 1. ``[project].dependencies`` — the packages we pin deliberately
2250# 2. ``[project.optional-dependencies]`` — group-only pins
2251# 3. ``requirements-lock.txt`` — transitives (cryptography, …) that
2252# no pyproject entry names but the repository still resolves exactly
2253# A package found in none of the three is a Lambda-only dependency with no
2254# central copy to disagree with, so it is skipped rather than reported.
2255#
2256# Only ``lambda/<name>/requirements.txt`` is read. The generated ``*-build``
2257# staging bundles copy their requirements from the source directory, so
2258# including them would double-report every finding when they happen to exist
2259# locally and report nothing in CI, where they do not.
2260#
2261# A requirements file that pins nothing (three of them only document that the
2262# Lambda runtime supplies boto3) is a skip, not a finding. A missing or
2263# unparseable ``pyproject.toml`` is reported, because it always exists here and
2264# a parse break must not silently downgrade this to a pass.
2265#
2266# Example output:
2267#
2268# lambda/secret-rotation/requirements.txt|boto3==1.43.74 must match pyproject.toml 1.43.85
2269#
2270# The PR-time half of this contract lives in
2271# tests/test_integration.py::TestDependencyVersionConsistency::test_lambda_requirements_match_pyproject.
2272check_lambda_requirements_pins() {
227335 local root="${1:-.}"
227435 local pyproject="${2:-pyproject.toml}"
227535 local lockfile="${3:-requirements-lock.txt}"
227635 python3 -c "
2277import re, sys, tomllib
2278from pathlib import Path
2279
2280root = Path(sys.argv[1]).resolve()
2281
2282def resolve(path):
2283 candidate = Path(path)
2284 return candidate if candidate.is_absolute() else root / candidate
2285
2286def normalize(name):
2287 # PEP 503 normalisation: lowercase, ``_`` + ``.`` -> ``-``.
2288 return re.sub(r'[-_.]+', '-', name).lower()
2289
2290pin = re.compile(r'([A-Za-z0-9][A-Za-z0-9._-]*)\s*==\s*([^\s;]+)')
2291
2292try:
2293 with open(resolve(sys.argv[2]), 'rb') as handle:
2294 data = tomllib.load(handle)
2295except Exception:
2296 print('pyproject.toml|missing or unparseable, cannot verify Lambda pins')
2297 sys.exit(0)
2298
2299project = data.get('project', {}) or {}
2300
2301def collect(specs):
2302 found = {}
2303 for spec in specs or []:
2304 if not isinstance(spec, str):
2305 continue
2306 match = pin.fullmatch(spec.strip())
2307 if match:
2308 found.setdefault(normalize(match.group(1)), set()).add(match.group(2))
2309 return found
2310
2311direct = collect(project.get('dependencies'))
2312optional = {}
2313for group in (project.get('optional-dependencies', {}) or {}).values():
2314 for name, versions in collect(group).items():
2315 optional.setdefault(name, set()).update(versions)
2316
2317locked = {}
2318try:
2319 for line in resolve(sys.argv[3]).read_text(encoding='utf-8').splitlines():
2320 # Lock annotations are indented ('' # via boto3''); pins are not.
2321 if not line or line[:1].isspace() or line.lstrip().startswith('#'):
2322 continue
2323 match = pin.match(line.split('#', 1)[0].strip())
2324 if match:
2325 locked.setdefault(normalize(match.group(1)), set()).add(match.group(2))
2326except OSError:
2327 pass
2328
2329def authority(name):
2330 for label, table in (
2331 ('pyproject.toml', direct),
2332 ('pyproject.toml optional groups', optional),
2333 ('requirements-lock.txt', locked),
2334 ):
2335 if name in table:
2336 return label, table[name]
2337 return None, None
2338
2339def owned(path):
2340 return not any(part.endswith('-build') for part in path.relative_to(root).parts[:-1])
2341
2342for requirements in sorted((root / 'lambda').glob('*/requirements.txt')):
2343 if not owned(requirements):
2344 continue
2345 relative = requirements.relative_to(root).as_posix()
2346 try:
2347 lines = requirements.read_text(encoding='utf-8').splitlines()
2348 except OSError:
2349 print(relative + '|unreadable, cannot verify Lambda pins')
2350 continue
2351 for line in lines:
2352 entry = line.split('#', 1)[0].strip()
2353 if not entry:
2354 continue
2355 match = pin.fullmatch(entry)
2356 if not match:
2357 continue
2358 name, version = normalize(match.group(1)), match.group(2)
2359 label, expected = authority(name)
2360 if not expected or version in expected:
2361 continue
2362 want = ','.join(sorted(expected))
2363 print(f'{relative}|{name}=={version} must match {label} {want}')
2364" "$root" "$pyproject" "$lockfile" 2>/dev/null
2365}
2366
2367# check_image_digest_consistency [root]
2368#
2369# Emits ``image|problem`` for every ``repo:tag`` this repository pins to two
2370# different ``@sha256:`` digests — the tag was re-pushed upstream and only some
2371# of the copies were refreshed.
2372#
2373# This exists because the drift sections answer "is this pin behind upstream?"
2374# and the version-consistency checks answer "do the copies of a *named* pin
2375# agree?", but neither could see a pin restated as a bare literal elsewhere in
2376# the tree. That is the class that keeps escaping: the #317 sweep refreshed the
2377# python-slim digest in a smoke manifest while a test kept the previous one, and
2378# it surfaced only when the CI shard holding that test happened to run.
2379#
2380# Deliberately narrow. Two broader variants were written and measured against
2381# this tree before being cut, because both were pure noise here:
2382#
2383# * "same repo, different tags" reported python at 3.14, 3.14.6-slim and
2384# 3.14.7-slim for three legitimate purposes, busybox pinned in examples
2385# versus ``:latest`` in property-test fixtures, and several synthetic
2386# Volcano tags — about fifteen rows, none of them drift.
2387# * "digest-pinned here but named at a bare tag there" reported only fixture
2388# image strings and the quoted registry-timeout error messages in the
2389# ``build-image-with-retry`` docs.
2390#
2391# A section that is mostly noise trains readers to skip it. Two digests under
2392# one tag, by contrast, cannot be anything but a copy someone missed: a fixture
2393# does not invent a real 64-hex digest. Images that need tag-level coverage get
2394# a dedicated guard instead — see ``tests/test_pinned_floci_version.py``.
2395#
2396# Excludes generated trees, sibling git worktrees, recorded terminal casts, and
2397# the generated flowchart HTML: all of them hold point-in-time copies of source
2398# that are not pins.
2399#
2400# Example output:
2401#
2402# docker.io/library/python|tag 3.14.7-slim has 2 digests: 656d12e7… in a.yaml, ce407646… in b.py
2403#
2404# Prints nothing when every digest-pinned image agrees with itself.
2405check_image_digest_consistency() {
240632 local root="${1:-.}"
240732 python3 -c "
2408import re, sys
2409from pathlib import Path
2410from collections import defaultdict
2411
2412root = Path(sys.argv[1]).resolve()
2413excluded = {
2414 '.git', '.kiro', '.mypy_cache', '.pytest_cache', '.ruff_cache', '.worktrees',
2415 'build', 'cdk.out', 'dist', 'node_modules', '__pycache__', 'site',
2416 '.example-job-validation',
2417}
2418suffixes = {'.py', '.yaml', '.yml', '.md', '.sh', '.txt', '.toml'}
2419
2420def owned(path):
2421 parts = path.relative_to(root).parts
2422 if any(p in excluded or p.startswith('.venv') or p.endswith('-build') for p in parts[:-1]):
2423 return False
2424 if parts and parts[0] in excluded:
2425 return False
2426 # Generated flowchart HTML embeds a copy of the source it charts.
2427 if parts and parts[0] == 'diagrams' and path.suffix != '.py':
2428 return False
2429 return path.suffix in suffixes or 'ockerfile' in path.name
2430
2431# repo:tag[@sha256:digest]. The tag must look like a version so ordinary
2432# ''key: value'' text and ''host:port'' pairs are never read as images.
2433reference = re.compile(
2434 r'(?<![\w./:-])'
2435 r'([a-z0-9][a-z0-9._-]*(?:/[a-z0-9][a-z0-9._-]*)+)'
2436 r':(v?[0-9][A-Za-z0-9._-]*)'
2437 r'(?:@sha256:([0-9a-f]{64}))?'
2438)
2439
2440pinned = defaultdict(lambda: defaultdict(set)) # repo -> tag -> {(digest, file)}
2441
2442for path in sorted(root.rglob('*')):
2443 if not path.is_file() or not owned(path):
2444 continue
2445 try:
2446 text = path.read_text(encoding='utf-8')
2447 except (OSError, UnicodeDecodeError):
2448 continue
2449 relative = path.relative_to(root).as_posix()
2450 # Rejoin references split across adjacent string literals. The stale copy
2451 # that caused the incident was written exactly this way —
2452 # ''docker.io/library/python:3.14.7-slim@'' on one line and
2453 # ''sha256:656d…'' on the next — so a line-at-a-time matcher saw no pin at
2454 # all and reported nothing. Only the seam around the digest is rejoined, so
2455 # unrelated adjacent literals cannot be welded into a false reference.
2456 text = re.sub(r'@[\"\x27][\s,]*[\"\x27]sha256:', '@sha256:', text)
2457 text = re.sub(r'[\"\x27][\s,]*[\"\x27]@sha256:', '@sha256:', text)
2458 for line in text.splitlines():
2459 for match in reference.finditer(line):
2460 repo, tag, digest = match.group(1), match.group(2), match.group(3)
2461 prefix = line[: match.start()]
2462 # ''https://host/path:1.2'' is a URL, not an image reference.
2463 if '://' in prefix[-12:] or prefix.endswith('//'):
2464 continue
2465 if digest:
2466 pinned[repo][tag].add((digest, relative))
2467
2468def listing(items):
2469 return ', '.join(sorted(items))
2470
2471for repo in sorted(pinned):
2472 for tag in sorted(pinned[repo]):
2473 seen = pinned[repo][tag]
2474 distinct = sorted({digest for digest, _ in seen})
2475 if len(distinct) > 1:
2476 detail = ', '.join(
2477 d[:8] + '… in ' + listing(f for g, f in seen if g == d) for d in distinct
2478 )
2479 print(f'{repo}|tag {tag} has {len(distinct)} digests: {detail}')
2480
2481" "$root" 2>/dev/null
2482}