diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c6cf47..fff1245 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,11 @@ env: MSVC_LINK_VERSION: "14.44" APPLE_LD_VERSION: "1053.12" LIBTINFO5_VERSION: "6.3-2ubuntu0.3" + # One pinned cargo-nextest for both the archive producer (phase1-cross-build) + # and the replaying runner (phase1-native-run), so they agree on the archive + # format. Kept equal to setup-soldr 0.9.18's pinned cargo-nextest; both jobs + # install it explicitly rather than relying on whatever soldr bundles. + NEXTEST_VERSION: "0.9.140" jobs: hygiene: @@ -137,14 +142,6 @@ jobs: kind: windows-gnu os: windows-2022 target: x86_64-pc-windows-gnu - - name: windows-msvc x86_64 - kind: windows-msvc - os: windows-2022 - target: x86_64-pc-windows-msvc - - name: macos arm64 - kind: macos-arm64 - os: macos-14 - target: aarch64-apple-darwin steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: @@ -166,13 +163,7 @@ jobs: path-type: inherit install: mingw-w64-ucrt-x86_64-clang - - if: matrix.kind == 'windows-msvc' - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1 - with: - toolset: "14.44" - - - if: matrix.kind != 'macos-arm64' - uses: KyleMayes/install-llvm-action@ebc0426251bc40c7cd31162802432c68818ab8f0 # v2 + - uses: KyleMayes/install-llvm-action@ebc0426251bc40c7cd31162802432c68818ab8f0 # v2 with: version: 18.1.8 @@ -293,33 +284,6 @@ jobs: "${RUNNER_TEMP}/smoke-bfd.exe" "${RUNNER_TEMP}/smoke-lld.exe" - - name: Verify and smoke Windows MSVC reference linkers - if: matrix.kind == 'windows-msvc' - shell: bash - run: uv run --no-sync python -m ci.windows_ci verify-msvc-linkers - - - name: Install macOS LLVM reference linker - if: matrix.kind == 'macos-arm64' - shell: bash - run: | - brew install llvm@18 - echo "$(brew --prefix llvm@18)/bin" >> "${GITHUB_PATH}" - - - name: Verify and smoke macOS reference linkers - if: matrix.kind == 'macos-arm64' - shell: bash - run: | - set -euxo pipefail - xcrun ld -v 2>&1 | head -2 | tee versions.txt - "$(brew --prefix llvm@18)/bin/ld64.lld" --version | head -1 | tee -a versions.txt - xcrun ld -v 2>&1 | grep -q "${APPLE_LD_VERSION}" - "$(brew --prefix llvm@18)/bin/ld64.lld" --version | grep -q "${LLD_VERSION}" - printf 'int main(void) { return 0; }\n' > "${RUNNER_TEMP}/smoke.c" - clang "${RUNNER_TEMP}/smoke.c" -o "${RUNNER_TEMP}/smoke-apple-ld" - clang "${RUNNER_TEMP}/smoke.c" -fuse-ld="$(brew --prefix llvm@18)/bin/ld64.lld" -o "${RUNNER_TEMP}/smoke-lld" - "${RUNNER_TEMP}/smoke-apple-ld" - "${RUNNER_TEMP}/smoke-lld" - - name: Linux build and native tests if: matrix.kind == 'linux-gnu' shell: bash @@ -412,6 +376,247 @@ jobs: $CARGO_COMMAND test --target "${{ matrix.target }}" -p reld --test acceptance-policy 2>&1 | tee -a platform-tests.log $CARGO_COMMAND test --target "${{ matrix.target }}" -p reld --test acceptance -- --list 2>&1 | tee -a platform-tests.log + - name: Record an earlier phase failure for the summary + if: failure() + shell: bash + run: printf 'PHASE1_UPSTREAM_FAILED=true\n' >> "${GITHUB_ENV}" + + - name: Publish Linux counts and skips + if: always() && matrix.kind == 'linux-gnu' + shell: bash + run: >- + uv run --no-sync python ci/phase1_summary.py --job linux-gnu + --log platform-tests.log --log acceptance-tests.log --log difftest.log + --log external-tests.log --versions versions.txt --minimum-run 507 + --exact-log-total difftest.log=100 --exact-log-total external-tests.log=407 + --upstream-failed "$PHASE1_UPSTREAM_FAILED" + + - name: Publish Windows GNU counts and skips + if: always() && matrix.kind == 'windows-gnu' + shell: bash + run: >- + uv run --no-sync python ci/phase1_summary.py --job windows-gnu --log platform-tests.log --versions versions.txt + --upstream-failed "$PHASE1_UPSTREAM_FAILED" + + # Goal (reld#130): the Windows MSVC and macos-arm64 phase-1 legs stop + # compiling on their scarce native runners. Compile the phase-1 test suite + # once here, on cheap Linux runners, through the blessed soldr cross front + # door (mirrors cross-ship.yml's cross-build job), and ship a cargo-nextest + # archive per target. The native runner (phase1-native-run) only downloads + # the archive and replays it -- no product compilation on-target. + # + # windows-gnu is intentionally NOT cross-built here: it isn't a soldr-blessed + # cross target (soldr blesses MSVC on Windows, not the MinGW toolchain used by + # windows-gnu), so it stays compiling on its own Windows host in + # phase1-native, unchanged. + phase1-cross-build: + name: Phase 1 cross build (linux -> ${{ matrix.name }}) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - name: windows-msvc + target: x86_64-pc-windows-msvc + artifact: phase1-tests-windows-msvc + format: PE32 + bins: reld.exe reld-link.exe + - name: macos-arm64 + target: aarch64-apple-darwin + artifact: phase1-tests-macos-arm64 + format: Mach-O + bins: reld + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + submodules: recursive + + # Provision soldr + the target toolchain from the catalogue. `cross-targets` + # runs `soldr prepare` for the triple and exports the compiler/linker/SDK/ + # sysroot/env plan -- no manual CC_/CXX_/AR_ wiring and no apt/choco/brew + # toolchain installs (mirrors cross-ship.yml's cross-build job). + - name: Setup Soldr (prepare cross toolchain) + id: setup + uses: zackees/setup-soldr@main + with: + # 0.9.18 ships the win-gnu prepare fix (soldr#3259); pinned to match + # cross-ship.yml. cargo-nextest is installed at NEXTEST_VERSION in the + # next step so the archive this job produces is replayable by the + # same pinned cargo-nextest that phase1-native-run installs. + version: 0.9.18 + cross-targets: ${{ matrix.target }} + linker: fast + cache: true + cache-preset: full + prebuild-deps: soldr-cook + + - name: Install pinned cargo-nextest + shell: bash + run: | + set -euxo pipefail + nextest_bin="${CARGO_HOME:-$HOME/.cargo}/bin" + mkdir -p "${nextest_bin}" + curl -fsSL --retry 3 "https://get.nexte.st/${NEXTEST_VERSION}/linux" | tar zxf - -C "${nextest_bin}" + cargo nextest --version | grep -q "${NEXTEST_VERSION}" + + - name: Build phase-1 nextest archive via soldr + shell: bash + run: | + set -euxo pipefail + mkdir -p package + soldr cargo nextest archive --locked --workspace \ + --target "${{ matrix.target }}" \ + --archive-file package/phase1-tests.tar.zst \ + --archive-format tar-zst + for bin in ${{ matrix.bins }}; do + cp "target/${{ matrix.target }}/debug/${bin}" "package/${bin}" + done + ls -la package + + - name: Verify cross artifact format + shell: bash + run: | + set -euxo pipefail + test -s package/phase1-tests.tar.zst + for bin in ${{ matrix.bins }}; do + file "package/${bin}" | grep -q '${{ matrix.format }}' + done + + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: package/ + if-no-files-found: error + retention-days: 3 + compression-level: 0 + + # Native runner: download the phase-1 nextest archive that phase1-cross-build + # compiled on Linux and replay it. No product compilation happens here for + # windows-msvc/macos-arm64 -- they only download and run (reld#130). + phase1-native-run: + name: Phase 1 / ${{ matrix.name }} + needs: phase1-cross-build + runs-on: ${{ matrix.os }} + timeout-minutes: 90 + env: + PHASE1_UPSTREAM_FAILED: "false" + PHASE1_NEXTEST_ARCHIVE: ${{ github.workspace }}/phase1-package/phase1-tests.tar.zst + strategy: + fail-fast: false + matrix: + include: + - name: windows-msvc x86_64 + kind: windows-msvc + os: windows-2022 + target: x86_64-pc-windows-msvc + artifact: phase1-tests-windows-msvc + - name: macos arm64 + kind: macos-arm64 + os: macos-14 + target: aarch64-apple-darwin + artifact: phase1-tests-macos-arm64 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + submodules: recursive + + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + version: "0.12.3" + + - name: Sync Python tooling + shell: bash + run: uv sync --extra dev + + - if: matrix.kind == 'windows-msvc' + uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1 + with: + toolset: "14.44" + + - if: matrix.kind == 'windows-msvc' + uses: KyleMayes/install-llvm-action@ebc0426251bc40c7cd31162802432c68818ab8f0 # v2 + with: + version: 18.1.8 + + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + with: + targets: ${{ matrix.target }} + + - name: Install host test tooling + uses: taiki-e/install-action@8261f1f64137bdbc4bc1035170d52408e108cbd4 # taplo-cli + with: + tool: taplo-cli@0.10.0 + + - name: Verify and smoke Windows MSVC reference linkers + if: matrix.kind == 'windows-msvc' + shell: bash + run: uv run --no-sync python -m ci.windows_ci verify-msvc-linkers + + - name: Install macOS LLVM reference linker + if: matrix.kind == 'macos-arm64' + shell: bash + run: | + brew install llvm@18 + echo "$(brew --prefix llvm@18)/bin" >> "${GITHUB_PATH}" + + - name: Verify and smoke macOS reference linkers + if: matrix.kind == 'macos-arm64' + shell: bash + run: | + set -euxo pipefail + xcrun ld -v 2>&1 | head -2 | tee versions.txt + "$(brew --prefix llvm@18)/bin/ld64.lld" --version | head -1 | tee -a versions.txt + xcrun ld -v 2>&1 | grep -q "${APPLE_LD_VERSION}" + "$(brew --prefix llvm@18)/bin/ld64.lld" --version | grep -q "${LLD_VERSION}" + printf 'int main(void) { return 0; }\n' > "${RUNNER_TEMP}/smoke.c" + clang "${RUNNER_TEMP}/smoke.c" -o "${RUNNER_TEMP}/smoke-apple-ld" + clang "${RUNNER_TEMP}/smoke.c" -fuse-ld="$(brew --prefix llvm@18)/bin/ld64.lld" -o "${RUNNER_TEMP}/smoke-lld" + "${RUNNER_TEMP}/smoke-apple-ld" + "${RUNNER_TEMP}/smoke-lld" + + - uses: actions/download-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: phase1-package + + - name: Stage cross-built reld binaries + shell: bash + run: | + set -euxo pipefail + mkdir -p target/debug + case "${{ matrix.kind }}" in + windows-msvc) + cp phase1-package/reld.exe target/debug/ + cp phase1-package/reld-link.exe target/debug/ + ;; + macos-arm64) + cp phase1-package/reld target/debug/reld + chmod +x target/debug/reld + ;; + esac + + - name: Install pinned cargo-nextest + shell: bash + run: | + set -euxo pipefail + nextest_bin="${CARGO_HOME:-$HOME/.cargo}/bin" + case "${{ matrix.kind }}" in + windows-msvc) + # CARGO_HOME is a native Windows path (C:\Users\...\.cargo) on the + # Windows runner; Git Bash's GNU tar reads "C:" as a remote host + # spec, so convert it to a POSIX path before handing it to tar -C. + nextest_bin="$(cygpath -u "${nextest_bin}")" + nextest_url="https://get.nexte.st/${NEXTEST_VERSION}/windows-tar" + ;; + macos-arm64) + nextest_url="https://get.nexte.st/${NEXTEST_VERSION}/mac" + ;; + esac + mkdir -p "${nextest_bin}" + curl -fsSL --retry 3 "${nextest_url}" | tar zxf - -C "${nextest_bin}" + cargo nextest --version | grep -q "${NEXTEST_VERSION}" + - name: Windows MSVC native tests if: matrix.kind == 'windows-msvc' shell: bash @@ -430,14 +635,14 @@ jobs: - name: macOS native tests if: matrix.kind == 'macos-arm64' shell: bash + env: + PHASE1_NATIVE_FILTER: "package(reld-layout-schema) | package(reld-diff) | package(reld-testkit) | (package(reld-core) & kind(lib)) | (package(reld) & (kind(bin) | binary(acceptance-policy)))" run: | set -euxo pipefail - $CARGO_COMMAND build --workspace --all-targets - $CARGO_COMMAND test -p reld-layout-schema -p reld-diff -p reld-testkit --all-targets 2>&1 | tee platform-tests.log - $CARGO_COMMAND test -p reld-core --lib 2>&1 | tee -a platform-tests.log - $CARGO_COMMAND test -p reld --bins 2>&1 | tee -a platform-tests.log - $CARGO_COMMAND test -p reld --test acceptance-policy 2>&1 | tee -a platform-tests.log - $CARGO_COMMAND test -p reld --test acceptance -- --list 2>&1 | tee -a platform-tests.log + cargo nextest run --archive-file "$PHASE1_NEXTEST_ARCHIVE" --workspace-remap "$GITHUB_WORKSPACE" \ + --no-fail-fast --color never -E "$PHASE1_NATIVE_FILTER" 2>&1 | tee platform-tests.log + cargo nextest list --archive-file "$PHASE1_NEXTEST_ARCHIVE" --workspace-remap "$GITHUB_WORKSPACE" \ + --color never -E 'package(reld) & binary(acceptance)' 2>&1 | tee -a platform-tests.log - name: macOS reld bridge e2e (sqlite) if: matrix.kind == 'macos-arm64' @@ -483,23 +688,6 @@ jobs: shell: bash run: printf 'PHASE1_UPSTREAM_FAILED=true\n' >> "${GITHUB_ENV}" - - name: Publish Linux counts and skips - if: always() && matrix.kind == 'linux-gnu' - shell: bash - run: >- - uv run --no-sync python ci/phase1_summary.py --job linux-gnu - --log platform-tests.log --log acceptance-tests.log --log difftest.log - --log external-tests.log --versions versions.txt --minimum-run 507 - --exact-log-total difftest.log=100 --exact-log-total external-tests.log=407 - --upstream-failed "$PHASE1_UPSTREAM_FAILED" - - - name: Publish Windows GNU counts and skips - if: always() && matrix.kind == 'windows-gnu' - shell: bash - run: >- - uv run --no-sync python ci/phase1_summary.py --job windows-gnu --log platform-tests.log --versions versions.txt - --upstream-failed "$PHASE1_UPSTREAM_FAILED" - - name: Publish Windows MSVC counts and skips if: always() && matrix.kind == 'windows-msvc' shell: bash diff --git a/ci/phase1_summary.py b/ci/phase1_summary.py index bad891b..5fde3c5 100644 --- a/ci/phase1_summary.py +++ b/ci/phase1_summary.py @@ -1,4 +1,8 @@ -"""Publish deterministic Phase-1 test counts and reference-linker versions.""" +"""Publish deterministic Phase-1 test counts and reference-linker versions. + +Counts both libtest `test result: ok. N passed; ...` summaries and +`cargo nextest run` `Summary [...] N tests run: ...` summaries. +""" from __future__ import annotations @@ -14,6 +18,9 @@ r"(?P\d+) failed; (?P\d+) ignored" ) DIFFTEST_RESULT = re.compile(r"(?P\d+) seeds, 0 differential failures") +NEXTEST_RESULT = re.compile( + r"Summary \[\s*[\d.]+s\]\s+(?P\d+)(?:/\d+)? tests? run:(?P[^\n]*)" +) ANSI_CSI = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") @@ -25,6 +32,10 @@ def counts(text: str) -> tuple[int, int]: skipped += int(match.group("skip")) for match in DIFFTEST_RESULT.finditer(text): run += int(match.group("run")) + for match in NEXTEST_RESULT.finditer(text): + run += int(match.group("run")) + skip_match = re.search(r"(?P\d+) skipped", match.group("rest")) + skipped += int(skip_match.group("skip")) if skip_match else 0 return run, skipped diff --git a/ci/tests/test_ci_workflow.py b/ci/tests/test_ci_workflow.py index 0af6fc2..c2a0cf4 100644 --- a/ci/tests/test_ci_workflow.py +++ b/ci/tests/test_ci_workflow.py @@ -1,5 +1,8 @@ +import re from pathlib import Path +from ci import windows_ci + WORKFLOW = Path(__file__).parents[2] / ".github" / "workflows" / "ci.yml" REPO_ROOT = WORKFLOW.parents[2] @@ -85,14 +88,68 @@ def test_ci_uses_bash_to_invoke_python_for_every_windows_msvc_script(): def test_ci_provisions_uv_and_has_no_bare_python_script_invocations(): text = WORKFLOW.read_text() - assert text.count("astral-sh/setup-uv@") == 2 - assert text.count("uv sync --extra dev") == 2 + # phase1-native, phase1-native-run, and python each provision their own uv. + assert text.count("astral-sh/setup-uv@") == 3 + assert text.count("uv sync --extra dev") == 3 assert "uv run --no-project" not in text for line in text.splitlines(): stripped = line.strip() assert not stripped.startswith(("python ", "python3 ")) +def _job_blocks(text: str) -> dict[str, str]: + """Split ci.yml's `jobs:` section into name -> block-text, keyed by job id.""" + + lines = text.splitlines() + job_header = re.compile(r"^ ([a-z0-9-]+):$") + starts: list[tuple[str, int]] = [] + for index, line in enumerate(lines): + match = job_header.match(line) + if match: + starts.append((match.group(1), index)) + + blocks: dict[str, str] = {} + for position, (name, start) in enumerate(starts): + end = starts[position + 1][1] if position + 1 < len(starts) else len(lines) + blocks[name] = "\n".join(lines[start:end]) + return blocks + + +def test_phase1_msvc_and_macos_compile_on_linux_and_only_replay_on_target(): + text = WORKFLOW.read_text() + blocks = _job_blocks(text) + + cross_build = blocks["phase1-cross-build"] + assert "runs-on: ubuntu-24.04" in cross_build + assert "zackees/setup-soldr@main" in cross_build + assert "version: 0.9.18" in cross_build + assert "soldr cargo nextest archive" in cross_build + assert "x86_64-pc-windows-msvc" in cross_build + assert "aarch64-apple-darwin" in cross_build + assert "x86_64-pc-windows-gnu" not in cross_build + + native_run = blocks["phase1-native-run"] + assert "needs: phase1-cross-build" in native_run + assert "name: Phase 1 / ${{ matrix.name }}" in native_run + assert "actions/download-artifact@v4" in native_run + assert "cargo nextest run --archive-file" in native_run + assert "--workspace-remap" in native_run + assert "ci.windows_ci native-tests" in native_run + assert "cargo build --workspace" not in native_run + assert "$CARGO_COMMAND build --workspace" not in native_run + assert "$CARGO_COMMAND test" not in native_run + + native = blocks["phase1-native"] + assert "windows-gnu x86_64" in native + assert "linux-gnu x86_64" in native + assert "$CARGO_COMMAND build --workspace --all-targets --target" in native + assert "windows-msvc" not in native + assert "macos-14" not in native + + assert 'NEXTEST_VERSION: "0.9.140"' in text + assert f'PHASE1_NATIVE_FILTER: "{windows_ci.PHASE1_NATIVE_FILTER}"' in text + + def test_phase1_summary_only_relaxes_missing_log_validation_after_failure(): text = WORKFLOW.read_text() diff --git a/ci/tests/test_phase1_summary.py b/ci/tests/test_phase1_summary.py index 2f6ab2b..dc820d9 100644 --- a/ci/tests/test_phase1_summary.py +++ b/ci/tests/test_phase1_summary.py @@ -29,6 +29,62 @@ def test_counts_ansi_colored_results() -> None: assert counts(text) == (413, 3) +def test_counts_nextest_passing_summary() -> None: + text = "Summary [ 1.234s] 507 tests run: 507 passed, 12 skipped\n" + assert counts(text) == (507, 12) + + +def test_counts_nextest_failing_summary_includes_failed() -> None: + text = "Summary [ 12.000s] 410 tests run: 400 passed, 10 failed, 3 skipped\n" + assert counts(text) == (410, 3) + + +def test_counts_nextest_singular_test() -> None: + text = "Summary [ 0.5s] 1 test run: 1 passed, 0 skipped\n" + assert counts(text) == (1, 0) + + +def test_counts_nextest_cancelled_run_uses_completed_count() -> None: + text = "Summary [ 3.1s] 7/10 tests run: 6 passed, 1 failed, 2 skipped\n" + assert counts(text) == (7, 2) + + +def test_counts_nextest_ansi_colored_summary() -> None: + text = "Summary [\x1b[32m 1.234s\x1b[0m] \x1b[1m507\x1b[0m tests run: 507 passed, 12 skipped\n" + assert counts(text) == (507, 12) + + +def test_counts_mixes_libtest_and_nextest_logs() -> None: + text = """ +test result: ok. 7 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out +Summary [ 1.234s] 507 tests run: 507 passed, 12 skipped +""" + assert counts(text) == (514, 14) + + +def test_successful_path_accepts_nextest_summary_log(tmp_path: Path) -> None: + versions = tmp_path / "versions.txt" + versions.write_text("lld 18\n") + log = tmp_path / "platform-tests.log" + log.write_text("Summary [ 1.234s] 507 tests run: 507 passed, 12 skipped\n") + + assert ( + main( + [ + "--job", + "windows-msvc", + "--log", + str(log), + "--versions", + str(versions), + "--minimum-run", + "507", + ] + ) + == 0 + ) + + def test_successful_path_requires_each_expected_log(tmp_path: Path) -> None: versions = tmp_path / "versions.txt" versions.write_text("lld 18\n") diff --git a/ci/tests/test_windows_ci.py b/ci/tests/test_windows_ci.py index a501bc4..9e7c8b0 100644 --- a/ci/tests/test_windows_ci.py +++ b/ci/tests/test_windows_ci.py @@ -5,6 +5,10 @@ from ci import windows_ci from ci.windows_ci import ( + PHASE1_ACCEPTANCE_LIST_FILTER, + PHASE1_ARCHIVE_ENV, + PHASE1_NATIVE_FILTER, + WindowsCiError, _msvc_linker, _msvc_path_env, ) @@ -49,3 +53,89 @@ def run(args, **_kwargs): ["build", "-p", "reld", "--bin", "reld"], [str(tmp_path / "target" / "debug" / "reld.exe"), "--version"], ] + + +def test_native_tests_replays_the_cross_built_archive_without_compiling( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + archive = tmp_path / "phase1-tests.tar.zst" + archive.write_bytes(b"archive") + + monkeypatch.setattr(windows_ci, "_msvc_path_env", lambda: {}) + monkeypatch.setattr(windows_ci, "_workspace", lambda: tmp_path) + monkeypatch.setattr(windows_ci, "_cargo", lambda *args: list(args)) + monkeypatch.setenv(PHASE1_ARCHIVE_ENV, str(archive)) + monkeypatch.chdir(tmp_path) + + commands: list[list[str]] = [] + + def run_logged(command, _log, **_kwargs): + commands.append(list(command)) + return "" + + monkeypatch.setattr(windows_ci, "_run_logged", run_logged) + + windows_ci.native_tests() + + assert len(commands) == 2 + run_command, list_command = commands + + assert run_command[:6] == [ + "nextest", + "run", + "--archive-file", + str(archive.resolve()), + "--workspace-remap", + str(tmp_path), + ] + assert "-E" in run_command + assert run_command[run_command.index("-E") + 1] == PHASE1_NATIVE_FILTER + + assert list_command[:6] == [ + "nextest", + "list", + "--archive-file", + str(archive.resolve()), + "--workspace-remap", + str(tmp_path), + ] + assert "-E" in list_command + assert list_command[list_command.index("-E") + 1] == PHASE1_ACCEPTANCE_LIST_FILTER + + assert not any("build" in command for command in commands) + + +def test_native_tests_requires_the_phase1_archive_env( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(windows_ci, "_msvc_path_env", lambda: {}) + monkeypatch.setattr(windows_ci, "_workspace", lambda: tmp_path) + monkeypatch.setattr(windows_ci, "_cargo", lambda *args: list(args)) + monkeypatch.delenv(PHASE1_ARCHIVE_ENV, raising=False) + monkeypatch.chdir(tmp_path) + + with pytest.raises(WindowsCiError): + windows_ci.native_tests() + + +def test_echoing_non_ascii_child_output_survives_a_cp1252_console(monkeypatch, tmp_path): + # The windows-msvc leg died here (reld#130): nextest printed characters cp1252 cannot encode, + # and echoing them to a cp1252 stdout raised after the child had already succeeded. + import io + import sys + + raw = io.BytesIO() + console = io.TextIOWrapper(raw, encoding="cp1252", errors="strict") + monkeypatch.setattr(sys, "stdout", console) + monkeypatch.setattr(sys, "stderr", io.TextIOWrapper(io.BytesIO(), encoding="cp1252")) + + windows_ci._utf8_console() + log = tmp_path / "out.log" + windows_ci._run_logged( + [sys.executable, "-c", "import sys; sys.stdout.buffer.write('PASS \\u2714 ok\\n'.encode())"], + log, + ) + console.flush() + + assert "✔" in log.read_text(encoding="utf-8") + assert "✔".encode("utf-8") in raw.getvalue() diff --git a/ci/windows_ci.py b/ci/windows_ci.py index 7161891..835933f 100644 --- a/ci/windows_ci.py +++ b/ci/windows_ci.py @@ -15,6 +15,18 @@ WINDOWS_TARGET = "x86_64-pc-windows-msvc" +# nextest filterset reproducing the tests the native legs ran under `cargo test` before reld#130: +# `-p reld-layout-schema -p reld-diff -p reld-testkit --all-targets`, `-p reld-core --lib`, +# `-p reld --bins`, and `-p reld --test acceptance-policy`. +PHASE1_NATIVE_FILTER = ( + "package(reld-layout-schema) | package(reld-diff) | package(reld-testkit)" + " | (package(reld-core) & kind(lib))" + " | (package(reld) & (kind(bin) | binary(acceptance-policy)))" +) +# The acceptance corpus is only listed on the native legs (was `--test acceptance -- --list`). +PHASE1_ACCEPTANCE_LIST_FILTER = "package(reld) & binary(acceptance)" +PHASE1_ARCHIVE_ENV = "PHASE1_NEXTEST_ARCHIVE" + class WindowsCiError(RuntimeError): """A Windows CI contract was not satisfied.""" @@ -104,6 +116,13 @@ def _require_file(path: Path, description: str) -> Path: return path +def _nextest_archive_args() -> list[str]: + archive = _require_file( + Path(_required_env(PHASE1_ARCHIVE_ENV)).resolve(), "phase-1 nextest archive" + ) + return ["--archive-file", str(archive), "--workspace-remap", str(_workspace())] + + def _capture_allow_failure(args: Sequence[str]) -> str: completed = subprocess.run( list(args), @@ -176,23 +195,34 @@ def install_benchmark_linkers() -> None: def native_tests() -> None: + """Replay the phase-1 nextest archive that the phase1-cross-build job compiled on Linux. + + Windows no longer compiles anything here: it downloads ``phase1-tests.tar.zst`` and reruns + the archived binaries in-place so the native leg only exercises the platform, not the build. + """ + env = _msvc_path_env() - _run(_cargo("build", "--workspace", "--all-targets"), env=env) + archive_args = _nextest_archive_args() commands = [ _cargo( - "test", - "-p", - "reld-layout-schema", - "-p", - "reld-diff", - "-p", - "reld-testkit", - "--all-targets", + "nextest", + "run", + *archive_args, + "--no-fail-fast", + "--color", + "never", + "-E", + PHASE1_NATIVE_FILTER, + ), + _cargo( + "nextest", + "list", + *archive_args, + "--color", + "never", + "-E", + PHASE1_ACCEPTANCE_LIST_FILTER, ), - _cargo("test", "-p", "reld-core", "--lib"), - _cargo("test", "-p", "reld", "--bins"), - _cargo("test", "-p", "reld", "--test", "acceptance-policy"), - _cargo("test", "-p", "reld", "--test", "acceptance", "--", "--list"), ] log = Path("platform-tests.log") for index, command in enumerate(commands): @@ -239,7 +269,22 @@ def self_host() -> None: } +def _utf8_console() -> None: + """Make stdout and stderr UTF-8 with replacement, whatever the console's code page. + + A Windows runner's Python defaults stdout to cp1252. `_run_logged` echoes child output line by + line, and nextest prints characters cp1252 cannot encode, so the echo itself raised + `UnicodeEncodeError` and killed the job mid-run (reld#130) — after the child had succeeded. + The child pipe was already decoded leniently; this makes the other end of the echo match. + """ + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is not None: + reconfigure(encoding="utf-8", errors="replace") + + def main(argv: list[str] | None = None) -> int: + _utf8_console() parser = argparse.ArgumentParser(prog="ci.windows_ci") parser.add_argument("command", choices=COMMANDS) args = parser.parse_args(argv) diff --git a/crates/reld-core/src/tidy_tests.rs b/crates/reld-core/src/tidy_tests.rs index 2dc16bb..d850559 100644 --- a/crates/reld-core/src/tidy_tests.rs +++ b/crates/reld-core/src/tidy_tests.rs @@ -7,6 +7,16 @@ use crate::error::Result; use std::fs::read_dir; use std::path::Path; +/// Crate root at run time. Archived test binaries (nextest `--workspace-remap`) run on a +/// different checkout path than they were built on, so prefer the runtime value that cargo and +/// nextest both set, and fall back to the compile-time path. +fn manifest_dir() -> std::path::PathBuf { + std::env::var_os("CARGO_MANIFEST_DIR") + .map(std::path::PathBuf::from) + .filter(|dir| dir.is_dir()) + .unwrap_or_else(|| std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))) +} + #[test] fn check_sources_format() -> Result { use std::process::Command; @@ -39,7 +49,8 @@ fn check_sources_format() -> Result { } let extensions = ["c", "cc", "h"]; - let sources_path = Path::new(env!("CARGO_MANIFEST_DIR")) + let manifest = manifest_dir(); + let sources_path = manifest .parent() .unwrap() .join("reld") @@ -101,7 +112,8 @@ fn check_toml_format() -> Result { return Ok(()); } - let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); + let manifest = manifest_dir(); + let root = manifest.parent().unwrap(); let taplo_out = Command::new("taplo") .arg("format") @@ -200,7 +212,8 @@ fn check_text_files() -> Result { Ok(()) } - let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); + let manifest = manifest_dir(); + let root = manifest.parent().unwrap(); let mut problems = Vec::new(); verify_path(root, &mut problems)?; @@ -215,7 +228,7 @@ fn check_text_files() -> Result { /// Checks that we don't put ELF-specific code in files where it shouldn't be. #[test] fn check_elf_specific_code() -> Result { - let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let src_dir = manifest_dir().join("src"); // Files where we don't allow ELF-specific code. const DISALLOWED: &[&str] = &[ diff --git a/crates/reld/tests/acceptance.rs b/crates/reld/tests/acceptance.rs index 31b0f4d..0323213 100644 --- a/crates/reld/tests/acceptance.rs +++ b/crates/reld/tests/acceptance.rs @@ -638,8 +638,17 @@ fn expand_test_arg_placeholders(arg: &str, config: &Config) -> String { ) } +/// Crate root at run time. Archived test binaries (nextest `--workspace-remap`) run on a +/// different checkout path than they were built on, so prefer the runtime value that cargo and +/// nextest both set, and fall back to the compile-time path. fn base_dir() -> &'static Path { - Path::new(env!("CARGO_MANIFEST_DIR")) + static VALUE: OnceLock = OnceLock::new(); + VALUE.get_or_init(|| { + std::env::var_os("CARGO_MANIFEST_DIR") + .map(PathBuf::from) + .filter(|dir| dir.is_dir()) + .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR"))) + }) } fn build_dir() -> PathBuf {