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/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/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 38b52c52..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,6 +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) { + // 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 d42b8d77..ebbf0fdc 100644 --- a/std/sys/windows/local_addr.wave +++ b/std/sys/windows/local_addr.wave @@ -1,12 +1,43 @@ -// Unix-domain sockets are intentionally unavailable through this Unix API. -// Windows named pipes and AF_UNIX require separate Windows-native contracts. +// 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. + +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 = 1; +pub const NATIVE_LOCAL_PATH_CAPACITY: i32 = 108; pub struct NativeLocalAddr { - unavailable: u8; + family: u16; + path: array; +} + +pub fun native_local_addr(path: str, output: ptr) -> i32 { + 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; + } + 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; + } + deref output = value; + return length + 3; } -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_abstract(name: str, output: ptr) -> i32 { + return -95; +} diff --git a/tests/boundary_regressions.rs b/tests/boundary_regressions.rs index 56b871e4..5c1a63c0 100644 --- a/tests/boundary_regressions.rs +++ b/tests/boundary_regressions.rs @@ -234,3 +234,44 @@ 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("--std-root") + .arg(case.home.join(".wave/lib/wave/std")) + .arg("build") + .arg(&path) + .arg("--run") + .output() + .unwrap(), + ); +} 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..57d675ee 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};", @@ -253,6 +345,8 @@ fn native_provider_os_boundary_failures() { 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/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/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): 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()