diff --git a/.ado/pipelines/azure-pipelines-build.yml b/.ado/pipelines/azure-pipelines-build.yml index 59220eceb..998e9aa79 100644 --- a/.ado/pipelines/azure-pipelines-build.yml +++ b/.ado/pipelines/azure-pipelines-build.yml @@ -112,23 +112,64 @@ stages: sourceDateEpoch: $[ stageDependencies.PrepareRelease.SelectRelease.outputs['release.sourceDateEpoch'] ] CARGO_CACHE_HOME: $(Pipeline.Workspace)/.cargo PNPM_STORE_PATH: $(Pipeline.Workspace)/.pnpm-store + # Pinned cross-compilation toolchain versions. All five target legs build + # on Linux; Windows and macOS legs cross-compile instead of running on + # native hosted pools, so these versions gate reproducibility for them. + # Bump deliberately, and update the matching `toolchainIdentity` cache + # label below alongside any version bump so stale caches are dropped. + cargoXwinVersion: '0.23.0' + llvmAptVersion: '18' + cargoZigbuildVersion: '0.23.0' + zigVersion: '0.13.0' + # Official SHA-256 of https://ziglang.org/download/0.13.0/zig-linux-x86_64-0.13.0.tar.xz. + # Update this alongside zigVersion if the pin is ever bumped. + zigSha256: 'd45312e61ebcc48032b77bc4cf7fd6915c11fa16e4aad116b66c9468211230ea' + # Name of the Azure DevOps Library secure file holding the Apple SDK used + # by macOS legs. The file itself is uploaded out-of-band by release + # engineering from a legally obtained Xcode/Apple SDK distribution; it is + # never embedded in source control. + appleSdkSecureFileName: 'WebUI-MacOSX-SDK.tar.xz' + # The Apple SDK archive's expected SHA-256 is intentionally NOT a pipeline + # variable here: it must never be committed as a placeholder. It comes + # from the required `WEBUI_APPLE_SDK_SHA256` Azure Pipelines/Library + # variable (set in the pipeline's variable group), validated for format + # and folded into the Cargo cache identity below so a rotated SDK busts + # stale macOS caches instead of silently reusing them. jobs: - - job: BuildLinuxAssets - displayName: Build Linux release asset + - job: BuildReleaseAssets + displayName: Build release asset strategy: matrix: LinuxX64: targetTriple: x86_64-unknown-linux-gnu artifactName: stage-linux-x64 + backend: linux installArm64Linker: 'false' manylinuxCrossImage: messense/manylinux2014-cross:x86_64@sha256:13670ccc63c35e072661938181c046243c01d7bca7976b3914177fb9b162998d - pythonNativeTest: 'true' + toolchainIdentity: manylinux2014-x86_64-13670ccc LinuxArm64: targetTriple: aarch64-unknown-linux-gnu artifactName: stage-linux-arm64 + backend: linux installArm64Linker: 'true' manylinuxCrossImage: messense/manylinux2014-cross:aarch64@sha256:32c92568ebee8db53e0598c21bcd06a6dd1649d45d507646a8a49313c50325f8 - pythonNativeTest: 'false' + toolchainIdentity: manylinux2014-aarch64-32c92568 + WindowsX64: + targetTriple: x86_64-pc-windows-msvc + artifactName: stage-windows-x64 + backend: windows + toolchainIdentity: xwin-0.23.0-llvm-18 + WindowsArm64: + targetTriple: aarch64-pc-windows-msvc + artifactName: stage-windows-arm64 + backend: windows + toolchainIdentity: xwin-0.23.0-llvm-18 + MacOSArm64: + targetTriple: aarch64-apple-darwin + artifactName: stage-macos-arm64 + backend: macos + macosDeploymentTarget: '11.0' + toolchainIdentity: zigbuild-0.23.0-zig-0.13.0-sdk-11.0 pool: vmImage: ubuntu-latest steps: @@ -138,23 +179,126 @@ stages: displayName: Use Python 3.11 inputs: versionSpec: '3.11' + - task: Bash@3 + displayName: Resolve Apple SDK cache identity + name: appleSdkCacheIdentity + inputs: + targetType: inline + script: | + # Copyright (c) Microsoft Corporation. + # Licensed under the MIT license. + + set -euo pipefail + + # Runs for every leg (not just macOS) so the Cargo cache key below + # can reference a single variable regardless of backend. Non-macOS + # legs never need the Apple SDK, so their cache identity segment is + # a fixed constant that never changes and never busts their cache. + if [[ "$BACKEND" != "macos" ]]; then + echo "##vso[task.setvariable variable=digest;isOutput=true]none" + exit 0 + fi + + : "${WEBUI_APPLE_SDK_SHA256:?WEBUI_APPLE_SDK_SHA256 is required for macOS legs. Set it as an Azure Pipelines/Library variable containing the Apple SDK secure file's SHA-256 digest.}" + + if [[ ! "$WEBUI_APPLE_SDK_SHA256" =~ ^[0-9a-f]{64}$ ]]; then + echo "##vso[task.logissue type=error]WEBUI_APPLE_SDK_SHA256 must be exactly 64 lowercase hex characters." + exit 1 + fi + + # Also feeds the Cargo cache key: rotating the Apple SDK changes + # this value, which invalidates every macOS leg's stale cache. + echo "##vso[task.setvariable variable=digest;isOutput=true]$WEBUI_APPLE_SDK_SHA256" + env: + BACKEND: $(backend) + WEBUI_APPLE_SDK_SHA256: $(WEBUI_APPLE_SDK_SHA256) - task: Cache@2 displayName: Restore Cargo cache inputs: - key: 'cargo-home | "$(Agent.OS)" | "$(targetTriple)" | Cargo.lock' + key: 'cargo-home | "$(Agent.OS)" | "$(targetTriple)" | "$(backend)" | "$(toolchainIdentity)" | "$(appleSdkCacheIdentity.digest)" | Cargo.lock' restoreKeys: | - cargo-home | "$(Agent.OS)" | "$(targetTriple)" - cargo-home | "$(Agent.OS)" + cargo-home | "$(Agent.OS)" | "$(targetTriple)" | "$(backend)" | "$(toolchainIdentity)" | "$(appleSdkCacheIdentity.digest)" + cargo-home | "$(Agent.OS)" | "$(targetTriple)" | "$(backend)" path: $(CARGO_CACHE_HOME) - task: Cache@2 displayName: Restore target cache inputs: - key: 'cargo-target | "$(Agent.OS)" | "$(targetTriple)" | Cargo.lock' + key: 'cargo-target | "$(Agent.OS)" | "$(targetTriple)" | "$(backend)" | "$(toolchainIdentity)" | "$(appleSdkCacheIdentity.digest)" | Cargo.lock' restoreKeys: | - cargo-target | "$(Agent.OS)" | "$(targetTriple)" + cargo-target | "$(Agent.OS)" | "$(targetTriple)" | "$(backend)" | "$(toolchainIdentity)" | "$(appleSdkCacheIdentity.digest)" path: $(Build.SourcesDirectory)/target + - task: DownloadSecureFile@1 + name: appleSdkSecureFile + displayName: Download Apple SDK secure file + condition: eq(variables['backend'], 'macos') + inputs: + secureFile: $(appleSdkSecureFileName) + - task: Bash@3 + displayName: Verify and extract Apple SDK + condition: eq(variables['backend'], 'macos') + inputs: + targetType: inline + script: | + # Copyright (c) Microsoft Corporation. + # Licensed under the MIT license. + + set -euo pipefail + + : "${SECURE_FILE_PATH:?SECURE_FILE_PATH is required}" + : "${EXPECTED_SHA256:?EXPECTED_SHA256 is required}" + : "${AGENT_TEMP_DIRECTORY:?AGENT_TEMP_DIRECTORY is required}" + + # EXPECTED_SHA256 is the validated WEBUI_APPLE_SDK_SHA256 digest + # (format-checked by the "Resolve Apple SDK cache identity" step + # above); re-validate here too so this step fails safely even if + # invoked on its own. + if [[ ! "$EXPECTED_SHA256" =~ ^[0-9a-f]{64}$ ]]; then + echo "##vso[task.logissue type=error]EXPECTED_SHA256 must be exactly 64 lowercase hex characters." + exit 1 + fi + + actual_sha256=$(sha256sum "$SECURE_FILE_PATH" | awk '{print $1}') + if [[ "$actual_sha256" != "$EXPECTED_SHA256" ]]; then + echo "##vso[task.logissue type=error]Apple SDK archive SHA-256 mismatch: expected $EXPECTED_SHA256, received $actual_sha256." + exit 1 + fi + + # Extracted only under a fixed path below Agent.TempDirectory: never + # cached, never published. The cleanup step always removes this + # exact path, even if this step fails partway through, so it never + # depends on a variable that only gets set on success. + sdk_extract_dir="$AGENT_TEMP_DIRECTORY/apple-sdk" + rm -rf "$sdk_extract_dir" + mkdir -p "$sdk_extract_dir" + tar -xf "$SECURE_FILE_PATH" -C "$sdk_extract_dir" + + mapfile -t sdk_dirs < <(find "$sdk_extract_dir" -maxdepth 2 -type d -name '*.sdk') + if [[ ${#sdk_dirs[@]} -ne 1 ]]; then + echo "##vso[task.logissue type=error]Expected exactly one *.sdk directory inside the extracted Apple SDK archive, found ${#sdk_dirs[@]}." + exit 1 + fi + + # Resolve symlinks/relative segments and confirm the SDK root did + # not escape the fixed extraction directory (e.g. via a symlinked + # archive entry), before ever pointing SDKROOT at it. + sdk_root=$(realpath "${sdk_dirs[0]}") + sdk_extract_dir_resolved=$(realpath "$sdk_extract_dir") + case "$sdk_root" in + "$sdk_extract_dir_resolved"/*) ;; + *) + echo "##vso[task.logissue type=error]Resolved Apple SDK path $sdk_root escapes the extraction directory $sdk_extract_dir_resolved." + exit 1 + ;; + esac + + echo "##vso[task.setvariable variable=appleSdkRoot]$sdk_root" + env: + SECURE_FILE_PATH: $(appleSdkSecureFile.secureFilePath) + EXPECTED_SHA256: $(appleSdkCacheIdentity.digest) + AGENT_TEMP_DIRECTORY: $(Agent.TempDirectory) - task: Bash@3 - displayName: Build and stage $(targetTriple) + displayName: Build and stage $(targetTriple) (Linux) + condition: eq(variables['backend'], 'linux') inputs: targetType: inline workingDirectory: '$(Build.SourcesDirectory)' @@ -190,7 +334,8 @@ stages: INSTALL_ARM64_LINKER: $(installArm64Linker) TARGET_TRIPLE: $(targetTriple) - task: Bash@3 - displayName: Build Python wheel ($(targetTriple)) + displayName: Build Python wheel ($(targetTriple)) in manylinux container + condition: eq(variables['backend'], 'linux') inputs: targetType: inline workingDirectory: '$(Build.SourcesDirectory)' @@ -208,50 +353,9 @@ stages: SOURCE_DATE_EPOCH: $(sourceDateEpoch) TARGET_TRIPLE: $(targetTriple) WHEEL_OUT: $(Build.ArtifactStagingDirectory)/$(artifactName) - - task: PublishPipelineArtifact@1 - displayName: Upload $(targetTriple) artifact - inputs: - targetPath: '$(Build.ArtifactStagingDirectory)/$(artifactName)' - artifactName: $(artifactName) - - - job: BuildMacOSAssets - displayName: Build macOS release asset - strategy: - matrix: - MacOSArm64: - targetTriple: aarch64-apple-darwin - artifactName: stage-macos-arm64 - pythonNativeTest: 'false' - MacOSX64: - targetTriple: x86_64-apple-darwin - artifactName: stage-macos-x64 - pythonNativeTest: 'true' - pool: - vmImage: macOS-latest - steps: - - checkout: self - clean: true - - task: UsePythonVersion@0 - displayName: Use Python 3.11 - inputs: - versionSpec: '3.11' - - task: Cache@2 - displayName: Restore Cargo cache - inputs: - key: 'cargo-home | "$(Agent.OS)" | "$(targetTriple)" | Cargo.lock' - restoreKeys: | - cargo-home | "$(Agent.OS)" | "$(targetTriple)" - cargo-home | "$(Agent.OS)" - path: $(CARGO_CACHE_HOME) - - task: Cache@2 - displayName: Restore target cache - inputs: - key: 'cargo-target | "$(Agent.OS)" | "$(targetTriple)" | Cargo.lock' - restoreKeys: | - cargo-target | "$(Agent.OS)" | "$(targetTriple)" - path: $(Build.SourcesDirectory)/target - task: Bash@3 - displayName: Build and stage $(targetTriple) + displayName: Build and stage $(targetTriple) (Windows via cargo-xwin) + condition: eq(variables['backend'], 'windows') inputs: targetType: inline workingDirectory: '$(Build.SourcesDirectory)' @@ -265,138 +369,136 @@ stages: : "${BUILD_ARTIFACT_STAGING_DIRECTORY:?BUILD_ARTIFACT_STAGING_DIRECTORY is required}" : "${TARGET_TRIPLE:?TARGET_TRIPLE is required}" : "${ARTIFACT_NAME:?ARTIFACT_NAME is required}" + : "${CARGO_XWIN_VERSION:?CARGO_XWIN_VERSION is required}" + : "${LLVM_APT_VERSION:?LLVM_APT_VERSION is required}" + + sudo apt-get update -q + sudo apt-get install -y -q protobuf-compiler "clang-${LLVM_APT_VERSION}" "lld-${LLVM_APT_VERSION}" "llvm-${LLVM_APT_VERSION}" + sudo ln -sf "/usr/bin/clang-${LLVM_APT_VERSION}" /usr/local/bin/clang-cl + sudo ln -sf "/usr/bin/lld-link-${LLVM_APT_VERSION}" /usr/local/bin/lld-link + sudo ln -sf "/usr/bin/llvm-lib-${LLVM_APT_VERSION}" /usr/local/bin/llvm-lib + export CARGO_HOME="$CARGO_CACHE_HOME" + export PATH="$CARGO_HOME/bin:/usr/local/bin:$PATH" - if ! command -v protoc >/dev/null 2>&1; then - brew install protobuf + installed_xwin_version=$(cargo-xwin --version 2>/dev/null || true) + if [[ "$installed_xwin_version" != "cargo-xwin ${CARGO_XWIN_VERSION}" ]]; then + cargo install cargo-xwin --locked --version "$CARGO_XWIN_VERSION" fi rustup toolchain install 1.93 --profile minimal --target "$TARGET_TRIPLE" - CARGO_HOME="$CARGO_CACHE_HOME" cargo +1.93 xtask publish-build \ + # publish-build's full (non --native-only) run also builds the + # Python wheel through maturin. maturin has no built-in `--xwin` + # flag, so `xtask` exports cargo-xwin's own linker/SDK environment + # (from `cargo xwin env`) onto the maturin process instead. + python3.11 -m pip install --upgrade "maturin==1.14.1" + + # cargo-xwin downloads the Windows SDK/CRT itself once the license is + # accepted; XWIN_CACHE_DIR lives under the cached Cargo home so + # repeat runs reuse it instead of re-downloading every build. + export XWIN_ACCEPT_LICENSE=1 + export XWIN_CACHE_DIR="$CARGO_CACHE_HOME/xwin-cache" + mkdir -p "$XWIN_CACHE_DIR" + + cargo +1.93 xtask publish-build \ --target "$TARGET_TRIPLE" \ --profile release \ --output "$BUILD_ARTIFACT_STAGING_DIRECTORY/$ARTIFACT_NAME" env: ARTIFACT_NAME: $(artifactName) BUILD_ARTIFACT_STAGING_DIRECTORY: $(Build.ArtifactStagingDirectory) + CARGO_XWIN_VERSION: $(cargoXwinVersion) + LLVM_APT_VERSION: $(llvmAptVersion) TARGET_TRIPLE: $(targetTriple) - - task: PublishPipelineArtifact@1 - displayName: Upload $(targetTriple) artifact - inputs: - targetPath: '$(Build.ArtifactStagingDirectory)/$(artifactName)' - artifactName: $(artifactName) - - - job: BuildWindowsAssets - displayName: Build Windows release asset - strategy: - matrix: - WindowsX64: - targetTriple: x86_64-pc-windows-msvc - artifactName: stage-windows-x64 - pythonNativeTest: 'true' - WindowsArm64: - targetTriple: aarch64-pc-windows-msvc - artifactName: stage-windows-arm64 - pythonNativeTest: 'false' - pool: - vmImage: windows-latest - steps: - - checkout: self - clean: true - - task: UsePythonVersion@0 - displayName: Use Python 3.11 - inputs: - versionSpec: '3.11' - - task: Cache@2 - displayName: Restore Cargo cache - inputs: - key: 'cargo-home | "$(Agent.OS)" | "$(targetTriple)" | Cargo.lock' - restoreKeys: | - cargo-home | "$(Agent.OS)" | "$(targetTriple)" - cargo-home | "$(Agent.OS)" - path: $(CARGO_CACHE_HOME) - - task: Cache@2 - displayName: Restore target cache - inputs: - key: 'cargo-target | "$(Agent.OS)" | "$(targetTriple)" | Cargo.lock' - restoreKeys: | - cargo-target | "$(Agent.OS)" | "$(targetTriple)" - path: $(Build.SourcesDirectory)/target - - task: PowerShell@2 - displayName: Build and stage $(targetTriple) + - task: Bash@3 + displayName: Build and stage $(targetTriple) (macOS via cargo-zigbuild) + condition: eq(variables['backend'], 'macos') inputs: targetType: inline - pwsh: true workingDirectory: '$(Build.SourcesDirectory)' script: | + #!/usr/bin/env bash # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. - $ErrorActionPreference = 'Stop' - $PSNativeCommandUseErrorActionPreference = $true - - if (-not $env:BUILD_ARTIFACT_STAGING_DIRECTORY) { - throw 'BUILD_ARTIFACT_STAGING_DIRECTORY is required.' - } - if (-not $env:TARGET_TRIPLE) { - throw 'TARGET_TRIPLE is required.' - } - if (-not $env:ARTIFACT_NAME) { - throw 'ARTIFACT_NAME is required.' - } - - if (-not (Get-Command protoc -ErrorAction SilentlyContinue)) { - $version = '29.5' - $expectedSha256 = '633d3e555fc97f0a1f55b4adb03256cd94b8059e51e7abbae98ff39e58a9dfa5' - $zip = "$env:AGENT_TEMP_DIRECTORY\protoc.zip" - $dest = "$env:AGENT_TEMP_DIRECTORY\protoc" - Invoke-WebRequest ` - -Uri "https://github.com/protocolbuffers/protobuf/releases/download/v$version/protoc-$version-win64.zip" ` - -OutFile $zip - $actualSha256 = (Get-FileHash -Path $zip -Algorithm SHA256).Hash - if (-not $actualSha256.Equals($expectedSha256, [System.StringComparison]::OrdinalIgnoreCase)) { - Remove-Item $zip -Force - throw "protoc archive SHA-256 mismatch: expected $expectedSha256, received $actualSha256." - } - Expand-Archive -Path $zip -DestinationPath $dest -Force - $env:PATH = "$dest\bin;$env:PATH" - } - - rustup toolchain install 1.93 --profile minimal --target $env:TARGET_TRIPLE - - $env:CARGO_HOME = $env:CARGO_CACHE_HOME - cargo +1.93 xtask publish-build ` - --target $env:TARGET_TRIPLE ` - --profile release ` - --output "$env:BUILD_ARTIFACT_STAGING_DIRECTORY\$env:ARTIFACT_NAME" + set -euo pipefail + + : "${BUILD_ARTIFACT_STAGING_DIRECTORY:?BUILD_ARTIFACT_STAGING_DIRECTORY is required}" + : "${TARGET_TRIPLE:?TARGET_TRIPLE is required}" + : "${ARTIFACT_NAME:?ARTIFACT_NAME is required}" + : "${CARGO_ZIGBUILD_VERSION:?CARGO_ZIGBUILD_VERSION is required}" + : "${ZIG_VERSION:?ZIG_VERSION is required}" + : "${ZIG_SHA256:?ZIG_SHA256 is required}" + : "${MACOSX_DEPLOYMENT_TARGET:?MACOSX_DEPLOYMENT_TARGET is required}" + : "${SDKROOT:?SDKROOT is required; the Apple SDK extraction step must run first}" + : "${AGENT_TEMP_DIRECTORY:?AGENT_TEMP_DIRECTORY is required}" + + sudo apt-get update -q + sudo apt-get install -y -q protobuf-compiler xz-utils + + zig_dir="$AGENT_TEMP_DIRECTORY/zig-${ZIG_VERSION}" + if [[ ! -x "$zig_dir/zig" ]]; then + rm -rf "$zig_dir" + mkdir -p "$zig_dir" + zig_archive="$AGENT_TEMP_DIRECTORY/zig-linux-x86_64-${ZIG_VERSION}.tar.xz" + curl -fsSL -o "$zig_archive" \ + "https://ziglang.org/download/${ZIG_VERSION}/zig-linux-x86_64-${ZIG_VERSION}.tar.xz" + if ! echo "${ZIG_SHA256} ${zig_archive}" | sha256sum --check --status; then + echo "##vso[task.logissue type=error]Zig archive SHA-256 mismatch for ${zig_archive}; expected ${ZIG_SHA256}." + exit 1 + fi + tar -xJf "$zig_archive" -C "$zig_dir" --strip-components=1 + rm -f "$zig_archive" + fi + export CARGO_HOME="$CARGO_CACHE_HOME" + export PATH="$CARGO_HOME/bin:$zig_dir:$PATH" + + installed_zigbuild_version=$(cargo-zigbuild --version 2>/dev/null || true) + if [[ "$installed_zigbuild_version" != "cargo-zigbuild ${CARGO_ZIGBUILD_VERSION}" ]]; then + cargo install cargo-zigbuild --locked --version "$CARGO_ZIGBUILD_VERSION" + fi + rustup toolchain install 1.93 --profile minimal --target "$TARGET_TRIPLE" + + # publish-build's full (non --native-only) run also builds the + # Python wheel through maturin, cross-compiled with its --zig flag. + python3.11 -m pip install --upgrade "maturin==1.14.1" + + cargo +1.93 xtask publish-build \ + --target "$TARGET_TRIPLE" \ + --profile release \ + --output "$BUILD_ARTIFACT_STAGING_DIRECTORY/$ARTIFACT_NAME" env: AGENT_TEMP_DIRECTORY: $(Agent.TempDirectory) ARTIFACT_NAME: $(artifactName) BUILD_ARTIFACT_STAGING_DIRECTORY: $(Build.ArtifactStagingDirectory) + CARGO_ZIGBUILD_VERSION: $(cargoZigbuildVersion) + MACOSX_DEPLOYMENT_TARGET: $(macosDeploymentTarget) + SDKROOT: $(appleSdkRoot) TARGET_TRIPLE: $(targetTriple) + ZIG_VERSION: $(zigVersion) + ZIG_SHA256: $(zigSha256) + - task: Bash@3 + displayName: Remove Apple SDK from agent + condition: and(always(), eq(variables['backend'], 'macos')) + inputs: + targetType: inline + script: | + set -euo pipefail + # Fixed path, matching the extraction step above exactly: cleanup + # must not depend on a variable that is only set after a successful + # extraction, so a failed or partial extraction is still removed. + rm -rf "${AGENT_TEMP_DIRECTORY}/apple-sdk" + env: + AGENT_TEMP_DIRECTORY: $(Agent.TempDirectory) - task: PublishPipelineArtifact@1 displayName: Upload $(targetTriple) artifact inputs: targetPath: '$(Build.ArtifactStagingDirectory)/$(artifactName)' artifactName: $(artifactName) - # Microsoft-hosted pools have no ARM64 Linux or Windows vmImage, and - # macOS-latest is Intel x64, so the ARM64 wheels built above can only be - # cross-compiled on those hosted pools, never genuinely executed there. - # Each of the following three jobs is a compile-time template conditional: - # if the matching pool parameter is set, it downloads the pre-built wheel - # and smoke-tests it on real matching hardware; otherwise it hard-fails - # with actionable guidance instead of silently skipping the test or - # claiming the wheel was validated when it was not. - # The configured self-hosted pool's agent must already have a Python 3.11 - # interpreter on PATH (as `python3.11` on Linux/macOS, `python` on - # Windows) - UsePythonVersion@0 is not used here because its tool cache - # is not guaranteed to be provisioned on arbitrary self-hosted agents. - job: AssembleRelease displayName: Assemble release packages dependsOn: - - BuildLinuxAssets - - BuildMacOSAssets - - BuildWindowsAssets + - BuildReleaseAssets pool: vmImage: ubuntu-latest steps: @@ -441,12 +543,6 @@ stages: buildType: current artifactName: stage-macos-arm64 targetPath: $(Build.SourcesDirectory) - - task: DownloadPipelineArtifact@2 - displayName: Download macOS x64 release asset - inputs: - buildType: current - artifactName: stage-macos-x64 - targetPath: $(Build.SourcesDirectory) - task: DownloadPipelineArtifact@2 displayName: Download Windows x64 release asset inputs: @@ -487,7 +583,7 @@ stages: corepack enable pnpm install --frozen-lockfile --store-dir "$PNPM_STORE_PATH" # Needed for the `cargo xtask publish-stage --pack-only` sdist step - # below; the six pre-built wheels were already downloaded above. + # below; the five pre-built wheels were already downloaded above. python3.11 -m pip install --upgrade "maturin==1.14.1" # Versioned package archives are outputs, not reusable compilation artifacts. rm -rf target/package @@ -498,7 +594,6 @@ stages: "publish/python/microsoft_webui-${RELEASE_VERSION}-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" \ "publish/python/microsoft_webui-${RELEASE_VERSION}-cp311-abi3-win_amd64.whl" \ "publish/python/microsoft_webui-${RELEASE_VERSION}-cp311-abi3-win_arm64.whl" \ - "publish/python/microsoft_webui-${RELEASE_VERSION}-cp311-abi3-macosx_10_12_x86_64.whl" \ "publish/python/microsoft_webui-${RELEASE_VERSION}-cp311-abi3-macosx_11_0_arm64.whl" \ "publish/python/microsoft_webui-${RELEASE_VERSION}.tar.gz" env: diff --git a/.ado/pipelines/azure-pipelines-cd.yml b/.ado/pipelines/azure-pipelines-cd.yml index 17686caa1..1c596c1e0 100644 --- a/.ado/pipelines/azure-pipelines-cd.yml +++ b/.ado/pipelines/azure-pipelines-cd.yml @@ -44,8 +44,8 @@ extends: sdl: sourceAnalysisPool: name: OneESPool - image: HostedPoolWindowsImage - os: windows + image: HostedPoolLinuxImage + os: linux settings: networkIsolationPolicy: Permissive stages: diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml index ef58ff393..41a82f1dd 100644 --- a/.github/actions/build/action.yml +++ b/.github/actions/build/action.yml @@ -1,5 +1,5 @@ name: Build WebUI -description: Shared build steps for WebUI — installs protoc, Rust, Node.js, pnpm, and optionally .NET. +description: Shared build steps for WebUI — installs protoc, Rust, Node.js, pnpm, and optionally .NET. Linux runners only. inputs: shared-cache-key: @@ -22,10 +22,6 @@ inputs: description: Install .NET SDK (set to version like '8.0.x' to enable) required: false default: '' - protoc-version: - description: Protobuf compiler version to install - required: false - default: '29.5' skip-build: description: Skip cargo build and build-examples steps required: false @@ -34,36 +30,10 @@ inputs: runs: using: composite steps: - - name: Disable Windows Defender on build dirs - if: runner.os == 'Windows' - shell: pwsh - run: | - Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE" - Add-MpPreference -ExclusionPath "$env:USERPROFILE\.cargo" - - - name: Install protoc (Linux) - if: runner.os == 'Linux' + - name: Install protoc shell: bash run: sudo apt-get update -qq && sudo apt-get install -y -qq protobuf-compiler - - name: Install protoc (macOS) - if: runner.os == 'macOS' - shell: bash - run: brew install protobuf - - - name: Install protoc (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $version = "${{ inputs.protoc-version }}" - $url = "https://github.com/protocolbuffers/protobuf/releases/download/v$version/protoc-$version-win64.zip" - $zip = "$env:RUNNER_TEMP\protoc.zip" - $dest = "$env:RUNNER_TEMP\protoc" - Invoke-WebRequest -Uri $url -OutFile $zip - Expand-Archive -Path $zip -DestinationPath $dest -Force - echo "$dest\bin" | Out-File -FilePath $env:GITHUB_PATH -Append - echo "PROTOC=$dest\bin\protoc.exe" | Out-File -FilePath $env:GITHUB_ENV -Append - - name: Setup Rust uses: dtolnay/rust-toolchain@stable with: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8d53b1a7c..98607d300 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1,420 +1,464 @@ -name: PR Checks - -on: - pull_request: - branches: [ main ] - -permissions: - contents: read - -env: - CARGO_TERM_COLOR: always - -jobs: - # ── Phase 1: Lint (fast, fail-fast) ──────────────────────────────── - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/build - with: - skip-build: 'true' - rust-components: clippy, rustfmt - - - name: License headers - run: cargo xtask license-headers - - - name: Format - run: cargo fmt --all --check - - - name: Clippy - run: cargo clippy --workspace -- -D warnings - - - name: Deny (licenses & advisories) - run: | - cargo install --locked cargo-deny || true - cargo deny check - - # ── Phase 2: Test (Ubuntu) ───────────────────────────────────────── - test: - name: Test - runs-on: ubuntu-latest - needs: lint - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/build - with: - skip-build: 'true' - - - name: Test - run: cargo test --workspace - - # ── Phase 2: Build (Linux + macOS + Windows) ──────────────────────── - build-linux: - name: Build (Linux) - runs-on: ubuntu-latest - needs: lint - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/build - with: - shared-cache-key: ubuntu-build - - build-macos: - name: Build (macOS) - runs-on: macos-latest-large - needs: lint - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/build - - build-windows: - name: Build (Windows) - runs-on: windows-latest - needs: lint - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/build - - # ── Phase 3: E2E (Ubuntu, after Linux build) ─────────────────────── - e2e: - name: E2E (Ubuntu) - runs-on: ubuntu-latest - needs: build-linux - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/build - with: - shared-cache-key: ubuntu-build - cache-on-failure: 'true' - - - name: Install Playwright browsers - run: pnpm exec playwright install --with-deps chromium - - - name: Run E2E tests - run: cargo xtask e2e - - - name: Regenerate baselines (on failure) - if: failure() - run: cargo xtask e2e --update-snapshots - - - name: Upload updated baselines (on failure) - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: e2e-updated-baselines - path: | - examples/app/*/tests/*.spec.ts-snapshots/ - packages/*/tests/*.spec.ts-snapshots/ - retention-days: 7 - - - name: Upload test results (on failure) - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: e2e-test-results - path: | - examples/app/*/test-results/ - packages/*/test-results/ - retention-days: 7 - - # ── Phase 2: WASM (Ubuntu) ───────────────────────────────────────── - wasm: - name: WASM - runs-on: ubuntu-latest - needs: lint - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/setup-wasm - - # ── Phase 2: Docs ───────────────────────────────────────────────── - docs: - name: Docs - runs-on: ubuntu-latest - needs: build-linux - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/build - with: - shared-cache-key: ubuntu-build - - - name: Build docs - run: pnpm --filter @webui/docs... build - - # ── Phase 3: FFI bindings (after Linux build) ────────────────────── - # Exercises the C ABI from Python, Go, and C#. Depends on build-linux and - # shares its cache key: these are the same debug workspace crates that job - # already compiled, so the cargo steps below are a cache hit rather than a - # second full build. - ffi: - name: FFI - runs-on: ubuntu-latest - needs: build-linux - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/build - with: - skip-build: 'true' - shared-cache-key: ubuntu-build - dotnet: '8.0.x' - - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: '3.12' - - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version: '1.21' - - - name: Build FFI library and protocol fixture - run: | - cargo build -p microsoft-webui-ffi - cargo run -p microsoft-webui-cli -- build \ - crates/webui-ffi/tests/fixtures/app \ - --out crates/webui-ffi/tests/fixtures/protocol.bin - - - name: Run Python FFI tests - run: python crates/webui-ffi/tests/python/test_webui_ffi.py -v - env: - LD_LIBRARY_PATH: target/debug - - - name: Run Go FFI tests - working-directory: crates/webui-ffi/tests/go - run: go test -v - env: - LD_LIBRARY_PATH: ${{ github.workspace }}/target/debug - CGO_LDFLAGS: -L${{ github.workspace }}/target/debug -lwebui_ffi -lm -ldl -lpthread - - - name: Run C# FFI tests - working-directory: crates/webui-ffi/tests/csharp - run: dotnet test -v normal - env: - LD_LIBRARY_PATH: ${{ github.workspace }}/target/debug - - # ── Phase 2: Python wheels ───────────────────────────────────────── - # Build every wheel in the release contract. The x64 wheels feed python-test - # below; the ARM64 wheels are cross-compiled exactly as the release pipeline - # cross-compiles them, so an ARM64 build break surfaces on the PR that causes - # it. They are never installed here - that needs ARM64 hardware. - python-wheel: - name: Python wheel (${{ matrix.platform.label }}) - runs-on: ${{ matrix.platform.os }} - needs: lint - strategy: - fail-fast: false - matrix: - platform: - - label: Linux x64 - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - platform_tag: manylinux_2_17_x86_64.manylinux2014_x86_64 - image: messense/manylinux2014-cross:x86_64@sha256:13670ccc63c35e072661938181c046243c01d7bca7976b3914177fb9b162998d - artifact: python-wheel-linux-x64 - - label: macOS x64 - os: macos-latest-large - target: x86_64-apple-darwin - platform_tag: macosx_10_12_x86_64 - artifact: python-wheel-macos-x64 - - label: Windows x64 - os: windows-latest - target: x86_64-pc-windows-msvc - platform_tag: win_amd64 - artifact: python-wheel-windows-x64 - - label: Linux ARM64 - os: ubuntu-latest - target: aarch64-unknown-linux-gnu - platform_tag: manylinux_2_17_aarch64.manylinux2014_aarch64 - image: messense/manylinux2014-cross:aarch64@sha256:32c92568ebee8db53e0598c21bcd06a6dd1649d45d507646a8a49313c50325f8 - artifact: '' - - label: macOS ARM64 - os: macos-latest-large - target: aarch64-apple-darwin - platform_tag: macosx_11_0_arm64 - artifact: '' - - label: Windows ARM64 - os: windows-latest - target: aarch64-pc-windows-msvc - platform_tag: win_arm64 - artifact: '' - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - # Linux wheels build entirely inside the pinned manylinux image, so the - # host toolchain is only needed for the targets built natively here. - - uses: ./.github/actions/build - if: runner.os != 'Linux' - with: - skip-build: 'true' - rust-targets: ${{ matrix.platform.target }} - shared-cache-key: python-${{ matrix.platform.target }} - - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: '3.11' - - # abi3 needs no target interpreter and `generate-import-lib` synthesizes - # the Windows ARM64 import library, so every target cross-compiles here. - - name: Build wheel - if: runner.os != 'Linux' - shell: bash - run: | - set -euo pipefail - python -m pip install --upgrade "maturin==1.14.1" - cargo xtask publish-build --target "${{ matrix.platform.target }}" --python-only - - - name: Build manylinux wheel - if: runner.os == 'Linux' - shell: bash - run: | - set -euo pipefail - docker run --rm --platform linux/amd64 \ - -v "$GITHUB_WORKSPACE:/io" -w /io "${{ matrix.platform.image }}" \ - bash crates/webui-python/scripts/build-manylinux-wheel.sh \ - "${{ matrix.platform.target }}" - - - name: Validate wheel name, tags, and license metadata - shell: bash - run: | - set -euo pipefail - python crates/webui-python/tests/validate_wheel.py \ - publish/python "${{ matrix.platform.platform_tag }}" - python crates/webui-python/tests/validate_artifacts.py publish/python/*.whl - - - name: Upload wheel - if: matrix.platform.artifact != '' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: ${{ matrix.platform.artifact }} - path: publish/python/*.whl - if-no-files-found: error - retention-days: 1 - - # ── Phase 3: Python tests (after wheels) ─────────────────────────── - # One abi3 wheel must serve every supported interpreter, so the Linux wheel is - # installed on each CPython. macOS and Windows only re-verify that the wheel - # loads on its platform, which 3.11 already proves. - python-test: - name: Python test (${{ matrix.platform.label }} / CPython ${{ matrix.python }}) - runs-on: ${{ matrix.platform.os }} - needs: python-wheel - strategy: - fail-fast: false - matrix: - platform: - - label: Linux x64 - os: ubuntu-latest - artifact: python-wheel-linux-x64 - python: ['3.11', '3.12', '3.13', '3.14'] - include: - - platform: - label: macOS x64 - os: macos-latest-large - artifact: python-wheel-macos-x64 - python: '3.11' - - platform: - label: Windows x64 - os: windows-latest - artifact: python-wheel-windows-x64 - python: '3.11' - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: ${{ matrix.python }} - - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: ${{ matrix.platform.artifact }} - path: publish/python - - - name: Install the built wheel and test it - shell: bash - run: | - set -euo pipefail - python -m pip install --upgrade "pytest>=8.3,<10" - python -m pip install --force-reinstall --no-deps --no-index publish/python/*.whl - python -m pytest crates/webui-python/tests -q - - - name: Validate lint, strict typing, and stubs - if: runner.os == 'Linux' && matrix.python == '3.11' - working-directory: crates/webui-python - run: | - python -m pip install --upgrade "mypy>=1.19,<2" "ruff>=0.15,<1" - python -m ruff check python tests benchmarks - python -m mypy - python -m mypy.stubtest microsoft_webui - - # ── Phase 3: Python fixture drift (after Linux build) ────────────── - # The committed protocol fixture is a build output, so rebuild it and fail if - # it drifted, keeping the Python tests from asserting against a stale binary. - python-fixture: - name: Python fixture - runs-on: ubuntu-latest - needs: build-linux - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/build - with: - skip-build: 'true' - shared-cache-key: ubuntu-build - - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: '3.11' - - - name: Verify the release-target contract is restated consistently - run: python crates/webui-python/tests/validate_release_targets.py - - - name: Rebuild and compare the protocol fixture - run: | - set -euo pipefail - output=target/python-fixture-check - rm -rf "$output" - cargo run --quiet -p microsoft-webui-cli -- build \ - crates/webui-python/tests/fixtures/app --plugin webui --out "$output" - cargo run --quiet -p microsoft-webui-cli -- inspect \ - crates/webui-python/tests/fixtures/protocol.bin > "$output/committed.json" - cargo run --quiet -p microsoft-webui-cli -- inspect \ - "$output/protocol.bin" > "$output/generated.json" - python crates/webui-python/tests/compare_fixture.py \ - "$output/committed.json" "$output/generated.json" - cmp crates/webui-python/tests/fixtures/greeting-card.css \ - "$output/greeting-card.css" - - # ── Gate: one stable required check ──────────────────────────────── - # Branch protection can only require per-job contexts, so requiring the jobs - # individually means editing that list every time one is added, renamed, or - # removed. Require this job instead: it is the only context that has to be - # protected, and it stays correct as the matrix above evolves. - pr-checks: - name: PR Checks - runs-on: ubuntu-latest - if: always() - needs: - - lint - - test - - build-linux - - build-macos - - build-windows - - e2e - - wasm - - docs - - ffi - - python-wheel - - python-test - - python-fixture - steps: - # `needs` collapses each matrix to a single result, so this covers every - # wheel and interpreter leg too. Skipped counts as failure: nothing here - # is conditionally skipped, so a skip only happens when a dependency - # failed and took its dependents with it. - - name: Verify every job succeeded - if: >- - contains(needs.*.result, 'failure') || - contains(needs.*.result, 'cancelled') || - contains(needs.*.result, 'skipped') - run: | - echo "::error::One or more PR checks did not succeed." - exit 1 +name: PR Checks + +on: + pull_request: + branches: [ main ] + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + # ── Phase 1: Lint (fast, fail-fast) ──────────────────────────────── + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/build + with: + skip-build: 'true' + rust-components: clippy, rustfmt + + - name: License headers + run: cargo xtask license-headers + + - name: Format + run: cargo fmt --all --check + + - name: Clippy + run: cargo clippy --workspace -- -D warnings + + - name: Deny (licenses & advisories) + run: | + cargo install --locked cargo-deny || true + cargo deny check + + # ── Phase 2: Test (Ubuntu) ───────────────────────────────────────── + test: + name: Test + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/build + with: + skip-build: 'true' + + - name: Test + run: cargo test --workspace + + # ── Phase 2: Build (Linux) ────────────────────────────────────────── + build-linux: + name: Build (Linux) + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/build + with: + shared-cache-key: ubuntu-build + + # ── Phase 2: macOS check (native ARM64 runner) ───────────────────── + # The Linux-hosted `cargo check` attempt added in this PR still executed C + # build scripts (for example `zstd-sys` via actix-web), which failed before + # linking because the generic Linux `cc` cannot compile Apple-targeted flags + # like `-arch arm64`. Keep PR validation on a native Apple Silicon runner, + # while the release pipeline continues to do the full Linux-hosted + # cargo-zigbuild packaging flow with its trusted SDK. + # `platform_tag` isn't consumed by `cargo check` itself; it's restated here + # only so `validate_release_targets.py` sees all five release-contract + # platform tags accounted for in this file. + macos-check: + name: macOS check (${{ matrix.platform.label }}) + runs-on: macos-14 + needs: lint + strategy: + fail-fast: false + matrix: + platform: + - label: ARM64 + target: aarch64-apple-darwin + platform_tag: macosx_11_0_arm64 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install protoc + shell: bash + run: | + if ! command -v protoc >/dev/null 2>&1; then + brew install protobuf + fi + + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.93 + targets: ${{ matrix.platform.target }} + + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + shared-key: macos-check-${{ matrix.platform.target }} + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.11' + + - name: Check release crates + shell: bash + run: | + cargo check --target ${{ matrix.platform.target }} \ + -p microsoft-webui-cli \ + -p microsoft-webui-ffi \ + -p microsoft-webui-node \ + -p microsoft-webui-python + + # ── Phase 2: Windows cross-build (from Linux, via cargo-xwin) ────── + # There is no Windows runner in this pipeline either. `cargo xtask + # publish-build` is host-aware: for a Windows MSVC target on a non-Windows + # host it selects the cargo-xwin backend for the native CLI/FFI/Node + # artifacts, then builds the abi3 wheel by running `maturin build` with + # `cargo-xwin`'s own linker/SDK environment (from `cargo xwin env`) + # exported onto it — maturin has no built-in `--xwin` flag — so one call + # stages everything. This mirrors the same cargo-xwin cross-compilation + # contract the release pipeline's Linux-hosted Windows legs use, pinned to + # the same cargo-xwin/LLVM versions. + windows-cross: + name: Windows cross-build (${{ matrix.platform.label }}) + runs-on: ubuntu-latest + needs: lint + strategy: + fail-fast: false + matrix: + platform: + - label: x64 + target: x86_64-pc-windows-msvc + platform_tag: win_amd64 + - label: ARM64 + target: aarch64-pc-windows-msvc + platform_tag: win_arm64 + env: + WINDOWS_STAGE: target/windows-stage + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/build + with: + skip-build: 'true' + rust-targets: ${{ matrix.platform.target }} + shared-cache-key: windows-cross-${{ matrix.platform.target }} + + - name: Install cargo-xwin and pin its LLVM toolchain + run: | + set -euo pipefail + sudo apt-get update -qq + sudo apt-get install -y -qq clang-18 lld-18 llvm-18 + sudo ln -sf /usr/bin/clang-18 /usr/local/bin/clang-cl + sudo ln -sf /usr/bin/lld-link-18 /usr/local/bin/lld-link + sudo ln -sf /usr/bin/llvm-lib-18 /usr/local/bin/llvm-lib + cargo install --locked cargo-xwin --version 0.23.0 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.11' + + - name: Build native artifacts and abi3 wheel (cargo xtask publish-build) + run: | + set -euo pipefail + python -m pip install --upgrade "maturin==1.14.1" + mkdir -p "$XWIN_CACHE_DIR" + cargo xtask publish-build --target ${{ matrix.platform.target }} \ + --output "$WINDOWS_STAGE" + env: + XWIN_ACCEPT_LICENSE: '1' + XWIN_CACHE_DIR: ${{ github.workspace }}/target/xwin-cache + + # ── Phase 3: E2E (Ubuntu, after Linux build) ─────────────────────── + e2e: + name: E2E (Ubuntu) + runs-on: ubuntu-latest + needs: build-linux + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/build + with: + shared-cache-key: ubuntu-build + cache-on-failure: 'true' + + - name: Install Playwright browsers + run: pnpm exec playwright install --with-deps chromium + + - name: Run E2E tests + run: cargo xtask e2e + + - name: Regenerate baselines (on failure) + if: failure() + run: cargo xtask e2e --update-snapshots + + - name: Upload updated baselines (on failure) + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-updated-baselines + path: | + examples/app/*/tests/*.spec.ts-snapshots/ + packages/*/tests/*.spec.ts-snapshots/ + retention-days: 7 + + - name: Upload test results (on failure) + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-test-results + path: | + examples/app/*/test-results/ + packages/*/test-results/ + retention-days: 7 + + # ── Phase 2: WASM (Ubuntu) ───────────────────────────────────────── + wasm: + name: WASM + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/setup-wasm + + # ── Phase 2: Docs ───────────────────────────────────────────────── + docs: + name: Docs + runs-on: ubuntu-latest + needs: build-linux + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/build + with: + shared-cache-key: ubuntu-build + + - name: Build docs + run: pnpm --filter @webui/docs... build + + # ── Phase 3: FFI bindings (after Linux build) ────────────────────── + # Exercises the C ABI from Python, Go, and C#. Depends on build-linux and + # shares its cache key: these are the same debug workspace crates that job + # already compiled, so the cargo steps below are a cache hit rather than a + # second full build. + ffi: + name: FFI + runs-on: ubuntu-latest + needs: build-linux + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/build + with: + skip-build: 'true' + shared-cache-key: ubuntu-build + dotnet: '8.0.x' + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12' + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: '1.21' + + - name: Build FFI library and protocol fixture + run: | + cargo build -p microsoft-webui-ffi + cargo run -p microsoft-webui-cli -- build \ + crates/webui-ffi/tests/fixtures/app \ + --out crates/webui-ffi/tests/fixtures/protocol.bin + + - name: Run Python FFI tests + run: python crates/webui-ffi/tests/python/test_webui_ffi.py -v + env: + LD_LIBRARY_PATH: target/debug + + - name: Run Go FFI tests + working-directory: crates/webui-ffi/tests/go + run: go test -v + env: + LD_LIBRARY_PATH: ${{ github.workspace }}/target/debug + CGO_LDFLAGS: -L${{ github.workspace }}/target/debug -lwebui_ffi -lm -ldl -lpthread + + - name: Run C# FFI tests + working-directory: crates/webui-ffi/tests/csharp + run: dotnet test -v normal + env: + LD_LIBRARY_PATH: ${{ github.workspace }}/target/debug + + # ── Phase 2: Python wheels (Linux) ────────────────────────────────── + # Build the Linux wheels in the release contract. The x64 wheel feeds + # python-test below; the ARM64 wheel is cross-compiled exactly as the + # release pipeline cross-compiles it, so an ARM64 build break surfaces on + # the PR that causes it. It is never installed here - that needs ARM64 + # hardware. Windows and macOS wheels are covered by windows-cross and + # macos-check instead: neither has a runtime test here, since non-Linux + # runtime tests are intentionally out of scope for this pipeline. + python-wheel: + name: Python wheel (${{ matrix.platform.label }}) + runs-on: ubuntu-latest + needs: lint + strategy: + fail-fast: false + matrix: + platform: + - label: Linux x64 + target: x86_64-unknown-linux-gnu + platform_tag: manylinux_2_17_x86_64.manylinux2014_x86_64 + image: messense/manylinux2014-cross:x86_64@sha256:13670ccc63c35e072661938181c046243c01d7bca7976b3914177fb9b162998d + artifact: python-wheel-linux-x64 + - label: Linux ARM64 + target: aarch64-unknown-linux-gnu + platform_tag: manylinux_2_17_aarch64.manylinux2014_aarch64 + image: messense/manylinux2014-cross:aarch64@sha256:32c92568ebee8db53e0598c21bcd06a6dd1649d45d507646a8a49313c50325f8 + artifact: '' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.11' + + # Linux wheels build entirely inside the pinned manylinux image, so no + # host Rust toolchain setup is needed here. + - name: Build manylinux wheel + shell: bash + run: | + set -euo pipefail + docker run --rm --platform linux/amd64 \ + -v "$GITHUB_WORKSPACE:/io" -w /io "${{ matrix.platform.image }}" \ + bash crates/webui-python/scripts/build-manylinux-wheel.sh \ + "${{ matrix.platform.target }}" + + - name: Validate wheel name, tags, and license metadata + shell: bash + run: | + set -euo pipefail + python crates/webui-python/tests/validate_wheel.py \ + publish/python "${{ matrix.platform.platform_tag }}" + python crates/webui-python/tests/validate_artifacts.py publish/python/*.whl + + - name: Upload wheel + if: matrix.platform.artifact != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ matrix.platform.artifact }} + path: publish/python/*.whl + if-no-files-found: error + retention-days: 1 + + # ── Phase 3: Python tests (Linux, after wheels) ──────────────────── + # One abi3 wheel must serve every supported interpreter, so the Linux x64 + # wheel is installed on each CPython. This is the only runtime test of the + # wheel in this pipeline - Windows and macOS are cross-built/checked only. + python-test: + name: Python test (Linux x64 / CPython ${{ matrix.python }}) + runs-on: ubuntu-latest + needs: python-wheel + strategy: + fail-fast: false + matrix: + python: ['3.11', '3.12', '3.13', '3.14'] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python }} + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-wheel-linux-x64 + path: publish/python + + - name: Install the built wheel and test it + shell: bash + run: | + set -euo pipefail + python -m pip install --upgrade "pytest>=8.3,<10" + python -m pip install --force-reinstall --no-deps --no-index publish/python/*.whl + python -m pytest crates/webui-python/tests -q + + - name: Validate lint, strict typing, and stubs + if: matrix.python == '3.11' + working-directory: crates/webui-python + run: | + python -m pip install --upgrade "mypy>=1.19,<2" "ruff>=0.15,<1" + python -m ruff check python tests benchmarks + python -m mypy + python -m mypy.stubtest microsoft_webui + + # ── Phase 3: Python fixture drift (after Linux build) ────────────── + # The committed protocol fixture is a build output, so rebuild it and fail if + # it drifted, keeping the Python tests from asserting against a stale binary. + python-fixture: + name: Python fixture + runs-on: ubuntu-latest + needs: build-linux + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/build + with: + skip-build: 'true' + shared-cache-key: ubuntu-build + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.11' + + - name: Verify the release-target contract is restated consistently + run: python crates/webui-python/tests/validate_release_targets.py + + - name: Rebuild and compare the protocol fixture + run: | + set -euo pipefail + output=target/python-fixture-check + rm -rf "$output" + cargo run --quiet -p microsoft-webui-cli -- build \ + crates/webui-python/tests/fixtures/app --plugin webui --out "$output" + cargo run --quiet -p microsoft-webui-cli -- inspect \ + crates/webui-python/tests/fixtures/protocol.bin > "$output/committed.json" + cargo run --quiet -p microsoft-webui-cli -- inspect \ + "$output/protocol.bin" > "$output/generated.json" + python crates/webui-python/tests/compare_fixture.py \ + "$output/committed.json" "$output/generated.json" + cmp crates/webui-python/tests/fixtures/greeting-card.css \ + "$output/greeting-card.css" + + # ── Gate: one stable required check ──────────────────────────────── + # Branch protection can only require per-job contexts, so requiring the jobs + # individually means editing that list every time one is added, renamed, or + # removed. Require this job instead: it is the only context that has to be + # protected, and it stays correct as the matrix above evolves. + pr-checks: + name: PR Checks + runs-on: ubuntu-latest + if: always() + needs: + - lint + - test + - build-linux + - macos-check + - windows-cross + - e2e + - wasm + - docs + - ffi + - python-wheel + - python-test + - python-fixture + steps: + # `needs` collapses each matrix to a single result, so this covers every + # wheel and interpreter leg too. Skipped counts as failure: nothing here + # is conditionally skipped, so a skip only happens when a dependency + # failed and took its dependents with it. + - name: Verify every job succeeded + if: >- + contains(needs.*.result, 'failure') || + contains(needs.*.result, 'cancelled') || + contains(needs.*.result, 'skipped') + run: | + echo "::error::One or more PR checks did not succeed." + exit 1 diff --git a/DESIGN.md b/DESIGN.md index 86bbd7e61..0c09736c2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -4372,7 +4372,6 @@ webui/ │ ├── @microsoft/ │ │ ├── webui/ # npm package (CLI + programmatic JS API) │ │ ├── webui-darwin-arm64/ # Platform binary (macOS ARM64) -│ │ ├── webui-darwin-x64/ # Platform binary (macOS x64) │ │ ├── webui-linux-x64/ # Platform binary (Linux x64) │ │ ├── webui-linux-arm64/ # Platform binary (Linux ARM64) │ │ ├── webui-win32-x64/ # Platform binary (Windows x64) @@ -4472,7 +4471,39 @@ Native assets are split into `Microsoft.WebUI.Runtime.` packages for each s `dotnet/Directory.Build.props` applies NuGet metadata to packable .NET projects: `Authors=Microsoft`, `PackageOwners=Microsoft`, the SPDX `MIT` license expression with `PackageRequireLicenseAcceptance=true`, project and repository URLs, Source Link, release notes links, discoverability tags, the required `© Microsoft Corporation. All rights reserved.` copyright notice, and `.snupkg` symbol package generation. `cargo xtask publish-stage --pack-only` invokes `dotnet pack` on `dotnet/Microsoft.WebUI.sln` and stages both `.nupkg` and `.snupkg` files under `publish/nuget`. -Azure release automation uses the `.ado/pipelines/azure-pipelines-build.yml` and `.ado/pipelines/azure-pipelines-cd.yml` definitions. `Web UI - CD Build` triggers on `main` and can also be queued manually. Each target leg runs `cargo xtask publish-build`, which produces that target's native binaries and its Python wheel together. Linux is the one split: the natives build on the host with `--native-only`, then the same command runs with `--python-only` inside a digest-pinned `manylinux2014` cross image so the wheel links an old glibc. Export is mode-aware, so the second run adds `publish/python/` without disturbing the natives the first run staged. All six wheels are cross-compiled on Microsoft-hosted x64 pools, the same way this pipeline has always produced the ARM64 npm, NuGet, FFI, and CLI binaries. `Web UI - CD` has no direct CI or pull-request trigger and starts only from a successful `BuildArtifacts` pipeline resource event on `main` or a manual queue. Its `PrepareRelease` stage selects an untagged stable workspace version. Production build and CD runs require the release build source to be `refs/heads/main`; other branches are accepted only in validation mode, which prevents feature-branch commits from becoming public release tags. `BuildArtifacts` runs three OS matrix jobs with two target legs each, providing six parallel native builds; each leg restores target-specific Cargo caches before invoking the single-target `cargo xtask publish-build`. The assembly job merges those six outputs and restores its Cargo, target, and pnpm caches. It preserves reusable Cargo compilation artifacts while removing `target/package` before and after `cargo xtask publish-stage --pack-only`, because that directory contains versioned release archives rather than incremental build inputs. The packer generates WASM, npm, crate, NuGet, Python, and standalone artifacts and validates the exact 9 npm, 15 crate, 8 NuGet package, 2 NuGet symbol package, 6 Python wheel, 1 Python sdist, and 20 standalone asset contract before Azure publishes the unsigned artifact sets and release metadata. Completion of `BuildArtifacts` on `main` triggers the unscheduled 1ES Official `Web UI - CD` pipeline. Its `SignArtifacts` stage restores the build outputs with Azure artifact tasks and runs ESRP signing. For production runs, `TagRelease` creates or verifies the annotated Git tag. `PublishRelease` publishes npm and Rust crates, then creates the GitHub Release after the Rust crates are available. Python wheels and the sdist are attached to the GitHub Release as downloadable assets. WebUI does not publish them to PyPI; that remains an explicit future step once package ownership and signing policy are settled. GitHub Releases include an issue-based changelog covering changes since the last full release instead of a static placeholder description. Validation runs stop after signing and retain unsigned npm tarballs, unsigned crate and Python archives, signed `.nupkg` and `.snupkg` files, and standalone assets for inspection. `standalone_release_assets` contains the six direct-download native binaries, twelve WASM files, `README.md`, and `package.json`. The GitHub Release uploads all five folders for 61 explicit assets, while GitHub supplies the source ZIP and tarball as two additional downloads. Publishing to NuGet.org remains a manual operation using `signed_nuget_packages`. Before NuGet.org publishing, ownership must be limited to the approved Microsoft package owner/co-owner accounts, every Authenticode-signable file in the package must be signed, and each `.nupkg` must be signed with the Microsoft certificate through the approved signing process. The queue-time `validationMode` parameter defaults to `false`; selecting `true` in both pipelines permits an existing-version artifact rebuild while omitting tag creation and external publication. The selected validation mode is carried in release metadata, and CD rejects builds whose mode does not match its own configuration. +Azure release automation uses `.ado/pipelines/azure-pipelines-build.yml` and +`.ado/pipelines/azure-pipelines-cd.yml`. `Web UI - CD Build` triggers on `main` +and can also be queued manually. All five target legs build in one Linux-hosted +matrix job. Linux natives build on the host and Linux wheels build in +digest-pinned `manylinux2014` cross images. Windows targets use pinned +`cargo-xwin` plus LLVM/LLD, while the macOS ARM64 target uses pinned +`cargo-zigbuild` and Zig. + +macOS legs require a legally obtained Apple SDK uploaded as the +`WebUI-MacOSX-SDK.tar.xz` Azure secure file. Its expected digest comes from the +required `WEBUI_APPLE_SDK_SHA256` pipeline/library variable. The pipeline +validates the digest, extracts exactly one contained `*.sdk` below +`Agent.TempDirectory`, sets `SDKROOT` and `MACOSX_DEPLOYMENT_TARGET=11.0`, and +removes the SDK +after the leg. SDK contents are never cached or published; the digest participates +in the macOS Cargo cache key. The pinned Zig archive is also checksum-verified. + +Each target leg runs `cargo xtask publish-build`, producing the CLI, FFI library, +Node addon, and Python wheel for that target. The Linux-only pipeline treats +successful cross-linking, staging, and package assembly as its target validation; +it does not inspect binary headers or execute Windows/macOS artifacts. Runtime +qualification remains a separate release step on matching hardware. + +The assembly job merges all five outputs, restores its Cargo, target, and pnpm +caches, and runs `cargo xtask publish-stage --pack-only`. The packer validates +the exact 8 npm, 15 crate, 7 NuGet package, 2 NuGet symbol package, 5 Python +wheel, 1 Python sdist, and 19 standalone asset contract. Completion on `main` +triggers the unscheduled 1ES Official `Web UI - CD` pipeline, whose main and SDL +source-analysis pools are Linux. It signs artifacts, creates or verifies the +annotated release tag, publishes npm and Rust crates, and creates the GitHub +Release. Python artifacts are attached to GitHub rather than published to PyPI; +NuGet publication remains manual. Validation mode permits existing-version +artifact rebuilds while omitting tags and external publication. ### Python Distribution @@ -4574,12 +4605,12 @@ these fields. See [Per-Render HTML Injection](#per-render-html-injection) and `webui-python` builds against PyO3's `abi3-py311` stable ABI, so one wheel per platform serves every CPython 3.11+ interpreter without a per-minor-version -build matrix. v1 ships six wheels plus one `sdist`: +build matrix. v1 ships five wheels plus one `sdist`: | Platform | Architectures | |----------|---------------| | Windows | x86_64, ARM64 | -| macOS | x86_64, ARM64 (separate wheels, not a `universal2` fat binary) | +| macOS | ARM64 | | manylinux | x86_64, ARM64 | Explicitly out of scope for v1: PyPy, GraalPy, free-threaded (`t`-suffixed) diff --git a/README.md b/README.md index f24325db8..cbc62b65a 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ For Python server-side bindings: pip install ./microsoft_webui--cp311-abi3-.whl ``` -`microsoft-webui` is a native PyO3 binding (not `ctypes`) for CPython 3.11+, distributed as prebuilt wheels for Windows, macOS, and manylinux on x86_64 and ARM64, plus one sdist. It is runtime-only: render `webui build` output, but don't compile templates from Python. +`microsoft-webui` is a native PyO3 binding (not `ctypes`) for CPython 3.11+, distributed as prebuilt x86_64/ARM64 wheels for Windows and manylinux plus an ARM64 wheel for macOS, alongside one sdist. It is runtime-only: render `webui build` output, but don't compile templates from Python. ## Learn @@ -69,19 +69,24 @@ Common commands: | `cargo xtask build` | Build the workspace and examples | | `cargo xtask dev ` | Run an example app in development mode | | `cargo xtask bench ` | Run benchmarks | -| `cargo xtask build-windows-local` | Manually build and stage Windows MSVC artifacts on macOS | +| `cargo xtask build-windows-local` | Manually build and stage Windows MSVC artifacts on Linux or macOS | -### Manual Windows builds on macOS +### Manual Windows cross-builds `cargo xtask build-windows-local` is a local-only helper for producing Windows -x64 and ARM64 release bits from macOS. It does not run in CI and does not -install tools automatically. +x64 and ARM64 release bits from Linux or macOS. It does not install tools +automatically. -Install the build prerequisites once: +Install LLVM, LLD, and the pinned cargo-xwin release once: ```bash +# Ubuntu/Debian +sudo apt-get install clang lld llvm + +# macOS brew install llvm lld -cargo install cargo-xwin --version 0.23.0 + +cargo install --locked cargo-xwin --version 0.23.0 rustup target add x86_64-pc-windows-msvc aarch64-pc-windows-msvc ``` @@ -104,6 +109,32 @@ and `dotnet/runtimes/win-*/native/`. `cargo-xwin` downloads Microsoft Windows SDK and CRT assets; using it requires accepting the Microsoft SDK license terms referenced by cargo-xwin. +### Manual macOS cross-builds on Linux + +macOS release artifacts build through cargo-zigbuild with a legally obtained +Apple SDK. Install Zig 0.13.0, cargo-zigbuild, maturin, and the Rust ARM64 target: + +```bash +zig version # must print 0.13.0 +cargo install --locked cargo-zigbuild --version 0.23.0 +python3.11 -m pip install "maturin==1.14.1" +rustup target add aarch64-apple-darwin +``` + +Point `SDKROOT` at the extracted SDK and retain the release contract's minimum +deployment version: + +```bash +export SDKROOT=/absolute/path/to/MacOSX.sdk + +MACOSX_DEPLOYMENT_TARGET=11.0 \ + cargo xtask publish-build --target aarch64-apple-darwin +``` + +The command stages the CLI, Node addon, FFI library, and abi3 Python wheel. +Apple SDK contents are licensed inputs: do not commit, publish, or expose them +to untrusted builds. + For a quick local sanity check of the x64 CLI artifact, install Wine Stable: ```bash diff --git a/crates/webui-python/tests/test_package.py b/crates/webui-python/tests/test_package.py index 4da50e17d..42527feea 100644 --- a/crates/webui-python/tests/test_package.py +++ b/crates/webui-python/tests/test_package.py @@ -120,7 +120,7 @@ def test_distribution_has_no_runtime_dependencies() -> None: def test_release_target_contract_is_restated_consistently() -> None: contract = validate_release_targets._contract_tags() - assert len(contract) == 6 + assert len(contract) == 5 assert validate_release_targets.main() == 0 diff --git a/crates/webui-python/tests/validate_release_targets.py b/crates/webui-python/tests/validate_release_targets.py index 0f5f0a131..6e816a7ad 100644 --- a/crates/webui-python/tests/validate_release_targets.py +++ b/crates/webui-python/tests/validate_release_targets.py @@ -52,9 +52,9 @@ def _contract_tags() -> set[str]: tags = CONTRACT_TAG.findall(source) unique = set(tags) - if len(unique) != 6: + if len(unique) != 5: raise ValueError( - f"{CONTRACT_SOURCE}: expected 6 unique python_platform_tag values, " + f"{CONTRACT_SOURCE}: expected 5 unique python_platform_tag values, " f"found {sorted(unique)}" ) return unique diff --git a/docs/guide/integrations/python.md b/docs/guide/integrations/python.md index a7caccfe7..05f09c4f6 100644 --- a/docs/guide/integrations/python.md +++ b/docs/guide/integrations/python.md @@ -259,9 +259,8 @@ already escaped yourself. `microsoft-webui` builds against PyO3's `abi3-py311` stable ABI, so one wheel per platform serves every CPython 3.11+ interpreter — no per-minor-version -build matrix. v1 ships six wheels (Windows, macOS, and manylinux, each for -x86_64 and ARM64; macOS ships separate x86_64/ARM64 wheels, not a -`universal2` fat binary) plus one `sdist`. +build matrix. v1 ships five wheels: x86_64 and ARM64 for Windows and manylinux, +plus ARM64 for macOS, alongside one `sdist`. v1 is **runtime-only**: it renders compiled protocols and does not expose a build/compile API. Produce `protocol.bin` with `webui build` (the npm or Rust diff --git a/dotnet/Microsoft.WebUI.sln b/dotnet/Microsoft.WebUI.sln index 520823e71..2914e773a 100644 --- a/dotnet/Microsoft.WebUI.sln +++ b/dotnet/Microsoft.WebUI.sln @@ -16,8 +16,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.WebUI.Runtime.lin EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.WebUI.Runtime.osx-arm64", "runtime\Microsoft.WebUI.Runtime.osx-arm64\Microsoft.WebUI.Runtime.osx-arm64.csproj", "{176CF5E3-7ADC-43D6-AE32-8840E19204B3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.WebUI.Runtime.osx-x64", "runtime\Microsoft.WebUI.Runtime.osx-x64\Microsoft.WebUI.Runtime.osx-x64.csproj", "{75413606-ECAE-4159-82F3-CDA3E6DD431A}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.WebUI.Runtime.win-arm64", "runtime\Microsoft.WebUI.Runtime.win-arm64\Microsoft.WebUI.Runtime.win-arm64.csproj", "{B68F4C51-5D33-4FAE-B989-2CBB33FD3B07}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.WebUI.Runtime.win-x64", "runtime\Microsoft.WebUI.Runtime.win-x64\Microsoft.WebUI.Runtime.win-x64.csproj", "{C63D0997-0E3B-4C47-A0AE-EFAD2AE596CF}" @@ -52,10 +50,6 @@ Global {176CF5E3-7ADC-43D6-AE32-8840E19204B3}.Debug|Any CPU.Build.0 = Debug|Any CPU {176CF5E3-7ADC-43D6-AE32-8840E19204B3}.Release|Any CPU.ActiveCfg = Release|Any CPU {176CF5E3-7ADC-43D6-AE32-8840E19204B3}.Release|Any CPU.Build.0 = Release|Any CPU - {75413606-ECAE-4159-82F3-CDA3E6DD431A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {75413606-ECAE-4159-82F3-CDA3E6DD431A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {75413606-ECAE-4159-82F3-CDA3E6DD431A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {75413606-ECAE-4159-82F3-CDA3E6DD431A}.Release|Any CPU.Build.0 = Release|Any CPU {B68F4C51-5D33-4FAE-B989-2CBB33FD3B07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B68F4C51-5D33-4FAE-B989-2CBB33FD3B07}.Debug|Any CPU.Build.0 = Debug|Any CPU {B68F4C51-5D33-4FAE-B989-2CBB33FD3B07}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -69,7 +63,6 @@ Global {77331337-2269-48D8-98AD-687C7EB96C5A} = {B9B45A75-0C0F-0D50-E9F8-ABF69D9594D8} {7C805D6B-162C-4FB4-A282-EF9B1DBD7D08} = {B9B45A75-0C0F-0D50-E9F8-ABF69D9594D8} {176CF5E3-7ADC-43D6-AE32-8840E19204B3} = {B9B45A75-0C0F-0D50-E9F8-ABF69D9594D8} - {75413606-ECAE-4159-82F3-CDA3E6DD431A} = {B9B45A75-0C0F-0D50-E9F8-ABF69D9594D8} {B68F4C51-5D33-4FAE-B989-2CBB33FD3B07} = {B9B45A75-0C0F-0D50-E9F8-ABF69D9594D8} {C63D0997-0E3B-4C47-A0AE-EFAD2AE596CF} = {B9B45A75-0C0F-0D50-E9F8-ABF69D9594D8} EndGlobalSection diff --git a/dotnet/runtime/Microsoft.WebUI.Runtime.osx-x64/Microsoft.WebUI.Runtime.osx-x64.csproj b/dotnet/runtime/Microsoft.WebUI.Runtime.osx-x64/Microsoft.WebUI.Runtime.osx-x64.csproj deleted file mode 100644 index b1e60aefc..000000000 --- a/dotnet/runtime/Microsoft.WebUI.Runtime.osx-x64/Microsoft.WebUI.Runtime.osx-x64.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - netstandard2.0 - Microsoft.WebUI.Runtime.osx-x64 - osx-x64 - WebUI native runtime for macOS x64. - false - false - true - true - - $(NoWarn);NU5128 - - - - - diff --git a/dotnet/runtime/README.md b/dotnet/runtime/README.md index fafbe1abb..0874a3ceb 100644 --- a/dotnet/runtime/README.md +++ b/dotnet/runtime/README.md @@ -20,7 +20,6 @@ Reference a runtime package directly only when you are manually assembling nativ | Windows ARM64 | `Microsoft.WebUI.Runtime.win-arm64` | | Linux x64 | `Microsoft.WebUI.Runtime.linux-x64` | | Linux ARM64 | `Microsoft.WebUI.Runtime.linux-arm64` | -| macOS x64 | `Microsoft.WebUI.Runtime.osx-x64` | | macOS ARM64 | `Microsoft.WebUI.Runtime.osx-arm64` | ## Documentation diff --git a/dotnet/src/Microsoft.WebUI/Microsoft.WebUI.csproj b/dotnet/src/Microsoft.WebUI/Microsoft.WebUI.csproj index 09d1c637b..aca0487ab 100644 --- a/dotnet/src/Microsoft.WebUI/Microsoft.WebUI.csproj +++ b/dotnet/src/Microsoft.WebUI/Microsoft.WebUI.csproj @@ -13,7 +13,6 @@ - diff --git a/dotnet/src/Microsoft.WebUI/README.md b/dotnet/src/Microsoft.WebUI/README.md index fb0ca961f..7ce6d96f6 100644 --- a/dotnet/src/Microsoft.WebUI/README.md +++ b/dotnet/src/Microsoft.WebUI/README.md @@ -67,7 +67,6 @@ The managed package depends on all supported `Microsoft.WebUI.Runtime.` pac | Windows ARM64 | `Microsoft.WebUI.Runtime.win-arm64` | | Linux x64 | `Microsoft.WebUI.Runtime.linux-x64` | | Linux ARM64 | `Microsoft.WebUI.Runtime.linux-arm64` | -| macOS x64 | `Microsoft.WebUI.Runtime.osx-x64` | | macOS ARM64 | `Microsoft.WebUI.Runtime.osx-arm64` | ### Package Metadata diff --git a/packages/webui-darwin-x64/README.md b/packages/webui-darwin-x64/README.md deleted file mode 100644 index 954d92391..000000000 --- a/packages/webui-darwin-x64/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# @microsoft/webui-darwin-x64 - -Platform-specific binary package for WebUI. This package is installed automatically -by `@microsoft/webui` — you should not need to install it directly. diff --git a/packages/webui-darwin-x64/package.json b/packages/webui-darwin-x64/package.json deleted file mode 100644 index bba9798b5..000000000 --- a/packages/webui-darwin-x64/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "cpu": [ - "x64" - ], - "description": "WebUI platform-specific binary for macOS x64", - "files": [ - "bin/webui", - "webui.node" - ], - "license": "MIT", - "name": "@microsoft/webui-darwin-x64", - "os": [ - "darwin" - ], - "preferUnplugged": true, - "version": "0.0.25" -} diff --git a/packages/webui/README.md b/packages/webui/README.md index 8f95759df..af2a4428d 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -11,10 +11,9 @@ npm install @microsoft/webui ``` The package automatically installs the correct platform-specific native binary -for your OS and architecture (Windows, macOS, Linux - x64 and arm64). The Node -API requires that native addon and surfaces loading errors directly. It never -falls back to a subprocess. Use the `webui` CLI explicitly for filesystem -builds. +for Windows and Linux on x64 or ARM64, and for macOS on ARM64. The Node API +requires that native addon and surfaces loading errors directly. It never falls +back to a subprocess. Use the `webui` CLI explicitly for filesystem builds. ## Quick start @@ -260,7 +259,6 @@ npx webui inspect ./dist/protocol.bin | Windows | x64 | `@microsoft/webui-win32-x64` | | Windows | arm64 | `@microsoft/webui-win32-arm64` | | macOS | arm64 | `@microsoft/webui-darwin-arm64` | -| macOS | x64 | `@microsoft/webui-darwin-x64` | | Linux | x64 | `@microsoft/webui-linux-x64` | | Linux | arm64 | `@microsoft/webui-linux-arm64` | diff --git a/packages/webui/package.json b/packages/webui/package.json index 08259178e..46aa8bc48 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -50,7 +50,6 @@ }, "optionalDependencies": { "@microsoft/webui-darwin-arm64": "workspace:*", - "@microsoft/webui-darwin-x64": "workspace:*", "@microsoft/webui-linux-arm64": "workspace:*", "@microsoft/webui-linux-x64": "workspace:*", "@microsoft/webui-win32-arm64": "workspace:*", diff --git a/packages/webui/src/platform.ts b/packages/webui/src/platform.ts index 5d700e1ab..96d191f97 100644 --- a/packages/webui/src/platform.ts +++ b/packages/webui/src/platform.ts @@ -12,7 +12,6 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PLATFORMS: Record = { "darwin-arm64": "@microsoft/webui-darwin-arm64", - "darwin-x64": "@microsoft/webui-darwin-x64", "linux-x64": "@microsoft/webui-linux-x64", "linux-arm64": "@microsoft/webui-linux-arm64", "win32-x64": "@microsoft/webui-win32-x64", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0d2dd805..6c4519b51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -459,9 +459,6 @@ importers: '@microsoft/webui-darwin-arm64': specifier: workspace:* version: link:../webui-darwin-arm64 - '@microsoft/webui-darwin-x64': - specifier: workspace:* - version: link:../webui-darwin-x64 '@microsoft/webui-linux-arm64': specifier: workspace:* version: link:../webui-linux-arm64 @@ -477,8 +474,6 @@ importers: packages/webui-darwin-arm64: {} - packages/webui-darwin-x64: {} - packages/webui-examples-theme: {} packages/webui-framework: diff --git a/xtask/src/publish.rs b/xtask/src/publish.rs index c6224c406..d046cbb28 100644 --- a/xtask/src/publish.rs +++ b/xtask/src/publish.rs @@ -13,7 +13,7 @@ //! - `publish/standalone/` — legacy direct-download native and WASM assets //! - `publish/python/` — pre-staged wheels plus the generated source distribution -use crate::util::{build_command, run_command, run_command_quiet}; +use crate::util::{build_command, run_command_quiet}; use crate::version; use std::fs; use std::path::{Component, Path, PathBuf}; @@ -33,6 +33,11 @@ struct PlatformEntry { platform_suffix: &'static str, /// Exact platform tag emitted by the pinned maturin build for this target. python_platform_tag: &'static str, + /// `MACOSX_DEPLOYMENT_TARGET` to pin when cross-compiling this target with + /// `cargo zigbuild` / `maturin --zig`, so the embedded minimum OS version + /// matches `python_platform_tag` regardless of toolchain defaults. + /// `None` for non-Darwin targets, which never use that backend. + macos_deployment_target: Option<&'static str>, } const PLATFORMS: &[PlatformEntry] = &[ @@ -45,6 +50,7 @@ const PLATFORMS: &[PlatformEntry] = &[ cli_binary: "webui", platform_suffix: "linux-x64", python_platform_tag: "manylinux_2_17_x86_64.manylinux2014_x86_64", + macos_deployment_target: None, }, PlatformEntry { triple: "aarch64-unknown-linux-gnu", @@ -55,6 +61,7 @@ const PLATFORMS: &[PlatformEntry] = &[ cli_binary: "webui", platform_suffix: "linux-arm64", python_platform_tag: "manylinux_2_17_aarch64.manylinux2014_aarch64", + macos_deployment_target: None, }, PlatformEntry { triple: "x86_64-pc-windows-msvc", @@ -65,6 +72,7 @@ const PLATFORMS: &[PlatformEntry] = &[ cli_binary: "webui.exe", platform_suffix: "win32-x64", python_platform_tag: "win_amd64", + macos_deployment_target: None, }, PlatformEntry { triple: "aarch64-pc-windows-msvc", @@ -75,16 +83,7 @@ const PLATFORMS: &[PlatformEntry] = &[ cli_binary: "webui.exe", platform_suffix: "win32-arm64", python_platform_tag: "win_arm64", - }, - PlatformEntry { - triple: "x86_64-apple-darwin", - npm_package: "webui-darwin-x64", - nuget_rid: "osx-x64", - ffi_lib: "libwebui_ffi.dylib", - node_addon: "libwebui_node.dylib", - cli_binary: "webui", - platform_suffix: "darwin-x64", - python_platform_tag: "macosx_10_12_x86_64", + macos_deployment_target: None, }, PlatformEntry { triple: "aarch64-apple-darwin", @@ -95,9 +94,97 @@ const PLATFORMS: &[PlatformEntry] = &[ cli_binary: "webui", platform_suffix: "darwin-arm64", python_platform_tag: "macosx_11_0_arm64", + macos_deployment_target: Some("11.0"), }, ]; +// ── Cross-compilation backend selection ───────────────────────────────── + +/// Host operating system running `publish-build`, distinct from the target +/// triple being built. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum HostOs { + Linux, + MacOs, + Windows, + Other, +} + +fn current_host_os() -> HostOs { + if cfg!(target_os = "linux") { + HostOs::Linux + } else if cfg!(target_os = "macos") { + HostOs::MacOs + } else if cfg!(target_os = "windows") { + HostOs::Windows + } else { + HostOs::Other + } +} + +/// Cargo wrapper used to build a target triple's native artifacts. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Backend { + /// Plain `cargo build`, for a host's own target and for Linux targets. + Cargo, + /// `cargo xwin build`, cross-compiles the two Windows MSVC targets using + /// an embedded MSVC-compatible toolchain (headers, import libs, CRT). + CargoXwin, + /// `cargo zigbuild`, cross-compiles the two Apple Darwin targets using + /// Zig as the C/Obj-C cross linker. + CargoZigbuild, +} + +impl Backend { + /// The `cargo` subcommand inserted before `build`, e.g. `cargo xwin + /// build`. `None` for plain `cargo build`. + fn subcommand(self) -> Option<&'static str> { + match self { + Backend::Cargo => None, + Backend::CargoXwin => Some("xwin"), + Backend::CargoZigbuild => Some("zigbuild"), + } + } +} + +/// Choose the cargo backend for building `triple` on `host`. +/// +/// Linux is the primary cross-compilation host: it builds its own Linux +/// targets with native `cargo build`, reaches Windows MSVC through `cargo +/// xwin build`, and reaches Apple Darwin through `cargo zigbuild`. A host +/// building its own native target family — a macOS runner building +/// `*-apple-darwin`, or a Windows runner building `*-pc-windows-msvc` — +/// always uses plain `cargo build` there instead, since no cross toolchain +/// is required. This mirrors the existing `build-windows-local` behavior +/// (macOS host, Windows MSVC target, `cargo-xwin`) as one case of the same +/// rule, so both entry points can share it. +fn select_backend_for_host(host: HostOs, triple: &str) -> Backend { + match host { + HostOs::MacOs if triple.ends_with("-apple-darwin") => Backend::Cargo, + HostOs::Windows if triple.ends_with("-pc-windows-msvc") => Backend::Cargo, + _ if triple.ends_with("-linux-gnu") => Backend::Cargo, + _ if triple.ends_with("-pc-windows-msvc") => Backend::CargoXwin, + _ if triple.ends_with("-apple-darwin") => Backend::CargoZigbuild, + _ => Backend::Cargo, + } +} + +pub(crate) fn select_backend(triple: &str) -> Backend { + select_backend_for_host(current_host_os(), triple) +} + +/// The `MACOSX_DEPLOYMENT_TARGET` env value to pin for `triple`, when +/// cross-compiling with `cargo zigbuild` (native build) or `maturin --zig` +/// (Python wheel). Both call sites derive the same value from `PLATFORMS`, +/// so the two build paths cannot drift apart. `None` for non-Darwin targets. +fn macos_deployment_target_env(triple: &str) -> Option<(&'static str, &'static str)> { + PLATFORMS + .iter() + .find(|platform| platform.triple == triple) + .and_then(|platform| platform.macos_deployment_target) + .map(|version| ("MACOSX_DEPLOYMENT_TARGET", version)) +} + /// Subdirectories created inside `publish/`. const PUBLISH_SUBDIRS: &[&str] = &[ "native", @@ -121,7 +208,6 @@ const WASM_VARIANT_DIRS: &[&str] = &["all", "handler", "parser"]; const STANDALONE_RELEASE_FILES: &[(&str, &str)] = &[ ("native/webui-darwin-arm64", "webui-darwin-arm64"), - ("native/webui-darwin-x64", "webui-darwin-x64"), ("native/webui-linux-arm64", "webui-linux-arm64"), ("native/webui-linux-x64", "webui-linux-x64"), ("native/webui-win32-arm64.exe", "webui-win32-arm64.exe"), @@ -645,7 +731,21 @@ fn python_interpreter() -> String { /// the ABI tag from the feature and the platform tag from `--target`. Naming an /// interpreter makes maturin match the host interpreter against the target /// architecture and skip it, which breaks every cross build. -fn maturin_build_args<'a>(manifest: &'a str, triple: &'a str, out: &'a str) -> Vec<&'a str> { +/// +/// `backend` adds the maturin flag for the cross toolchain it maps to. +/// `CargoZigbuild` gets maturin's own `--zig` flag; `Backend::Cargo` adds +/// nothing, since that is either a native build or a Linux target, where the +/// `manylinux` container's own cross toolchain applies instead. `CargoXwin` +/// also adds nothing here: unlike `--zig`, maturin has no built-in `--xwin` +/// flag (passing one is a clap error), so Windows MSVC cross builds instead +/// export `cargo-xwin`'s own linker/SDK environment onto the `maturin` +/// process; see [`cargo_xwin_env`]. +fn maturin_build_args<'a>( + manifest: &'a str, + triple: &'a str, + out: &'a str, + backend: Backend, +) -> Vec<&'a str> { let mut args = vec![ "-m", "maturin", @@ -664,9 +764,54 @@ fn maturin_build_args<'a>(manifest: &'a str, triple: &'a str, out: &'a str) -> V if triple.ends_with("-linux-gnu") { args.extend_from_slice(&["--compatibility", "manylinux_2_17"]); } + if backend == Backend::CargoZigbuild { + args.push("--zig"); + } args } +/// The cross-compilation environment variables `cargo xwin build` would set +/// for `triple` (the MSVC linker, `CC`/`CXX`/`AR`, and the downloaded CRT/SDK +/// include and lib paths), obtained via `cargo xwin env` instead of +/// duplicating cargo-xwin's toolchain/SDK resolution here. +/// +/// maturin invokes `cargo` itself to build the extension module; it has no +/// `--xwin` flag to ask it to route that invocation through cargo-xwin +/// (unlike its `--zig` flag for cargo-zigbuild). Exporting the same +/// environment cargo-xwin uses onto the `maturin` process makes its internal +/// `cargo build` cross-link against the MSVC target exactly like the +/// `cargo xwin build` step that stages the native binaries. +fn cargo_xwin_env(triple: &str) -> Result, String> { + let output = build_command("cargo", &["xwin", "env", "--target", triple]) + .output() + .map_err(|e| format!("failed to run cargo xwin env: {e}"))?; + if !output.status.success() { + let mut msg = String::from_utf8_lossy(&output.stderr).into_owned(); + if msg.is_empty() { + msg = format!("exit code {}", output.status.code().unwrap_or(1)); + } + return Err(format!("cargo xwin env failed for {triple}: {msg}")); + } + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(parse_cargo_xwin_env(&stdout)) +} + +/// Parse `cargo xwin env`'s `export KEY="VALUE";` lines into key/value pairs. +/// Split out from [`cargo_xwin_env`] so the parsing logic is unit-testable +/// without actually invoking `cargo xwin`. +fn parse_cargo_xwin_env(output: &str) -> Vec<(String, String)> { + output + .lines() + .filter_map(|line| { + let rest = line.trim().strip_prefix("export ")?; + let rest = rest.strip_suffix(';').unwrap_or(rest); + let (key, value) = rest.split_once('=')?; + let value = value.trim_matches('"'); + Some((key.to_string(), value.to_string())) + }) + .collect() +} + fn build_python_wheel(root: &Path, triple: &str, out_dir: &Path) -> Result { let platform = PLATFORMS .iter() @@ -684,12 +829,31 @@ fn build_python_wheel(root: &Path, triple: &str, out_dir: &Path) -> Result = macos_deployment_target_env(triple) + .map(|(key, value)| vec![(key.to_string(), value.to_string())]) + .unwrap_or_default(); + if backend == Backend::CargoXwin { + env.extend(cargo_xwin_env(triple)?); + } + let env_refs: Vec<(&str, &str)> = env + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + .collect(); - run_command_quiet(&interpreter, &maturin_args, Some(root)) + run_command_quiet_with_env(&interpreter, &maturin_args, root, &env_refs) .map_err(|e| format!("maturin build failed: {e}"))?; let expected = format!( @@ -784,12 +948,28 @@ fn set_build_mode(current: BuildMode, requested: BuildMode) -> Result Result<(), String> { - let args = native_build_args(triple, profile)?; - run_command("cargo", &args, Some(root)) + let backend = select_backend(triple); + preflight_backend(backend)?; + + let args = native_build_args(triple, profile, backend)?; + let env = macos_deployment_target_env(triple); + run_command_with_env("cargo", &args, root, env.as_slice()) } -fn native_build_args<'a>(triple: &'a str, profile: &str) -> Result, String> { - let mut args = Vec::with_capacity(13); +/// Assemble the `cargo build` arguments for one target, prefixed with the +/// backend's cargo subcommand (e.g. `xwin`, `zigbuild`) when it needs one. +/// +/// `pub(crate)` so `windows_local::build_target` can share it for the +/// `CargoXwin` backend instead of duplicating the argument list. +pub(crate) fn native_build_args<'a>( + triple: &'a str, + profile: &str, + backend: Backend, +) -> Result, String> { + let mut args = Vec::with_capacity(14); + if let Some(subcommand) = backend.subcommand() { + args.push(subcommand); + } args.push("build"); match profile { "release" => args.push("--release"), @@ -809,6 +989,251 @@ fn native_build_args<'a>(triple: &'a str, profile: &str) -> Result, Ok(args) } +/// Actionable preflight checks for the backend chosen for a target, so a +/// missing or mis-pinned cross toolchain fails immediately with install +/// guidance instead of a confusing linker error partway through the build. +/// Reuses `build-windows-local`'s cargo-xwin/LLVM checks rather than +/// duplicating them, since both entry points depend on the same toolchain. +fn preflight_backend(backend: Backend) -> Result<(), String> { + match backend { + Backend::Cargo => Ok(()), + Backend::CargoXwin => { + crate::windows_local::ensure_cargo_xwin()?; + crate::windows_local::ensure_llvm_tools() + } + Backend::CargoZigbuild => { + ensure_cargo_zigbuild()?; + ensure_zig()?; + ensure_sdkroot_for_zigbuild(current_host_os()) + } + } +} + +/// Pinned `cargo-zigbuild` version, matching the `cargo-xwin` pin in +/// `windows_local.rs` so both cross toolchains stay reproducible. +const CARGO_ZIGBUILD_VERSION: &str = "0.23.0"; + +fn ensure_cargo_zigbuild() -> Result<(), String> { + match installed_cargo_zigbuild_version()? { + Some(found) if found == CARGO_ZIGBUILD_VERSION => Ok(()), + Some(found) => Err(format!( + "cargo-zigbuild {CARGO_ZIGBUILD_VERSION} is required, found {found}.\n help: install the pinned version with: cargo install cargo-zigbuild --version {CARGO_ZIGBUILD_VERSION} --locked" + )), + None => Err(format!( + "cargo-zigbuild {CARGO_ZIGBUILD_VERSION} is required but was not found on PATH.\n help: install it with: cargo install cargo-zigbuild --version {CARGO_ZIGBUILD_VERSION} --locked" + )), + } +} + +fn installed_cargo_zigbuild_version() -> Result, String> { + let output = match std::process::Command::new("cargo-zigbuild") + .arg("--version") + .output() + { + Ok(output) => output, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("failed to run cargo-zigbuild --version: {error}")), + }; + + if !output.status.success() { + return Err(format!( + "cargo-zigbuild --version failed with {}", + output.status + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(cargo_zigbuild_version(&stdout).map(str::to_string)) +} + +fn cargo_zigbuild_version(output: &str) -> Option<&str> { + let mut parts = output.split_whitespace(); + if parts.next() == Some("cargo-zigbuild") { + return parts.next(); + } + None +} + +/// Pinned Zig version, matching the Azure release pipeline's toolchain pin +/// so `cargo zigbuild`'s cross-linking behavior stays reproducible between +/// a Linux developer machine and CI. +const ZIG_VERSION: &str = "0.13.0"; + +/// Environment variable naming an alternate `zig` executable, for machines +/// where the pinned Zig isn't the one on `PATH` (e.g. a version manager or +/// a toolchain cache directory). Mirrors `cargo-zigbuild`'s own lookup. +const CARGO_ZIGBUILD_ZIG_PATH_VAR: &str = "CARGO_ZIGBUILD_ZIG_PATH"; + +/// Resolve which `zig` executable to check/run: a non-empty +/// `CARGO_ZIGBUILD_ZIG_PATH` override, or the default `zig` on `PATH`. +/// +/// Pure and independent of `std::env` so it can be unit-tested with mock +/// inputs; `zig_executable` below is the real entry point that reads the +/// actual environment variable. +fn resolve_zig_executable(env_value: Option<&str>) -> &str { + match env_value { + Some(value) if !value.trim().is_empty() => value, + _ => "zig", + } +} + +fn zig_executable() -> String { + let value = std::env::var(CARGO_ZIGBUILD_ZIG_PATH_VAR).ok(); + resolve_zig_executable(value.as_deref()).to_string() +} + +/// Verify Zig is on the resolved executable path and matches [`ZIG_VERSION`]. +/// +/// `cargo zigbuild` shells out to `zig cc`/`zig c++` as its cross linker, so +/// a missing or mismatched Zig produces confusing link-time errors rather +/// than an actionable message; this fails fast with install guidance instead. +fn ensure_zig() -> Result<(), String> { + let executable = zig_executable(); + match installed_zig_version(&executable)? { + Some(found) if found == ZIG_VERSION => Ok(()), + Some(found) => Err(format!( + "Zig {ZIG_VERSION} is required for cargo zigbuild (matching the Azure release pipeline's pin), found {found} via {executable}.\n help: install Zig {ZIG_VERSION} from https://ziglang.org/download/, or set {CARGO_ZIGBUILD_ZIG_PATH_VAR} to a Zig {ZIG_VERSION} binary" + )), + None => Err(format!( + "Zig {ZIG_VERSION} is required for cargo zigbuild but {executable} was not found.\n help: install Zig {ZIG_VERSION} from https://ziglang.org/download/, or set {CARGO_ZIGBUILD_ZIG_PATH_VAR} to a Zig {ZIG_VERSION} binary" + )), + } +} + +fn installed_zig_version(executable: &str) -> Result, String> { + let output = match std::process::Command::new(executable) + .arg("version") + .output() + { + Ok(output) => output, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("failed to run {executable} version: {error}")), + }; + + if !output.status.success() { + return Err(format!( + "{executable} version failed with {}", + output.status + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(zig_version(&stdout).map(str::to_string)) +} + +/// Parse `zig version` output, which — unlike `cargo-xwin`/`cargo-zigbuild` +/// `--version` — prints only the bare version number (e.g. `0.13.0`), with +/// no leading binary name to strip. Pure so it can be unit-tested directly. +fn zig_version(output: &str) -> Option<&str> { + output.lines().next()?.split_whitespace().next() +} + +/// Pure SDKROOT validation for the `CargoZigbuild` backend, so tests can +/// exercise every case (missing, empty, non-directory, valid) with mock +/// inputs instead of mutating the real process environment — `std::env` +/// mutation is unsafe and races across tests that run in parallel. +/// +/// Unlike a native macOS toolchain, `cargo zigbuild` neither vendors nor +/// lazily downloads an Apple SDK: without a real `SDKROOT`, it silently +/// links against Zig's minimal libc/libSystem stubs instead of failing. +/// Public PRs validate with a direct `cargo check`/`cargo build`, so +/// `publish-build` must fail loudly here rather than let that happen. +/// xtask itself never downloads an SDK; trusted CI sets `SDKROOT` to a +/// vendored one explicitly. Native macOS hosts don't reach this backend +/// (see `select_backend_for_host`), so the check only gates the actual +/// cross case. +fn validate_sdkroot( + host: HostOs, + backend: Backend, + sdkroot: Option<&str>, + is_directory: impl Fn(&str) -> bool, +) -> Result<(), String> { + if backend != Backend::CargoZigbuild || host == HostOs::MacOs { + return Ok(()); + } + + match sdkroot { + None => Err( + "SDKROOT is required to cross-compile Apple targets with cargo zigbuild from a non-macOS host.\n help: set SDKROOT to an extracted macOS SDK directory, e.g. SDKROOT=/opt/MacOSX14.sdk" + .to_string(), + ), + Some(path) if path.trim().is_empty() => Err( + "SDKROOT is set but empty; cargo zigbuild requires it to point at a real macOS SDK directory".to_string(), + ), + Some(path) if !is_directory(path) => Err(format!( + "SDKROOT={path} does not name an existing directory.\n help: point SDKROOT at an extracted macOS SDK directory" + )), + Some(_) => Ok(()), + } +} + +fn ensure_sdkroot_for_zigbuild(host: HostOs) -> Result<(), String> { + let sdkroot = std::env::var("SDKROOT").ok(); + validate_sdkroot(host, Backend::CargoZigbuild, sdkroot.as_deref(), |path| { + Path::new(path).is_dir() + }) +} + +/// Like [`crate::util::run_command`], but also sets extra environment +/// variables (e.g. `MACOSX_DEPLOYMENT_TARGET` for the `cargo zigbuild` +/// backend; see `macos_deployment_target_env`). Every other backend passes +/// an empty slice and behaves exactly like `run_command`. +fn run_command_with_env( + cmd: &str, + args: &[&str], + cwd: &Path, + env: &[(&str, &str)], +) -> Result<(), String> { + let mut command = build_command(cmd, args); + command.current_dir(cwd); + for (key, value) in env { + command.env(key, value); + } + + match command.status() { + Ok(status) if status.success() => Ok(()), + Ok(status) => Err(format!("exit code {}", status.code().unwrap_or(1))), + Err(error) => Err(error.to_string()), + } +} + +/// Like [`crate::util::run_command_quiet`], but also sets extra environment +/// variables. Used for the `maturin --zig` backend; see +/// `run_command_with_env`. +fn run_command_quiet_with_env( + cmd: &str, + args: &[&str], + cwd: &Path, + env: &[(&str, &str)], +) -> Result<(), String> { + use std::process::Stdio; + + let mut command = build_command(cmd, args); + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + command.current_dir(cwd); + for (key, value) in env { + command.env(key, value); + } + + match command.output() { + Ok(output) if output.status.success() => Ok(()), + Ok(output) => { + let mut msg = String::new(); + if let Ok(s) = String::from_utf8(output.stdout) { + msg.push_str(&s); + } + if let Ok(s) = String::from_utf8(output.stderr) { + msg.push_str(&s); + } + if msg.is_empty() { + msg = format!("exit code {}", output.status.code().unwrap_or(1)); + } + Err(msg) + } + Err(error) => Err(error.to_string()), + } +} + /// Copy this target's freshly built artifacts into an external output root. /// /// Each mode cleans and rewrites only the subtrees it owns, so a Linux leg can @@ -1773,7 +2198,7 @@ fn validate_release_artifact_counts(root: &Path, version: &str) -> Result<(), St let publish = root.join("publish"); validate_artifact_count( count_files_with_extension(&publish.join("npm"), "tgz"), - 9, + 8, "npm packages", )?; validate_artifact_count( @@ -1783,7 +2208,7 @@ fn validate_release_artifact_counts(root: &Path, version: &str) -> Result<(), St )?; validate_artifact_count( count_files_with_extension(&publish.join("nuget"), "nupkg"), - 8, + 7, "NuGet packages", )?; validate_artifact_count( @@ -1793,7 +2218,7 @@ fn validate_release_artifact_counts(root: &Path, version: &str) -> Result<(), St )?; validate_artifact_count( count_regular_files(&publish.join("standalone")), - 20, + 19, "standalone release assets", )?; validate_python_release_artifacts(&publish, version) @@ -1959,7 +2384,7 @@ mod tests { #[test] fn maturin_build_args_never_name_an_interpreter() { for platform in PLATFORMS { - let args = maturin_build_args("Cargo.toml", platform.triple, "out"); + let args = maturin_build_args("Cargo.toml", platform.triple, "out", Backend::Cargo); assert!( !args.contains(&"--interpreter"), @@ -2036,6 +2461,14 @@ mod tests { assert!(error.contains("unknown target triple")); } + #[test] + fn parse_build_options_rejects_removed_macos_x64_target() { + let error = parse_build(&["--target", "x86_64-apple-darwin"]) + .expect_err("Intel macOS is no longer a release target"); + + assert!(error.contains("unknown target triple")); + } + #[test] fn parse_build_options_rejects_all_target() { let error = @@ -2054,7 +2487,7 @@ mod tests { #[test] fn native_build_args_map_debug_to_cargo_dev_profile() { - let args = native_build_args("x86_64-unknown-linux-gnu", "debug") + let args = native_build_args("x86_64-unknown-linux-gnu", "debug", Backend::Cargo) .expect("debug profile should be supported"); assert_eq!(args[0], "build"); @@ -2064,12 +2497,305 @@ mod tests { #[test] fn native_build_args_map_release_to_release_flag() { - let args = native_build_args("x86_64-unknown-linux-gnu", "release") + let args = native_build_args("x86_64-unknown-linux-gnu", "release", Backend::Cargo) .expect("release profile should be supported"); assert!(args.contains(&"--release")); } + #[test] + fn native_build_args_prefix_backend_subcommand() { + let cargo = native_build_args("x86_64-unknown-linux-gnu", "release", Backend::Cargo) + .expect("cargo backend should be supported"); + assert_eq!(cargo[0], "build"); + + let xwin = native_build_args("x86_64-pc-windows-msvc", "release", Backend::CargoXwin) + .expect("xwin backend should be supported"); + assert_eq!(&xwin[..2], &["xwin", "build"]); + + let zigbuild = native_build_args("aarch64-apple-darwin", "release", Backend::CargoZigbuild) + .expect("zigbuild backend should be supported"); + assert_eq!(&zigbuild[..2], &["zigbuild", "build"]); + + // Every backend still builds the same three native packages. + for args in [&cargo, &xwin, &zigbuild] { + assert!(args.contains(&"microsoft-webui-cli")); + assert!(args.contains(&"microsoft-webui-ffi")); + assert!(args.contains(&"microsoft-webui-node")); + } + } + + #[test] + fn select_backend_for_host_uses_native_cargo_for_each_host_family() { + for platform in PLATFORMS { + let backend = select_backend_for_host(HostOs::Linux, platform.triple); + let expected = if platform.triple.ends_with("-linux-gnu") { + Backend::Cargo + } else if platform.triple.ends_with("-pc-windows-msvc") { + Backend::CargoXwin + } else { + Backend::CargoZigbuild + }; + assert_eq!( + backend, expected, + "Linux host backend mismatch for {}", + platform.triple + ); + } + } + + #[test] + fn select_backend_for_host_linux_accepts_every_platform_entry() { + // A Linux host must have a defined (non-panicking) backend for all + // five release targets: this is the primary cross-compilation host. + for platform in PLATFORMS { + let backend = select_backend_for_host(HostOs::Linux, platform.triple); + assert_ne!( + format!("{backend:?}"), + "", + "Linux host should accept {}", + platform.triple + ); + } + } + + #[test] + fn select_backend_for_host_macos_builds_its_own_darwin_targets_natively() { + assert_eq!( + select_backend_for_host(HostOs::MacOs, "aarch64-apple-darwin"), + Backend::Cargo + ); + // macOS still needs cargo-xwin to reach Windows MSVC. + assert_eq!( + select_backend_for_host(HostOs::MacOs, "x86_64-pc-windows-msvc"), + Backend::CargoXwin + ); + } + + #[test] + fn select_backend_for_host_windows_builds_its_own_msvc_targets_natively() { + assert_eq!( + select_backend_for_host(HostOs::Windows, "x86_64-pc-windows-msvc"), + Backend::Cargo + ); + assert_eq!( + select_backend_for_host(HostOs::Windows, "aarch64-pc-windows-msvc"), + Backend::Cargo + ); + } + + #[test] + fn backend_subcommand_matches_expected_cargo_plugin() { + assert_eq!(Backend::Cargo.subcommand(), None); + assert_eq!(Backend::CargoXwin.subcommand(), Some("xwin")); + assert_eq!(Backend::CargoZigbuild.subcommand(), Some("zigbuild")); + } + + #[test] + fn maturin_build_args_add_backend_flag_only_for_cross_toolchains() { + let cargo = maturin_build_args( + "Cargo.toml", + "x86_64-unknown-linux-gnu", + "out", + Backend::Cargo, + ); + assert!(!cargo.contains(&"--xwin")); + assert!(!cargo.contains(&"--zig")); + + // maturin has no `--xwin` flag (unlike `--zig`): passing one is a + // clap error. The `CargoXwin` backend instead exports cargo-xwin's + // environment onto the maturin process; see `cargo_xwin_env`. + let xwin = maturin_build_args( + "Cargo.toml", + "x86_64-pc-windows-msvc", + "out", + Backend::CargoXwin, + ); + assert!(!xwin.contains(&"--xwin")); + assert!(!xwin.contains(&"--zig")); + + let zigbuild = maturin_build_args( + "Cargo.toml", + "aarch64-apple-darwin", + "out", + Backend::CargoZigbuild, + ); + assert!(zigbuild.contains(&"--zig")); + assert!(!zigbuild.contains(&"--xwin")); + } + + #[test] + fn parse_cargo_xwin_env_extracts_exported_pairs() { + let output = "export CC_x86_64_pc_windows_msvc=\"clang-cl\";\n\ + export CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER=\"lld-link\";\n\ + \n\ + not an export line\n"; + let vars = parse_cargo_xwin_env(output); + assert_eq!( + vars, + vec![ + ( + "CC_x86_64_pc_windows_msvc".to_string(), + "clang-cl".to_string() + ), + ( + "CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER".to_string(), + "lld-link".to_string() + ), + ] + ); + } + + #[test] + fn parse_cargo_xwin_env_ignores_blank_and_malformed_lines() { + assert!(parse_cargo_xwin_env("").is_empty()); + assert!(parse_cargo_xwin_env("export NO_VALUE;\n").is_empty()); + } + + #[test] + fn maturin_build_args_preserve_abi3_target_output_and_manylinux() { + for platform in PLATFORMS { + let backend = select_backend_for_host(HostOs::Linux, platform.triple); + let args = maturin_build_args("Cargo.toml", platform.triple, "out", backend); + + // abi3: no --interpreter is ever pinned (see maturin_build_args docs). + assert!(!args.contains(&"--interpreter")); + assert!(args.contains(&"--target")); + assert!(args.contains(&platform.triple)); + assert!(args.contains(&"--out")); + assert!(args.contains(&"out")); + assert_eq!( + args.contains(&"manylinux_2_17"), + platform.triple.ends_with("-linux-gnu") + ); + } + } + + #[test] + fn macos_deployment_target_env_matches_platform_metadata() { + assert_eq!( + macos_deployment_target_env("aarch64-apple-darwin"), + Some(("MACOSX_DEPLOYMENT_TARGET", "11.0")) + ); + assert_eq!( + macos_deployment_target_env("x86_64-unknown-linux-gnu"), + None + ); + assert_eq!(macos_deployment_target_env("unknown-target"), None); + } + + #[test] + fn cargo_zigbuild_version_parses_expected_output() { + assert_eq!( + cargo_zigbuild_version("cargo-zigbuild 0.23.0\n"), + Some(CARGO_ZIGBUILD_VERSION) + ); + assert_eq!(cargo_zigbuild_version("cargo 1.93.0\n"), None); + } + + #[test] + fn ensure_cargo_zigbuild_error_mentions_pinned_version_when_missing() { + // `cargo-zigbuild` is not expected to be on PATH in the test + // environment, so this exercises the "not found" branch's message. + if crate::util::which_exists("cargo-zigbuild") { + return; + } + + let error = ensure_cargo_zigbuild().expect_err("cargo-zigbuild should be missing in CI"); + assert!(error.contains(CARGO_ZIGBUILD_VERSION)); + assert!(error.contains("cargo install cargo-zigbuild")); + } + + #[test] + fn resolve_zig_executable_prefers_non_empty_override() { + assert_eq!( + resolve_zig_executable(Some("/opt/zig-0.13.0/zig")), + "/opt/zig-0.13.0/zig" + ); + } + + #[test] + fn resolve_zig_executable_falls_back_to_default_when_unset_or_blank() { + assert_eq!(resolve_zig_executable(None), "zig"); + assert_eq!(resolve_zig_executable(Some("")), "zig"); + assert_eq!(resolve_zig_executable(Some(" ")), "zig"); + } + + #[test] + fn zig_version_parses_bare_version_number() { + assert_eq!(zig_version("0.13.0\n"), Some(ZIG_VERSION)); + assert_eq!(zig_version("0.13.0"), Some("0.13.0")); + assert_eq!(zig_version(""), None); + assert_eq!(zig_version("\n"), None); + } + + #[test] + fn ensure_zig_error_mentions_pinned_version_and_override_var_when_missing() { + // Zig is not expected to be on PATH in the test environment, so + // this exercises the "not found" branch's message. + if crate::util::which_exists("zig") { + return; + } + + let error = ensure_zig().expect_err("zig should be missing in CI"); + assert!(error.contains(ZIG_VERSION)); + assert!(error.contains(CARGO_ZIGBUILD_ZIG_PATH_VAR)); + } + + #[test] + fn validate_sdkroot_requires_sdkroot_for_zigbuild_on_non_macos_hosts() { + let error = validate_sdkroot(HostOs::Linux, Backend::CargoZigbuild, None, |_| true) + .expect_err("missing SDKROOT should fail"); + assert!(error.contains("SDKROOT")); + assert!(error.contains("cargo zigbuild")); + } + + #[test] + fn validate_sdkroot_rejects_empty_value() { + let error = validate_sdkroot(HostOs::Linux, Backend::CargoZigbuild, Some(" "), |_| true) + .expect_err("empty SDKROOT should fail"); + assert!(error.contains("SDKROOT")); + } + + #[test] + fn validate_sdkroot_rejects_nonexistent_directory() { + let error = validate_sdkroot( + HostOs::Linux, + Backend::CargoZigbuild, + Some("/does/not/exist.sdk"), + |_| false, + ) + .expect_err("a SDKROOT that is not a real directory should fail"); + assert!(error.contains("/does/not/exist.sdk")); + assert!(error.contains("existing directory")); + } + + #[test] + fn validate_sdkroot_accepts_a_real_directory() { + validate_sdkroot( + HostOs::Linux, + Backend::CargoZigbuild, + Some("/opt/MacOSX14.sdk"), + |_| true, + ) + .expect("an existing SDKROOT directory should pass"); + } + + #[test] + fn validate_sdkroot_skips_check_on_macos_host_and_non_zigbuild_backends() { + // Native macOS hosts never reach the CargoZigbuild backend (see + // select_backend_for_host), so the check is a no-op there even + // without SDKROOT. + validate_sdkroot(HostOs::MacOs, Backend::CargoZigbuild, None, |_| false) + .expect("macOS host should skip the SDKROOT check"); + + // Only the CargoZigbuild backend needs an Apple SDK. + validate_sdkroot(HostOs::Linux, Backend::Cargo, None, |_| false) + .expect("Cargo backend should not require SDKROOT"); + validate_sdkroot(HostOs::Linux, Backend::CargoXwin, None, |_| false) + .expect("CargoXwin backend should not require SDKROOT"); + } + #[test] fn test_native_binary_name_unix() { let p = PlatformEntry { @@ -2081,6 +2807,7 @@ mod tests { cli_binary: "webui", platform_suffix: "darwin-arm64", python_platform_tag: "macosx_11_0_arm64", + macos_deployment_target: Some("11.0"), }; assert_eq!(native_binary_name(&p), "webui-darwin-arm64"); } @@ -2096,6 +2823,7 @@ mod tests { cli_binary: "webui.exe", platform_suffix: "win32-x64", python_platform_tag: "win_amd64", + macos_deployment_target: None, }; assert_eq!(native_binary_name(&p), "webui-win32-x64.exe"); } @@ -2263,7 +2991,6 @@ mod tests { "cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64", "cp311-abi3-win_amd64", "cp311-abi3-win_arm64", - "cp311-abi3-macosx_10_12_x86_64", "cp311-abi3-macosx_11_0_arm64", ] { fs::write( @@ -2295,8 +3022,8 @@ mod tests { let root = tempfile::TempDir::new().expect("root should be created"); let python_dir = root.path().join("publish").join("python"); fs::create_dir_all(&python_dir).expect("publish/python should be created"); - // Only stage 5 of the 6 expected wheels, plus a matching sdist. - for platform in &PLATFORMS[..5] { + // Only stage 4 of the 5 expected wheels, plus a matching sdist. + for platform in &PLATFORMS[..4] { fs::write( python_dir.join(format!( "{PYTHON_DISTRIBUTION_NAME}-1.2.3-{}.whl", @@ -2315,7 +3042,7 @@ mod tests { let error = validate_python_release_artifacts(&root.path().join("publish"), "1.2.3") .expect_err("missing wheel should fail validation"); - assert!(error.contains("expected 6 Python wheels, found 5")); + assert!(error.contains("expected 5 Python wheels, found 4")); assert!(error.contains("pre-stage one wheel per supported target")); assert!(error.contains("macosx_11_0_arm64")); } @@ -2447,11 +3174,11 @@ mod tests { fs::create_dir_all(root.path().join("publish").join(directory)) .expect("publish directory should be created"); } - write_numbered_files(root.path().join("publish/npm"), 9, "tgz"); + write_numbered_files(root.path().join("publish/npm"), 8, "tgz"); write_numbered_files(root.path().join("publish/crates"), 15, "crate"); - write_numbered_files(root.path().join("publish/nuget"), 8, "nupkg"); + write_numbered_files(root.path().join("publish/nuget"), 7, "nupkg"); write_numbered_files(root.path().join("publish/nuget"), 2, "snupkg"); - write_numbered_files(root.path().join("publish/standalone"), 20, "asset"); + write_numbered_files(root.path().join("publish/standalone"), 19, "asset"); write_python_release_fixtures(&root.path().join("publish/python"), "1.2.3"); assert!(validate_release_artifact_counts(root.path(), "1.2.3").is_ok()); @@ -2464,16 +3191,16 @@ mod tests { fs::create_dir_all(root.path().join("publish").join(directory)) .expect("publish directory should be created"); } - write_numbered_files(root.path().join("publish/npm"), 8, "tgz"); + write_numbered_files(root.path().join("publish/npm"), 7, "tgz"); write_numbered_files(root.path().join("publish/crates"), 15, "crate"); - write_numbered_files(root.path().join("publish/nuget"), 8, "nupkg"); + write_numbered_files(root.path().join("publish/nuget"), 7, "nupkg"); write_numbered_files(root.path().join("publish/nuget"), 2, "snupkg"); - write_numbered_files(root.path().join("publish/standalone"), 20, "asset"); + write_numbered_files(root.path().join("publish/standalone"), 19, "asset"); let error = validate_release_artifact_counts(root.path(), "1.2.3") .expect_err("missing npm package should fail validation"); - assert!(error.contains("expected 9 npm packages, found 8")); + assert!(error.contains("expected 8 npm packages, found 7")); } fn write_numbered_files(directory: PathBuf, count: u32, extension: &str) { @@ -2483,7 +3210,7 @@ mod tests { } } - /// Write six correctly-tagged wheel fixtures plus one matching sdist into + /// Write five correctly-tagged wheel fixtures plus one matching sdist into /// `python_dir`, mirroring what a real `AssembleRelease` job would have /// pre-staged before `publish-stage --pack-only` runs. fn write_python_release_fixtures(python_dir: &Path, version: &str) { @@ -2716,12 +3443,12 @@ mod tests { let copied = stage_standalone_release_assets(root.path()).unwrap(); - assert_eq!(copied, 20); + assert_eq!(copied, 19); let output = publish.join("standalone"); for (_, destination) in STANDALONE_RELEASE_FILES { assert!(output.join(destination).is_file()); } - assert_eq!(fs::read_dir(output).unwrap().count(), 20); + assert_eq!(fs::read_dir(output).unwrap().count(), 19); } #[test] diff --git a/xtask/src/windows_local.rs b/xtask/src/windows_local.rs index 80cb96ef1..ba0f0baf9 100644 --- a/xtask/src/windows_local.rs +++ b/xtask/src/windows_local.rs @@ -1,7 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -//! Local macOS Windows artifact builds through cargo-xwin. +//! Local Windows artifact builds through cargo-xwin, for developers on +//! macOS or Linux who want to reproduce a Windows MSVC release leg without +//! CI. `publish-build` shares this module's cargo-xwin preflight checks +//! (`ensure_cargo_xwin`, `ensure_llvm_tools`) when it selects the same +//! `cargo xwin` backend for a Linux host. use crate::util::which_exists; use std::fmt::Write; @@ -12,11 +16,6 @@ use std::process::{Command, ExitCode}; const CARGO_XWIN_VERSION: &str = "0.23.0"; const PROFILE: &str = "release"; const XWIN_CACHE_DIR: &str = "target/xwin-cache"; -const NATIVE_PACKAGES: &[&str] = &[ - "microsoft-webui-cli", - "microsoft-webui-ffi", - "microsoft-webui-node", -]; #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct WindowsTarget { @@ -69,7 +68,7 @@ fn run_inner(args: &[String]) -> Result<(), String> { }; let root = std::env::current_dir().map_err(|e| format!("failed to read current dir: {e}"))?; - ensure_macos_host()?; + ensure_supported_host()?; ensure_cargo_xwin()?; ensure_llvm_tools()?; ensure_rustup_targets(&targets)?; @@ -175,27 +174,33 @@ fn find_target(value: &str) -> Option<&'static WindowsTarget> { fn print_usage() { eprintln!( "Usage: cargo xtask build-windows-local [--target all|x64|arm64|]\n\n\ - Builds and stages Windows MSVC artifacts locally on macOS using cargo-xwin.\n\ + Builds and stages Windows MSVC artifacts locally on macOS or Linux using cargo-xwin.\n\ Defaults to both x86_64-pc-windows-msvc and aarch64-pc-windows-msvc." ); } -fn ensure_macos_host() -> Result<(), String> { - if cfg!(target_os = "macos") { +fn ensure_supported_host() -> Result<(), String> { + if cfg!(target_os = "macos") || cfg!(target_os = "linux") { return Ok(()); } - Err("build-windows-local is intended for local macOS use; CI and release workflows are unchanged".to_string()) + Err("build-windows-local is intended for local macOS or Linux use only".to_string()) } -fn ensure_cargo_xwin() -> Result<(), String> { +/// Verify the pinned `cargo-xwin` is on PATH. +/// +/// Shared with `publish-build`'s `CargoXwin` backend (see +/// `publish::preflight_backend`), so a Linux host cross-compiling Windows +/// MSVC artifacts gets the same actionable version check as this local +/// command instead of a duplicated one. +pub(crate) fn ensure_cargo_xwin() -> Result<(), String> { match installed_cargo_xwin_version()? { Some(found) if found == CARGO_XWIN_VERSION => Ok(()), Some(found) => Err(format!( - "cargo-xwin {CARGO_XWIN_VERSION} is required, found {found}.\n help: install the pinned version with: cargo install cargo-xwin --version {CARGO_XWIN_VERSION}" + "cargo-xwin {CARGO_XWIN_VERSION} is required, found {found}.\n help: install the pinned version with: cargo install cargo-xwin --version {CARGO_XWIN_VERSION} --locked" )), None => Err(format!( - "cargo-xwin {CARGO_XWIN_VERSION} is required but was not found on PATH.\n help: install it with: cargo install cargo-xwin --version {CARGO_XWIN_VERSION}" + "cargo-xwin {CARGO_XWIN_VERSION} is required but was not found on PATH.\n help: install it with: cargo install cargo-xwin --version {CARGO_XWIN_VERSION} --locked" )), } } @@ -266,11 +271,15 @@ fn missing_targets_message(missing: &[&str]) -> String { message } -fn ensure_llvm_tools() -> Result<(), String> { +/// Verify `clang-cl`, `lld-link`, and `llvm-lib` are on PATH. +/// +/// Shared with `publish-build`'s `CargoXwin` backend; see `ensure_cargo_xwin`. +pub(crate) fn ensure_llvm_tools() -> Result<(), String> { let has_clang_cl = which_exists("clang-cl"); let has_lld = which_exists("lld-link") || which_exists("ld.lld") || which_exists("lld"); + let has_llvm_lib = which_exists("llvm-lib"); - if has_clang_cl && has_lld { + if has_clang_cl && has_lld && has_llvm_lib { return Ok(()); } @@ -278,7 +287,7 @@ fn ensure_llvm_tools() -> Result<(), String> { } fn llvm_tools_help() -> &'static str { - "clang-cl and LLD are required for cargo-xwin. On macOS: brew install llvm lld, then ensure both Homebrew bin directories are on PATH" + "clang-cl, lld-link, and llvm-lib are required for cargo-xwin. On macOS: brew install llvm lld; on Linux: install clang, lld, and llvm from your package manager, then ensure clang-cl, lld-link, and llvm-lib are on PATH" } fn build_target(root: &Path, target: &WindowsTarget, cache_dir: &Path) -> Result<(), String> { @@ -288,11 +297,19 @@ fn build_target(root: &Path, target: &WindowsTarget, cache_dir: &Path) -> Result console::style(target.triple).bold(), ); - let args = cargo_xwin_build_args(target); - let mut command = Command::new("cargo-xwin"); - command.args(&args); + let args = crate::publish::native_build_args( + target.triple, + PROFILE, + crate::publish::Backend::CargoXwin, + ) + .map_err(|e| { + format!( + "failed to assemble cargo-xwin build args for {}: {e}", + target.triple + ) + })?; + let mut command = crate::util::build_command("cargo", &args); command.current_dir(root); - command.env("CARGO_INCREMENTAL", "0"); command.env("XWIN_CACHE_DIR", cache_dir); match command.status() { @@ -308,19 +325,6 @@ fn build_target(root: &Path, target: &WindowsTarget, cache_dir: &Path) -> Result } } -fn cargo_xwin_build_args(target: &WindowsTarget) -> Vec { - let mut args = Vec::with_capacity(4 + (NATIVE_PACKAGES.len() * 2)); - args.push("build".to_string()); - args.push("--release".to_string()); - args.push("--target".to_string()); - args.push(target.triple.to_string()); - for package in NATIVE_PACKAGES { - args.push("-p".to_string()); - args.push((*package).to_string()); - } - args -} - fn validate_staged_artifacts(root: &Path, targets: &[&WindowsTarget]) -> Result<(), String> { let mut missing = Vec::new(); @@ -439,6 +443,17 @@ mod tests { assert_eq!(cargo_xwin_version("cargo 1.93.0\n"), None); } + #[test] + fn ensure_supported_host_accepts_macos_and_linux_only() { + let result = ensure_supported_host(); + if cfg!(target_os = "macos") || cfg!(target_os = "linux") { + assert!(result.is_ok()); + } else { + let error = result.expect_err("unsupported hosts should fail"); + assert!(error.contains("macOS or Linux")); + } + } + #[test] fn missing_targets_message_includes_install_command() { let message = @@ -453,10 +468,12 @@ mod tests { } #[test] - fn llvm_tools_help_mentions_clang_cl() { + fn llvm_tools_help_mentions_required_tools() { let message = llvm_tools_help(); assert!(message.contains("clang-cl")); + assert!(message.contains("lld-link")); + assert!(message.contains("llvm-lib")); assert!(message.contains("brew install llvm lld")); } @@ -467,11 +484,19 @@ mod tests { None => panic!("x64 target should exist"), }; - let args = cargo_xwin_build_args(target); + // build_target shares its argument construction with publish-build's + // CargoXwin backend; assert on that shared function directly. + let args = crate::publish::native_build_args( + target.triple, + PROFILE, + crate::publish::Backend::CargoXwin, + ) + .expect("cargo-xwin build args should be assembled for x64"); assert_eq!( args, vec![ + "xwin", "build", "--release", "--target",