From 59244b92f592a603a565140ef401ed364f351b8c Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Fri, 11 Sep 2026 01:52:02 +0200 Subject: [PATCH] Require native example links with a matching Windows runtime profile --- .github/workflows/test.yml | 41 ++++++ README.md | 24 +++- docs/evidence/windows-example-gate-v1.md | 53 ++++++++ docs/windows-engine-plan.md | 18 +-- scripts/ci-check.sh | 43 ++++--- tools/check-ci-contract.js | 7 +- tools/ci/compile_examples.py | 106 ++++++++++++---- tools/ci/setup_windows_perry.py | 153 +++++++++++++++++++++++ tools/ci/test_compile_examples.py | 83 ++++++++++++ 9 files changed, 480 insertions(+), 48 deletions(-) create mode 100644 docs/evidence/windows-example-gate-v1.md create mode 100644 tools/ci/setup_windows_perry.py create mode 100644 tools/ci/test_compile_examples.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 651e05e7..94a0453e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -257,6 +257,47 @@ jobs: path: target/ci/full-host-build.json if-no-files-found: error + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Cache matching Perry source build + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/bloom-perry + key: windows-perry-${{ hashFiles('tools/ci/setup_windows_perry.py') }} + + - name: Prepare pinned Windows example toolchain + shell: pwsh + env: + CARGO_BUILD_JOBS: '2' + run: python tools/ci/setup_windows_perry.py --out "$env:RUNNER_TEMP/bloom-perry" --github-env + + - name: full / example-compile + env: + CARGO_BUILD_JOBS: '2' + run: ./scripts/ci-check.sh --full --component example-compile + + - name: Retain executed example-check summary + if: always() + uses: actions/upload-artifact@v4 + with: + name: windows-example-summary + path: target/ci/full-example-compile.json + if-no-files-found: error + + - name: Retain native example build evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: windows-example-builds + path: | + target/ci/examples/result.json + target/ci/examples/logs/ + ${{ runner.temp }}/bloom-perry/toolchain.json + ${{ runner.temp }}/bloom-perry/runtime-build.log + if-no-files-found: error + - name: Upload failure evidence if: failure() uses: ./.github/actions/upload-ci-failure diff --git a/README.md b/README.md index 93c36922..63f1c860 100644 --- a/README.md +++ b/README.md @@ -179,14 +179,34 @@ dependencies listed in `.github/workflows/test.yml` (CMake and a C++ compiler everywhere, X11/audio development packages on Linux, and the MSVC developer environment on Windows). -Run the platform-independent PR gates while iterating: +Example checks require Perry on `PATH`. Windows CI pins Perry 0.5.1220 and its +matching source because the release's prebuilt standard library has unresolved +HTTP extension symbols. Prepare that same toolchain from PowerShell: + +```powershell +python tools/ci/setup_windows_perry.py --out "$env:LOCALAPPDATA/Bloom/perry-ci-0.5.1220" +$env:PATH = "$env:LOCALAPPDATA/Bloom/perry-ci-0.5.1220/bin;$env:PATH" +$env:PERRY_WORKSPACE_ROOT = "$env:LOCALAPPDATA/Bloom/perry-ci-0.5.1220/source" +$env:PERRY_RUNTIME_DIR = "$env:LOCALAPPDATA/Bloom/perry-ci-0.5.1220/runtime-build/release" +$env:PERRY_NO_AUTO_OPTIMIZE = '1' +``` + +Setup builds matching runtime libraries once, with the feature set used by the +examples and the same unwind profile as Bloom's Rust library. Reusing that +profile avoids Windows linker collisions with per-app panic-abort libraries. +Other hosts also need a compiler supporting `bloomViewGetNativeHandle` for the +embedded-view example; the complete Windows matrix uses the pinned toolchain +above. Native compilation does not establish browser or rendered-frame startup. + +Run the quick gates while iterating, including a native compile and link of Pong: ```bash ./scripts/ci-check.sh --quick ``` Before handing off a change, run the complete suite for the current host, -including its native crate and the packaged WebAssembly build: +including its native crate, all 20 canonical example links, and the packaged +WebAssembly build: ```bash ./scripts/ci-check.sh --full diff --git a/docs/evidence/windows-example-gate-v1.md b/docs/evidence/windows-example-gate-v1.md new file mode 100644 index 00000000..36c364c5 --- /dev/null +++ b/docs/evidence/windows-example-gate-v1.md @@ -0,0 +1,53 @@ +# Native example build gate + +All 20 canonical examples compile and link on Windows with the same Perry +0.5.1220 compiler and matching source-built runtime profile. This includes +`perry-embed`, whose native-view API is absent from the earlier 0.5.1182 release. +The gate retains a fresh executable's byte count and SHA-256, compiler command, +environment, and logs for every example. This establishes native linking; +startup and rendered-frame acceptance remain separate. + +## Toolchain findings + +The 0.5.1219 and 0.5.1220 Windows bundles have unresolved HTTP-extension symbols +in their prebuilt full standard library. Rebuilding the exact 0.5.1220 source +at `06137858dc8c6f80975238377138f2f948d6ef88` resolves the embedded-view link. +Perry's automatic per-app profile then exposes five different link failures: +Dungeon Crawl, Isometric RPG, Pong, Space Blaster, and Test Scene Watch fail +with relocations into discarded Rust COMDAT sections when linking Bloom's +unwind library with panic-abort runtime archives. All other 15 examples link. + +Using one unwind profile with the union of required runtime features resolves +those failures. The profile enables runtime `full` and `regex-engine`, and +stdlib `async-runtime` and `crypto`. The setup tool verifies the official ZIP +SHA-256, exact source commit, unchanged source, and Cargo lockfile; builds the +libraries with `--locked`; and records their hashes and Rust compiler identity. +It uses Perry's supported runtime-directory and auto-optimization overrides. +The compiler and its source are unmodified. + +The initial all-example invocation with this profile passes 20/20 native links +in 157.672 seconds on this host. Build time is an observation, not a performance +budget. Earlier failed profiles and their logs are retained. + +## Required checks + +The quick lane links Pong. The full and hardware lanes link every canonical +example. Windows PR CI runs the same full-lane component after building the +engine, with a pinned source/toolchain cache and required execution summary. +The compiler gate rejects zero exit status without a new native binary, so an +old output or object-only compilation cannot count as success. Failures and +timeouts do not hide later example results. + +Three orchestration regressions, the full quality-contract component, and +repository contracts pass locally. Hosted example execution is pending. + +The preceding [Windows CI correction](windows-example-ci-v1.md) now proves an +actual hosted native build at `636b69a`. Its shared suite exposes two DX12 GPU +failures and a process crash. Both focused GPU failures also reproduce on the +Radeon and the Microsoft software adapter with explicit DX12 selection; Vulkan +passes. Those failures remain required and are under separate investigation. +No broader CI, startup, packaging, or hardware issue is closed by this report. + +Commands, failed profiles, successful executable hashes, setup receipts, and +logs are retained under +`tools/quality/out/windows-engine-plan/all-examples/`. diff --git a/docs/windows-engine-plan.md b/docs/windows-engine-plan.md index 89951dfc..816e24e1 100644 --- a/docs/windows-engine-plan.md +++ b/docs/windows-engine-plan.md @@ -54,16 +54,18 @@ audit are saved in `tools/quality/out/windows-engine-plan/plan-requirements.json ## Current next steps -1. Verify actual hosted Windows test and build execution. The +1. Complete hosted Windows test and build execution. The [CI and example correction](evidence/windows-example-ci-v1.md) explicitly - selects Bash and requires execution-summary artifacts. Its hosted run must - show Cargo output and successful summary contents before this gap is closed. + selects Bash, restores the MSVC linker ahead of Git's tools, and requires + execution-summary artifacts. The hosted native build passes at `636b69a`. + The shared suite exposes two DX12 GPU failures and a process crash; both + focused GPU failures also reproduce locally under DX12 and remain open. 2. Finish all-example native linking, real starter/example startup, and clean - Windows installation. Six palette corrections bring the local link audit - from 13 to 19 successful examples out of 20. The embedded-view example needs - a newer Perry API, and both available newer Windows bundles have an - independent standard-library link failure that still needs resolution. - All-example compilation must become a required PR check. + Windows installation. The [native example gate](evidence/windows-example-gate-v1.md) + passes all 20 links locally using Perry 0.5.1220 and one matching source-built + runtime profile. It adds required full-lane Windows PR compilation and + rejects missing or stale executable outputs. Hosted example validation, + actual startup, and clean package installation remain required. 3. Complete the wider temporal/geometry, performance, memory, resize, and capability corpus. The [HD surface correction](evidence/windows-ssgi-surface-v1.md) and two valid diff --git a/scripts/ci-check.sh b/scripts/ci-check.sh index 65cc3870..852869c8 100755 --- a/scripts/ci-check.sh +++ b/scripts/ci-check.sh @@ -77,8 +77,8 @@ done lane_components() { case "$1" in - quick) printf '%s\n' "contracts lint shared-tests wasm-check quality-contract example-inventory" ;; - full) printf '%s\n' "contracts lint shared-tests wasm-check quality-contract example-inventory host-build wasm-build" ;; + quick) printf '%s\n' "contracts lint shared-tests wasm-check quality-contract example-inventory example-compile" ;; + full) printf '%s\n' "contracts lint shared-tests wasm-check quality-contract example-inventory host-build example-compile wasm-build" ;; web) printf '%s\n' "wasm-check wasm-build browser-smoke" ;; cross) printf '%s\n' "target-check" ;; hardware) printf '%s\n' "example-compile quality-check quality-faults quality-run fractional-native-throughput virtual-geometry-stress" ;; @@ -113,6 +113,12 @@ case "$host_os" in *) host_crate="" ;; esac +python_cmd="python3" +if [ "$host_crate" = "windows" ]; then + # Windows installers expose python.exe; python3 may be a Store alias. + python_cmd="python" +fi + ALLOWED_COMPONENTS="$(lane_components "$LANE")" if [ -n "$COMPONENT" ]; then case " $ALLOWED_COMPONENTS " in @@ -260,7 +266,7 @@ run_component() { ;; quality-contract) hr "quality orchestration syntax and governance tests" - python3 -m py_compile \ + "$python_cmd" -m py_compile \ tools/quality/run.py \ tools/quality/build_example.py \ tools/quality/khronos_materials.py \ @@ -272,8 +278,11 @@ run_component() { tools/quality/vsm_motion_corpus.py \ tools/quality/prepare_bistro.py \ tools/ci/web_smoke.py \ - tools/ci/test_web_smoke.py - python3 -m unittest \ + tools/ci/test_web_smoke.py \ + tools/ci/compile_examples.py \ + tools/ci/setup_windows_perry.py \ + tools/ci/test_compile_examples.py + "$python_cmd" -m unittest \ tools/quality/test_run.py \ tools/quality/test_khronos_materials.py \ tools/quality/test_shadow_detail.py \ @@ -283,6 +292,7 @@ run_component() { tools/quality/test_vsm_debug_views.py \ tools/quality/test_vsm_motion_corpus.py \ tools/ci/test_web_smoke.py \ + tools/ci/test_compile_examples.py \ -v hr "visual metric and fault-engine tests" cargo test --release --manifest-path tools/bloom-diff/Cargo.toml @@ -297,7 +307,7 @@ run_component() { ;; example-inventory) hr "canonical TypeScript example inventory" - python3 tools/ci/compile_examples.py --check + "$python_cmd" tools/ci/compile_examples.py --check ;; host-build) if [ -z "$host_crate" ]; then @@ -317,7 +327,7 @@ run_component() { ;; browser-smoke) hr "Bloom WebGPU real-browser known-frame smoke" - python3 tools/ci/web_smoke.py + "$python_cmd" tools/ci/web_smoke.py ;; target-check) cross_crate="${BLOOM_CROSS_CRATE:-}" @@ -382,16 +392,21 @@ run_component() { ( cd "native/$cross_crate" && cargo "${cargo_args[@]}" ) ;; example-compile) - hr "compile every canonical TypeScript example" - python3 tools/ci/compile_examples.py + if [ "$LANE" = "quick" ]; then + hr "compile and link the representative Pong example" + "$python_cmd" tools/ci/compile_examples.py --example examples/pong + else + hr "compile and link every canonical TypeScript example" + "$python_cmd" tools/ci/compile_examples.py + fi ;; quality-check) hr "validate quality manifest, assets, and approved baselines" - python3 tools/quality/run.py check + "$python_cmd" tools/quality/run.py check ;; quality-faults) hr "prove seeded quality regressions are detected" - python3 tools/quality/run.py faults \ + "$python_cmd" tools/quality/run.py faults \ --out "${BLOOM_QUALITY_FAULTS_OUT:-tools/quality/out/ci-faults}" \ --timeout "${BLOOM_QUALITY_TIMEOUT:-900}" ;; @@ -404,14 +419,14 @@ run_component() { quality_out="${BLOOM_QUALITY_OUT:-tools/quality/out/ci-hardware}" hr "run '$quality_suite' quality suite on $BLOOM_QUALITY_MACHINE_CLASS" if [ -n "${BLOOM_QUALITY_CASE:-}" ]; then - python3 tools/quality/run.py run "$quality_suite" \ + "$python_cmd" tools/quality/run.py run "$quality_suite" \ --case "$BLOOM_QUALITY_CASE" \ --machine-class "$BLOOM_QUALITY_MACHINE_CLASS" \ --out "$quality_out" \ --host-idle-timeout "${BLOOM_QUALITY_HOST_IDLE_TIMEOUT:-120}" \ --timeout "${BLOOM_QUALITY_TIMEOUT:-1800}" else - python3 tools/quality/run.py run "$quality_suite" \ + "$python_cmd" tools/quality/run.py run "$quality_suite" \ --machine-class "$BLOOM_QUALITY_MACHINE_CLASS" \ --out "$quality_out" \ --host-idle-timeout "${BLOOM_QUALITY_HOST_IDLE_TIMEOUT:-120}" \ @@ -444,7 +459,7 @@ run_component() { vg_stress_out="${BLOOM_VIRTUAL_STRESS_OUT:-tools/quality/out/ci-virtual-geometry}" vg_stress_work="${BLOOM_VIRTUAL_STRESS_WORK:-${RUNNER_TEMP:-/tmp}/bloom-virtual-geometry-stress}" hr "run 10M virtual-geometry stress on $BLOOM_VIRTUAL_STRESS_BACKEND" - python3 tools/quality/virtual_geometry_stress.py \ + "$python_cmd" tools/quality/virtual_geometry_stress.py \ --platform "$BLOOM_VIRTUAL_STRESS_PLATFORM" \ --backend "$BLOOM_VIRTUAL_STRESS_BACKEND" \ --work "$vg_stress_work" \ diff --git a/tools/check-ci-contract.js b/tools/check-ci-contract.js index 335739bf..83ca8737 100755 --- a/tools/check-ci-contract.js +++ b/tools/check-ci-contract.js @@ -10,8 +10,8 @@ const read = (relative) => fs.readFileSync(path.join(root, relative), "utf8").replace(/\r\n/g, "\n"); const expectedLanes = new Map([ - ["quick", ["contracts", "lint", "shared-tests", "wasm-check", "quality-contract", "example-inventory"]], - ["full", ["contracts", "lint", "shared-tests", "wasm-check", "quality-contract", "example-inventory", "host-build", "wasm-build"]], + ["quick", ["contracts", "lint", "shared-tests", "wasm-check", "quality-contract", "example-inventory", "example-compile"]], + ["full", ["contracts", "lint", "shared-tests", "wasm-check", "quality-contract", "example-inventory", "host-build", "example-compile", "wasm-build"]], ["web", ["wasm-check", "wasm-build", "browser-smoke"]], ["cross", ["target-check"]], ["hardware", ["example-compile", "quality-check", "quality-faults", "quality-run", "fractional-native-throughput", "virtual-geometry-stress"]], @@ -50,7 +50,7 @@ if (!/^defaults:\n run:\n(?: #[^\n]*\n)* shell: bash$/m.test(testWorkflow console.error("FAIL Tests must execute the shared Bash entry point with an explicit Bash shell on Windows"); failures += 1; } -for (const summary of ["target/ci/quick-shared-tests.json", "target/ci/full-host-build.json"]) { +for (const summary of ["target/ci/quick-shared-tests.json", "target/ci/full-host-build.json", "target/ci/full-example-compile.json"]) { if (!testWorkflow.includes(`path: ${summary}\n if-no-files-found: error`)) { console.error(`FAIL Tests must reject missing execution evidence: ${summary}`); failures += 1; @@ -61,6 +61,7 @@ const workflowCommands = [ "./scripts/ci-check.sh --quick --component contracts", "./scripts/ci-check.sh --quick --component lint", "./scripts/ci-check.sh --full --component host-build", + "./scripts/ci-check.sh --full --component example-compile", "./scripts/ci-check.sh --web --component wasm-check", "./scripts/ci-check.sh --web --component wasm-build", "./scripts/ci-check.sh --web --component browser-smoke", diff --git a/tools/ci/compile_examples.py b/tools/ci/compile_examples.py index 6d1d7d37..0e97f9eb 100644 --- a/tools/ci/compile_examples.py +++ b/tools/ci/compile_examples.py @@ -4,19 +4,21 @@ from __future__ import annotations import argparse +import hashlib import json import os import shutil import subprocess import sys import time +import uuid from pathlib import Path from typing import Any REPO_ROOT = Path(__file__).resolve().parents[2] MANIFEST_PATH = Path(__file__).with_name("examples.json") -REPORT_SCHEMA = "bloom-example-compile-v1" +REPORT_SCHEMA = "bloom-example-compile-v2" def load_inventory() -> tuple[list[str], list[str]]: @@ -87,9 +89,22 @@ def write_report(path: Path, report: dict[str, Any]) -> None: ) +def is_native_binary(path: Path) -> bool: + if not path.is_file() or path.stat().st_size < 32: + return False + with path.open("rb") as binary: + magic = binary.read(4) + return magic[:2] == b"MZ" or magic in { + b"\x7fELF", b"\xcf\xfa\xed\xfe", b"\xfe\xed\xfa\xcf", + b"\xca\xfe\xba\xbe", b"\xca\xfe\xba\xbf", + } + + def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--check", action="store_true", help="validate inventory only") + parser.add_argument("--example", action="append", help="select an inventory entry; default: all") + parser.add_argument("--timeout", type=int, default=1800, help="seconds per example, including native dependencies") parser.add_argument( "--out", default=str(REPO_ROOT / "target" / "ci" / "examples"), @@ -105,6 +120,14 @@ def main() -> int: print("PASS: canonical example inventory is complete") return 0 + if args.timeout <= 0: + parser.error("--timeout must be positive") + if args.example: + unknown = sorted(set(args.example) - set(examples)) + if unknown: + parser.error(f"examples are not in the canonical inventory: {unknown}") + examples = [name for name in examples if name in args.example] + perry = shutil.which("perry") if perry is None: print("FAIL perry is required to compile canonical examples", file=sys.stderr) @@ -117,55 +140,96 @@ def main() -> int: log_dir.mkdir(parents=True, exist_ok=True) records: list[dict[str, Any]] = [] started = time.perf_counter() + report = { + "schema": REPORT_SCHEMA, + "status": "running", + "mode": "native-compile-link", + "selected_examples": examples, + "examples": records, + "compiler": perry, + "environment": { + name: os.environ[name] + for name in ("PERRY_WORKSPACE_ROOT", "PERRY_RUNTIME_DIR", "PERRY_NO_AUTO_OPTIMIZE") + if name in os.environ + }, + } + if (REPO_ROOT / ".git").exists(): + report["source_commit"] = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=REPO_ROOT, check=True, + stdout=subprocess.PIPE, encoding="utf-8", + ).stdout.strip() + write_report(out_dir / "result.json", report) for relative in examples: directory = REPO_ROOT / relative name = directory.name - output = bin_dir / name + output = bin_dir / (name + (".exe" if os.name == "nt" else "")) + # A successful command must produce a new executable. A previous run's + # output must never turn a compiler that emitted nothing into a pass. + fresh_output = output.with_name(f"{name}-{uuid.uuid4().hex}{output.suffix}") print(f"[example] {relative}", flush=True) - ensure_engine_dependency(directory) case_started = time.perf_counter() - result = subprocess.run( - [perry, "compile", "main.ts", "-o", str(output), "--no-link"], - cwd=directory, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=False, - ) - (log_dir / f"{name}.stdout.log").write_text(result.stdout, encoding="utf-8") - (log_dir / f"{name}.stderr.log").write_text(result.stderr, encoding="utf-8") + command = [perry, "compile", "main.ts", "-o", str(fresh_output)] + error = None + exit_code = None + with (log_dir / f"{name}.stdout.log").open("w", encoding="utf-8") as stdout, \ + (log_dir / f"{name}.stderr.log").open("w", encoding="utf-8") as stderr: + try: + ensure_engine_dependency(directory) + result = subprocess.run( + command, cwd=directory, stdout=stdout, stderr=stderr, + check=False, timeout=args.timeout, + ) + exit_code = result.returncode + if exit_code != 0: + error = f"compiler exited with code {exit_code}" + elif not is_native_binary(fresh_output): + error = "compiler did not produce a new native executable" + else: + fresh_output.replace(output) + except (OSError, RuntimeError, subprocess.SubprocessError) as exc: + error = str(exc) + stderr.write(f"\n{error}\n") record = { "example": relative, - "status": "pass" if result.returncode == 0 else "fail", - "mode": "codegen-no-link", - "exit_code": result.returncode, + "status": "pass" if error is None else "fail", + "mode": "native-compile-link", + "command": command, + "exit_code": exit_code, + "error": error, "duration_ms": round((time.perf_counter() - case_started) * 1000, 3), "stdout": f"logs/{name}.stdout.log", "stderr": f"logs/{name}.stderr.log", } + if error is None: + with output.open("rb") as binary: + digest = hashlib.file_digest(binary, "sha256").hexdigest() + record.update(artifact=f"bin/{output.name}", bytes=output.stat().st_size, sha256=digest) records.append(record) + write_report(out_dir / "result.json", report) print(f"[example] {relative}: {record['status']}", flush=True) + if error: + print(f" {error}; see {log_dir / (name + '.stderr.log')}", file=sys.stderr) failed = [record["example"] for record in records if record["status"] != "pass"] - report = { - "schema": REPORT_SCHEMA, + report.update({ "status": "fail" if failed else "pass", "duration_ms": round((time.perf_counter() - started) * 1000, 3), "perry": subprocess.run( [perry, "--version"], text=True, + encoding="utf-8", + errors="replace", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False, ).stdout.strip(), - "examples": records, "failures": failed, - } + }) write_report(out_dir / "result.json", report) if failed: print(f"FAIL: {len(failed)} canonical example(s) failed: {failed}", file=sys.stderr) return 1 - print(f"PASS: all {len(records)} canonical examples compiled") + print(f"PASS: all {len(records)} selected canonical examples compiled and linked") return 0 diff --git a/tools/ci/setup_windows_perry.py b/tools/ci/setup_windows_perry.py new file mode 100644 index 00000000..039ac1e2 --- /dev/null +++ b/tools/ci/setup_windows_perry.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Prepare the pinned Windows example compiler and its matching source libraries.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import platform +import shutil +import subprocess +import urllib.request +import zipfile +from pathlib import Path + + +VERSION = "0.5.1220" +SOURCE_SHA = "06137858dc8c6f80975238377138f2f948d6ef88" +ARCHIVE_SHA256 = "f3f817c806ae296d7e7a2e58dcc9e335711e1715360df9df68ce12e43e011117" +REPOSITORY = "https://github.com/PerryTS/perry.git" +ARCHIVE_NAME = "perry-windows-x86_64.zip" +ARCHIVE_URL = f"https://github.com/PerryTS/perry/releases/download/v{VERSION}/{ARCHIVE_NAME}" +RUNTIME_FEATURES = "perry-runtime/full,perry-runtime/regex-engine,perry-stdlib/async-runtime,perry-stdlib/crypto" + + +def run(*args: str, cwd: Path | None = None) -> str: + result = subprocess.run( + args, cwd=cwd, check=True, stdout=subprocess.PIPE, + encoding="utf-8", errors="replace", timeout=600, + ) + return result.stdout.strip() + + +def sha256(path: Path) -> str: + with path.open("rb") as stream: + return hashlib.file_digest(stream, "sha256").hexdigest() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", required=True, help="toolchain directory outside the engine checkout") + parser.add_argument("--archive", help="reuse a downloaded release ZIP; its hash is still verified") + parser.add_argument("--github-env", action="store_true", help="export paths for subsequent Actions steps") + args = parser.parse_args() + if os.name != "nt" or platform.machine().lower() not in {"amd64", "x86_64"}: + parser.error("this toolchain is for Windows x86_64") + out = Path(args.out).resolve() + engine = Path(__file__).resolve().parents[2] + if out == engine or engine in out.parents: + parser.error("--out must be outside the engine checkout (the compiler source has its own checks)") + out.mkdir(parents=True, exist_ok=True) + archive = Path(args.archive).resolve() if args.archive else out / ARCHIVE_NAME + if not archive.is_file(): + if args.archive: + parser.error(f"archive does not exist: {archive}") + print(f"Downloading Perry {VERSION}", flush=True) + partial = out / (ARCHIVE_NAME + ".partial") + request = urllib.request.Request(ARCHIVE_URL, headers={"User-Agent": "Bloom-CI"}) + with urllib.request.urlopen(request, timeout=120) as response, partial.open("wb") as target: + shutil.copyfileobj(response, target) + if sha256(partial) != ARCHIVE_SHA256: + raise RuntimeError("Perry release archive SHA-256 does not match the pinned release") + partial.replace(archive) + if sha256(archive) != ARCHIVE_SHA256: + raise RuntimeError("Perry release archive SHA-256 does not match the pinned release") + + binary_dir = out / "bin" + binary_dir.mkdir(exist_ok=True) + with zipfile.ZipFile(archive) as package: + for member in package.infolist(): + destination = (binary_dir / member.filename).resolve() + if binary_dir != destination and binary_dir not in destination.parents: + raise RuntimeError(f"archive entry escapes toolchain directory: {member.filename}") + package.extractall(binary_dir) + compiler = binary_dir / "perry.exe" + actual_version = run(str(compiler), "--version") + if actual_version != f"perry {VERSION}": + raise RuntimeError(f"unexpected compiler version: {actual_version}") + + # The release's full stdlib references unbundled HTTP extension symbols. + # Build the union of runtime features used by the canonical examples, with + # the same unwind profile as Bloom. Per-app panic=abort archives can collide + # with Bloom's Rust COMDAT sections on Windows. Keep release source and + # Cargo.lock unmodified; no compiler patch or synthetic FFI stub. + source = out / "source" + if not source.exists(): + print(f"Checking out Perry source {SOURCE_SHA}", flush=True) + run("git", "clone", "--depth", "1", "--branch", f"v{VERSION}", + "--filter=blob:none", "--sparse", REPOSITORY, str(source)) + if run("git", "rev-parse", "HEAD", cwd=source) != SOURCE_SHA: + raise RuntimeError(f"Perry source at {source} is not the pinned release; use a fresh --out directory") + if run("git", "status", "--porcelain", "--untracked-files=no", cwd=source): + raise RuntimeError(f"Perry source at {source} has tracked modifications") + run("git", "sparse-checkout", "set", "crates", ".cargo", + "docs/examples/_fixtures/native-libraries/my-bindings", cwd=source) + + build_dir = out / "runtime-build" + build_env = os.environ.copy() + build_env["CARGO_TARGET_DIR"] = str(build_dir) + build_env["RUSTFLAGS"] = "-C panic=unwind" + build_env.pop("CARGO_ENCODED_RUSTFLAGS", None) + build_command = [ + "cargo", "build", "--locked", "--release", "-p", "perry-runtime-static", + "-p", "perry-stdlib-static", "--no-default-features", "--features", RUNTIME_FEATURES, + ] + print("Building matching Perry libraries for the native example profile", flush=True) + with (out / "runtime-build.log").open("w", encoding="utf-8") as log: + result = subprocess.run( + build_command, cwd=source, env=build_env, stdout=log, + stderr=subprocess.STDOUT, timeout=1800, check=False, + ) + if result.returncode != 0: + raise RuntimeError(f"Perry library build failed; see {out / 'runtime-build.log'}") + if run("git", "status", "--porcelain", "--untracked-files=no", cwd=source): + raise RuntimeError("Perry library build modified its pinned source") + library_dir = build_dir / "release" + libraries = { + name: sha256(library_dir / name) + for name in ("perry_runtime.lib", "perry_stdlib.lib") + } + environment = { + "PERRY_WORKSPACE_ROOT": str(source), + "PERRY_RUNTIME_DIR": str(library_dir), + "PERRY_NO_AUTO_OPTIMIZE": "1", + } + + receipt = { + "schema": "bloom-windows-perry-toolchain-v1", "version": VERSION, + "archive_url": ARCHIVE_URL, "archive_sha256": ARCHIVE_SHA256, + "source_repository": REPOSITORY, "source_sha": SOURCE_SHA, + "source_lock_sha256": sha256(source / "Cargo.lock"), + "compiler": str(compiler), "compiler_sha256": sha256(compiler), + "environment": environment, + "runtime_features": RUNTIME_FEATURES, + "runtime_build_command": build_command, + "runtime_rustflags": build_env["RUSTFLAGS"], + "runtime_libraries_sha256": libraries, + "rustc": run("rustc", "-Vv"), + } + (out / "toolchain.json").write_text(json.dumps(receipt, indent=2) + "\n", encoding="utf-8") + if args.github_env: + with Path(os.environ["GITHUB_PATH"]).open("a", encoding="utf-8") as stream: + stream.write(str(binary_dir) + "\n") + with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as stream: + for key, value in environment.items(): + stream.write(f"{key}={value}\n") + print(f"Ready: {compiler}\nEnvironment: {json.dumps(environment)}", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/ci/test_compile_examples.py b/tools/ci/test_compile_examples.py new file mode 100644 index 00000000..d2d3c86d --- /dev/null +++ b/tools/ci/test_compile_examples.py @@ -0,0 +1,83 @@ +"""The example gate must reject false compiler success and retain partial failures.""" + +import contextlib +import hashlib +import io +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest import mock + +from tools.ci import compile_examples + + +class NativeExampleGateTests(unittest.TestCase): + def exercise(self, outcomes): + with tempfile.TemporaryDirectory(prefix="bloom-example-gate-") as directory: + root = Path(directory).resolve() + self.assertEqual(root.parent, Path(tempfile.gettempdir()).resolve()) + examples = [f"examples/case-{i}" for i in range(len(outcomes))] + out = root / "out" + (out / "bin").mkdir(parents=True) + # Valid-looking executable left by a previous successful invocation. + stale = out / "bin" / ("case-0.exe" if os.name == "nt" else "case-0") + stale.write_bytes(b"MZ" + b"previous build" * 8) + commands = [] + + def compiler(command, **kwargs): + if command[1:] == ["--version"]: + return subprocess.CompletedProcess(command, 0, stdout="perry test") + commands.append(command) + outcome = outcomes[len(commands) - 1] + if outcome == "timeout": + raise subprocess.TimeoutExpired(command, 1) + if outcome == "link-error": + kwargs["stderr"].write("undefined symbol: removed_palette\n") + return subprocess.CompletedProcess(command, 1) + if isinstance(outcome, bytes): + Path(command[command.index("-o") + 1]).write_bytes(outcome) + return subprocess.CompletedProcess(command, 0) + + with mock.patch.object(compile_examples, "REPO_ROOT", root), \ + mock.patch.object(compile_examples, "load_inventory", return_value=(examples, [])), \ + mock.patch.object(compile_examples, "ensure_engine_dependency"), \ + mock.patch.object(compile_examples.shutil, "which", return_value="perry-test"), \ + mock.patch.object(compile_examples.subprocess, "run", side_effect=compiler), \ + mock.patch("sys.argv", ["compile_examples.py", "--out", str(out)]), \ + contextlib.redirect_stdout(io.StringIO()), \ + contextlib.redirect_stderr(io.StringIO()): + result = compile_examples.main() + report = json.loads((out / "result.json").read_text(encoding="utf-8")) + return result, report, commands + + def test_zero_exit_without_new_executable_rejects_stale_success(self): + result, report, commands = self.exercise([None, b"COFF object code" * 8]) + self.assertEqual(result, 1) + self.assertEqual(report["status"], "fail") + self.assertEqual(len(report["failures"]), 2) + self.assertTrue(all("sha256" not in row for row in report["examples"])) + self.assertTrue(all("--no-link" not in command for command in commands)) + + def test_link_failure_and_timeout_do_not_hide_later_results(self): + executable = b"MZ" + bytes(range(64)) + result, report, _ = self.exercise(["link-error", "timeout", executable]) + self.assertEqual(result, 1) + self.assertEqual([row["status"] for row in report["examples"]], ["fail", "fail", "pass"]) + self.assertEqual(report["examples"][2]["sha256"], hashlib.sha256(executable).hexdigest()) + + def test_success_records_native_link_mode_and_artifact_hash(self): + executable = b"\x7fELF" + bytes(range(64)) + result, report, _ = self.exercise([executable]) + self.assertEqual(result, 0) + self.assertEqual(report["status"], "pass") + record = report["examples"][0] + self.assertEqual(record["mode"], "native-compile-link") + self.assertEqual(record["bytes"], len(executable)) + self.assertEqual(record["sha256"], hashlib.sha256(executable).hexdigest()) + + +if __name__ == "__main__": + unittest.main()