Skip to content

Commit 02e68bf

Browse files
abrichrclaude
andauthored
fix: require Flow production release evidence (#366)
* fix: require Flow production release evidence * fix: preserve resized capture coordinate spaces * chore: regenerate the artifact inventory after the rebase The rebase onto main conflicted only on the generated inventory. Resolved by taking main's side and regenerating. One hash moves, for .github/workflows/quickstart-lifecycle.yml, which this branch edits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * build: refresh the lock after the rebase The rebase onto main left uv.lock stale against this branch's pyproject change, so 'uv lock --locked' failed in the lint job. Regenerating removes opencv-python-headless, which is precisely this branch's purpose: exactly one cv2 provider, opencv-python. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent a5a0bbb commit 02e68bf

10 files changed

Lines changed: 789 additions & 110 deletions

File tree

.github/workflows/quickstart-lifecycle.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,4 @@ jobs:
104104
runs/lifecycle/artifacts/**/REPORT.md
105105
runs/lifecycle/artifacts/**/report.json
106106
runs/lifecycle/artifacts/**/patch.json
107-
if-no-files-found: warn
107+
if-no-files-found: error

openadapt_flow/adapters/capture.py

Lines changed: 329 additions & 62 deletions
Large diffs are not rendered by default.

public-artifacts.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@
113113
},
114114
{
115115
"path": ".github/workflows/quickstart-lifecycle.yml",
116-
"sha256": "bb8269715c3023f5f42beb3b53e3744b8c03bcbd43f19e989a5f318063d290d6"
116+
"sha256": "8eae9755b0ba287c708dd078b996f4b139e949ae375cfc5bb7a6e0f441b38ece"
117117
},
118118
{
119119
"path": ".github/workflows/release-health.yml",

pyproject.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@ authors = [{ name = "OpenAdapt.AI" }]
1717
dependencies = [
1818
"pydantic>=2.5",
1919
"numpy>=1.26",
20-
"opencv-python-headless>=4.9",
20+
# rapidocr-onnxruntime requires opencv-python by distribution name. Python
21+
# packaging has no provider/alias mechanism through which the headless
22+
# distribution can satisfy that requirement. Declaring both installs two
23+
# distributions that own the same cv2 package. Use the one provider that
24+
# RapidOCR's published metadata requires, and enforce this in the clean
25+
# wheel lifecycle on every supported OS.
26+
"opencv-python>=4.9",
2127
"pillow>=10.0",
2228
"rapidocr-onnxruntime>=1.3",
2329
# GPU-less runners call the on-prem VLM appliance over HTTP

scripts/check_release_ci.py

Lines changed: 116 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@
2424
"test-matrix (macos-latest, 3.12)",
2525
}
2626
)
27+
EXPECTED_CLEAN_MACHINE_JOBS = frozenset(
28+
{
29+
"lifecycle (ubuntu-latest)",
30+
"lifecycle (macos-latest)",
31+
"lifecycle (windows-latest)",
32+
}
33+
)
2734
PER_PAGE = 100
2835
MAX_PAGES = 100
2936
GITHUB_API_VERSION = "2022-11-28"
@@ -48,6 +55,12 @@ class Qualification:
4855
job_names: frozenset[str]
4956

5057

58+
@dataclass(frozen=True)
59+
class ProductionQualification:
60+
full_matrix: Qualification
61+
clean_machine: Qualification
62+
63+
5164
class GitHubJSONFetcher:
5265
"""Small authenticated GitHub REST reader with fail-closed decoding."""
5366

@@ -207,6 +220,103 @@ def require_exact_full_matrix(
207220
)
208221

209222

223+
def require_exact_clean_machine(
224+
fetch_json: JSONFetcher,
225+
*,
226+
repository: str,
227+
sha: str,
228+
) -> Qualification:
229+
"""Require the three-OS clean-wheel Browser lifecycle on the exact SHA."""
230+
231+
if not _REPOSITORY_RE.fullmatch(repository):
232+
raise QualificationError(f"invalid GitHub repository: {repository!r}")
233+
if not _SHA_RE.fullmatch(sha):
234+
raise QualificationError(f"invalid Git commit SHA: {sha!r}")
235+
236+
runs = _paginate(
237+
fetch_json,
238+
f"/repos/{repository}/actions/workflows/quickstart-lifecycle.yml/runs",
239+
"workflow_runs",
240+
{"head_sha": sha, "event": "workflow_dispatch"},
241+
)
242+
exact_runs = [
243+
run
244+
for run in runs
245+
if run.get("head_sha") == sha and run.get("event") == "workflow_dispatch"
246+
]
247+
if not exact_runs:
248+
raise QualificationPending(
249+
f"no exact-SHA workflow_dispatch clean-machine run exists for {sha}"
250+
)
251+
latest = max(
252+
exact_runs,
253+
key=lambda run: (str(run.get("created_at", "")), int(run.get("id", 0))),
254+
)
255+
run_id = latest.get("id")
256+
if not isinstance(run_id, int) or run_id <= 0:
257+
raise QualificationError("clean-machine qualification run has an invalid id")
258+
status = latest.get("status")
259+
conclusion = latest.get("conclusion")
260+
if status != "completed":
261+
raise QualificationPending(
262+
f"exact-SHA clean-machine run {run_id} is {status!r}"
263+
)
264+
if conclusion != "success":
265+
raise QualificationError(
266+
f"exact-SHA clean-machine run {run_id} concluded {conclusion!r}"
267+
)
268+
269+
jobs = _paginate(
270+
fetch_json,
271+
f"/repos/{repository}/actions/runs/{run_id}/jobs",
272+
"jobs",
273+
{"filter": "latest"},
274+
)
275+
lifecycle_jobs = [
276+
job
277+
for job in jobs
278+
if isinstance(job.get("name"), str)
279+
and str(job["name"]).startswith("lifecycle")
280+
]
281+
counts = Counter(str(job.get("name")) for job in lifecycle_jobs)
282+
expected_counts = Counter({name: 1 for name in EXPECTED_CLEAN_MACHINE_JOBS})
283+
if counts != expected_counts:
284+
raise QualificationError(
285+
"exact-SHA clean-machine job set/count mismatch: "
286+
f"expected={dict(sorted(expected_counts.items()))}, "
287+
f"observed={dict(sorted(counts.items()))}"
288+
)
289+
non_success = {
290+
str(job["name"]): job.get("conclusion")
291+
for job in lifecycle_jobs
292+
if job.get("conclusion") != "success"
293+
}
294+
if non_success:
295+
raise QualificationError(
296+
"exact-SHA clean-machine run has non-success jobs: "
297+
f"{dict(sorted(non_success.items()))}"
298+
)
299+
return Qualification(run_id=run_id, sha=sha, job_names=frozenset(counts))
300+
301+
302+
def require_production_qualification(
303+
fetch_json: JSONFetcher,
304+
*,
305+
repository: str,
306+
sha: str,
307+
) -> ProductionQualification:
308+
"""Require both code-level and clean-wheel product qualification."""
309+
310+
return ProductionQualification(
311+
full_matrix=require_exact_full_matrix(
312+
fetch_json, repository=repository, sha=sha
313+
),
314+
clean_machine=require_exact_clean_machine(
315+
fetch_json, repository=repository, sha=sha
316+
),
317+
)
318+
319+
210320
def _parser() -> argparse.ArgumentParser:
211321
parser = argparse.ArgumentParser(
212322
description="Require an exact-SHA dispatched CI full-matrix qualification."
@@ -232,7 +342,7 @@ def main(argv: list[str] | None = None) -> int:
232342
deadline = time.monotonic() + args.wait_seconds
233343
while True:
234344
try:
235-
qualification = require_exact_full_matrix(
345+
qualification = require_production_qualification(
236346
fetch_json,
237347
repository=args.repository,
238348
sha=args.sha,
@@ -249,8 +359,11 @@ def main(argv: list[str] | None = None) -> int:
249359
return 1
250360
print(
251361
"Release qualification passed: "
252-
f"sha={qualification.sha} run_id={qualification.run_id} "
253-
f"matrix_jobs={len(qualification.job_names)}"
362+
f"sha={qualification.full_matrix.sha} "
363+
f"matrix_run_id={qualification.full_matrix.run_id} "
364+
f"clean_machine_run_id={qualification.clean_machine.run_id} "
365+
f"matrix_jobs={len(qualification.full_matrix.job_names)} "
366+
f"clean_machine_jobs={len(qualification.clean_machine.job_names)}"
254367
)
255368
return 0
256369

scripts/quickstart_lifecycle.py

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,38 @@ def _console_script(root: Path) -> Path:
100100
)
101101

102102

103+
def _inspect_opencv_provider(
104+
python: Path,
105+
*,
106+
cwd: Path,
107+
env: dict[str, str],
108+
log: Path,
109+
) -> str:
110+
"""Require one reviewed distribution to own the installed ``cv2`` package."""
111+
112+
probe = _run(
113+
[
114+
str(python),
115+
"-c",
116+
(
117+
"import importlib.metadata as m, json; "
118+
"names={d.metadata['Name'].lower() for d in m.distributions() "
119+
"if d.metadata.get('Name')}; "
120+
"providers=sorted(names & {'opencv-python','opencv-python-headless',"
121+
"'opencv-contrib-python','opencv-contrib-python-headless'}); "
122+
"import cv2; "
123+
"print(json.dumps({'providers':providers,'cv2_version':cv2.__version__})); "
124+
"assert providers == ['opencv-python'], providers"
125+
),
126+
],
127+
cwd=cwd,
128+
env=env,
129+
log=log,
130+
)
131+
payload = json.loads(probe.stdout.splitlines()[-1])
132+
return str(payload["providers"][0])
133+
134+
103135
def _load_report(path: Path) -> dict:
104136
if not path.is_file():
105137
raise AssertionError(f"missing machine-readable run report: {path}")
@@ -290,14 +322,20 @@ def run_lifecycle(
290322
log=logs / "01-install.log",
291323
)
292324
installed = True
325+
summary["opencv_provider"] = _inspect_opencv_provider(
326+
python,
327+
cwd=artifacts,
328+
env=env,
329+
log=logs / "02-opencv-provider.log",
330+
)
293331
console = _console_script(venv_dir)
294332
if not console.is_file():
295333
raise AssertionError(f"console entry point was not installed: {console}")
296334
_run(
297335
[str(console), "--help"],
298336
cwd=artifacts,
299337
env=env,
300-
log=logs / "02-cli-help.log",
338+
log=logs / "03-cli-help.log",
301339
)
302340

303341
# Linux needs host libraries that the ordinary unprivileged first-run
@@ -312,7 +350,7 @@ def run_lifecycle(
312350
browser_command,
313351
cwd=artifacts,
314352
env=env,
315-
log=logs / "03-browser-install.log",
353+
log=logs / "04-browser-install.log",
316354
)
317355

318356
cli = [str(python), "-m", "openadapt_flow"]
@@ -322,7 +360,7 @@ def run_lifecycle(
322360
[*cli, "demo-record", "--out", str(recording)],
323361
cwd=artifacts,
324362
env=env,
325-
log=logs / "04-record.log",
363+
log=logs / "05-record.log",
326364
)
327365
_run(
328366
[
@@ -336,7 +374,7 @@ def run_lifecycle(
336374
],
337375
cwd=artifacts,
338376
env=env,
339-
log=logs / "05-compile.log",
377+
log=logs / "06-compile.log",
340378
)
341379

342380
# The bundled tutorial is deliberately not production-certified. The
@@ -349,20 +387,20 @@ def run_lifecycle(
349387
[*cli, "lint", str(bundle), "--strict"],
350388
cwd=artifacts,
351389
env=env,
352-
log=logs / "06-strict-lint-expected-refusal.log",
390+
log=logs / "07-strict-lint-expected-refusal.log",
353391
expected=1,
354392
)
355393
_run(
356394
[*cli, "certify", str(bundle), "--policy", "permissive"],
357395
cwd=artifacts,
358396
env=env,
359-
log=logs / "07-certify-permissive.log",
397+
log=logs / "08-certify-permissive.log",
360398
)
361399
_run(
362400
[*cli, "certify", str(bundle), "--policy", "clinical-write"],
363401
cwd=artifacts,
364402
env=env,
365-
log=logs / "08-certify-clinical-expected-refusal.log",
403+
log=logs / "09-certify-clinical-expected-refusal.log",
366404
expected=2,
367405
)
368406
_run(
@@ -375,7 +413,7 @@ def run_lifecycle(
375413
],
376414
cwd=artifacts,
377415
env=env,
378-
log=logs / "09-replay-baseline.log",
416+
log=logs / "10-replay-baseline.log",
379417
)
380418
_run(
381419
[
@@ -391,15 +429,15 @@ def run_lifecycle(
391429
],
392430
cwd=artifacts,
393431
env=env,
394-
log=logs / "10-replay-drift.log",
432+
log=logs / "11-replay-drift.log",
395433
)
396434
# The COMPOSED free path. Every command above passed while this loop
397435
# was broken; only running it end to end catches that.
398436
_run(
399437
[*cli, "tutorial", "--out", str(artifacts / "tutorial")],
400438
cwd=artifacts,
401439
env=env,
402-
log=logs / "11-tutorial-verified.log",
440+
log=logs / "12-tutorial-verified.log",
403441
)
404442
summary.update(_inspect_artifacts(artifacts))
405443
finally:
@@ -408,7 +446,7 @@ def run_lifecycle(
408446
[str(python), "-m", "pip", "uninstall", "-y", "openadapt-flow"],
409447
cwd=artifacts,
410448
env=env,
411-
log=logs / "12-uninstall.log",
449+
log=logs / "13-uninstall.log",
412450
)
413451
probe = _run(
414452
[
@@ -421,7 +459,7 @@ def run_lifecycle(
421459
],
422460
cwd=artifacts,
423461
env=env,
424-
log=logs / "13-uninstall-probe.log",
462+
log=logs / "14-uninstall-probe.log",
425463
)
426464
summary["uninstall_verified"] = probe.returncode == 0
427465
(work_dir / "summary.json").write_text(

0 commit comments

Comments
 (0)