Skip to content

Commit f6bdcd3

Browse files
authored
ci(deps): add advisory manifest tri-source drift gate (#1191)
* ci(deps): add advisory manifest tri-source drift gate Add scripts/check_manifest_drift.py comparing the 7 internal-dep SHAs across dependency-manifest.json, the checkout-kcenon-deps action, and docs/SOUP.md, plus an advisory (continue-on-error) workflow on PRs to main/develop. Surfaces the existing drift (database manifest b90b0f3 vs action/SOUP 593a18a7; thread db8d36f dangling) so future re-pin churn cannot silently add new dangling/mismatched SHAs. Advisory until the manifest is repaired (#1189), then flip to required. Closes #1188 * ci(deps): make manifest drift gate advisory (warning-only, exit 0) The drift check previously reported known unrepaired drift as a failed continue-on-error check, indistinguishable from a real CI failure. Switch to warning-only: emit ::warning:: annotations and exit 0 by default (the check now passes green), with a --strict flag that exits non-zero for the post-#1189 required mode. Drop continue-on-error.
1 parent 4e4e492 commit f6bdcd3

2 files changed

Lines changed: 339 additions & 0 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# SOUP/SBOM provenance drift gate (IEC 62304 §8.1.2).
2+
#
3+
# Detects drift between the three places this repo records the pinned commit
4+
# SHA of each internal kcenon ecosystem dependency:
5+
# 1. dependency-manifest.json
6+
# 2. .github/actions/checkout-kcenon-deps/action.yml
7+
# 3. docs/SOUP.md
8+
#
9+
# ADVISORY: the script runs in warning-only mode (exits 0, emits ::warning::
10+
# annotations) so this check passes green while surfacing the known drift
11+
# (dangling thread SHA + database manifest/action mismatch, repair tracked in
12+
# #1189). After #1189 repairs the manifest, make this a hard gate by adding
13+
# `--strict` to the run step below and marking the check required.
14+
15+
name: Manifest Drift Gate
16+
17+
on:
18+
pull_request:
19+
branches: [main, develop]
20+
paths:
21+
- 'dependency-manifest.json'
22+
- '.github/actions/checkout-kcenon-deps/action.yml'
23+
- 'docs/SOUP.md'
24+
- 'scripts/check_manifest_drift.py'
25+
- '.github/workflows/manifest-drift.yml'
26+
27+
permissions:
28+
contents: read
29+
30+
jobs:
31+
manifest-drift:
32+
name: SOUP manifest drift (advisory)
33+
runs-on: ubuntu-latest
34+
timeout-minutes: 5
35+
36+
steps:
37+
- name: Checkout code
38+
uses: actions/checkout@v4
39+
40+
- name: Set up Python
41+
uses: actions/setup-python@v5
42+
with:
43+
python-version: '3.12'
44+
45+
- name: Check internal SHA provenance drift
46+
run: python3 scripts/check_manifest_drift.py

scripts/check_manifest_drift.py

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
#!/usr/bin/env python3
2+
# SOUP/SBOM provenance drift gate for pacs_system (IEC 62304 SaMD).
3+
#
4+
# Cross-checks the pinned commit SHA of every internal kcenon ecosystem
5+
# dependency across the THREE places this repo records that provenance:
6+
#
7+
# 1. dependency-manifest.json
8+
# -> internal_ecosystem[].name + .version (full 40-char SHA per system)
9+
# 2. .github/actions/checkout-kcenon-deps/action.yml
10+
# -> <SYSTEM>_SYSTEM_SHA: '...' env defaults (full 40-char SHA)
11+
# 3. docs/SOUP.md
12+
# -> the per-system pinned SHA table (short SHA, treated as a prefix)
13+
#
14+
# Goal: catch re-pin churn that silently introduces a dangling or mismatched
15+
# SHA in one source but not the others. This gate DETECTS and REPORTS; it does
16+
# not repair (repair is tracked separately in #1189).
17+
#
18+
# ADVISORY until #1189 repairs the manifest (dangling thread SHA +
19+
# database manifest/action mismatch); flip the CI step to required after #1189.
20+
#
21+
# Python 3, standard library only. No third-party deps (must run in a bare CI
22+
# step before any pip install).
23+
24+
import json
25+
import re
26+
import subprocess
27+
import sys
28+
from pathlib import Path
29+
30+
# The seven internal ecosystem systems, in a stable display order.
31+
SYSTEMS = [
32+
"common_system",
33+
"container_system",
34+
"thread_system",
35+
"logger_system",
36+
"monitoring_system",
37+
"network_system",
38+
"database_system",
39+
]
40+
41+
# Repo root = parent of this script's directory (scripts/).
42+
REPO_ROOT = Path(__file__).resolve().parent.parent
43+
44+
MANIFEST_PATH = REPO_ROOT / "dependency-manifest.json"
45+
ACTION_PATH = REPO_ROOT / ".github" / "actions" / "checkout-kcenon-deps" / "action.yml"
46+
SOUP_PATH = REPO_ROOT / "docs" / "SOUP.md"
47+
48+
# A SHA token: hex, 7..40 chars (SOUP uses short SHAs, others use full).
49+
_SHA_RE = re.compile(r"\b([0-9a-f]{7,40})\b", re.IGNORECASE)
50+
51+
52+
def _system_to_action_key(system: str) -> str:
53+
"""thread_system -> THREAD_SYSTEM_SHA"""
54+
return system.upper() + "_SHA"
55+
56+
57+
def _system_to_soup_base(system: str) -> str:
58+
"""thread_system -> thread (the SOUP link/name uses the full system name,
59+
but matching is done on the full system name anyway)."""
60+
return system
61+
62+
63+
def parse_manifest(path: Path) -> dict:
64+
"""Return {system: sha} from dependency-manifest.json internal_ecosystem."""
65+
result = {}
66+
if not path.is_file():
67+
return result
68+
data = json.loads(path.read_text(encoding="utf-8"))
69+
for entry in data.get("internal_ecosystem", []):
70+
name = entry.get("name")
71+
version = entry.get("version")
72+
if name in SYSTEMS and isinstance(version, str):
73+
result[name] = version.strip().lower()
74+
return result
75+
76+
77+
def parse_action(path: Path) -> dict:
78+
"""Return {system: sha} from the <SYSTEM>_SYSTEM_SHA env defaults in the
79+
composite action. The same SHA appears in both the Unix and Windows env
80+
blocks; we take the first occurrence and (best effort) verify consistency."""
81+
result = {}
82+
if not path.is_file():
83+
return result
84+
text = path.read_text(encoding="utf-8")
85+
for system in SYSTEMS:
86+
key = _system_to_action_key(system)
87+
# Match e.g. THREAD_SYSTEM_SHA: 'db8d36f1...'
88+
# Collect every occurrence so an internally inconsistent action
89+
# (Unix vs Windows block disagreeing) is itself surfaced as drift.
90+
found = re.findall(
91+
r"\b" + re.escape(key) + r"\s*:\s*['\"]([0-9a-fA-F]{7,40})['\"]",
92+
text,
93+
)
94+
if not found:
95+
continue
96+
shas = {s.strip().lower() for s in found}
97+
if len(shas) > 1:
98+
# Encode the internal disagreement so the report flags it.
99+
result[system] = "MULTIPLE:" + "/".join(sorted(shas))
100+
else:
101+
result[system] = found[0].strip().lower()
102+
return result
103+
104+
105+
def parse_soup(path: Path) -> dict:
106+
"""Return {system: sha} from the kcenon-ecosystem table in docs/SOUP.md.
107+
108+
Each row links the system name (e.g. [thread_system](...)) and carries the
109+
pinned SHA in a backtick-quoted cell (e.g. `db8d36f1`). We anchor on the
110+
system name appearing in the same row, then take the first SHA-looking
111+
backtick token on that row."""
112+
result = {}
113+
if not path.is_file():
114+
return result
115+
for line in path.read_text(encoding="utf-8").splitlines():
116+
if "|" not in line:
117+
continue
118+
for system in SYSTEMS:
119+
base = _system_to_soup_base(system)
120+
# Row references the system by name (link text or plain).
121+
if base not in line:
122+
continue
123+
# SHA cells are backtick-wrapped, e.g. `593a18a7`.
124+
m = re.search(r"`([0-9a-fA-F]{7,40})`", line)
125+
if m:
126+
result[system] = m.group(1).strip().lower()
127+
break
128+
return result
129+
130+
131+
def shas_agree(a: str, b: str) -> bool:
132+
"""Two SHAs agree if one is a case-insensitive prefix of the other
133+
(SOUP short SHA vs full SHA). Sentinel MULTIPLE: values never agree."""
134+
if a is None or b is None:
135+
return True # absence is handled separately, not a mismatch
136+
if a.startswith("MULTIPLE:") or b.startswith("MULTIPLE:"):
137+
return False
138+
lo, hi = sorted((a, b), key=len)
139+
return hi.startswith(lo)
140+
141+
142+
def short(sha):
143+
if sha is None:
144+
return "(absent)"
145+
if sha.startswith("MULTIPLE:"):
146+
return sha
147+
return sha[:8]
148+
149+
150+
def resolvability(system: str, sha: str) -> str:
151+
"""Best-effort, NON-FATAL: if a sibling repo exists at ../<system>, try to
152+
resolve the SHA as a commit. Returns 'resolvable', 'UNRESOLVABLE', or ''
153+
(siblings absent -> skip silently)."""
154+
if sha is None or sha.startswith("MULTIPLE:"):
155+
return ""
156+
sibling = REPO_ROOT.parent / system
157+
if not (sibling / ".git").exists() and not sibling.is_dir():
158+
return ""
159+
git_dir_present = (sibling / ".git").exists()
160+
if not git_dir_present:
161+
return ""
162+
try:
163+
rc = subprocess.run(
164+
["git", "-C", str(sibling), "cat-file", "-e", sha + "^{commit}"],
165+
stdout=subprocess.DEVNULL,
166+
stderr=subprocess.DEVNULL,
167+
timeout=15,
168+
).returncode
169+
except (OSError, subprocess.SubprocessError):
170+
return ""
171+
return "resolvable" if rc == 0 else "UNRESOLVABLE"
172+
173+
174+
def main() -> int:
175+
manifest = parse_manifest(MANIFEST_PATH)
176+
action = parse_action(ACTION_PATH)
177+
soup = parse_soup(SOUP_PATH)
178+
179+
sources_present = {
180+
"manifest": MANIFEST_PATH.is_file(),
181+
"action": ACTION_PATH.is_file(),
182+
"soup": SOUP_PATH.is_file(),
183+
}
184+
185+
print("=" * 78)
186+
print("SOUP/SBOM manifest drift gate -- internal kcenon ecosystem SHAs")
187+
print("ADVISORY (non-blocking) until #1189 repairs the manifest.")
188+
print("=" * 78)
189+
print("Sources:")
190+
print(f" manifest : {MANIFEST_PATH.relative_to(REPO_ROOT)} "
191+
f"[{'found' if sources_present['manifest'] else 'MISSING'}]")
192+
print(f" action : {ACTION_PATH.relative_to(REPO_ROOT)} "
193+
f"[{'found' if sources_present['action'] else 'MISSING'}]")
194+
print(f" soup : {SOUP_PATH.relative_to(REPO_ROOT)} "
195+
f"[{'found' if sources_present['soup'] else 'MISSING'}]")
196+
print()
197+
198+
# Per-system table.
199+
header = f"{'system':<20} {'manifest':<10} {'action':<10} {'soup':<10} {'status':<10} resolvability"
200+
print(header)
201+
print("-" * len(header))
202+
203+
mismatches = [] # (system, [(srcname, sha), ...])
204+
any_sibling_checked = False
205+
206+
for system in SYSTEMS:
207+
m = manifest.get(system)
208+
a = action.get(system)
209+
s = soup.get(system)
210+
211+
present = {"manifest": m, "action": a, "soup": s}
212+
non_null = {k: v for k, v in present.items() if v is not None}
213+
214+
# Pairwise agreement across all present sources.
215+
agree = True
216+
keys = list(non_null.keys())
217+
for i in range(len(keys)):
218+
for j in range(i + 1, len(keys)):
219+
if not shas_agree(non_null[keys[i]], non_null[keys[j]]):
220+
agree = False
221+
status = "OK" if agree else "DRIFT"
222+
if not agree:
223+
mismatches.append((system, list(non_null.items())))
224+
225+
# Resolvability: report against the manifest SHA (canonical provenance
226+
# input per SOUP.md); fall back to action SHA if manifest absent.
227+
probe_sha = m if m is not None else a
228+
res = resolvability(system, probe_sha)
229+
if res:
230+
any_sibling_checked = True
231+
232+
print(f"{system:<20} {short(m):<10} {short(a):<10} {short(s):<10} "
233+
f"{status:<10} {res}")
234+
235+
print()
236+
237+
if mismatches:
238+
print("DRIFT REPORT -- tri-source SHA mismatches detected:")
239+
print("-" * 78)
240+
for system, items in mismatches:
241+
print(f" * {system}:")
242+
for srcname, sha in items:
243+
print(f" {srcname:<10} = {sha}")
244+
# Spell out which sources disagree.
245+
disagreeing = []
246+
for i in range(len(items)):
247+
for j in range(i + 1, len(items)):
248+
if not shas_agree(items[i][1], items[j][1]):
249+
disagreeing.append(f"{items[i][0]} != {items[j][0]}")
250+
if disagreeing:
251+
print(f" -> disagreement: {', '.join(disagreeing)}")
252+
print()
253+
254+
# Surface UNRESOLVABLE annotations explicitly (only meaningful if siblings
255+
# were checked). These do NOT change the exit code by themselves, but a
256+
# dangling SHA is almost always also a drift signal worth surfacing.
257+
if any_sibling_checked:
258+
unresolvable = []
259+
for system in SYSTEMS:
260+
probe = manifest.get(system) or action.get(system)
261+
if resolvability(system, probe) == "UNRESOLVABLE":
262+
unresolvable.append((system, probe))
263+
if unresolvable:
264+
print("UNRESOLVABLE SHAs (sibling repos checked locally):")
265+
print("-" * 78)
266+
for system, sha in unresolvable:
267+
print(f" * {system}: {sha} cannot be resolved as a commit "
268+
f"in ../{system}")
269+
print()
270+
else:
271+
print("(sibling repos not available locally -- resolvability skipped)")
272+
print()
273+
274+
if mismatches:
275+
strict = "--strict" in sys.argv
276+
for system, _items in mismatches:
277+
print(f"::warning title=SOUP manifest drift::{system}: internal-dep "
278+
f"SHA disagrees across manifest/action/SOUP (repair tracked in #1189)")
279+
print(f"RESULT: DRIFT -- {len(mismatches)} system(s) disagree across "
280+
f"sources. See report above. (Repair tracked in #1189.)")
281+
if strict:
282+
return 1
283+
print("ADVISORY MODE (default): drift surfaced as warnings, exit 0. "
284+
"Run with --strict and mark this check required after #1189 "
285+
"repairs the manifest.")
286+
return 0
287+
288+
print("RESULT: OK -- all present sources agree on every internal SHA.")
289+
return 0
290+
291+
292+
if __name__ == "__main__":
293+
sys.exit(main())

0 commit comments

Comments
 (0)