diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f58cae2..6d11b6d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,8 @@ name: CI # too slow/OS-bound for PRs (full Python matrix + macOS + windows-mock). # - schedule: a nightly run of the same heavy suites, so long-lived branches # and environment drift are caught even without a merge. +# - workflow_dispatch: let maintainers validate those post-merge suites on an +# exact repair branch before merging a change to their own CI contract. # There is intentionally NO bare `push:` -- a bare push trigger fires a SECOND # identical run alongside `pull_request` on every PR-branch push (double the # runner spend for zero extra signal). @@ -29,6 +31,7 @@ on: branches: [main] schedule: - cron: "0 7 * * *" # 07:00 UTC nightly full matrix + workflow_dispatch: # Cancel any superseded in-flight run for the same ref (e.g. a force-push or a # rapid second push to a PR branch) instead of letting both run to completion. @@ -244,7 +247,8 @@ jobs: # the resolved playwright version so a dependency bump busts the cache. - name: Resolve Playwright version id: pw - run: echo "version=$(python -c 'import importlib.metadata as m; print(m.version(\"playwright\"))')" >> "$GITHUB_OUTPUT" + run: | + python -c "import importlib.metadata as m; print('version=' + m.version('playwright'))" >> "$GITHUB_OUTPUT" - name: Cache Playwright browsers uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -318,7 +322,8 @@ jobs: - name: Resolve Playwright version id: pw - run: echo "version=$(python -c 'import importlib.metadata as m; print(m.version(\"playwright\"))')" >> "$GITHUB_OUTPUT" + run: | + python -c "import importlib.metadata as m; print('version=' + m.version('playwright'))" >> "$GITHUB_OUTPUT" - name: Cache Playwright browsers uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -346,10 +351,12 @@ jobs: runs/**/*.png if-no-files-found: warn - # --- Post-merge / nightly: full Python matrix + macOS, FULL suite -------- + # --- Post-merge / nightly: full Python matrix + macOS platform suite ------ # NOT a required check and NOT run on PRs (so merges never block on it). Runs # the COMPLETE suite including the slow tests/e2e browser/OCR leg across - # Python 3.10-3.12 on Linux plus a macOS leg, on push to main and nightly. + # Python 3.10-3.12 on Linux. The macOS leg preserves every OS-specific test + # while omitting one platform-neutral harness already counted on Ubuntu 3.12, + # as documented at its exact pytest selection below. # Python 3.13 remains outside the supported boundary until the validated # rapidocr-onnxruntime identity path can be safely migrated and requalified. test-matrix: @@ -378,7 +385,8 @@ jobs: - name: Resolve Playwright version id: pw - run: echo "version=$(python -c 'import importlib.metadata as m; print(m.version(\"playwright\"))')" >> "$GITHUB_OUTPUT" + run: | + python -c "import importlib.metadata as m; print('version=' + m.version('playwright'))" >> "$GITHUB_OUTPUT" - name: Cache Playwright browsers uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -389,11 +397,24 @@ jobs: - name: Install Playwright browser run: playwright install --with-deps chromium - - name: Test (full suite incl. e2e) + # The platform-neutral identity-ladder harness is intentionally counted + # on the canonical Ubuntu matrix (including Python 3.12). Running its + # same browser/OCR corpus again on macOS exceeded the 900-second test + # budget after all other macOS tests passed, without adding OS-specific + # coverage. Keep every other macOS test and deselect only that exact node. + - name: Test (full suite incl. e2e, canonical Ubuntu) + if: runner.os == 'Linux' run: | mkdir -p runs pytest -q --basetemp=runs/ci + - name: Test (full suite incl. e2e, macOS platform coverage) + if: runner.os == 'macOS' + run: | + mkdir -p runs + pytest -q --basetemp=runs/ci \ + --deselect=tests/test_identity_ladder.py::test_harness_zero_false_accept_all_configs + - name: Upload run artifacts if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/quickstart-lifecycle.yml b/.github/workflows/quickstart-lifecycle.yml index 4583c12e..f43946c6 100644 --- a/.github/workflows/quickstart-lifecycle.yml +++ b/.github/workflows/quickstart-lifecycle.yml @@ -15,6 +15,11 @@ permissions: jobs: lifecycle: name: lifecycle (${{ matrix.os }}) + # Windows otherwise inherits the legacy console code page. Keep the + # harness, child CLIs, and uploaded JSON/log evidence on one UTF-8 contract. + env: + PYTHONUTF8: "1" + PYTHONIOENCODING: "utf-8" strategy: fail-fast: false matrix: diff --git a/openadapt_flow/hosted.py b/openadapt_flow/hosted.py index a97f3254..4af5f2c1 100644 --- a/openadapt_flow/hosted.py +++ b/openadapt_flow/hosted.py @@ -1045,7 +1045,7 @@ def report_break( report_path = run_path / "report.json" if not report_path.is_file(): raise HostedError(f"No report.json in {run_path} — nothing to report.") - report = RunReport.model_validate_json(report_path.read_text()) + report = RunReport.model_validate_json(report_path.read_text(encoding="utf-8")) if report.halt is None and report.success: return {"emitted": False, "reason": "run succeeded; no halt to report"} diff --git a/openadapt_flow/ir.py b/openadapt_flow/ir.py index 417b08b7..cf05dbf1 100644 --- a/openadapt_flow/ir.py +++ b/openadapt_flow/ir.py @@ -1592,7 +1592,7 @@ def save(self, run_dir: Path | str) -> Path: run = Path(run_dir) run.mkdir(parents=True, exist_ok=True) path = run / "report.json" - path.write_text(self.model_dump_json(indent=2)) + path.write_text(self.model_dump_json(indent=2), encoding="utf-8") return path diff --git a/openadapt_flow/learning/teach.py b/openadapt_flow/learning/teach.py index f7b8fefd..246f2ca4 100644 --- a/openadapt_flow/learning/teach.py +++ b/openadapt_flow/learning/teach.py @@ -120,7 +120,7 @@ def load_halt_report(run_dir: Path | str) -> RunReport: f"no run report at {report_path} -- teach needs the directory of a " "run that HALTED (holding report.json)" ) - report = RunReport.model_validate_json(report_path.read_text()) + report = RunReport.model_validate_json(report_path.read_text(encoding="utf-8")) if report.halt is None: raise TeachError( f"the run at {run} did not halt (report.json has no halt observation) " diff --git a/openadapt_flow/report.py b/openadapt_flow/report.py index 36142ebd..a47681cf 100644 --- a/openadapt_flow/report.py +++ b/openadapt_flow/report.py @@ -115,7 +115,9 @@ def render_run_report(run_dir: Path | str) -> Path: FileNotFoundError: If ``run_dir/report.json`` does not exist. """ run = Path(run_dir) - report = RunReport.model_validate_json((run / "report.json").read_text()) + report = RunReport.model_validate_json( + (run / "report.json").read_text(encoding="utf-8") + ) _warn_if_plaintext_phi(report) ok_count = sum(1 for r in report.results if r.ok) diff --git a/scripts/quickstart_lifecycle.py b/scripts/quickstart_lifecycle.py index c3eeb676..38cd4847 100644 --- a/scripts/quickstart_lifecycle.py +++ b/scripts/quickstart_lifecycle.py @@ -31,11 +31,18 @@ def _run( """Run a lifecycle command, persist its output, and enforce its exit code.""" printable = subprocess.list2cmdline(list(command)) print(f"\n$ {printable}", flush=True) + child_env = env.copy() + # Windows runners otherwise inherit a legacy console code page (commonly + # cp1252). The CLI deliberately prints status glyphs, and its JSON evidence + # is a UTF-8 artifact contract, so make the subprocess boundary explicit. + child_env["PYTHONUTF8"] = "1" + child_env["PYTHONIOENCODING"] = "utf-8" result = subprocess.run( list(command), cwd=cwd, - env=env, + env=child_env, text=True, + encoding="utf-8", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False, @@ -154,6 +161,8 @@ def run_lifecycle( python = _venv_python(venv_dir) env = os.environ.copy() env.pop("PYTHONPATH", None) + env["PYTHONUTF8"] = "1" + env["PYTHONIOENCODING"] = "utf-8" # MockMed contains synthetic identities. Disable the optional PHI warning so # lifecycle output stays actionable; real regulated runs must use SCRUB=on. env["OPENADAPT_FLOW_SCRUB"] = "off" diff --git a/tests/test_ci_workflow_contract.py b/tests/test_ci_workflow_contract.py new file mode 100644 index 00000000..67d9f671 --- /dev/null +++ b/tests/test_ci_workflow_contract.py @@ -0,0 +1,60 @@ +"""Regression contracts for cross-platform GitHub Actions selection.""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CI = ROOT / ".github/workflows/ci.yml" +QUICKSTART = ROOT / ".github/workflows/quickstart-lifecycle.yml" + + +def test_playwright_version_probes_are_valid_python() -> None: + workflow = CI.read_text(encoding="utf-8") + probe = ( + 'python -c "import importlib.metadata as m; ' + "print('version=' + m.version('playwright'))\" >> \"$GITHUB_OUTPUT\"" + ) + + assert workflow.count("- name: Resolve Playwright version") == 3 + assert workflow.count(probe) == 3 + assert r"m.version(\"playwright\")" not in workflow + + +def test_full_matrix_can_be_dispatched_on_an_exact_branch() -> None: + workflow = CI.read_text(encoding="utf-8") + on_start = workflow.index("on:\n") + jobs_start = workflow.index("\njobs:\n", on_start) + + assert " workflow_dispatch:\n" in workflow[on_start:jobs_start] + + +def test_macos_deselects_only_redundant_heavy_identity_harness() -> None: + workflow = CI.read_text(encoding="utf-8") + node = "tests/test_identity_ladder.py::test_harness_zero_false_accept_all_configs" + linux_start = workflow.index( + "- name: Test (full suite incl. e2e, canonical Ubuntu)" + ) + macos_start = workflow.index( + "- name: Test (full suite incl. e2e, macOS platform coverage)" + ) + upload_start = workflow.index("- name: Upload run artifacts", macos_start) + linux_step = workflow[linux_start:macos_start] + macos_step = workflow[macos_start:upload_start] + + assert "if: runner.os == 'Linux'" in linux_step + assert "--deselect" not in linux_step + assert "pytest -q --basetemp=runs/ci" in linux_step + assert "if: runner.os == 'macOS'" in macos_step + assert macos_step.count(f"--deselect={node}") == 1 + assert workflow.count(f"--deselect={node}") == 1 + + +def test_clean_machine_lifecycle_declares_utf8_on_every_os() -> None: + workflow = QUICKSTART.read_text(encoding="utf-8") + lifecycle_start = workflow.index(" lifecycle:") + strategy_start = workflow.index(" strategy:", lifecycle_start) + lifecycle_header = workflow[lifecycle_start:strategy_start] + + assert 'PYTHONUTF8: "1"' in lifecycle_header + assert 'PYTHONIOENCODING: "utf-8"' in lifecycle_header diff --git a/tests/test_quickstart_lifecycle.py b/tests/test_quickstart_lifecycle.py index b8f4e939..8310720f 100644 --- a/tests/test_quickstart_lifecycle.py +++ b/tests/test_quickstart_lifecycle.py @@ -4,6 +4,7 @@ import importlib.util import json +import subprocess from pathlib import Path import pytest @@ -30,6 +31,30 @@ def test_resolve_wheel_requires_exactly_one_match(tmp_path): lifecycle._resolve_wheel(str(tmp_path / "*.whl")) +def test_run_forces_utf8_for_child_cli_and_log(tmp_path, monkeypatch): + lifecycle = _module() + captured = {} + + def fake_run(command, **kwargs): + captured.update(kwargs) + return subprocess.CompletedProcess(command, 0, stdout="✓ UTF-8\n") + + monkeypatch.setattr(lifecycle.subprocess, "run", fake_run) + log = tmp_path / "child.log" + + lifecycle._run( + ["openadapt-flow", "--help"], + cwd=tmp_path, + env={"PYTHONUTF8": "0", "PYTHONIOENCODING": "cp1252"}, + log=log, + ) + + assert captured["env"]["PYTHONUTF8"] == "1" + assert captured["env"]["PYTHONIOENCODING"] == "utf-8" + assert captured["encoding"] == "utf-8" + assert log.read_bytes().decode("utf-8").endswith("✓ UTF-8\n") + + def test_inspect_artifacts_requires_reports_repairs_and_healed_bundle(tmp_path): lifecycle = _module() artifacts = tmp_path / "artifacts" diff --git a/tests/test_release_contract.py b/tests/test_release_contract.py index 0e6d6bd2..30b8fa61 100644 --- a/tests/test_release_contract.py +++ b/tests/test_release_contract.py @@ -4,7 +4,11 @@ from pathlib import Path import pytest -import tomllib + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - exercised on Python 3.10 CI + import tomli as tomllib from scripts.check_release_consistency import release_versions, sync_lock_version diff --git a/tests/test_report.py b/tests/test_report.py index 217087ac..2999697b 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -136,6 +136,18 @@ def _make_run_dir(tmp_path: Path, *, success: bool) -> Path: # -- render_run_report -------------------------------------------------------- +def test_run_report_json_is_utf8(tmp_path: Path) -> None: + report = RunReport( + workflow_name="Crème brûlée — ✓", + started_at="2026-07-17T12:00:00+00:00", + ) + + path = report.save(tmp_path / "unicode-run") + + payload = path.read_bytes().decode("utf-8") + assert "Crème brûlée — ✓" in payload + + def test_run_report_success(tmp_path: Path) -> None: run_dir = _make_run_dir(tmp_path, success=True) out = render_run_report(run_dir)