diff --git a/docs/CHANNEL_ADAPTERS.md b/docs/CHANNEL_ADAPTERS.md index b91d3da9d..3dcb69d62 100644 --- a/docs/CHANNEL_ADAPTERS.md +++ b/docs/CHANNEL_ADAPTERS.md @@ -135,6 +135,9 @@ unknown keys fail closed and name the config path plus Remote sentinel checks require both exit status zero and the expected success token. Missing tokens fail the adapter even when the subprocess exits zero. +macOS build-host make failures preserve stderr and stdout separately under +labeled stderr: and stdout: sections. + Retrieved build artifacts and proof files are checked for regular-file presence, non-empty bytes, and the digest/byte count reported by the remote harness before the response is written. Missing, malformed, duplicate, unexpected, or invalid diff --git a/scripts/channel_adapters/build_host_macos.py b/scripts/channel_adapters/build_host_macos.py index 6e8b0f3db..8e1010f34 100644 --- a/scripts/channel_adapters/build_host_macos.py +++ b/scripts/channel_adapters/build_host_macos.py @@ -60,6 +60,17 @@ def _failure_detail(failures: object) -> str: return "\n".join(lines) +def _stream_detail(stderr: str, stdout: str) -> str: + sections: list[str] = [] + normalized_stderr = stderr.strip() + normalized_stdout = stdout.strip() + if normalized_stderr: + sections.append(f"stderr:\n{normalized_stderr}") + if normalized_stdout: + sections.append(f"stdout:\n{normalized_stdout}") + return "\n".join(sections) + + def _parse_observed_tool_lines(stdout: str) -> dict[str, str]: observed: dict[str, str] = {} for line in stdout.splitlines(): @@ -247,7 +258,7 @@ echo CHECKOUT_OK if unlock.returncode != 0: die( "make unlock-signing failed on macOS build host", - detail=(unlock.stderr or unlock.stdout or ""), + detail=_stream_detail(unlock.stderr, unlock.stdout), ) build = ssh_run( @@ -264,7 +275,7 @@ echo CHECKOUT_OK if build.returncode != 0: die( "make wheel-macos failed on macOS build host", - detail=(build.stderr or build.stdout or ""), + detail=_stream_detail(build.stderr, build.stdout), ) expected_files = [ diff --git a/tests/test_channel_adapters.py b/tests/test_channel_adapters.py index 9cf532b04..fc99ffa60 100644 --- a/tests/test_channel_adapters.py +++ b/tests/test_channel_adapters.py @@ -156,6 +156,22 @@ def _artifact_listing_stdout(artifact_bytes: dict[str, bytes]) -> str: return "\n".join(lines) + "\n" +MACOS_MAKE_FAILURE_STDERR = "session wrapper attached" +MACOS_MAKE_FAILURE_STDOUT = "make target failed" +MACOS_MAKE_FAILURE_DETAIL = ( + f"stderr:\n{MACOS_MAKE_FAILURE_STDERR}\nstdout:\n{MACOS_MAKE_FAILURE_STDOUT}" +) + + +def _macos_make_failure_stderr(headline: str) -> str: + return f"adapter error: {headline}\n{MACOS_MAKE_FAILURE_DETAIL}\n" + + +def _scp_retrieval_argvs(argvs: list[list[str]]) -> list[list[str]]: + """scp invocations whose source is remote, not the bundle upload.""" + return [argv for argv in argvs if argv[0] == "scp" and ":" in argv[-2]] + + def _write_build_request(tmp_path: Path) -> tuple[Path, build_rail.SourceBundle, dict]: bundle = tmp_path / "source.bundle" bundle.write_bytes(b"bundle") @@ -416,6 +432,111 @@ def test_build_request_response_round_trip_through_rail_parser( ) +def test_build_host_macos_unlock_failure_preserves_labeled_streams( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + request_path, _source_bundle, _payload = _write_build_request(tmp_path) + scripts: list[str] = [] + argvs: list[list[str]] = [] + + def fake_runner(argv, **kwargs): + argvs.append(list(argv)) + script = kwargs.get("input_text") or "" + scripts.append(script) + if "emit python" in script: + return _completed(_tool_stdout()) + if "git checkout" in script: + return _completed(f"{build_host_macos.CHECKOUT_TOKEN}\n") + if "make unlock-signing" in script: + return _completed( + MACOS_MAKE_FAILURE_STDOUT, + stderr=MACOS_MAKE_FAILURE_STDERR, + returncode=2, + ) + return _completed() + + monkeypatch.setattr(common, "run", fake_runner) + monkeypatch.chdir(request_path.parent) + + with pytest.raises(SystemExit) as excinfo: + build_host_macos.build_macos(_lane(), request_path) + + stderr = capsys.readouterr().err + assert excinfo.value.code != 0 + assert not (request_path.parent / "response.json").exists() + assert stderr == _macos_make_failure_stderr( + "make unlock-signing failed on macOS build host" + ) + assert stderr.count(MACOS_MAKE_FAILURE_STDERR) == 1 + assert stderr.count(MACOS_MAKE_FAILURE_STDOUT) == 1 + assert not any("make wheel-macos" in script for script in scripts) + assert not any("for f in" in script for script in scripts) + assert _scp_retrieval_argvs(argvs) == [] + + +def test_build_host_macos_wheel_failure_preserves_labeled_streams( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + request_path, _source_bundle, _payload = _write_build_request(tmp_path) + scripts: list[str] = [] + argvs: list[list[str]] = [] + + def fake_runner(argv, **kwargs): + argvs.append(list(argv)) + script = kwargs.get("input_text") or "" + scripts.append(script) + if "emit python" in script: + return _completed(_tool_stdout()) + if "git checkout" in script: + return _completed(f"{build_host_macos.CHECKOUT_TOKEN}\n") + if "make wheel-macos" in script: + return _completed( + MACOS_MAKE_FAILURE_STDOUT, + stderr=MACOS_MAKE_FAILURE_STDERR, + returncode=2, + ) + return _completed() + + monkeypatch.setattr(common, "run", fake_runner) + monkeypatch.chdir(request_path.parent) + + with pytest.raises(SystemExit) as excinfo: + build_host_macos.build_macos(_lane(), request_path) + + stderr = capsys.readouterr().err + assert excinfo.value.code != 0 + assert not (request_path.parent / "response.json").exists() + assert stderr == _macos_make_failure_stderr( + "make wheel-macos failed on macOS build host" + ) + assert stderr.count(MACOS_MAKE_FAILURE_STDERR) == 1 + assert stderr.count(MACOS_MAKE_FAILURE_STDOUT) == 1 + assert not any("for f in" in script for script in scripts) + assert _scp_retrieval_argvs(argvs) == [] + + +def test_stream_detail_omits_blank_stderr() -> None: + assert ( + build_host_macos._stream_detail("", "compile step reported failure\n") + == "stdout:\ncompile step reported failure" + ) + + +def test_stream_detail_omits_whitespace_stdout() -> None: + assert ( + build_host_macos._stream_detail("build step reported failure\n", " \n\t") + == "stderr:\nbuild step reported failure" + ) + + +def test_stream_detail_returns_empty_for_blank_streams() -> None: + assert build_host_macos._stream_detail("\n", " \n\t") == "" + + def test_build_retrieved_artifact_digest_mismatch_writes_no_response( tmp_path: Path, monkeypatch: pytest.MonkeyPatch,