Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 27 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/quickstart-lifecycle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion openadapt_flow/hosted.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
2 changes: 1 addition & 1 deletion openadapt_flow/ir.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
2 changes: 1 addition & 1 deletion openadapt_flow/learning/teach.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) "
Expand Down
4 changes: 3 additions & 1 deletion openadapt_flow/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 10 additions & 1 deletion scripts/quickstart_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down
60 changes: 60 additions & 0 deletions tests/test_ci_workflow_contract.py
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions tests/test_quickstart_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import importlib.util
import json
import subprocess
from pathlib import Path

import pytest
Expand All @@ -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"
Expand Down
6 changes: 5 additions & 1 deletion tests/test_release_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions tests/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading