From a30ba968c32fd559a5357e6534d735187bb085a4 Mon Sep 17 00:00:00 2001 From: LunaStev Date: Fri, 2 Oct 2026 19:27:23 +0900 Subject: [PATCH 1/6] Publish validated master builds through a rolling Nightly release Signed-off-by: LunaStev --- .github/workflows/nightly.yml | 466 +++++++++++++++++++++++++++++++ tools/ci/nightly.py | 459 ++++++++++++++++++++++++++++++ tools/ci/nightly_installers.json | 4 + tools/ci/procedures.json | 15 +- tools/ci/release.py | 9 + tools/test_nightly.py | 454 ++++++++++++++++++++++++++++++ 6 files changed, 1402 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/nightly.yml create mode 100644 tools/ci/nightly.py create mode 100644 tools/ci/nightly_installers.json create mode 100644 tools/test_nightly.py diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 00000000..108331fe --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,466 @@ +# SPDX-License-Identifier: MPL-2.0 +name: Wave Nightly +'on': + workflow_run: + workflows: + - Wave CI + types: + - completed + branches: + - master + workflow_dispatch: + inputs: + ci_run_id: + description: Successful canonical master Wave CI run ID to retry + required: true + type: string +permissions: + contents: read + actions: read +env: + CARGO_TERM_COLOR: always + CARGO_BUILD_JOBS: '2' + NO_COLOR: '1' + PYTHONUNBUFFERED: '1' + PYTHONUTF8: '1' + PYTHONIOENCODING: utf-8 +jobs: + gate: + if: >- + ${{ github.repository == 'wavefnd/Wave' && + ((github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master') || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_repository.full_name == 'wavefnd/Wave' && + github.event.workflow_run.head_branch == 'master')) }} + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + source_sha: ${{ steps.gate.outputs.source_sha }} + compiler_version: ${{ steps.gate.outputs.compiler_version }} + ci_run_id: ${{ steps.gate.outputs.ci_run_id }} + steps: + - uses: actions/checkout@v4 + with: + ref: master + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Validate canonical CI source and installer deployment + id: gate + env: + GH_TOKEN: ${{ github.token }} + WAVE_NIGHTLY_CI_RUN: ${{ github.event.workflow_run.id || inputs.ci_run_id }} + run: python -m tools.ci.release --channel nightly --stage gate + package-linux: + name: Package Linux ${{ matrix.arch == 'x86_64' && 'amd64' || matrix.arch }} + needs: + - gate + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + runner: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + archive_target: x86_64-linux-gnu + - arch: arm64 + runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + archive_target: aarch64-linux-gnu + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.gate.outputs.source_sha }} + persist-credentials: false + - name: Setup Rust + uses: dtolnay/rust-toolchain@1.89.0 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Build and validate Nightly package + id: procedure + env: + WAVE_CI_MATRIX: ${{ toJSON(matrix) }} + GH_TOKEN: ${{ github.token }} + run: python -m tools.ci.release --channel nightly --stage package --target "${{ matrix.target }}" --provision --report-json + ci-report.json + - uses: actions/upload-artifact@v4 + with: + name: nightly-linux-${{ matrix.arch }} + path: 'wave-v${{ needs.gate.outputs.compiler_version }}-${{ matrix.archive_target }}.tar.gz + + wave-v${{ needs.gate.outputs.compiler_version }}-${{ matrix.archive_target }}.tar.gz.sha256 + + wave-v${{ needs.gate.outputs.compiler_version }}-${{ matrix.archive_target }}.tar.gz.metadata.json + + ' + if-no-files-found: error + retention-days: 7 + - name: Upload procedure report and command logs + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: procedures-nightly-package-linux-${{ matrix.arch }} + path: 'ci-report.json + + ${{ runner.temp }}/wave-ci-*/*.log + + ' + if-no-files-found: warn + retention-days: 7 + env: + RELEASE_VERSION: ${{ needs.gate.outputs.compiler_version }} + WAVE_NIGHTLY_CI_RUN: ${{ needs.gate.outputs.ci_run_id }} + package-linux-loongarch64: + name: Package Linux loong64 (cross) + needs: + - gate + runs-on: ubuntu-24.04 + timeout-minutes: 240 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.gate.outputs.source_sha }} + persist-credentials: false + - name: Setup Rust + uses: dtolnay/rust-toolchain@1.89.0 + - name: Enable LoongArch64 compiler execution + uses: docker/setup-qemu-action@v3 + with: + platforms: loong64 + - name: Cache cross-built LoongArch64 LLVM + uses: actions/cache@v4 + with: + path: /tmp/wave-loongarch64-release + key: loongarch64-llvm-${{ runner.os }}-${{ hashFiles('tools/ci/toolchains.json') }}-${{ hashFiles('tools/package_linux_loongarch64.sh') + }} + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Build and validate Nightly package + id: procedure + env: + WAVE_CI_MATRIX: ${{ toJSON(matrix) }} + GH_TOKEN: ${{ github.token }} + run: python -m tools.ci.release --channel nightly --stage package --target "linux-loong64" --provision --report-json + ci-report.json + - uses: actions/upload-artifact@v4 + with: + name: nightly-linux-loongarch64 + path: 'wave-v${{ needs.gate.outputs.compiler_version }}-loongarch64-linux-gnu.tar.gz + + wave-v${{ needs.gate.outputs.compiler_version }}-loongarch64-linux-gnu.tar.gz.sha256 + + wave-v${{ needs.gate.outputs.compiler_version }}-loongarch64-linux-gnu.tar.gz.metadata.json + + ' + if-no-files-found: error + retention-days: 7 + - name: Upload procedure report and command logs + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: procedures-nightly-package-linux-loongarch64-single + path: 'ci-report.json + + ${{ runner.temp }}/wave-ci-*/*.log + + ' + if-no-files-found: warn + retention-days: 7 + env: + RELEASE_VERSION: ${{ needs.gate.outputs.compiler_version }} + WAVE_NIGHTLY_CI_RUN: ${{ needs.gate.outputs.ci_run_id }} + package-linux-riscv64: + name: Package Linux riscv64 (cross) + needs: + - gate + runs-on: ubuntu-24.04 + timeout-minutes: 360 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.gate.outputs.source_sha }} + persist-credentials: false + - name: Enable RISC-V container execution + uses: docker/setup-qemu-action@v3 + with: + platforms: riscv64 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Build and validate Nightly package + id: procedure + env: + WAVE_CI_MATRIX: ${{ toJSON(matrix) }} + GH_TOKEN: ${{ github.token }} + run: python -m tools.ci.release --channel nightly --stage package --target "linux-riscv64" --provision --report-json + ci-report.json + - uses: actions/upload-artifact@v4 + with: + name: nightly-linux-riscv64 + path: 'wave-v${{ needs.gate.outputs.compiler_version }}-riscv64-linux-gnu.tar.gz + + wave-v${{ needs.gate.outputs.compiler_version }}-riscv64-linux-gnu.tar.gz.sha256 + + wave-v${{ needs.gate.outputs.compiler_version }}-riscv64-linux-gnu.tar.gz.metadata.json + + ' + if-no-files-found: error + retention-days: 7 + - name: Upload procedure report and command logs + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: procedures-nightly-package-linux-riscv64-single + path: 'ci-report.json + + ${{ runner.temp }}/wave-ci-*/*.log + + ' + if-no-files-found: warn + retention-days: 7 + env: + RELEASE_VERSION: ${{ needs.gate.outputs.compiler_version }} + WAVE_NIGHTLY_CI_RUN: ${{ needs.gate.outputs.ci_run_id }} + package-macos: + name: Package macOS ${{ matrix.arch == 'x86_64' && 'amd64' || matrix.arch }} + needs: + - gate + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + runner: macos-15-intel + target: x86_64-apple-darwin + - arch: arm64 + runner: macos-15 + target: aarch64-apple-darwin + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.gate.outputs.source_sha }} + persist-credentials: false + - name: Setup Rust + uses: dtolnay/rust-toolchain@1.89.0 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Build and validate Nightly package + id: procedure + env: + WAVE_CI_MATRIX: ${{ toJSON(matrix) }} + GH_TOKEN: ${{ github.token }} + run: python -m tools.ci.release --channel nightly --stage package --target "${{ matrix.target }}" --provision --report-json + ci-report.json + - uses: actions/upload-artifact@v4 + with: + name: nightly-macos-${{ matrix.arch }} + path: 'wave-v${{ needs.gate.outputs.compiler_version }}-${{ matrix.target }}.tar.gz + + wave-v${{ needs.gate.outputs.compiler_version }}-${{ matrix.target }}.tar.gz.sha256 + + wave-v${{ needs.gate.outputs.compiler_version }}-${{ matrix.target }}.tar.gz.metadata.json + + ' + if-no-files-found: error + retention-days: 7 + - name: Upload procedure report and command logs + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: procedures-nightly-package-macos-${{ matrix.arch }} + path: 'ci-report.json + + ${{ runner.temp }}/wave-ci-*/*.log + + ' + if-no-files-found: warn + retention-days: 7 + env: + RELEASE_VERSION: ${{ needs.gate.outputs.compiler_version }} + WAVE_NIGHTLY_CI_RUN: ${{ needs.gate.outputs.ci_run_id }} + package-windows: + name: Package Windows amd64 (MSVC) + needs: + - gate + runs-on: windows-2025 + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.gate.outputs.source_sha }} + persist-credentials: false + - uses: dtolnay/rust-toolchain@1.89.0 + with: + toolchain: 1.89.0-x86_64-pc-windows-msvc + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + architecture: x64 + - name: Build and validate Nightly package + id: procedure + env: + WAVE_CI_MATRIX: ${{ toJSON(matrix) }} + GH_TOKEN: ${{ github.token }} + run: python -m tools.ci.release --channel nightly --stage package --target "windows-amd64" --provision --report-json + ci-report.json + - name: Preserve native execution evidence + if: ${{ always() && steps.procedure.outputs.native_acceptance != 'skipped' }} + uses: actions/upload-artifact@v4 + with: + name: msvc-native-x64-evidence + path: ${{ runner.temp }}/wave-msvc-native-x64/ + retention-days: 7 + - name: Preserve case evidence + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: wave-cases-windows-amd64 + path: wave-cases-windows-amd64.json + - name: Preserve package acceptance evidence + if: ${{ always() && steps.procedure.outputs.package_smoke != 'skipped' }} + uses: actions/upload-artifact@v4 + with: + name: msvc-package-x64-evidence + path: ${{ runner.temp }}/wave-package-x64/ + retention-days: 7 + - uses: actions/upload-artifact@v4 + with: + name: nightly-windows-x64 + path: 'wave-v${{ needs.gate.outputs.compiler_version }}-x86_64-pc-windows-msvc.zip + + wave-v${{ needs.gate.outputs.compiler_version }}-x86_64-pc-windows-msvc.zip.sha256 + + wave-v${{ needs.gate.outputs.compiler_version }}-x86_64-pc-windows-msvc.zip.metadata.json + + ' + if-no-files-found: error + - name: Upload procedure report and command logs + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: procedures-nightly-package-windows-single + path: 'ci-report.json + + ${{ runner.temp }}/wave-ci-*/*.log + + ' + if-no-files-found: warn + retention-days: 7 + env: + RELEASE_VERSION: ${{ needs.gate.outputs.compiler_version }} + WAVE_NIGHTLY_CI_RUN: ${{ needs.gate.outputs.ci_run_id }} + package-windows-arm64: + name: Package Windows arm64 (MSVC) + needs: + - gate + runs-on: windows-11-vs2026-arm + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.gate.outputs.source_sha }} + persist-credentials: false + - uses: dtolnay/rust-toolchain@1.89.0 + with: + toolchain: 1.89.0-aarch64-pc-windows-msvc + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + architecture: arm64 + - name: Build and validate Nightly package + id: procedure + env: + WAVE_CI_MATRIX: ${{ toJSON(matrix) }} + GH_TOKEN: ${{ github.token }} + run: python -m tools.ci.release --channel nightly --stage package --target "windows-arm64" --provision --report-json + ci-report.json + - name: Preserve native execution evidence + if: ${{ always() && steps.procedure.outputs.native_acceptance != 'skipped' }} + uses: actions/upload-artifact@v4 + with: + name: msvc-native-arm64-evidence + path: ${{ runner.temp }}/wave-msvc-native-arm64/ + retention-days: 7 + - name: Preserve case evidence + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: wave-cases-windows-arm64 + path: wave-cases-windows-arm64.json + - name: Preserve package acceptance evidence + if: ${{ always() && steps.procedure.outputs.package_smoke != 'skipped' }} + uses: actions/upload-artifact@v4 + with: + name: msvc-package-arm64-evidence + path: ${{ runner.temp }}/wave-package-arm64/ + retention-days: 7 + - uses: actions/upload-artifact@v4 + with: + name: nightly-windows-arm64 + path: 'wave-v${{ needs.gate.outputs.compiler_version }}-aarch64-pc-windows-msvc.zip + + wave-v${{ needs.gate.outputs.compiler_version }}-aarch64-pc-windows-msvc.zip.sha256 + + wave-v${{ needs.gate.outputs.compiler_version }}-aarch64-pc-windows-msvc.zip.metadata.json + + ' + if-no-files-found: error + - name: Upload procedure report and command logs + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: procedures-nightly-package-windows-arm64-single + path: 'ci-report.json + + ${{ runner.temp }}/wave-ci-*/*.log + + ' + if-no-files-found: warn + retention-days: 7 + env: + RELEASE_VERSION: ${{ needs.gate.outputs.compiler_version }} + WAVE_NIGHTLY_CI_RUN: ${{ needs.gate.outputs.ci_run_id }} + publish: + needs: + - gate + - package-linux + - package-linux-loongarch64 + - package-linux-riscv64 + - package-macos + - package-windows + - package-windows-arm64 + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: write + actions: read + concurrency: + group: wave-nightly-publish + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.gate.outputs.source_sha }} + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: actions/download-artifact@v4 + with: + pattern: nightly-* + path: release-assets + merge-multiple: true + - name: Promote the complete Nightly generation + env: + GH_TOKEN: ${{ github.token }} + WAVE_NIGHTLY_CI_RUN: ${{ needs.gate.outputs.ci_run_id }} + run: python -m tools.ci.release --channel nightly --stage publish --publish diff --git a/tools/ci/nightly.py b/tools/ci/nightly.py new file mode 100644 index 00000000..b33bdc8a --- /dev/null +++ b/tools/ci/nightly.py @@ -0,0 +1,459 @@ +# SPDX-License-Identifier: MPL-2.0 +"""Rolling Nightly identity, staging and recoverable GitHub publication. + +The release body's state marker is the committed generation. Assets are immutable +and verified before the tag/body promotion. A retry repairs a tag left ahead of +that marker by an interrupted promotion; old assets survive until commit. +""" + +import argparse +import base64 +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import tomllib +from urllib.request import urlopen + +from tools.ci.common import ROOT +from tools.ci.release import verify_metadata + +REPO = "wavefnd/Wave" +SHA = r"[0-9a-f]{40}" +MARKER = r"" +INSTALLERS_FILE = Path(__file__).with_name("nightly_installers.json") + + +def sha(value): + if not isinstance(value, str) or not re.fullmatch(SHA, value): + raise ValueError("expected a full source SHA") + return value + + +class GitHub: + def api(self, endpoint, *, method="GET", data=None, optional=False): + args = ["gh", "api", f"repos/{REPO}/{endpoint}", "--method", method] + if data is not None: + args += ["--input", "-"] + result = subprocess.run( + args, + input=json.dumps(data) if data is not None else None, + text=True, + capture_output=True, + timeout=120, + ) + if result.returncode: + if optional and "HTTP 404" in result.stderr: + return None + raise RuntimeError(f"GitHub {method} {endpoint}: {result.stderr.strip()}") + return json.loads(result.stdout) if result.stdout.strip() else None + + def assets(self, release_id): + result = [] + page = 1 + while True: + batch = self.api(f"releases/{release_id}/assets?per_page=100&page={page}") + result.extend(batch) + if len(batch) < 100: + return result + page += 1 + + def upload(self, path): + # Never --clobber: an existing usable asset must not be deleted first. + subprocess.run( + ["gh", "release", "upload", "nightly", str(path), "--repo", REPO], + check=True, + timeout=1800, + ) + + +def ancestor(github, older, newer): + sha(older) + sha(newer) + if older == newer: + return True + comparison = github.api(f"compare/{older}...{newer}") + return ( + comparison["status"] == "ahead" + and comparison["merge_base_commit"]["sha"] == older + ) + + +def eligible(github, run_id): + if not str(run_id).isdigit() or int(run_id) <= 0: + raise ValueError("a successful canonical master CI run ID is required") + run = github.api(f"actions/runs/{run_id}") + if ( + run.get("repository", {}).get("full_name") != REPO + or run.get("head_repository", {}).get("full_name") != REPO + or run.get("event") != "push" + or run.get("head_branch") != "master" + or run.get("path") != ".github/workflows/ci.yml" + or run.get("status") != "completed" + or run.get("conclusion") != "success" + ): + raise ValueError("Nightly requires successful canonical master push CI") + jobs = [] + page = 1 + while True: + batch = github.api( + f"actions/runs/{run_id}/attempts/{run['run_attempt']}/jobs?per_page=100&page={page}" + )["jobs"] + jobs.extend(batch) + if len(batch) < 100: + break + page += 1 + if not jobs or any( + j.get("status") != "completed" or j.get("conclusion") != "success" for j in jobs + ): + raise ValueError( + "CI jobs must all complete successfully; skipped jobs are not validation" + ) + source = sha(run["head_sha"]) + master = github.api("git/ref/heads/master")["object"]["sha"] + if not ancestor(github, source, master): + raise ValueError("CI source is not in canonical master history") + content = github.api(f"contents/Cargo.toml?ref={source}") + version = tomllib.loads(base64.b64decode(content["content"]).decode())["package"][ + "version" + ] + if not re.fullmatch(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", version): + raise ValueError("invalid compiler version") + return {"source_sha": source, "compiler_version": version, "ci_run_id": int(run_id)} + + +def checkout_identity(identity): + source = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + version = tomllib.loads((ROOT / "Cargo.toml").read_text())["package"]["version"] + if source != identity["source_sha"] or version != identity["compiler_version"]: + raise ValueError("checkout differs from the exact CI-validated source/version") + + +def installers_ready(): + # The installer change lives in wavefnd/wave-platform. Do not publish until + # those reviewed bytes are actually deployed, regardless of merge order. + pins = json.loads(INSTALLERS_FILE.read_text()) + required = {"https://wave-lang.dev/install.sh", "https://wave-lang.dev/install.ps1"} + if set(pins) != required or any( + not re.fullmatch(r"[0-9a-f]{64}", value) for value in pins.values() + ): + raise ValueError("both reviewed installer deployment hashes are required") + for url, expected in pins.items(): + with urlopen(url, timeout=30) as response: + data = response.read(1024 * 1024 + 1) + if len(data) > 1024 * 1024 or hashlib.sha256(data).hexdigest() != expected: + raise ValueError(f"versioned-only installer is not deployed: {url}") + + +def file_record(path): + with path.open("rb") as stream: + digest = hashlib.file_digest(stream, "sha256").hexdigest() + return {"sha256": digest, "size": path.stat().st_size} + + +def stage(directory, output, identity, run_id, attempt, revision): + if ( + not str(run_id).isdigit() + or not str(attempt).isdigit() + or min(int(run_id), int(attempt)) < 1 + ): + raise ValueError("invalid Nightly workflow run identity") + source, version = sha(identity["source_sha"]), identity["compiler_version"] + verify_metadata(directory, version, source, revision) + generation = f"{source}-{run_id}-{attempt}" + output.mkdir() + records = {} + checksums = [] + for descriptor in sorted(directory.glob("*.metadata.json")): + metadata = json.loads(descriptor.read_text()) + original = metadata["archive"] + name = original.replace(f"wave-v{version}-", f"wave-nightly-{generation}-", 1) + archive = output / name + shutil.copyfile(directory / original, archive) + if file_record(archive)["sha256"] != metadata["sha256"]: + raise ValueError("package changed while staging Nightly") + metadata.update(archive=name, channel="nightly", generation=generation) + (output / (name + ".metadata.json")).write_text( + json.dumps(metadata, indent=2) + "\n" + ) + line = f"{metadata['sha256']} {name}\n" + (output / (name + ".sha256")).write_text(line) + checksums.append(line) + (output / f"nightly-{generation}-SHA256SUMS").write_text("".join(checksums)) + for path in sorted(output.iterdir()): + records[path.name] = file_record(path) + state = dict( + identity, + schema_version=1, + generation=generation, + run_id=int(run_id), + built_at=datetime.now(timezone.utc).isoformat(), + assets=records, + ) + manifest = output / f"nightly-{generation}.json" + manifest.write_text(json.dumps(state, indent=2) + "\n") + state["assets"][manifest.name] = file_record(manifest) + return state + + +def active_state(release): + if release is None: + return None + if ( + release.get("tag_name") != "nightly" + or release.get("name") != "Wave Nightly" + or not release.get("prerelease") + ): + raise ValueError("refusing to replace an unmanaged Nightly release") + match = re.search(MARKER, release.get("body") or "") + if match is None: + if release.get("draft") and not release.get("body"): + return None # Interrupted first publication, before promotion. + raise ValueError("Nightly release has no recovery state") + state = json.loads(match[1]) + sha(state["source_sha"]) + if state.get("schema_version") != 1 or not state.get("assets"): + raise ValueError("invalid Nightly recovery state") + return state + + +def verify_remote(github, release_id, state): + assets = {a["name"]: a for a in github.assets(release_id)} + for name, record in state["assets"].items(): + asset = assets.get(name, {}) + if ( + asset.get("state") != "uploaded" + or asset.get("size") != record["size"] + or asset.get("digest") != "sha256:" + record["sha256"] + ): + raise ValueError(f"unverified Nightly asset: {name}") + + +def set_tag(github, source): + sha(source) + tag = github.api("git/ref/tags/nightly", optional=True) + if tag is None: + github.api( + "git/refs", method="POST", data={"ref": "refs/tags/nightly", "sha": source} + ) + elif tag["object"]["sha"] != source: + if tag["object"]["type"] != "commit": + raise ValueError("Nightly must use a lightweight commit tag") + github.api( + "git/refs/tags/nightly", method="PATCH", data={"sha": source, "force": True} + ) + + +def notes(state): + source = state["source_sha"] + lines = [ + "Wave Nightly", + "", + f"Commit: [{source}](https://github.com/{REPO}/commit/{source})", + "Branch: master", + f"Built: {state['built_at']}", + f"Compiler version: {state['compiler_version']}", + f"Build: https://github.com/{REPO}/actions/runs/{state['run_id']}", + f"Validated CI: https://github.com/{REPO}/actions/runs/{state['ci_run_id']}", + "", + "This release tracks the latest successfully validated master build.", + "", + "Download manually below. Nightly is not available through install.sh or install.ps1.", + "", + ] + for name in sorted(state["assets"]): + lines.append( + f"- [{name}](https://github.com/{REPO}/releases/download/nightly/{name})" + ) + lines += [ + "", + "", + ] + return "\n".join(lines) + + +def cleanup(github, release_id, state): + for asset in github.assets(release_id): + if ( + asset["name"].startswith(("wave-nightly-", "nightly-")) + and asset["name"] not in state["assets"] + ): + try: + github.api(f"releases/assets/{asset['id']}", method="DELETE") + except RuntimeError as error: + # A cleanup failure leaves only surplus assets; retry is safe. + print(f"Nightly cleanup deferred: {error}", file=sys.stderr) + + +def promote(github, directory, state): + release = github.api("releases/tags/nightly", optional=True) + # Draft releases may not be returned by the tag endpoint on first retries. + if release is None: + candidates = github.api("releases?per_page=100") + release = next((r for r in candidates if r["tag_name"] == "nightly"), None) + previous = active_state(release) + source = state["source_sha"] + if previous: + verify_remote(github, release["id"], previous) + if source != previous["source_sha"] and not ancestor( + github, previous["source_sha"], source + ): + print("Skipping stale or unrelated Nightly generation") + return "stale" + # Reconcile a tag moved before a cancelled/failed release-body update. + set_tag(github, previous["source_sha"]) + if source == previous["source_sha"]: + cleanup(github, release["id"], previous) + return "unchanged" + if release is None: + set_tag(github, source) + release = github.api( + "releases", + method="POST", + data={ + "tag_name": "nightly", + "target_commitish": source, + "name": "Wave Nightly", + "body": "", + "draft": True, + "prerelease": True, + "make_latest": "false", + }, + ) + existing = {a["name"]: a for a in github.assets(release["id"])} + for name, record in state["assets"].items(): + if name in existing: + asset = existing[name] + if ( + asset.get("digest") != "sha256:" + record["sha256"] + or asset.get("size") != record["size"] + ): + raise ValueError(f"conflicting immutable Nightly asset: {name}") + else: + github.upload(directory / name) + verify_remote(github, release["id"], state) + # Check ancestry again after a long upload; a rewritten master cannot publish. + master = github.api("git/ref/heads/master")["object"]["sha"] + if not ancestor(github, source, master): + raise ValueError("source left canonical master history during upload") + try: + set_tag(github, source) + github.api( + f"releases/{release['id']}", + method="PATCH", + data={ + "name": "Wave Nightly", + "body": notes(state), + "draft": False, + "prerelease": True, + "make_latest": "false", + "target_commitish": source, + }, + ) + except BaseException: + # The API may have committed before the response was lost. Read back + # before rollback; otherwise we could corrupt a successful promotion. + observed = github.api(f"releases/{release['id']}") + if active_state(observed) != state: + if previous: + set_tag(github, previous["source_sha"]) + raise + observed = github.api(f"releases/{release['id']}") + if active_state(observed) != state or observed.get("draft"): + raise ValueError("Nightly release promotion could not be verified") + verify_remote(github, release["id"], state) + if github.api("git/ref/tags/nightly")["object"]["sha"] != source: + raise ValueError("Nightly tag differs from the active generation") + cleanup(github, release["id"], state) + return "published" + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--stage", choices=("gate", "package", "publish"), required=True + ) + parser.add_argument("--ci-run-id", default=os.environ.get("WAVE_NIGHTLY_CI_RUN")) + parser.add_argument("--target", default="linux-amd64") + parser.add_argument("--provision", action="store_true") + parser.add_argument("--report-json", type=Path) + parser.add_argument("--publish", action="store_true") + parser.add_argument("--directory", type=Path, default=ROOT / "release-assets") + options = parser.parse_args(argv) + try: + if options.publish and options.stage != "publish": + raise ValueError("--publish is only valid for the publish stage") + if os.environ.get("GITHUB_REPOSITORY") != REPO: + raise ValueError("Nightly requires the canonical repository") + github = GitHub() + identity = eligible(github, options.ci_run_id) + if options.stage == "gate": + installers_ready() + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + for key, value in identity.items(): + output.write(f"{key}={value}\n") + return 0 + checkout_identity(identity) + if options.stage == "package": + from tools.ci.common import main as run_plan + + os.environ["RELEASE_VERSION"] = identity["compiler_version"] + args = ["--target", options.target] + if options.provision: + args += ["--provision"] + if options.report_json: + args += ["--report-json", str(options.report_json)] + return run_plan("package", args) + if ( + not options.publish + or os.environ.get("GITHUB_ACTIONS") != "true" + or os.environ.get("GITHUB_REF") != "refs/heads/master" + ): + raise ValueError( + "Nightly publication requires --publish in the canonical master workflow" + ) + run_id = os.environ["GITHUB_RUN_ID"] + own_run = github.api(f"actions/runs/{run_id}") + if ( + own_run.get("path") != ".github/workflows/nightly.yml" + or own_run.get("repository", {}).get("full_name") != REPO + or own_run.get("event") not in ("workflow_run", "workflow_dispatch") + ): + raise ValueError("publication must run in the canonical Nightly workflow") + installers_ready() + revision = json.loads((ROOT / "std/manifest.json").read_text())[ + "compatibility_revision" + ] + with tempfile.TemporaryDirectory(prefix="wave-nightly-") as temporary: + output = Path(temporary) / "assets" + state = stage( + options.directory, + output, + identity, + run_id, + os.environ["GITHUB_RUN_ATTEMPT"], + revision, + ) + print(promote(github, output, state)) + return 0 + except ( + OSError, + ValueError, + KeyError, + RuntimeError, + subprocess.SubprocessError, + ) as error: + print(f"Nightly failed: {error}", file=sys.stderr) + return 1 diff --git a/tools/ci/nightly_installers.json b/tools/ci/nightly_installers.json new file mode 100644 index 00000000..9d5c115c --- /dev/null +++ b/tools/ci/nightly_installers.json @@ -0,0 +1,4 @@ +{ + "https://wave-lang.dev/install.sh": "a43f2ccf74204f7a4d9b22178929a10ba94f0e5c2e33a86b42e2e2d6ec465d44", + "https://wave-lang.dev/install.ps1": "7ac625d01f4aa1a908fe575ab772585c7188e2582bd7394b0004771318ba117c" +} diff --git a/tools/ci/procedures.json b/tools/ci/procedures.json index e3be711a..160a893d 100644 --- a/tools/ci/procedures.json +++ b/tools/ci/procedures.json @@ -105,7 +105,8 @@ "tools.test_check_msvc_native", "tools.test_diagnose_windows_arm64", "tools.test_runtime_selection", - "tools.test_ci" + "tools.test_ci", + "tools.test_nightly" ] ] }, @@ -228,7 +229,8 @@ "tools.test_test_contracts", "tools.test_process_tree", "tools.test_check_msvc_native", - "tools.test_ci" + "tools.test_ci", + "tools.test_nightly" ] ] }, @@ -803,7 +805,8 @@ "tools.test_case_manifest", "tools.test_test_contracts", "tools.test_process_tree", - "tools.test_ci" + "tools.test_ci", + "tools.test_nightly" ] ] }, @@ -979,7 +982,8 @@ "tools.test_check_case_sources", "tools.test_case_execution", "tools.test_process_tree", - "tools.test_ci" + "tools.test_ci", + "tools.test_nightly" ] ] }, @@ -1486,7 +1490,8 @@ "tools.test_case_manifest", "tools.test_test_contracts", "tools.test_release_publish", - "tools.test_ci" + "tools.test_ci", + "tools.test_nightly" ] ] }, diff --git a/tools/ci/release.py b/tools/ci/release.py index 9dfec320..6fc8dc13 100644 --- a/tools/ci/release.py +++ b/tools/ci/release.py @@ -157,4 +157,13 @@ def publish(r, _): OPERATIONS = {"release_identity": release_identity, "publish": publish} if __name__ == "__main__": + if "--channel" in sys.argv: + args = sys.argv[1:] + index = args.index("--channel") + if args[index : index + 2] != ["--channel", "nightly"]: + raise SystemExit("only --channel nightly is supported") + del args[index : index + 2] + from tools.ci.nightly import main as nightly_main + + sys.exit(nightly_main(args)) sys.exit(main("release")) diff --git a/tools/test_nightly.py b/tools/test_nightly.py new file mode 100644 index 00000000..915c8027 --- /dev/null +++ b/tools/test_nightly.py @@ -0,0 +1,454 @@ +# SPDX-License-Identifier: MPL-2.0 +"""Nightly tests use an in-memory GitHub; no live release writes.""" +import base64 +import copy +import hashlib +import io +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from tools.ci import nightly, package, release, targets +from tools.check_release_assets import ARCHIVE_TARGETS + +A, B, C, OTHER = (c * 40 for c in "abcd") + + +class FakeGitHub: + def __init__(self): + self.release = None + self.tag = None + self.files = {} + self.calls = [] + self.fail = None + self.upload_failure = False + self.corrupt = False + self.run = dict( + repository={"full_name": nightly.REPO}, + head_repository={"full_name": nightly.REPO}, + event="push", + head_branch="master", + path=".github/workflows/ci.yml", + status="completed", + conclusion="success", + head_sha=A, + run_attempt=1, + ) + self.jobs = [dict(status="completed", conclusion="success")] + + def api(self, endpoint, *, method="GET", data=None, optional=False): + self.calls.append((method, endpoint, copy.deepcopy(data))) + fault = self.fail and self.fail[:2] == (method, endpoint) + if fault and self.fail[2] == "before": + self.fail = None + raise RuntimeError("injected API failure") + if endpoint == "git/ref/heads/master": + value = {"object": {"sha": C}} + elif endpoint.startswith("compare/"): + before, after = endpoint.removeprefix("compare/").split("...") + ordered = [A, B, C] + ahead = ( + before in ordered + and after in ordered + and ordered.index(before) < ordered.index(after) + ) + value = { + "status": "ahead" if ahead else "behind", + "merge_base_commit": {"sha": before}, + } + elif "/jobs?" in endpoint: + value = {"jobs": self.jobs} + elif endpoint.startswith("actions/runs/"): + value = self.run + elif endpoint.startswith("contents/Cargo.toml"): + value = { + "content": base64.b64encode( + b'[package]\nversion="0.2.1-pre-beta-dev"' + ).decode() + } + elif endpoint == "git/ref/tags/nightly": + value = ( + {"object": {"sha": self.tag, "type": "commit"}} if self.tag else None + ) + elif endpoint in ("git/refs", "git/refs/tags/nightly"): + self.tag = data["sha"] + value = {} + elif endpoint == "releases/tags/nightly" or ( + endpoint == "releases/1" and method == "GET" + ): + value = self.release + elif endpoint.startswith("releases?"): + value = [self.release] if self.release else [] + elif endpoint == "releases" and method == "POST": + self.release = dict(data, id=1) + value = self.release + elif endpoint == "releases/1" and method == "PATCH": + self.release.update(data) + value = self.release + elif endpoint.startswith("releases/assets/") and method == "DELETE": + name = next( + name + for name, a in self.files.items() + if a["id"] == int(endpoint.split("/")[-1]) + ) + del self.files[name] + value = None + else: + raise AssertionError((method, endpoint)) + if fault: + mode = self.fail[2] + self.fail = None + if mode == "kill": + raise SystemExit("simulated process death") + raise RuntimeError("response lost after commit") + return copy.deepcopy(value) + + def assets(self, release_id): + return copy.deepcopy(list(self.files.values())) + + def upload(self, path): + self.calls.append(("UPLOAD", path.name, None)) + if self.upload_failure: + raise RuntimeError("upload failed") + record = nightly.file_record(path) + self.files[path.name] = dict( + id=max([a["id"] for a in self.files.values()] + [0]) + 1, + name=path.name, + size=record["size"], + state="uploaded", + digest="sha256:" + ("0" * 64 if self.corrupt else record["sha256"]), + ) + + +class NightlyTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.github = FakeGitHub() + self.version = "0.2.1-pre-beta-dev" + self.counter = 0 + + def generation(self, source=A): + self.counter += 1 + folder = self.root / str(self.counter) + folder.mkdir() + for target in ARCHIVE_TARGETS: + name = f"wave-v{self.version}-{target}" + ( + ".zip" if "windows" in target else ".tar.gz" + ) + path = folder / name + path.write_bytes((target + source).encode()) + digest = hashlib.sha256(path.read_bytes()).hexdigest() + (folder / (name + ".sha256")).write_text(f"{digest} {name}\n") + metadata = dict( + schema_version=1, + compiler_version=self.version, + source_sha=source, + std_compatibility_revision=4, + target=target, + archive=name, + sha256=digest, + **package.artifact_contract(target), + ) + (folder / (name + ".metadata.json")).write_text(json.dumps(metadata)) + output = folder / "staged" + state = nightly.stage( + folder, + output, + dict(source_sha=source, compiler_version=self.version, ci_run_id=10), + 20 + self.counter, + 1, + 4, + ) + return output, state + + def seed(self): + directory, state = self.generation() + self.assertEqual(nightly.promote(self.github, directory, state), "published") + self.github.calls.clear() + return state + + def test_eligibility_checks_repository_event_branch_run_and_ancestry(self): + self.assertEqual(nightly.eligible(self.github, 10)["source_sha"], A) + for key, value in [ + ("repository", {"full_name": "fork/Wave"}), + ("head_repository", {"full_name": "fork/Wave"}), + ("event", "pull_request"), + ("head_branch", "feature"), + ("path", ".github/workflows/other.yml"), + ("status", "in_progress"), + ("conclusion", "failure"), + ("conclusion", "cancelled"), + ("head_sha", OTHER), + ]: + with self.subTest(key=key, value=value): + original = self.github.run[key] + self.github.run[key] = value + with self.assertRaises(ValueError): + nightly.eligible(self.github, 10) + self.github.run[key] = original + for jobs in [ + [], + [{"status": "completed", "conclusion": "skipped"}], + [{"status": "completed", "conclusion": "failure"}], + ]: + self.github.jobs = jobs + with self.assertRaises(ValueError): + nightly.eligible(self.github, 10) + for value in [None, "../10", "0", "-1"]: + with self.assertRaises(ValueError): + nightly.eligible(self.github, value) + + def test_all_eight_packages_have_generation_specific_matching_sidecars(self): + directory, state = self.generation() + archives = list(directory.glob("*.zip")) + list(directory.glob("*.tar.gz")) + self.assertEqual(len(archives), 8) + self.assertEqual(len(state["assets"]), 26) + for path in archives: + self.assertIn(A, path.name) + metadata = json.loads( + (directory / (path.name + ".metadata.json")).read_text() + ) + self.assertEqual(metadata["archive"], path.name) + self.assertEqual(metadata["compiler_version"], self.version) + self.assertEqual(metadata["source_sha"], A) + self.assertIn(path.name, (directory / (path.name + ".sha256")).read_text()) + self.assertEqual( + {t.archive_target for t in targets.TARGETS.values() if t.distribution}, + set(ARCHIVE_TARGETS), + ) + + def test_bad_package_sets_fail_before_any_remote_write(self): + for bad in ("missing", "checksum", "metadata", "revision"): + with self.subTest(bad=bad): + directory, _ = self.generation() + source = directory.parent + archive = next(source.glob("*.tar.gz")) + if bad == "missing": + archive.unlink() + elif bad == "checksum": + archive.write_bytes(b"changed") + elif bad == "metadata": + (source / (archive.name + ".metadata.json")).write_text("{}") + with self.assertRaises(ValueError): + nightly.stage( + source, + source / "bad", + dict(source_sha=A, compiler_version=self.version), + 1, + 1, + 999 if bad == "revision" else 4, + ) + self.assertEqual(self.github.calls, []) + + def test_first_publication_and_replacement_preserve_previous_until_commit(self): + previous = self.seed() + directory, state = self.generation(B) + self.assertEqual(nightly.promote(self.github, directory, state), "published") + self.assertEqual(self.github.tag, B) + self.assertEqual(nightly.active_state(self.github.release), state) + self.assertFalse(self.github.release["draft"]) + self.assertTrue(self.github.release["prerelease"]) + self.assertEqual(self.github.release["make_latest"], "false") + calls = self.github.calls + commit = next( + i for i, c in enumerate(calls) if c[:2] == ("PATCH", "releases/1") + ) + self.assertTrue( + all(i > commit for i, c in enumerate(calls) if c[0] == "DELETE") + ) + self.assertTrue(set(previous["assets"]).isdisjoint(self.github.files)) + self.assertEqual(set(state["assets"]), set(self.github.files)) + + def test_failed_or_corrupt_upload_preserves_current_generation_and_tag(self): + for mode in ("upload_failure", "corrupt"): + with self.subTest(mode=mode): + self.github = FakeGitHub() + previous = self.seed() + directory, state = self.generation(B) + setattr(self.github, mode, True) + with self.assertRaises((ValueError, RuntimeError)): + nightly.promote(self.github, directory, state) + self.assertEqual(nightly.active_state(self.github.release), previous) + self.assertEqual(self.github.tag, A) + self.assertTrue(set(previous["assets"]).issubset(self.github.files)) + self.assertFalse(any(c[0] == "DELETE" for c in self.github.calls)) + + def test_out_of_order_runs_and_duplicate_publication(self): + self.seed() + directory, state = self.generation(C) + nightly.promote(self.github, directory, state) + before = copy.deepcopy(self.github.release) + for source, expected in [(B, "stale"), (OTHER, "stale"), (C, "unchanged")]: + directory, state = self.generation(source) + self.assertEqual(nightly.promote(self.github, directory, state), expected) + self.assertEqual(self.github.tag, C) + self.assertEqual(self.github.release, before) + + def test_body_failure_rolls_back_tag_without_deleting_old_assets(self): + previous = self.seed() + directory, state = self.generation(B) + self.github.fail = ("PATCH", "releases/1", "before") + with self.assertRaises(RuntimeError): + nightly.promote(self.github, directory, state) + self.assertEqual(self.github.tag, A) + self.assertEqual(nightly.active_state(self.github.release), previous) + self.assertTrue(set(previous["assets"]).issubset(self.github.files)) + self.assertEqual(nightly.promote(self.github, directory, state), "published") + + def test_lost_promotion_response_is_read_back_not_rolled_back(self): + self.seed() + directory, state = self.generation(B) + self.github.fail = ("PATCH", "releases/1", "after") + self.assertEqual(nightly.promote(self.github, directory, state), "published") + self.assertEqual(self.github.tag, B) + + def test_retry_recovers_after_process_death_between_tag_and_body(self): + previous = self.seed() + directory, state = self.generation(B) + # Model SIGKILL after the tag request: no Python finally/except runs. + for name in state["assets"]: + self.github.upload(directory / name) + self.github.tag = B + self.assertEqual(self.github.tag, B) + self.assertEqual(nightly.active_state(self.github.release), previous) + self.assertTrue(set(previous["assets"]).issubset(self.github.files)) + self.assertEqual(nightly.promote(self.github, directory, state), "published") + + def test_interrupted_first_upload_keeps_draft_and_can_retry(self): + directory, state = self.generation() + self.github.upload_failure = True + with self.assertRaises(RuntimeError): + nightly.promote(self.github, directory, state) + self.assertTrue(self.github.release["draft"]) + self.github.upload_failure = False + self.assertEqual(nightly.promote(self.github, directory, state), "published") + + def test_unmanaged_release_and_conflicting_assets_are_not_overwritten(self): + self.seed() + self.github.release["body"] = "manually owned release" + directory, state = self.generation(B) + with self.assertRaises(ValueError): + nightly.promote(self.github, directory, state) + self.assertEqual(self.github.release["body"], "manually owned release") + + def test_cleanup_failure_is_recoverable_without_rolling_back_new_release(self): + self.seed() + old_asset = next(iter(self.github.files.values())) + directory, state = self.generation(B) + self.github.fail = ("DELETE", f"releases/assets/{old_asset['id']}", "before") + self.assertEqual(nightly.promote(self.github, directory, state), "published") + self.assertEqual(self.github.tag, B) + self.assertIn(old_asset["name"], self.github.files) + self.assertEqual(nightly.promote(self.github, directory, state), "unchanged") + self.assertEqual(set(self.github.files), set(state["assets"])) + + def test_conflicting_immutable_upload_is_never_clobbered(self): + previous = self.seed() + directory, state = self.generation(B) + name = next(iter(state["assets"])) + self.github.upload(directory / name) + self.github.files[name]["digest"] = "sha256:" + "0" * 64 + with self.assertRaisesRegex(ValueError, "conflicting immutable"): + nightly.promote(self.github, directory, state) + self.assertEqual(nightly.active_state(self.github.release), previous) + self.assertEqual(self.github.tag, A) + self.assertTrue(set(previous["assets"]).issubset(self.github.files)) + + def test_publication_authorization_precedes_any_release_write(self): + identity = dict(source_sha=A, compiler_version=self.version, ci_run_id=10) + base = dict( + GITHUB_REPOSITORY=nightly.REPO, + GITHUB_ACTIONS="true", + GITHUB_REF="refs/heads/master", + GITHUB_RUN_ID="30", + GITHUB_RUN_ATTEMPT="1", + ) + for changes, flag in [ + ({}, False), + ({"GITHUB_REF": "refs/heads/topic"}, True), + ({"GITHUB_ACTIONS": "false"}, True), + ]: + with self.subTest(changes=changes, flag=flag), patch.dict( + os.environ, dict(base, **changes) + ), patch.object(nightly, "eligible", return_value=identity), patch.object( + nightly, "checkout_identity" + ), patch.object( + nightly, "promote" + ) as promote: + args = ["--stage", "publish"] + (["--publish"] if flag else []) + self.assertEqual(nightly.main(args), 1) + promote.assert_not_called() + + def test_gate_exports_the_validated_sha_and_version(self): + identity = dict(source_sha=A, compiler_version=self.version, ci_run_id=10) + output = self.root / "outputs" + with patch.dict( + os.environ, + {"GITHUB_REPOSITORY": nightly.REPO, "GITHUB_OUTPUT": str(output)}, + ), patch.object(nightly, "eligible", return_value=identity), patch.object( + nightly, "installers_ready" + ), patch.object( + nightly, "promote" + ) as promote: + self.assertEqual(nightly.main(["--stage", "gate", "--ci-run-id", "10"]), 0) + self.assertIn("source_sha=" + A, output.read_text()) + self.assertIn("compiler_version=" + self.version, output.read_text()) + promote.assert_not_called() + + def test_package_uses_the_existing_target_package_plan(self): + identity = dict(source_sha=A, compiler_version=self.version, ci_run_id=10) + with patch.dict(os.environ, {"GITHUB_REPOSITORY": nightly.REPO}), patch.object( + nightly, "eligible", return_value=identity + ), patch.object(nightly, "checkout_identity"), patch( + "tools.ci.common.main", return_value=0 + ) as run: + self.assertEqual( + nightly.main( + ["--stage", "package", "--target", "linux-riscv64", "--provision"] + ), + 0, + ) + run.assert_called_once_with( + "package", ["--target", "linux-riscv64", "--provision"] + ) + self.assertEqual(os.environ["RELEASE_VERSION"], self.version) + + def test_installers_must_match_reviewed_deployed_bytes(self): + data = b"fixture" + pins = self.root / "pins.json" + pins.write_text( + json.dumps( + { + url: hashlib.sha256(data).hexdigest() + for url in [ + "https://wave-lang.dev/install.sh", + "https://wave-lang.dev/install.ps1", + ] + } + ) + ) + with patch.object(nightly, "INSTALLERS_FILE", pins), patch.object( + nightly, "urlopen", side_effect=lambda *a, **k: io.BytesIO(data) + ): + nightly.installers_ready() + with patch.object(nightly, "INSTALLERS_FILE", pins), patch.object( + nightly, "urlopen", return_value=io.BytesIO(b"old") + ): + with self.assertRaises(ValueError): + nightly.installers_ready() + + def test_publication_is_opt_in_and_versioned_rules_remain_strict(self): + with patch.dict(os.environ, {"GITHUB_REPOSITORY": "fork/Wave"}), patch.object( + nightly, "GitHub" + ) as github: + self.assertEqual(nightly.main(["--stage", "publish"]), 1) + github.assert_not_called() + + +if __name__ == "__main__": + unittest.main() From a1c63f783b7556a608c0182da6d22422095ed11d Mon Sep 17 00:00:00 2001 From: chenzeyan54-commits Date: Sat, 26 Sep 2026 12:03:37 +0800 Subject: [PATCH 2/6] fix(#795): Support pathname Unix-domain streams on Windows The Windows local-address provider always returns -95, and the Windows branches of `_unix_socket` and `unix_remove` do the same. Consequently, portabl... Signed-off-by: chenzeyan54-commits (cherry picked from commit 950ae4f1c6d4b636868d07ebf214d925bcf02857) Signed-off-by: LunaStev --- std/sys/windows/local_addr.wave | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/std/sys/windows/local_addr.wave b/std/sys/windows/local_addr.wave index d42b8d77..4991ac0f 100644 --- a/std/sys/windows/local_addr.wave +++ b/std/sys/windows/local_addr.wave @@ -1,12 +1,30 @@ -// Unix-domain sockets are intentionally unavailable through this Unix API. -// Windows named pipes and AF_UNIX require separate Windows-native contracts. +// Unix-domain sockets are supported on Windows via AF_UNIX for local IPC. +// This provider supports pathname sockets only. Abstract sockets are not +// available on Windows and remain unsupported. -pub const AF_LOCAL: i32 = 1; -pub const NATIVE_LOCAL_PATH_CAPACITY: i32 = 1; +pub const AF_LOCAL: i32 = 16; +pub const NATIVE_LOCAL_PATH_CAPACITY: i32 = 260; pub struct NativeLocalAddr { - unavailable: u8; + path: char; + path_len: u32; } -pub fun native_local_addr(path: str, output: ptr) -> i32 { return -95; } -pub fun native_local_abstract(name: str, output: ptr) -> i32 { return -95; } +pub fun native_local_addr(path: str, output: ptr) -> i32 { + if len(path) > NATIVE_LOCAL_PATH_CAPACITY { + return -95; + } + output.path = null_char; + output.path_len = 0; + for i in 0..len(path) { + output.path = path[i]; + output.path_len = i + 1; + } + output.path = path[path.len]; + output.path_len = len(path); + return 0; +} + +pub fun native_local_abstract(name: str, output: ptr) -> i32 { + return -95; +} \ No newline at end of file From e5dc44fc32081ae2930a6544d4e4a7e3463ee339 Mon Sep 17 00:00:00 2001 From: tenkeren11 <332676327+Tenkeren11@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:13:15 -0400 Subject: [PATCH 3/6] fix(windows): handle empty event sets without WSAPoll (cherry picked from commit dd90054ac0d189fd382ddfacf452705528debcf1) Signed-off-by: LunaStev --- std/sys/windows/event.wave | 1 + tests/boundary_regressions.rs | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/std/sys/windows/event.wave b/std/sys/windows/event.wave index 38b52c52..00d6b5d4 100644 --- a/std/sys/windows/event.wave +++ b/std/sys/windows/event.wave @@ -89,6 +89,7 @@ pub fun event_wait( || capacity > WINDOWS_EVENT_CAPACITY || timeout_ms < -1) { return -22; } var descriptors: array; var count: i32 = deref state.count; + if (count == 0) { return 0; } var i: i32 = 0; while (i < count) { descriptors[i] = PollFd { diff --git a/tests/boundary_regressions.rs b/tests/boundary_regressions.rs index 56b871e4..cc6cf1db 100644 --- a/tests/boundary_regressions.rs +++ b/tests/boundary_regressions.rs @@ -234,3 +234,42 @@ fn read_to_end_rejects_capacity_overflow_and_preserves_normal_growth() { fs::write(case.root.join("empty.bin"), []).unwrap(); case.run(&source("tests/fixtures/boundaries/read_to_end.wave")); } +#[test] +#[cfg(windows)] +fn empty_windows_event_set_returns_zero() { + let case = Case::new("empty-windows-event"); + + let path = case.root.join("empty_event.wave"); + + fs::write( + &path, + r#" +import("std::sys::event")::{ + NativeEvent, event_create, event_wait, event_close +}; + +fun main() -> i32 { + var handle: i64 = event_create(4); + if (handle < 0) { return 1; } + + var events: array; + var count: i64 = event_wait(handle, &events[0], 4, 0); + + event_close(handle); + + if (count != 0) { return 2; } + return 0; +} +"#, + ) + .unwrap(); + + success( + case.command() + .arg("build") + .arg(&path) + .arg("--run") + .output() + .unwrap(), + ); +} \ No newline at end of file From 795e9e5eba449b4214c9d4f22173b7911c883f17 Mon Sep 17 00:00:00 2001 From: tenkeren11 <332676327+Tenkeren11@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:34:32 -0400 Subject: [PATCH 4/6] style: format boundary regression test (cherry picked from commit a5520b1e19b52083f548782e59c03a5ebf16304f) Signed-off-by: LunaStev --- tests/boundary_regressions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/boundary_regressions.rs b/tests/boundary_regressions.rs index cc6cf1db..b2a24fd3 100644 --- a/tests/boundary_regressions.rs +++ b/tests/boundary_regressions.rs @@ -272,4 +272,4 @@ fun main() -> i32 { .output() .unwrap(), ); -} \ No newline at end of file +} From ff5b5ce04c04a351bcc695417be4207f02a1490a Mon Sep 17 00:00:00 2001 From: LunaStev Date: Fri, 2 Oct 2026 20:35:06 +0900 Subject: [PATCH 5/6] Complete Windows pathname streams and preserve empty wait timeouts Use the Windows sockaddr_un layout and validate UTF-8 pathname bytes before opening sockets. Enable the existing stream operations and explicit removal, retaining native errors and closing sockets on failed setup. Preserve finite and infinite empty event waits through Sleep without calling WSAPoll with no descriptors. Cover native Windows targets and injected OS failures, including cleanup errors, byte boundaries, and timeout behavior. Follow up on #796 and #797; address #795 and #793. Signed-off-by: LunaStev --- examples/std/net_unix.wave | 17 ++-- std/net/error.wave | 3 +- std/net/unix.wave | 12 ++- std/sys/windows/event.wave | 9 +- std/sys/windows/local_addr.wave | 49 ++++++---- tests/boundary_regressions.rs | 2 + .../native_providers/network_errors.wave | 3 + .../native_providers/windows_event.wave | 25 +++++ .../windows_event_infinite.wave | 12 +++ .../native_providers/windows_event_mock.c | 19 ++++ .../native_providers/windows_event_mock.wave | 27 ++++++ .../native_providers/windows_local_addr.wave | 28 ++++++ .../windows_local_addr_mock.c | 10 ++ .../windows_local_addr_mock.wave | 26 ++++++ .../native_providers/windows_unix.wave | 65 +++++++++++++ .../native_providers/windows_unix_mock.c | 37 ++++++++ .../native_providers/windows_unix_mock.wave | 29 ++++++ tests/native_providers.rs | 92 +++++++++++++++++++ 18 files changed, 432 insertions(+), 33 deletions(-) create mode 100644 tests/fixtures/native_providers/windows_event.wave create mode 100644 tests/fixtures/native_providers/windows_event_infinite.wave create mode 100644 tests/fixtures/native_providers/windows_event_mock.c create mode 100644 tests/fixtures/native_providers/windows_event_mock.wave create mode 100644 tests/fixtures/native_providers/windows_local_addr.wave create mode 100644 tests/fixtures/native_providers/windows_local_addr_mock.c create mode 100644 tests/fixtures/native_providers/windows_local_addr_mock.wave create mode 100644 tests/fixtures/native_providers/windows_unix.wave create mode 100644 tests/fixtures/native_providers/windows_unix_mock.c create mode 100644 tests/fixtures/native_providers/windows_unix_mock.wave diff --git a/examples/std/net_unix.wave b/examples/std/net_unix.wave index 1ce387d9..d77887bc 100644 --- a/examples/std/net_unix.wave +++ b/examples/std/net_unix.wave @@ -1,4 +1,4 @@ -import("std::net::error")::{NET_ERROR_UNSUPPORTED, NetError, NetIoResult, NetResult}; +import("std::net::error")::{NetError, NetIoResult, NetResult}; import("std::net::unix")::{ UnixListener, UnixStream, unix_listen, unix_connect, unix_accept, @@ -7,20 +7,15 @@ import("std::net::unix")::{ }; #[target(os="linux")] -fun main() -> i32 { return run_unix_stream(); } +fun main() -> i32 { return run_unix_stream("/tmp/wave-std-net-unix.sock"); } #[target(os="macos")] -fun main() -> i32 { return run_unix_stream(); } +fun main() -> i32 { return run_unix_stream("/tmp/wave-std-net-unix.sock"); } #[target(os="freebsd")] -fun main() -> i32 { return run_unix_stream(); } +fun main() -> i32 { return run_unix_stream("/tmp/wave-std-net-unix.sock"); } #[target(os="windows")] -fun main() -> i32 { - var unavailable: NetResult = unix_listen("wave-unavailable.sock", 1); - if (unavailable.ok || unavailable.error.kind != NET_ERROR_UNSUPPORTED) { return 1; } - return 0; -} +fun main() -> i32 { return run_unix_stream("wave-std-net-unix.sock"); } -fun run_unix_stream() -> i32 { - var path: str = "/tmp/wave-std-net-unix.sock"; +fun run_unix_stream(path: str) -> i32 { unix_remove(path); var listener: NetResult = unix_listen(path, 4); diff --git a/std/net/error.wave b/std/net/error.wave index 7fdc430d..3df1670d 100644 --- a/std/net/error.wave +++ b/std/net/error.wave @@ -109,7 +109,8 @@ fun classify_native_error(code: i64) -> i32 { if (code == -10054) { return NET_ERROR_RESET; } if (code == -10048) { return NET_ERROR_ADDRESS_IN_USE; } if (code == -10057) { return NET_ERROR_NOT_CONNECTED; } - if (code == -95 || code == -10045) { return NET_ERROR_UNSUPPORTED; } + if (code == -95 || code == -10043 || code == -10044 + || code == -10045 || code == -10047) { return NET_ERROR_UNSUPPORTED; } return NET_ERROR_OTHER; } diff --git a/std/net/unix.wave b/std/net/unix.wave index ed3ab12c..629a7628 100644 --- a/std/net/unix.wave +++ b/std/net/unix.wave @@ -15,6 +15,8 @@ import("std::sys::fs")::{unlink}; import("std::sys::fs")::{unlink}; #[target(os="freebsd")] import("std::sys::fs")::{unlink}; +#[target(os="windows")] +import("std::sys::fs")::{unlink}; import("std::net::error")::{ NetError, NetResult, NetIoResult, net_error_none, net_error_from_native, @@ -54,7 +56,7 @@ fun _unix_socket() -> i64 { #[target(os="freebsd")] fun _unix_socket() -> i64 { return socket(AF_LOCAL, SOCK_STREAM, 0); } #[target(os="windows")] -fun _unix_socket() -> i64 { return -95; } +fun _unix_socket() -> i64 { return socket(AF_LOCAL, SOCK_STREAM, 0); } fun _unix_address(path: str, address: ptr) -> i32 { return native_local_addr(path, address); @@ -202,7 +204,13 @@ pub fun unix_remove(path: str) -> NetError { return net_error_none(); } #[target(os="windows")] -pub fun unix_remove(path: str) -> NetError { return net_error_from_native(-95); } +pub fun unix_remove(path: str) -> NetError { + // Removal is explicit, including a path left by successful bind followed + // by failed listen. Never remove an existing path automatically on bind. + var result: i64 = unlink(path); + if (result < 0) { return net_error_from_native(result); } + return net_error_none(); +} pub fun unix_stream_valid(stream: UnixStream) -> bool { return net_fd_valid(stream.fd); } pub fun unix_listener_valid(listener: UnixListener) -> bool { return net_fd_valid(listener.fd); } diff --git a/std/sys/windows/event.wave b/std/sys/windows/event.wave index 00d6b5d4..0c20afe8 100644 --- a/std/sys/windows/event.wave +++ b/std/sys/windows/event.wave @@ -6,6 +6,7 @@ import("std::sys::windows::socket")::{ POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL, PollFd, poll, }; import("std::sys::windows::memory")::{sys_alloc, sys_free}; +extern(system, "Sleep") fun event_sleep(milliseconds: u32); pub const EVENT_BACKEND_KIND: i32 = 3; pub const EVENT_READABLE: i32 = 1; @@ -89,7 +90,13 @@ pub fun event_wait( || capacity > WINDOWS_EVENT_CAPACITY || timeout_ms < -1) { return -22; } var descriptors: array; var count: i32 = deref state.count; - if (count == 0) { return 0; } + if (count == 0) { + // There is no concurrent registration/wakeup contract for this set. + // Sleep(INFINITE) blocks without spinning; WSAPoll rejects nfds=0. + if (timeout_ms == -1) { event_sleep(4294967295 as u32); } + if (timeout_ms > 0) { event_sleep(timeout_ms as u32); } + return 0; + } var i: i32 = 0; while (i < count) { descriptors[i] = PollFd { diff --git a/std/sys/windows/local_addr.wave b/std/sys/windows/local_addr.wave index 4991ac0f..ebbf0fdc 100644 --- a/std/sys/windows/local_addr.wave +++ b/std/sys/windows/local_addr.wave @@ -1,30 +1,43 @@ -// Unix-domain sockets are supported on Windows via AF_UNIX for local IPC. -// This provider supports pathname sockets only. Abstract sockets are not -// available on Windows and remain unsupported. +// Windows 10 1803+ pathname AF_UNIX streams. The OS reports unavailable +// providers through Winsock; abstract addresses are outside this API contract. +// SOCKADDR_UN uses a u16 family followed by 108 UTF-8 bytes, including NUL. -pub const AF_LOCAL: i32 = 16; -pub const NATIVE_LOCAL_PATH_CAPACITY: i32 = 260; +extern(system, "MultiByteToWideChar") fun local_path_to_wide( + page: u32, flags: u32, source: ptr, bytes: i32, output: ptr, capacity: i32 +) -> i32; + +pub const AF_LOCAL: i32 = 1; +pub const NATIVE_LOCAL_PATH_CAPACITY: i32 = 108; pub struct NativeLocalAddr { - path: char; - path_len: u32; + family: u16; + path: array; } pub fun native_local_addr(path: str, output: ptr) -> i32 { - if len(path) > NATIVE_LOCAL_PATH_CAPACITY { - return -95; + if (path as ptr == null || output == null) { return -22; } + var length: i32 = 0; + while (path[length] != 0) { + if (length >= NATIVE_LOCAL_PATH_CAPACITY - 1) { return -36; } + length += 1; + } + if (length == 0) { return -22; } + // Validate UTF-8 without allocating or changing the caller's output. + if (local_path_to_wide(65001, 8, path as ptr, length, null, 0) == 0) { + return -84; } - output.path = null_char; - output.path_len = 0; - for i in 0..len(path) { - output.path = path[i]; - output.path_len = i + 1; + var value: NativeLocalAddr; + value.family = AF_LOCAL as u16; + var i: i32 = 0; + while (i < NATIVE_LOCAL_PATH_CAPACITY) { + value.path[i] = 0; + if (i < length) { value.path[i] = path[i]; } + i += 1; } - output.path = path[path.len]; - output.path_len = len(path); - return 0; + deref output = value; + return length + 3; } pub fun native_local_abstract(name: str, output: ptr) -> i32 { return -95; -} \ No newline at end of file +} diff --git a/tests/boundary_regressions.rs b/tests/boundary_regressions.rs index b2a24fd3..5c1a63c0 100644 --- a/tests/boundary_regressions.rs +++ b/tests/boundary_regressions.rs @@ -266,6 +266,8 @@ fun main() -> i32 { success( case.command() + .arg("--std-root") + .arg(case.home.join(".wave/lib/wave/std")) .arg("build") .arg(&path) .arg("--run") diff --git a/tests/fixtures/native_providers/network_errors.wave b/tests/fixtures/native_providers/network_errors.wave index d88f630c..a7d92891 100644 --- a/tests/fixtures/native_providers/network_errors.wave +++ b/tests/fixtures/native_providers/network_errors.wave @@ -88,6 +88,9 @@ fun platform() -> i32 { if (!check(-10057, NET_ERROR_NOT_CONNECTED)) { return 58; } if (!check(-95, NET_ERROR_UNSUPPORTED)) { return 96; } if (!check(-10045, NET_ERROR_UNSUPPORTED)) { return 46; } + if (!check(-10043, NET_ERROR_UNSUPPORTED)) { return 47; } + if (!check(-10044, NET_ERROR_UNSUPPORTED)) { return 48; } + if (!check(-10047, NET_ERROR_UNSUPPORTED)) { return 49; } if (!check(-4, NET_ERROR_OTHER)) { return 201; } if (!check(-11, NET_ERROR_OTHER)) { return 201; } if (!check(-35, NET_ERROR_OTHER)) { return 201; } diff --git a/tests/fixtures/native_providers/windows_event.wave b/tests/fixtures/native_providers/windows_event.wave new file mode 100644 index 00000000..29cf1a72 --- /dev/null +++ b/tests/fixtures/native_providers/windows_event.wave @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MPL-2.0 +import("std::sys::event")::{NativeEvent, event_create, event_add, event_remove, event_wait, event_close, EVENT_READABLE}; +import("std::sys::socket")::{socket, bind, close_socket, AF_INET, SOCK_DGRAM}; +struct Address { family: u16; port: u16; ip: array; padding: array; } +extern(system, "GetTickCount64") fun tick() -> u64; +fun empty_wait(handle: i64) -> bool { + var output: NativeEvent; + if (event_wait(handle, &output, 1, 0) != 0) { return false; } + var start: u64 = tick(); + if (event_wait(handle, &output, 1, 150) != 0) { return false; } + // Allow Windows clock granularity, but reject an immediate return. + return tick() - start >= 120; +} +fun main() -> i32 { + var handle: i64 = event_create(1); + if (handle < 0 || !empty_wait(handle)) { return 1; } + var fd: i64 = socket(AF_INET, SOCK_DGRAM, 0); + var address: Address = Address { family: 2, port: 0, ip: [127, 0, 0, 1], padding: [0,0,0,0,0,0,0,0] }; + if (fd < 0 || bind(fd, &address as ptr, 16) != 0 || event_add(handle, fd, 42, EVENT_READABLE) != 0) { return 2; } + var output: NativeEvent; + if (event_wait(handle, &output, 1, 0) < 0) { return 3; } + if (event_remove(handle, fd) != 0 || !empty_wait(handle)) { return 4; } + if (close_socket(fd) != 0 || event_close(handle) != 0) { return 5; } + return 0; +} diff --git a/tests/fixtures/native_providers/windows_event_infinite.wave b/tests/fixtures/native_providers/windows_event_infinite.wave new file mode 100644 index 00000000..9c7b4e34 --- /dev/null +++ b/tests/fixtures/native_providers/windows_event_infinite.wave @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MPL-2.0 +import("std::sys::event")::{NativeEvent, event_create, event_wait}; +import("std::sys::fs")::{open, close, FS_O_CREAT, FS_O_EXCL, FS_O_WRONLY}; +fun main() -> i32 { + var handle: i64 = event_create(1); + if (handle < 0) { return 1; } + var fd: i64 = open("ready", FS_O_CREAT | FS_O_EXCL | FS_O_WRONLY, 0); + if (fd < 0 || close(fd) != 0) { return 2; } + var output: NativeEvent; + event_wait(handle, &output, 1, -1); + return 3; +} diff --git a/tests/fixtures/native_providers/windows_event_mock.c b/tests/fixtures/native_providers/windows_event_mock.c new file mode 100644 index 00000000..745d2d53 --- /dev/null +++ b/tests/fixtures/native_providers/windows_event_mock.c @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MPL-2.0 +#include +#include +#include +#include +static int sleeps, polls; +static uint32_t milliseconds; +void reset_calls(void) { sleeps = polls = 0; milliseconds = 0; } +bool calls_ok(int s, uint32_t ms, int p) { return sleeps == s && milliseconds == ms && polls == p; } +void Sleep(uint32_t ms) { sleeps++; milliseconds = ms; } +void *sys_alloc(int64_t size) { return calloc(1, (size_t)size); } +int64_t sys_free(void *p, int64_t size) { (void)size; free(p); return 0; } +struct pollfd { int64_t fd; int16_t events, revents; }; +int64_t poll(struct pollfd *fds, int64_t count, int32_t timeout) { + polls++; + assert(count == 1 && timeout == 12 && fds[0].fd == 17 && fds[0].events == 256); + fds[0].revents = 256; + return 1; +} diff --git a/tests/fixtures/native_providers/windows_event_mock.wave b/tests/fixtures/native_providers/windows_event_mock.wave new file mode 100644 index 00000000..bcf1507e --- /dev/null +++ b/tests/fixtures/native_providers/windows_event_mock.wave @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MPL-2.0 +extern(c) fun reset_calls(); +extern(c) fun calls_ok(sleeps: i32, milliseconds: u32, polls: i32) -> bool; +fun verify_empty(handle: i64) -> bool { + var output: NativeEvent; + output.token = 987; + reset_calls(); + if (event_wait(handle, &output, 1, 0) != 0 || !calls_ok(0, 0, 0) || output.token != 987) { return false; } + if (event_wait(handle, &output, 1, 123) != 0 || !calls_ok(1, 123, 0)) { return false; } + reset_calls(); + if (event_wait(handle, &output, 1, -1) != 0 || !calls_ok(1, 4294967295 as u32, 0)) { return false; } + reset_calls(); + if (event_wait(handle, &output, 1, -2) != -22 || event_wait(handle, null, 1, 0) != -22 + || !calls_ok(0, 0, 0)) { return false; } + return true; +} +fun main() -> i32 { + var handle: i64 = event_create(1); + if (handle < 0 || !verify_empty(handle)) { return 1; } + if (event_add(handle, 17, 42, EVENT_READABLE) != 0) { return 2; } + var output: NativeEvent; + reset_calls(); + if (event_wait(handle, &output, 1, 12) != 1 || output.token != 42 + || output.fd != 17 || output.flags != EVENT_READABLE || !calls_ok(0, 0, 1)) { return 3; } + if (event_remove(handle, 17) != 0 || !verify_empty(handle) || event_close(handle) != 0) { return 4; } + return 0; +} diff --git a/tests/fixtures/native_providers/windows_local_addr.wave b/tests/fixtures/native_providers/windows_local_addr.wave new file mode 100644 index 00000000..c9a812a2 --- /dev/null +++ b/tests/fixtures/native_providers/windows_local_addr.wave @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MPL-2.0 +import("std::sys::windows::local_addr")::{ + AF_LOCAL, NativeLocalAddr, native_local_addr, native_local_abstract +}; +import("std::mem::layout")::{size_of}; +fun main() -> i32 { + var address: NativeLocalAddr; + if (AF_LOCAL != 1 || size_of() != 110) { return 1; } + if (native_local_addr("한글.sock", &address) != 14 || address.family != 1 + || address.path[0] != 237 || address.path[11] != 0 || address.path[107] != 0) { return 2; } + var path: array; + var i: i32 = 0; + while (i < 108) { path[i] = 97; i += 1; } + path[107] = 0; + if (native_local_addr(&path[0] as str, &address) != 110 || address.path[106] != 97 + || address.path[107] != 0) { return 3; } + address.family = 99; + path[107] = 97; path[108] = 0; + if (native_local_addr(&path[0] as str, &address) != -36 || address.family != 99) { return 4; } + var nil: ptr = null; + if (native_local_addr("", &address) != -22 || address.family != 99 + || native_local_addr(nil as str, &address) != -22 + || native_local_addr("ok", null) != -22) { return 5; } + var invalid: array = [192, 175, 0]; + if (native_local_addr(&invalid[0] as str, &address) != -84 || address.family != 99) { return 6; } + if (native_local_abstract("name", &address) != -95 || address.family != 99) { return 7; } + return 0; +} diff --git a/tests/fixtures/native_providers/windows_local_addr_mock.c b/tests/fixtures/native_providers/windows_local_addr_mock.c new file mode 100644 index 00000000..5ce92439 --- /dev/null +++ b/tests/fixtures/native_providers/windows_local_addr_mock.c @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MPL-2.0 +#include +#include +int MultiByteToWideChar(uint32_t page, uint32_t flags, const unsigned char *s, + int bytes, uint16_t *output, int capacity) { + assert(page == 65001 && flags == 8 && bytes > 0 && bytes <= 107); + assert(output == 0 && capacity == 0); + // Boundary mock: native Windows tests validate the actual UTF-8 decoder. + return s[0] == 192 ? 0 : bytes; +} diff --git a/tests/fixtures/native_providers/windows_local_addr_mock.wave b/tests/fixtures/native_providers/windows_local_addr_mock.wave new file mode 100644 index 00000000..95a7473f --- /dev/null +++ b/tests/fixtures/native_providers/windows_local_addr_mock.wave @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 + +import("std::mem::layout")::{size_of}; +fun main() -> i32 { + var address: NativeLocalAddr; + if (AF_LOCAL != 1 || size_of() != 110) { return 1; } + if (native_local_addr("한글.sock", &address) != 14 || address.family != 1 + || address.path[0] != 237 || address.path[11] != 0 || address.path[107] != 0) { return 2; } + var path: array; + var i: i32 = 0; + while (i < 108) { path[i] = 97; i += 1; } + path[107] = 0; + if (native_local_addr(&path[0] as str, &address) != 110 || address.path[106] != 97 + || address.path[107] != 0) { return 3; } + address.family = 99; + path[107] = 97; path[108] = 0; + if (native_local_addr(&path[0] as str, &address) != -36 || address.family != 99) { return 4; } + var nil: ptr = null; + if (native_local_addr("", &address) != -22 || address.family != 99 + || native_local_addr(nil as str, &address) != -22 + || native_local_addr("ok", null) != -22) { return 5; } + var invalid: array = [192, 175, 0]; + if (native_local_addr(&invalid[0] as str, &address) != -84 || address.family != 99) { return 6; } + if (native_local_abstract("name", &address) != -95 || address.family != 99) { return 7; } + return 0; +} diff --git a/tests/fixtures/native_providers/windows_unix.wave b/tests/fixtures/native_providers/windows_unix.wave new file mode 100644 index 00000000..e189c43c --- /dev/null +++ b/tests/fixtures/native_providers/windows_unix.wave @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MPL-2.0 +import("std::net::unix")::{ + UnixListener, UnixStream, unix_listen, unix_connect, unix_accept, + unix_read, unix_write_all, unix_shutdown_write, unix_close, + unix_close_listener, unix_remove, unix_listen_abstract +}; +import("std::net::error")::{NetResult, NetIoResult, NET_ERROR_UNSUPPORTED}; +import("std::sys::fs")::{access, open, close, write, read, FS_O_CREAT, FS_O_EXCL, FS_O_RDWR}; + +fun exchange(path: str) -> i32 { + var listener: NetResult = unix_listen(path, 4); + if (!listener.ok) { return 10; } + var occupied: NetResult = unix_listen(path, 4); + if (occupied.ok || occupied.error.native_code >= 0) { return 11; } + var client: NetResult = unix_connect(path); + if (!client.ok) { return 12; } + var accepted: NetResult = unix_accept(listener.value); + if (!accepted.ok) { return 13; } + var bytes: array = [10, 20, 30]; + var sent: NetIoResult = unix_write_all(client.value, &bytes[0], 3); + if (sent.error.kind != 0 || sent.count != 3) { return 14; } + var got: array; + var count: i64 = 0; + while (count < 3) { + var received: NetIoResult = unix_read(accepted.value, &got[count], 3 - count); + if (received.error.kind != 0 || received.count <= 0) { return 15; } + count += received.count; + } + if (got[0] != 10 || got[1] != 20 || got[2] != 30) { return 16; } + if (unix_shutdown_write(client.value).kind != 0) { return 17; } + var eof: NetIoResult = unix_read(accepted.value, &got[0], 1); + if (!eof.eof || eof.count != 0) { return 18; } + if (unix_close(client.value).kind != 0 || unix_close(accepted.value).kind != 0 + || unix_close_listener(listener.value).kind != 0) { return 19; } + if (access(path, 0) != 0 || unix_remove(path).kind != 0 || access(path, 0) == 0) { return 20; } + listener = unix_listen(path, 1); + if (!listener.ok || unix_close_listener(listener.value).kind != 0 + || unix_remove(path).kind != 0) { return 21; } + return 0; +} +fun main() -> i32 { + var missing: NetResult = unix_connect("missing.sock"); + if (missing.ok || missing.error.native_code >= 0) { return 1; } + var bad_parent: NetResult = unix_listen("missing-dir/socket", 1); + if (bad_parent.ok || bad_parent.error.native_code >= 0) { return 2; } + var abstract: NetResult = unix_listen_abstract("name", 1); + if (abstract.ok || abstract.error.kind != NET_ERROR_UNSUPPORTED) { return 3; } + var file: i64 = open("ordinary-file", FS_O_CREAT | FS_O_EXCL | FS_O_RDWR, 0); + var marker: u8 = 42; + if (file < 0 || write(file, &marker, 1) != 1 || close(file) != 0) { return 4; } + var occupied: NetResult = unix_listen("ordinary-file", 1); + if (occupied.ok) { return 5; } + file = open("ordinary-file", 0, 0); + marker = 0; + if (file < 0 || read(file, &marker, 1) != 1 || marker != 42 || close(file) != 0) { return 6; } + var result: i32 = exchange("ascii.sock"); + if (result != 0) { return result; } + result = exchange("한글.sock"); + if (result != 0) { return result; } + var path: array; + var i: i32 = 0; + while (i < 107) { path[i] = 97; i += 1; } + path[107] = 0; + return exchange(&path[0] as str); +} diff --git a/tests/fixtures/native_providers/windows_unix_mock.c b/tests/fixtures/native_providers/windows_unix_mock.c new file mode 100644 index 00000000..9997bccd --- /dev/null +++ b/tests/fixtures/native_providers/windows_unix_mock.c @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +#include +#include +#include +static int failure, closed, sockets; +void prepare_failure(int stage) { failure = stage; closed = sockets = 0; } +int closed_count(void) { return closed; } +int socket_count(void) { return sockets; } +int MultiByteToWideChar(uint32_t page, uint32_t flags, const char *text, int bytes, void *out, int cap) { + assert(page == 65001 && flags == 8 && bytes == 9 && !out && cap == 0); + assert(memcmp(text, "unit.sock", 9) == 0); + return bytes; +} +int64_t socket(int domain, int type, int protocol) { + assert(domain == 1 && type == 1 && protocol == 0); + sockets++; + return failure == 1 ? -10047 : 71; +} +struct address { uint16_t family; char path[108]; }; +static void check_address(int64_t fd, const struct address *a, int length) { + assert(fd == 71 && length == 12 && a->family == 1); + assert(strcmp(a->path, "unit.sock") == 0 && a->path[107] == 0); +} +int64_t bind(int64_t fd, const struct address *a, int length) { + check_address(fd, a, length); return failure == 2 ? -10048 : 0; +} +int64_t listen(int64_t fd, int backlog) { + assert(fd == 71 && backlog == 1); return failure == 3 ? -10022 : 0; +} +int64_t connect(int64_t fd, const struct address *a, int length) { + check_address(fd, a, length); return failure == 4 ? -10061 : 0; +} +int64_t net_close(int64_t fd) { + assert(fd == 71); closed++; + // Even a cleanup error must not replace the original operation's error. + return -10038; +} diff --git a/tests/fixtures/native_providers/windows_unix_mock.wave b/tests/fixtures/native_providers/windows_unix_mock.wave new file mode 100644 index 00000000..cfc40bd4 --- /dev/null +++ b/tests/fixtures/native_providers/windows_unix_mock.wave @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MPL-2.0 +extern(c) fun prepare_failure(stage: i32); +extern(c) fun closed_count() -> i32; +extern(c) fun socket_count() -> i32; +fun main() -> i32 { + prepare_failure(0); + var invalid: NetResult = unix_listen("", 1); + if (invalid.ok || invalid.error.native_code != -22 || socket_count() != 0) { return 1; } + invalid = unix_listen("unit.sock", -1); + if (invalid.ok || invalid.error.native_code != -22 || socket_count() != 0) { return 2; } + prepare_failure(1); + var listener: NetResult = unix_listen("unit.sock", 1); + if (listener.ok || listener.error.native_code != -10047 || closed_count() != 0) { return 3; } + prepare_failure(2); + listener = unix_listen("unit.sock", 1); + if (listener.ok || listener.error.native_code != -10048 || closed_count() != 1) { return 4; } + prepare_failure(3); + listener = unix_listen("unit.sock", 1); + if (listener.ok || listener.error.native_code != -10022 || closed_count() != 1) { return 5; } + prepare_failure(4); + var stream: NetResult = unix_connect("unit.sock"); + if (stream.ok || stream.error.native_code != -10061 || closed_count() != 1) { return 6; } + prepare_failure(0); + listener = unix_listen("unit.sock", 1); + stream = unix_connect("unit.sock"); + if (!listener.ok || !stream.ok || listener.value.fd != 71 || stream.value.fd != 71 + || socket_count() != 2 || closed_count() != 0) { return 7; } + return 0; +} diff --git a/tests/native_providers.rs b/tests/native_providers.rs index 261c2649..ee3ceab6 100644 --- a/tests/native_providers.rs +++ b/tests/native_providers.rs @@ -98,6 +98,62 @@ fn host_target() -> String { format!("{}-{os}", std::env::consts::ARCH) } +#[test] +fn windows_pathname_streams_and_empty_events() { + let case = Case::new(); + for target in ["x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"] { + if !supported(target) { + continue; + } + for name in [ + "windows_local_addr.wave", + "windows_unix.wave", + "windows_event.wave", + "windows_event_infinite.wave", + ] { + for opt in ["-O0", "-O2"] { + let native = target == host_target(); + let output = case.0.join(if native { "ipc.exe" } else { "ipc.o" }); + build(&case, &fixture(name), target, opt, &output, !native); + if native && name != "windows_event_infinite.wave" { + let run_dir = case.0.join(format!("{name}-{opt}")); + fs::create_dir(&run_dir).unwrap(); + checked(Command::new(&output).current_dir(&run_dir), name, &case.0); + } + } + } + } +} + +#[cfg(windows)] +#[test] +fn windows_empty_infinite_wait_blocks_until_process_is_stopped() { + let case = Case::new(); + // A marker proves the child reached the wait, rather than merely starting slowly. + let source = fixture("windows_event_infinite.wave"); + for opt in ["-O0", "-O2"] { + let binary = case.0.join("infinite.exe"); + build(&case, &source, &host_target(), opt, &binary, false); + let mut child = Command::new(binary).current_dir(&case.0).spawn().unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + let marker = case.0.join("ready"); + while !marker.exists() && Instant::now() < deadline { + if let Some(status) = child.try_wait().unwrap() { + panic!("infinite wait exited before readiness: {status}"); + } + std::thread::sleep(Duration::from_millis(10)); + } + let ready = marker.exists(); + std::thread::sleep(Duration::from_millis(250)); + let premature = child.try_wait().unwrap(); + let _ = child.kill(); + let _ = child.wait(); + assert!(ready, "child did not reach the wait"); + assert!(premature.is_none(), "infinite wait returned: {premature:?}"); + fs::remove_file(marker).unwrap(); + } +} + #[test] fn loongarch_attribute_aliases_select_identical_declarations() { let target = "loongarch64-unknown-linux-gnu"; @@ -206,6 +262,9 @@ fn native_provider_os_boundary_failures() { let case = Case::new(); for (name, provider) in [ ("windows_time", "std/sys/windows/time.wave"), + ("windows_local_addr", "std/sys/windows/local_addr.wave"), + ("windows_unix", "std/sys/windows/local_addr.wave"), + ("windows_event", "std/sys/windows/event.wave"), ("windows_random", "std/sys/windows/random.wave"), ("freebsd_random", "std/sys/freebsd/amd64/random.wave"), ("macos_event", "std/sys/macos/event.wave"), @@ -213,6 +272,39 @@ fn native_provider_os_boundary_failures() { let mut text = fs::read_to_string(root().join(provider)) .unwrap() .replace("extern(system,", "extern(c,"); + if name == "windows_unix" { + let unix = fs::read_to_string(root().join("std/net/unix.wave")).unwrap(); + text += "\nimport(\"std::net::error\")::{NetResult, NetError, net_error_from_native, net_result_err, net_result_ok};\n"; + let types = unix.find("pub struct UnixListener").unwrap(); + let end_types = unix[types..].find("#[target").unwrap() + types; + text += &unix[types..end_types]; + let windows = unix + .find("#[target(os=\"windows\")]\nfun _unix_socket") + .unwrap(); + let start = windows + "#[target(os=\"windows\")]\n".len(); + let end = unix[start..].find('\n').unwrap() + start; + text += &unix[start..end]; + let start = unix.find("fun _unix_address").unwrap(); + let end = unix[start..].find("#[target").unwrap() + start; + text += &unix[start..end]; + text += r#" +const SOCK_STREAM: i32 = 1; +extern(c) fun socket(domain: i32, ty: i32, protocol: i32) -> i64; +extern(c) fun bind(fd: i64, address: ptr, length: i32) -> i64; +extern(c) fun listen(fd: i64, backlog: i32) -> i64; +extern(c) fun connect(fd: i64, address: ptr, length: i32) -> i64; +extern(c) fun net_close(fd: i64) -> i64; +"#; + } + if name == "windows_event" { + text = text.replace( + "import(\"std::sys::windows::socket\")::{\n POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL, PollFd, poll,\n};", + "pub const POLLIN: i16 = 256; pub const POLLOUT: i16 = 16; pub const POLLERR: i16 = 1; pub const POLLHUP: i16 = 2; pub const POLLNVAL: i16 = 4; struct PollFd { fd: i64; events: i16; revents: i16; } extern(c) fun poll(fds: ptr, count: i64, timeout: i32) -> i64;", + ).replace( + "import(\"std::sys::windows::memory\")::{sys_alloc, sys_free};", + "extern(c) fun sys_alloc(size: i64) -> ptr; extern(c) fun sys_free(p: ptr, size: i64) -> i64;", + ); + } if name == "freebsd_random" { text = text.replace( "import(\"std::sys::freebsd::amd64::syscall\")::{syscall3};", From 82171fa1babb324bc937ce02429e2500b0e51151 Mon Sep 17 00:00:00 2001 From: LunaStev Date: Fri, 2 Oct 2026 21:21:18 +0900 Subject: [PATCH 6/6] Fix static mock linking and make WASI host startup synchronous Link OS-boundary mocks without PIE, exercise fatal WASI exits with live host handles, and point the README build badge at ci.yml. Signed-off-by: LunaStev --- README.md | 2 +- tests/native_providers.rs | 2 ++ tools/run_wasi_smoke.mjs | 9 ++++++--- tools/test_ci.py | 2 ++ 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 999f4f41..e4ce6a0e 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@

- Build status + Build status Latest release Sponsor Wave on OpenCollective

diff --git a/tests/native_providers.rs b/tests/native_providers.rs index ee3ceab6..57d675ee 100644 --- a/tests/native_providers.rs +++ b/tests/native_providers.rs @@ -345,6 +345,8 @@ extern(c) fun net_close(fd: i64) -> i64; let binary = case.0.join("probe"); checked( Command::new("clang") + // Wave emits static objects; Ubuntu's clang defaults to PIE. + .arg("-no-pie") .arg(&object) .arg(&c_object) .arg("-o") diff --git a/tools/run_wasi_smoke.mjs b/tools/run_wasi_smoke.mjs index 5a171a1d..5774fbe5 100644 --- a/tools/run_wasi_smoke.mjs +++ b/tools/run_wasi_smoke.mjs @@ -1,5 +1,5 @@ // Runs a WASI Preview 1 command with the supplied directory preopened as fd 3. -import { readFile } from "node:fs/promises"; +import { readFileSync } from "node:fs"; import { WASI } from "node:wasi"; const modulePath = process.argv[2]; @@ -16,8 +16,11 @@ const wasi = new WASI({ env: process.env, preopens: { ".": preopenPath }, }); -const module = await WebAssembly.compile(await readFile(modulePath)); -const instance = await WebAssembly.instantiate(module, wasi.getImportObject()); +// This is a synchronous command runner. Avoid asynchronous module compilation +// and top-level await so startup and termination do not depend on worker/event +// loop progress while a preloaded host handle is keeping the process alive. +const module = new WebAssembly.Module(readFileSync(modulePath)); +const instance = new WebAssembly.Instance(module, wasi.getImportObject()); const leaked = Object.keys(instance.exports).filter((name) => name.startsWith("__wave_")); if (leaked.length !== 0) { throw new Error(`private Wave functions leaked into exports: ${leaked.join(", ")}`); diff --git a/tools/test_ci.py b/tools/test_ci.py index fd1d793c..3a0ab9ed 100644 --- a/tools/test_ci.py +++ b/tools/test_ci.py @@ -84,6 +84,8 @@ def test_wasi_host_propagates_guest_failure_status(self): ("proc_exit_failure", module, 7), ("proc_exit_success", module.replace(bytes.fromhex("410710000b"), bytes.fromhex("410010000b")), 0), ("start_returns", module.replace(bytes.fromhex("0a08010600410710000b"), bytes.fromhex("0a040102000b")), 0), + ("start_traps", module.replace(bytes.fromhex("0a08010600410710000b"), bytes.fromhex("0a05010300000b")), 1), + ("invalid_module", b"not a WebAssembly module", 1), ] for name, contents, expected in cases: with self.subTest(case=name):