diff --git a/.gitattributes b/.gitattributes index a6c3c3a..7c52e3a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,4 @@ .squad/agents/*/history.md merge=union .squad/log/** merge=union .squad/orchestration-log/** merge=union +*.sh text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4928f2..28d9fdc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,8 +34,8 @@ jobs: # ───────────────────────────────────────────── # Backend-specific distribution builds (one Cargo feature each) # - # The published packages are single-backend builds: exactly one backend - # feature, stamped with OSCAN_DISTRIBUTION_BACKEND. Those cfg + # Published slim packages are single-backend builds: exactly one backend + # feature, stamped with the legacy OSCAN_DISTRIBUTION_BACKEND input. Those cfg # configurations compile different code from the default all-backends # build, so they are tested here as first-class configurations rather # than only being discovered at release time. @@ -83,6 +83,52 @@ jobs: grep -qx "default-backend: ${{ matrix.backend }}" <<<"$metadata" grep -qx "distribution: ${{ matrix.backend }}" <<<"$metadata" + # The full package is a strict distribution too, not an unstamped development + # build: all backends are present, LLVM is deterministic, and packaged lookup + # remains enabled. + full-distribution: + name: Linux full-profile distribution build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + submodules: recursive + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + key: linux-full-profile-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: linux-full-profile-cargo- + + - name: Test the full profile contract and CLI + env: + OSCAN_DISTRIBUTION_PROFILE: full + OSCAN_DEFAULT_BACKEND: llvm + run: | + cargo test backend::select::tests + cargo test --test cli_help + + - name: The built compiler reports the full profile + env: + OSCAN_DISTRIBUTION_PROFILE: full + OSCAN_DEFAULT_BACKEND: llvm + shell: bash + run: | + set -euo pipefail + cargo build --release + metadata="$(./target/release/oscan --version)" + echo "$metadata" + grep -qx "backends: llvm, cranelift, c" <<<"$metadata" + grep -qx "default-backend: llvm" <<<"$metadata" + grep -qx "distribution: full" <<<"$metadata" + # ───────────────────────────────────────────── # Verify generated docs are up to date # ───────────────────────────────────────────── @@ -144,6 +190,9 @@ jobs: - name: Run unit tests (53 tests) run: cargo test + - name: Archive installer coexistence tests + run: python -m unittest scripts.test_installers.WindowsInstallerCoexistenceTests + - name: Integration tests — positive (freestanding) shell: bash run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b447873..4165023 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,11 @@ name: Oscan Release -# Release contract schema 2: every published artifact is one (target, -# backend) pair — Windows/Linux ship llvm, cranelift and c packages, macOS +# Release contract schema 3: every published artifact is one (target, +# profile) pair — Windows/Linux ship full, llvm, cranelift and c; macOS # ships c. The package job is keyed on the *target*, not on target x -# backend, because a target's variants reuse the same pinned toolchain +# profile, because a target's variants reuse the same pinned toolchain # download and freestanding runtime archives. Sidecar variants also share one -# prepared native-link set; the strict Windows LLVM variant additionally uses +# prepared native-link set; strict Windows full/LLVM variants additionally use # one cached static LLVM/LLD build. on: @@ -120,12 +120,12 @@ jobs: # interpolated into script bodies, so nothing here depends on how a # given shell would quote them. RELEASE_TARGET: ${{ matrix.target }} - RELEASE_BACKENDS: ${{ matrix.backends }} + RELEASE_PROFILES: ${{ matrix.profiles }} RELEASE_ARCHIVES: ${{ matrix.archives }} RELEASE_RUNTIME_PROFILES: ${{ matrix.runtime_profiles }} RELEASE_BINARY_NAME: ${{ matrix.binary_name }} RELEASE_BINARY_PATH: ${{ matrix.binary_path }} - RELEASE_MSI_BACKEND: ${{ matrix.msi_backend }} + RELEASE_MSI_PROFILE: ${{ matrix.msi_profile }} RELEASE_NEEDS_PROVIDER_ARCHIVE: ${{ matrix.needs_provider_archive }} RELEASE_NEEDS_INPROCESS_TOOLCHAIN: ${{ matrix.needs_inprocess_toolchain }} RELEASE_VERSION: ${{ needs.prepare.outputs.version }} @@ -145,8 +145,8 @@ jobs: python-version: "3.x" # Only the downloaded crate sources are cached. target/ deliberately is - # not: this job builds the same crate three times with three different, - # mutually exclusive feature sets, and a shared target/ cache across + # not: this job builds the same crate once per package profile with + # different feature sets, and a shared target/ cache across # runs buys nothing there while risking a stale artifact being packaged. - name: Cache Cargo dependencies uses: actions/cache@v4 @@ -166,8 +166,8 @@ jobs: path: build/inprocess-toolchain/${{ matrix.target }} key: release-inprocess-${{ matrix.target }}-${{ hashFiles('packaging/toolchains/windows-x86_64.json', 'scripts/build-inprocess-toolchain.ps1', 'toolchain/inprocess/**') }} - # The packaged LLVM code generator is dlopen()ed by the llvm package's - # smoke test, so its shared-library dependencies must exist on the + # The packaged LLVM code generator is dlopen()ed by LLVM-capable package + # smoke tests, so its shared-library dependencies must exist on the # runner even though they are never shipped. - name: Install packaged LLVM provider runtime dependencies if: matrix.needs_provider_archive == 'true' @@ -181,8 +181,8 @@ jobs: # One shared download cache per target: the base toolchain archive is # resolved (and verified against the digest the manifest pins) once and # then reused by the extraction below and by C-package staging, and the - # separately pinned LLVM provider archive is resolved once for the llvm - # package. Staging and assembly never download anything themselves. + # separately pinned LLVM provider archive is resolved once for profiles + # containing LLVM. Staging and assembly never download anything themselves. - name: Resolve the pinned release archives if: matrix.needs_base_toolchain == 'true' id: archives @@ -298,9 +298,10 @@ jobs: throw "Preparing the strict compiler notices for $target failed." } - # Built once per target with that target's pinned compiler/archiver and - # shared by every object package of this target. - - name: Build the freestanding runtime archives + # Built once per target with that target's pinned compiler/archiver. + # Slim object packages consume only freestanding modes; full also + # consumes hosted mode through its bundled compiler driver. + - name: Build the declared runtime archives if: matrix.runtime_profiles != '' shell: pwsh run: | @@ -360,32 +361,30 @@ jobs: throw "Preparing the strict in-process link assets for $target failed." } - # One binary per backend, each built with exactly one backend feature - # and stamped with the matching distribution backend, then copied to a - # stable per-backend path before the next build overwrites + # One binary per package profile, built with exactly the profile's + # backend features and stamped with independent profile/default values, + # then copied aside before the next build overwrites # target/release. - - name: Build the backend-specific compilers + - name: Build the profile-specific compilers shell: pwsh run: | # Sidecar releases consume native-link assets at run time. The strict - # LLVM release uses the separate in-process asset contract below, + # Windows full/LLVM releases use the separate in-process asset contract below, # never OSCAN_EMBED_ASSETS_DIR. Remove-Item Env:OSCAN_EMBED_ASSETS_DIR, Env:OSCAN_REQUIRE_EMBEDDED_ASSETS ` -ErrorAction SilentlyContinue $contract = Get-Content 'packaging/toolchains/release-contract.json' -Raw | ConvertFrom-Json -AsHashtable - $variants = $contract['variants'][${env:RELEASE_TARGET}]['backends'] - $backends = $env:RELEASE_BACKENDS.Split(',', [System.StringSplitOptions]::RemoveEmptyEntries) - if ($backends.Count -eq 0) { - throw "The release contract lists no backends for ${env:RELEASE_TARGET}." + $variants = $contract['variants'][${env:RELEASE_TARGET}]['profiles'] + $profiles = $env:RELEASE_PROFILES.Split(',', [System.StringSplitOptions]::RemoveEmptyEntries) + if ($profiles.Count -eq 0) { + throw "The release contract lists no profiles for ${env:RELEASE_TARGET}." } $binRoot = Join-Path $env:RUNNER_TEMP 'oscan-release-binaries' - foreach ($backend in $backends) { - $variant = $variants[$backend] - $features = @([string]$variant['cargo_feature']) - if ($variant.ContainsKey('extra_cargo_features')) { - $features += @($variant['extra_cargo_features']) - } + foreach ($profile in $profiles) { + $variant = $variants[$profile] + $profileSpec = $contract['profiles'][$profile] + $features = @($variant['cargo_features']) $featureList = $features -join ',' Remove-Item Env:OSCAN_INPROCESS_TOOLCHAIN_DIR, Env:OSCAN_INPROCESS_ASSETS_DIR, Env:RUSTFLAGS ` -ErrorAction SilentlyContinue @@ -394,7 +393,7 @@ jobs: $inprocessAssets = "build/inprocess-assets/${env:RELEASE_TARGET}" foreach ($path in @($inprocessToolchain, $inprocessAssets)) { if (-not (Test-Path -LiteralPath $path -PathType Container)) { - throw "$backend needs strict in-process input '$path', but it is missing." + throw "$profile needs strict in-process input '$path', but it is missing." } } $env:OSCAN_INPROCESS_TOOLCHAIN_DIR = (Resolve-Path -LiteralPath $inprocessToolchain).Path @@ -402,16 +401,18 @@ jobs: $env:RUSTFLAGS = [string]$variant['rustflags'] } Write-Host "::group::cargo build --release --no-default-features --features $featureList" - $env:OSCAN_DISTRIBUTION_BACKEND = $backend + Remove-Item Env:OSCAN_DISTRIBUTION_BACKEND -ErrorAction SilentlyContinue + $env:OSCAN_DISTRIBUTION_PROFILE = $profile + $env:OSCAN_DEFAULT_BACKEND = [string]$profileSpec['default_backend'] $env:OSCAN_VERSION = $env:RELEASE_TAG & cargo build --release --no-default-features --features $featureList if ($LASTEXITCODE -ne 0) { - throw "cargo build failed for backend '$backend'." + throw "cargo build failed for profile '$profile'." } if (-not (Test-Path -LiteralPath $env:RELEASE_BINARY_PATH -PathType Leaf)) { - throw "cargo produced no binary at ${env:RELEASE_BINARY_PATH} for backend '$backend'." + throw "cargo produced no binary at ${env:RELEASE_BINARY_PATH} for profile '$profile'." } - $destinationDir = Join-Path $binRoot $backend + $destinationDir = Join-Path $binRoot $profile New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null Copy-Item -LiteralPath $env:RELEASE_BINARY_PATH ` -Destination (Join-Path $destinationDir $env:RELEASE_BINARY_NAME) -Force @@ -421,15 +422,15 @@ jobs: # Each variant is assembled from only the prepared inputs its component # list declares, and smoked immediately: an archive is never uploaded # before the package it contains has been installed and exercised. - - name: Assemble and smoke every backend package + - name: Assemble and smoke every profile package shell: pwsh run: | $target = $env:RELEASE_TARGET $version = $env:RELEASE_VERSION $contract = Get-Content 'packaging/toolchains/release-contract.json' -Raw | ConvertFrom-Json -AsHashtable - $variants = $contract['variants'][$target]['backends'] - $backends = $env:RELEASE_BACKENDS.Split(',', [System.StringSplitOptions]::RemoveEmptyEntries) + $variants = $contract['variants'][$target]['profiles'] + $profiles = $env:RELEASE_PROFILES.Split(',', [System.StringSplitOptions]::RemoveEmptyEntries) $binRoot = Join-Path $env:RUNNER_TEMP 'oscan-release-binaries' $outputDir = Join-Path $env:RUNNER_TEMP 'oscan-release-artifacts' $uploadDir = Join-Path $env:GITHUB_WORKSPACE 'release-upload' @@ -438,20 +439,20 @@ jobs: $providerArchive = '${{ steps.archives.outputs.provider_archive }}' $produced = @() - foreach ($backend in $backends) { - if (-not $variants.ContainsKey($backend)) { - throw "The release contract has no $target/$backend variant." + foreach ($profile in $profiles) { + if (-not $variants.ContainsKey($profile)) { + throw "The release contract has no $target/$profile variant." } - $variant = $variants[$backend] + $variant = $variants[$profile] $components = @($variant['components']) - $binary = Join-Path (Join-Path $binRoot $backend) $env:RELEASE_BINARY_NAME + $binary = Join-Path (Join-Path $binRoot $profile) $env:RELEASE_BINARY_NAME if (-not (Test-Path -LiteralPath $binary -PathType Leaf)) { - throw "No $backend compiler was built for $target (expected $binary)." + throw "No $profile compiler was built for $target (expected $binary)." } $assembleArgs = @{ Target = $target - Backend = $backend + Profile = $profile Version = $version BinaryPath = $binary OutputDir = $outputDir @@ -459,52 +460,52 @@ jobs: if ($components -contains 'direct_link_sidecar') { $nativeLinkDir = "build/native-link/$target" if (-not (Test-Path -LiteralPath $nativeLinkDir)) { - throw "$target/$backend needs a prepared native-link sidecar, but $nativeLinkDir is missing." + throw "$target/$profile needs a prepared native-link sidecar, but $nativeLinkDir is missing." } $assembleArgs['NativeLinkDir'] = (Resolve-Path -LiteralPath $nativeLinkDir).Path } if ($variant['link_mode'] -eq 'inprocess') { $noticesDir = "build/inprocess-notices/$target" if (-not (Test-Path -LiteralPath $noticesDir -PathType Container)) { - throw "$target/$backend needs embedded-input notices, but $noticesDir is missing." + throw "$target/$profile needs embedded-input notices, but $noticesDir is missing." } $assembleArgs['EmbeddedNoticesDir'] = (Resolve-Path -LiteralPath $noticesDir).Path } if ($components -contains 'runtime_archives') { $runtimeArchiveDir = "build/runtime-archives/$target" if (-not (Test-Path -LiteralPath $runtimeArchiveDir)) { - throw "$target/$backend needs prebuilt runtime archives, but $runtimeArchiveDir is missing." + throw "$target/$profile needs prebuilt runtime archives, but $runtimeArchiveDir is missing." } $assembleArgs['PrebuiltRuntimeArchiveDir'] = (Resolve-Path -LiteralPath $runtimeArchiveDir).Path } if ($components -contains 'c_toolchain') { if (-not $toolchainArchive) { - throw "$target/$backend needs the pinned C toolchain archive, which was not resolved." + throw "$target/$profile needs the pinned C toolchain archive, which was not resolved." } $assembleArgs['ToolchainArchive'] = $toolchainArchive } if ($components -contains 'llvm_provider' -and $variant['llvm_provider_source'] -eq 'toolchain-manifest') { if (-not $providerArchive) { - throw "$target/$backend needs the pinned LLVM provider archive, which was not resolved." + throw "$target/$profile needs the pinned LLVM provider archive, which was not resolved." } $assembleArgs['LlvmProviderArchive'] = $providerArchive } - Write-Host "::group::assemble $target/$backend" + Write-Host "::group::assemble $target/$profile" $archivePath = & ./scripts/assemble-release.ps1 @assembleArgs | Select-Object -Last 1 if (-not $archivePath -or -not (Test-Path -LiteralPath $archivePath -PathType Leaf)) { - throw "Assembling $target/$backend produced no archive." + throw "Assembling $target/$profile produced no archive." } Write-Host "::endgroup::" - Write-Host "::group::smoke $target/$backend" + Write-Host "::group::smoke $target/$profile" & ./scripts/smoke-release.ps1 ` -Target $target ` - -Backend $backend ` + -Profile $profile ` -ArchivePath $archivePath ` -Version $version ` - -ScratchDir (Join-Path $env:RUNNER_TEMP "oscan-release-smoke/$target-$backend") + -ScratchDir (Join-Path $env:RUNNER_TEMP "oscan-release-smoke/$target-$profile") Write-Host "::endgroup::" $archiveName = Split-Path -Path $archivePath -Leaf @@ -534,20 +535,20 @@ jobs: # staged bundle directory the assemble step above left in place, so it # contains exactly what the verified archive contains. - name: Build the recommended MSI installer - if: matrix.msi_backend != '' + if: matrix.msi_profile != '' shell: pwsh run: | $target = $env:RELEASE_TARGET - $backend = $env:RELEASE_MSI_BACKEND + $profile = $env:RELEASE_MSI_PROFILE $version = $env:RELEASE_VERSION $contract = Get-Content 'packaging/toolchains/release-contract.json' -Raw | ConvertFrom-Json -AsHashtable - $variant = $contract['variants'][$target]['backends'][$backend] + $variant = $contract['variants'][$target]['profiles'][$profile] $bundleName = ([string]$variant['archive_root_template']).Replace('{version}', $version) $outputDir = Join-Path $env:RUNNER_TEMP 'oscan-release-artifacts' $bundleDir = Join-Path (Join-Path $outputDir 'stage') $bundleName if (-not (Test-Path -LiteralPath $bundleDir -PathType Container)) { - throw "The staged $target/$backend bundle is missing at $bundleDir; the MSI must be built from the bundle that was packaged and smoked." + throw "The staged $target/$profile bundle is missing at $bundleDir; the MSI must be built from the bundle that was packaged and smoked." } $uploadDir = Join-Path $env:GITHUB_WORKSPACE 'release-upload' New-Item -ItemType Directory -Path $uploadDir -Force | Out-Null @@ -556,7 +557,7 @@ jobs: if (-not (Test-Path -LiteralPath $msiPath -PathType Leaf)) { throw "The MSI build produced no installer at $msiPath." } - $msiScratch = Join-Path $env:RUNNER_TEMP "oscan-release-msi/$target-$backend" + $msiScratch = Join-Path $env:RUNNER_TEMP "oscan-release-msi/$target-$profile" if (Test-Path -LiteralPath $msiScratch) { Remove-Item -LiteralPath $msiScratch -Recurse -Force } @@ -574,12 +575,12 @@ jobs: $installedRoot = Join-Path $adminRoot 'PFiles64\Oscan' & ./scripts/smoke-release.ps1 ` -Target $target ` - -Backend $backend ` + -Profile $profile ` -InstalledPackageDir $installedRoot ` -Version $version ` -ScratchDir (Join-Path $msiScratch 'smoke') if ($LASTEXITCODE -ne 0) { - throw "The MSI payload smoke failed for $target/$backend." + throw "The MSI payload smoke failed for $target/$profile." } - name: Upload packaged artifacts diff --git a/README.md b/README.md index a4baf23..0c3eb0e 100644 --- a/README.md +++ b/README.md @@ -30,15 +30,18 @@ It is designed to be approachable for people and reliable for AI coding tools. Download the latest release from [GitHub Releases](https://github.com/lucabol/Oscan/releases). Choose the -**LLVM package** unless you specifically need the Cranelift or C backend. +**LLVM slim package** for the smallest recommended install, or choose **full** +to use LLVM, Cranelift, and C from one compiler with `--backend`. | Platform | Recommended download | Notes | |---|---|---| -| Windows x86_64 | `oscan-vX.Y.Z-windows-x86_64-llvm.msi` or `oscan-vX.Y.Z-windows-x86_64-llvm.zip` | The MSI is the simplest option | -| Linux x86_64 | `oscan-vX.Y.Z-linux-x86_64-llvm.tar.xz` | Requires glibc 2.34 or newer | +| Windows x86_64 | `oscan-vX.Y.Z-windows-x86_64-llvm.msi` or `oscan-vX.Y.Z-windows-x86_64-llvm.zip` | LLVM remains the transition default; `-full.zip` includes all backends | +| Linux x86_64 | `oscan-vX.Y.Z-linux-x86_64-llvm.tar.xz` | `-full.tar.xz` includes all backends; LLVM requires glibc 2.34+ | | macOS x86_64 | `oscan-vX.Y.Z-macos-x86_64-c.tar.gz` | Requires Xcode Command Line Tools | -Windows and Linux also provide `cranelift` and `c` packages. Each release +Windows and Linux provide `full`, `llvm`, `cranelift`, and `c` profiles. Full +contains one backend-neutral compiler; the others are smaller slim packages. +Each release includes `SHA256SUMS`; keep the downloaded file's original name and verify it before installing. The [installation guide](docs/guide.md#installation) has step-by-step verification, upgrade, and uninstall instructions. @@ -51,16 +54,20 @@ Install the latest recommended package: iwr -useb https://raw.githubusercontent.com/lucabol/Oscan/master/scripts/install-latest.ps1 | iex ``` -To select a backend explicitly: +Install full, or select a slim package explicitly: ```powershell iwr -useb https://raw.githubusercontent.com/lucabol/Oscan/master/scripts/install-latest.ps1 -OutFile install-latest.ps1 +.\install-latest.ps1 -Profile full -SetDefault .\install-latest.ps1 -Backend llvm .\install-latest.ps1 -Backend cranelift .\install-latest.ps1 -Backend c ``` -Open a new terminal after installation, then check: +Archive profiles coexist as `oscan-full`, `oscan-llvm`, `oscan-cranelift`, and +`oscan-c`. The first archive install safely creates `oscan`; later installs +preserve that selection unless `-SetDefault` is passed. Open a new terminal, +then check: ```powershell oscan --version @@ -68,16 +75,17 @@ oscan --version ### Linux -Download the recommended archive and `SHA256SUMS`, verify the archive, extract -it, and run the included installer: +Download an archive and `SHA256SUMS`, verify it, extract it, and run the +included installer. This example installs full and selects it as the default: ```bash -tar xf oscan-vX.Y.Z-linux-x86_64-llvm.tar.xz -./oscan-vX.Y.Z-linux-x86_64-llvm/install.sh +tar xf oscan-vX.Y.Z-linux-x86_64-full.tar.xz +./oscan-vX.Y.Z-linux-x86_64-full/install.sh --set-default oscan --version +oscan app.osc --backend cranelift ``` -On Debian or Ubuntu, the LLVM package also needs: +On Debian or Ubuntu, the LLVM and full packages also need: ```bash sudo apt-get install libedit2 libffi8 libxml2 libz3-4 libzstd1 zlib1g @@ -93,8 +101,9 @@ xcode-select --install tar xf oscan-vX.Y.Z-macos-x86_64-c.tar.gz ``` -Copy `oscan` to a directory in your `PATH`, such as `/usr/local/bin`, then run -`oscan --version`. +Run the included `install.sh` to create `oscan-c` without disturbing another +profile, then run `oscan-c --version`. Pass `--set-default` only when the plain +`oscan` command should select this profile. ### Build from source @@ -188,9 +197,9 @@ small `wc`-style command-line program step by step. | `oscan app.osc --debuginfo line-tables` | Keep Oscan source locations for debugging | | `oscan --help` | Show every option | -A release package contains one backend. If you request another one, Oscan tells -you which package to install. `--opt-level size|speed` applies to LLVM and -Cranelift generated code; the default is `size`. +Full contains every backend; slim packages contain one and identify the full or +matching slim package when another is requested. `--opt-level size|speed` +applies to LLVM and Cranelift generated code; the default is `size`. Debug information is opt-in: `--debuginfo none` is the default, while `--debuginfo line-tables` enables source breakpoints, stepping, stack @@ -240,8 +249,8 @@ See the [full built-in function reference](docs/builtins.md) for signatures and `free` calls or a garbage collector. - **Targets:** release and cross-compilation support varies by platform and backend. -- **Distribution:** each release package contains one backend and its required - support files. +- **Distribution:** full contains all backends; coexistence-safe slim profiles + contain one backend and its required support files. See [Compiler Technical Details](docs/technical-details.md) for backend selection, package layouts, runtime and linker behavior, supported targets, diff --git a/build.rs b/build.rs index 27973db..3db30a7 100644 --- a/build.rs +++ b/build.rs @@ -11,29 +11,27 @@ use sha2::{Digest, Sha256}; fn main() { stamp_git_version(); - stamp_distribution_backend(); + stamp_distribution_config(); generate_native_link_assets(); configure_inprocess_toolchain(); generate_inprocess_link_assets(); } // --------------------------------------------------------------------------- -// Backend-specific distribution builds. +// Packaged distribution builds. // -// `OSCAN_DISTRIBUTION_BACKEND=llvm|cranelift|c` marks a build as *the* -// packaged compiler for one backend: the resulting `oscan` defaults to that -// backend deterministically instead of probing host capabilities, and every -// other backend is compiled out (`--no-default-features --features -// backend-`). Leaving it unset is the ordinary development build: all -// enabled features, existing capability-based default behavior. +// `OSCAN_DISTRIBUTION_PROFILE=full|llvm|cranelift|c` identifies the package, +// while `OSCAN_DEFAULT_BACKEND=llvm|cranelift|c` independently fixes its +// implicit backend. `OSCAN_DISTRIBUTION_BACKEND` remains a compatibility input +// for legacy slim builds and expands to matching profile/default values. // // The rules themselves live in `src/backend/distribution_contract.rs`, // shared verbatim with the compiler (which reads the stamp and unit-tests // the rules) so the two can never drift apart. Validation happens here, at // build time, so a mismatched pair — a stamp naming a disabled backend, or // a stamp on a build that still contains every backend — fails the build -// with a named reason instead of producing an artifact whose name promises -// something it is not. +// with a named reason instead of producing an artifact whose profile/default +// metadata promises something it is not. // --------------------------------------------------------------------------- mod distribution_contract { @@ -48,7 +46,9 @@ const BACKEND_FEATURES: [(&str, &str); 3] = [ ("c", "CARGO_FEATURE_BACKEND_C"), ]; -fn stamp_distribution_backend() { +fn stamp_distribution_config() { + println!("cargo:rerun-if-env-changed=OSCAN_DISTRIBUTION_PROFILE"); + println!("cargo:rerun-if-env-changed=OSCAN_DEFAULT_BACKEND"); println!("cargo:rerun-if-env-changed=OSCAN_DISTRIBUTION_BACKEND"); println!("cargo:rerun-if-changed=src/backend/distribution_contract.rs"); @@ -57,17 +57,29 @@ fn stamp_distribution_backend() { .filter(|(_, feature)| std::env::var_os(feature).is_some()) .map(|(name, _)| *name) .collect(); - let raw = std::env::var("OSCAN_DISTRIBUTION_BACKEND").unwrap_or_default(); - let stamp = distribution_contract::validate_distribution_stamp(&raw, &enabled) - .unwrap_or_else(|reason| panic!("{reason}")); - - // Always stamped (empty means "not a distribution build") so the - // compiler can read it with a plain `env!` rather than an `option_env!` - // whose absence would be indistinguishable from a stale build. - println!( - "cargo:rustc-env=OSCAN_DISTRIBUTION_BACKEND={}", - stamp.unwrap_or_default() - ); + let profile_raw = std::env::var("OSCAN_DISTRIBUTION_PROFILE").unwrap_or_default(); + let default_raw = std::env::var("OSCAN_DEFAULT_BACKEND").unwrap_or_default(); + let legacy_raw = std::env::var("OSCAN_DISTRIBUTION_BACKEND").unwrap_or_default(); + let config = distribution_contract::validate_distribution_config( + &profile_raw, + &default_raw, + &legacy_raw, + &enabled, + ) + .unwrap_or_else(|reason| panic!("{reason}")); + + let profile = config.profile.as_deref().unwrap_or(""); + let default_backend = config.default_backend.as_deref().unwrap_or(""); + let legacy_backend = match profile { + "llvm" | "cranelift" | "c" => profile, + _ => "", + }; + + // Always stamp every normalized value. Empty means "not configured", and + // lets the compiler use env! without stale option_env! ambiguity. + println!("cargo:rustc-env=OSCAN_DISTRIBUTION_PROFILE={profile}"); + println!("cargo:rustc-env=OSCAN_DEFAULT_BACKEND={default_backend}"); + println!("cargo:rustc-env=OSCAN_DISTRIBUTION_BACKEND={legacy_backend}"); } fn stamp_git_version() { diff --git a/docs/guide.md b/docs/guide.md index 3034185..5025b06 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -6,21 +6,21 @@ A concise guide to writing correct Oscan programs. For the full formal specifica ## Compiler Backends -Oscan has three production backends, and each release package contains exactly -one of them: +Oscan has three production backends. The `full` release profile contains all +three in one compiler; `llvm`, `cranelift`, and `c` are smaller slim profiles: | Backend | Purpose | Packaged behavior | |---|---|---| -| `llvm` | **Recommended** optimized backend | Direct LLVM IR and in-process object emission; the `llvm` package's default | +| `llvm` | **Recommended** optimized backend | Direct LLVM IR and in-process object emission; the `full` and `llvm` profiles' default | | `cranelift` | Independent direct-codegen backend | Direct object emission; the `cranelift` package's default, and the fallback in a multi-backend build when LLVM is unavailable | | `c` | Portable C99, readable source output, and correctness oracle | The `c` package's default; also used for `.c` output, macOS, WASI, and C-only targets | `--backend native` is a deprecated compatibility alias for `--backend cranelift`; it warns once and is never used as a package or artifact name. -The packaged freestanding LLVM and Cranelift paths invoke no C compiler and -ship none. Hosted mode, extra C sources, and the C backend need the C -package's bundled toolchain (or a host one in a source build). See +The slim packaged freestanding LLVM and Cranelift paths invoke no C compiler +and ship none. The full and C profiles include a C toolchain for hosted mode, +extra C sources, and the C backend. See [Backend Roles and Toolchain Lookup](#backend-roles-and-toolchain-lookup-windowslinux) for the full selection and fallback rules. @@ -31,16 +31,16 @@ for the full selection and fallback rules. ### Download Prebuilt Releases (Recommended) The easiest way to get started is to download a prebuilt Oscan package from -[GitHub Releases](https://github.com/lucabol/Oscan/releases). Every artifact is -one (platform, backend) package: Windows and Linux publish `llvm`, `cranelift` -and `c` archives; macOS publishes `c`. See the +[GitHub Releases](https://github.com/lucabol/Oscan/releases). Windows and Linux +publish `full`, `llvm`, `cranelift`, and `c` archives; macOS publishes `c`. +Full is for backend switching, while slim profiles minimize download size. See the [release package matrix](technical-details.md#release-packages) for the full list and what each package contains. **Windows x86_64:** -1. Download `oscan-vX.Y.Z-windows-x86_64-llvm.zip` (recommended), or the - `-cranelift.zip`/`-c.zip` package, or the recommended installer +1. Download `oscan-vX.Y.Z-windows-x86_64-llvm.zip` (recommended slim), + `-full.zip`, `-cranelift.zip`, or `-c.zip`; the transition MSI remains `oscan-vX.Y.Z-windows-x86_64-llvm.msi` 2. Verify exactly that asset against the release's `SHA256SUMS` (filter the file to the asset you downloaded rather than checking every line): @@ -49,32 +49,34 @@ list and what each package contains. $expected = (Select-String -Path SHA256SUMS -Pattern "\s\*?$([regex]::Escape($asset))$").Line.Split(' ')[0] if ((Get-FileHash $asset -Algorithm SHA256).Hash.ToLowerInvariant() -ne $expected.ToLowerInvariant()) { throw "checksum mismatch" } ``` -3. Extract to a stable location and run the included `install.ps1` (or add the - directory to your PATH); for the MSI, just run it -4. Verify: `oscan --version` +3. Extract and run the included `install.ps1`; pass `-SetDefault` to change an + existing unqualified `oscan` selection. For the MSI, just run it. +4. Verify the qualified command, for example `oscan-full --version` -`scripts/install-latest.ps1 -Backend llvm|cranelift|c` does all of this for you -and verifies the checksum (and refuses to install unverified when a release -publishes no `SHA256SUMS`, unless you pass `-SkipChecksum`). +`scripts/install-latest.ps1 -Profile full -SetDefault` installs the all-backend +profile. `-Backend llvm|cranelift|c` remains compatibility shorthand for the +matching slim profile. The script verifies checksums and refuses an unverified +release unless `-SkipChecksum` is supplied. **Linux x86_64:** -1. Download `oscan-vX.Y.Z-linux-x86_64-llvm.tar.xz` (recommended), or the - `-cranelift.tar.xz`/`-c.tar.xz` package, keeping its original file name +1. Download `oscan-vX.Y.Z-linux-x86_64-llvm.tar.xz` (recommended slim), + `-full.tar.xz`, `-cranelift.tar.xz`, or `-c.tar.xz`, keeping its original name 2. Verify exactly that asset, then extract: ```bash grep -E "[[:space:]]\*?oscan-vX.Y.Z-linux-x86_64-llvm\.tar\.xz$" SHA256SUMS | sha256sum -c - tar xf oscan-vX.Y.Z-linux-x86_64-llvm.tar.xz ``` -3. For the `llvm` package on Debian/Ubuntu: +3. For the `llvm` or `full` package on Debian/Ubuntu: `sudo apt-get install libedit2 libffi8 libxml2 libz3-4 libzstd1 zlib1g` -4. Add to PATH or run the included `install.sh` -5. Verify: `oscan --version` +4. Run the included `install.sh`; pass `--set-default` to change an existing + unqualified selection +5. Verify, for example: `oscan-llvm --version` The Linux `llvm` package ships the pinned LLVM code generator only (no clang, no GCC, no headers, no sysroot). It requires glibc 2.34 or newer plus the host runtime libraries listed above, but no installed C/Clang/LLVM toolchain. The -`cranelift` package needs nothing extra; the `c` package carries its own +`cranelift` package needs nothing extra; the `c` and `full` packages carry a pinned C toolchain. **macOS:** @@ -112,8 +114,8 @@ cargo build --release ``` The binary is `target/release/oscan` (or `oscan.exe` on Windows). A default -source build contains all three backends; released packages are single-backend -builds (`--no-default-features --features backend-`). +source build and the `full` profile contain all three backends; slim releases +use `--no-default-features --features backend-`. ### Why `toolchain/` Is Not in Git @@ -130,16 +132,37 @@ lives alongside the `oscan` binary in the unpacked archive. Oscan discovers its LLVM provider or C compiler there — relative to its own executable, never from `PATH` or the current directory. -### Upgrade and Uninstall (Phase 1) +### Side-by-side Profiles, Upgrade, and Uninstall -**Phase 1 releases** do not yet use a system package manager, so upgrades and uninstalls are manual — and the right procedure depends on how you installed: +Archive installers read `oscan-package.json` and install each profile +independently: -- **Upgrade (archive install):** Download the new package and extract it to the same location (or a new location and update your PATH). -- **Upgrade (Windows MSI):** Run the new MSI; it replaces the previous MSI install in place. -- **Uninstall (archive install):** Remove the directory containing `oscan` and, if the package had one, its `toolchain/`/`native-link/` subdirectories, then remove it from your PATH. +```text +Windows: %LOCALAPPDATA%\Programs\Oscan\profiles\\\ +Unix: ~/.local/share/oscan/profiles/// +``` + +The stable bin directory exposes `oscan-full`, `oscan-llvm`, +`oscan-cranelift`, and `oscan-c`. The first archive profile safely creates the +unqualified `oscan` selector. Installing or upgrading another profile does not +change it; use `-SetDefault` on Windows or `--set-default` on Unix explicitly. +An upgrade is staged and activated atomically, replaces only the same profile, +and preserves every other profile. + +- **Uninstall (archive install):** Run that profile's extracted installer with + `-Uninstall` or `--uninstall`. It removes only the named profile and qualified + command. If it was selected, the unqualified selector is removed rather than + redirected implicitly. +- **Upgrade (Windows MSI):** Run the new LLVM MSI; it replaces the previous MSI install in place. - **Uninstall (Windows MSI):** Use **Settings → Apps → Installed apps → Oscan → Uninstall**, or run `msiexec /x oscan-vX.Y.Z-windows-x86_64-llvm.msi /quiet`. Do **not** just delete the installed directory: that leaves the product registered with Windows Installer. -A future release may add WinGet, Homebrew, and Scoop support for easier package management. +The legacy LLVM MSI retains its existing product identity and unqualified PATH +command. The quick installer and MSI detect a per-user archive selector for the +same Windows account and refuse the ambiguous PATH setup unless explicitly +overridden with `-AllowMsiCommandConflict` or +`OSCAN_ALLOW_ARCHIVE_CONFLICT=1`, respectively. +Legacy flat archive installs are left intact and reported; confirm the new +qualified command works, then remove the old directory/PATH entry. --- @@ -1022,11 +1045,10 @@ let x: i32 = 1; ## Backend Roles and Toolchain Lookup (Windows/Linux) -A released package contains exactly one backend and defaults to it. In a -multi-backend (development) build, ordinary executable, object, and `--run` -builds prefer the **LLVM backend** when the host has a supported object target -and a compatible packaged LLVM provider; if LLVM is not available, Oscan falls -back to the **Cranelift backend**, then to the **C backend**. Explicit +A slim package contains exactly one backend and defaults to it. A full package +contains all three and deterministically defaults to LLVM. In an unstamped +multi-backend development build, ordinary executable, object, and `--run` +builds prefer LLVM when compatible, then Cranelift, then C. Explicit `--backend llvm|cranelift|c` always wins, and an explicit LLVM failure never falls back. `--backend native` still selects Cranelift, with one deprecation warning. diff --git a/docs/releasing.md b/docs/releasing.md index 3cd313d..398b630 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -2,18 +2,20 @@ Release builds are handled by GitHub Actions workflows. Two manual workflows must be run **once** (and again whenever their upstream dependencies change) before creating a release. -## What a release publishes (contract schema 2) +## What a release publishes (contract schema 3) -`packaging/toolchains/release-contract.json` (`schema_version: 2`) is the +`packaging/toolchains/release-contract.json` (`schema_version: 3`) is the single source of truth, and every published artifact is one **(target, -backend) pair**. A release therefore publishes **seven archives, one MSI and +profile) pair**. A release therefore publishes **nine archives, one MSI and one `SHA256SUMS`** — and nothing else: -| Target | Backend | Archive | Components staged | +| Target | Profile | Archive | Components staged | |---|---|---|---| +| `windows-x86_64` | `full` | `oscan-v{version}-windows-x86_64-full.zip` | all-backend strict compiler with static LLVM/in-process LLD plus pinned C toolchain | | `windows-x86_64` | `llvm` | `oscan-v{version}-windows-x86_64-llvm.zip` | one strict compiler executable with static LLVM, in-process LLD, runtime archives and link inputs embedded; third-party notices | | `windows-x86_64` | `cranelift` | `oscan-v{version}-windows-x86_64-cranelift.zip` | compiler, native-link sidecar, freestanding runtime archives | | `windows-x86_64` | `c` | `oscan-v{version}-windows-x86_64-c.zip` | compiler, pinned C toolchain | +| `linux-x86_64` | `full` | `oscan-v{version}-linux-x86_64-full.tar.xz` | all-backend compiler, native-link sidecar, runtimes, LLVM provider, pinned C toolchain | | `linux-x86_64` | `llvm` | `oscan-v{version}-linux-x86_64-llvm.tar.xz` | compiler, native-link sidecar, freestanding runtime archives, LLVM provider from the pinned provider archive | | `linux-x86_64` | `cranelift` | `oscan-v{version}-linux-x86_64-cranelift.tar.xz` | compiler, native-link sidecar, freestanding runtime archives | | `linux-x86_64` | `c` | `oscan-v{version}-linux-x86_64-c.tar.xz` | compiler, pinned C toolchain | @@ -21,23 +23,24 @@ one `SHA256SUMS`** — and nothing else: Plus exactly **one installer**: `oscan-v{version}-windows-x86_64-llvm.msi`, cut from the *already staged and smoked* Windows LLVM bundle directory. The -MSI backend is declared by the contract (`ci-matrix`'s `msi_backend`), so -adding a second installer is a contract change, not a workflow tweak. There is -no combined/`-full` archive of any kind, and `native` is never an artifact -label — it survives only as the compiler's deprecated `--backend` alias for -`cranelift`. +MSI profile is declared by the contract (`ci-matrix`'s `msi_profile`), so +adding a second installer is a contract change, not a workflow tweak. It +remains `llvm`. `native` is never an +artifact label — it survives only as the compiler's deprecated `--backend` +alias for `cranelift`. Invariants the tooling enforces (see `scripts/test_release_workflow.py`, `scripts/test_release_packaging.py`, `scripts/test_smoke_release.py`): -* **One backend per package.** Each binary is built with the exact Cargo - feature set in the variant contract and - `OSCAN_DISTRIBUTION_BACKEND=`, so the other backends' code is absent - and the package defaults deterministically to its own backend. Windows LLVM +* **Profile and default are independent.** Each binary is built with the exact + `cargo_features` in the variant contract, + `OSCAN_DISTRIBUTION_PROFILE=`, and + `OSCAN_DEFAULT_BACKEND=`. Full includes all three backends and + defaults to LLVM; slim profiles contain only their named backend. Windows LLVM uses `backend-llvm,inprocess-lld,static-llvm` plus `RUSTFLAGS="-C target-feature=+crt-static"`; ordinary variants use only - `backend-`. -* **Object packages are toolchain-free.** An `llvm`/`cranelift` package may not + `backend-`. +* **Slim object packages are toolchain-free.** An `llvm`/`cranelift` package may not contain a C compiler executable, a C header outside `native-link/`, a sysroot/`include/` tree, the hosted runtime archive, `native-runtime/`, or `cross-linkers/`. The Oscan runtime is precompiled from C *by the release @@ -50,6 +53,11 @@ Invariants the tooling enforces (see `scripts/test_release_workflow.py`, LLVM/LLD and embedded llvm-mingw inputs. * **C packages carry no object payload.** A `c` package may not ship `native-link/`, `build/`, `native-runtime/`, or `cross-linkers/`. +* **Full is target-local.** Full is the union of that target's backend + components, not a copy of the slim archives and not a source of cross-target + sidecars. Shared files are staged once and retain component digests. Its + runtime-archive component includes hosted mode for `--libc` and the + freestanding profiles used by object-backend and `--extra-c` links. * **No cross-target sidecars.** Minimum packages ship only their own target's assets; the `cross-linkers//` staging that earlier full bundles used is refused outright now (see "Cross-target linking" below). @@ -59,22 +67,23 @@ Invariants the tooling enforces (see `scripts/test_release_workflow.py`, per-file-hashed native-link asset directory. The former `--toolchain-dir` and `--llvm-provider-dir` inputs are hard errors: a prepared directory cannot be checked against the digest its manifest pins. -* **Every archive is smoked before it is uploaded**, with its own backend, and +* **Every archive is smoked before it is uploaded**. Full is compiled with its + implicit LLVM default and every explicit backend; slim packages are exercised + with their one backend. The the produced set is compared against the contract's expected archive names in both directions (nothing missing, nothing extra). -### Why the package job is per target, not per (target, backend) +### Why the package job is per target, not per (target, profile) All of a target's variants reuse the same pinned toolchain download and freestanding runtime archives. Sidecar variants also share one prepared native-link set; Windows LLVM additionally reuses a cached static LLVM/LLD build. The workflow therefore fans out over **targets**, prepares those -expensive inputs once, and then builds the target's backends **sequentially** -in one job — three -`cargo build`s into `$RUNNER_TEMP/oscan-release-binaries//`, each +expensive inputs once, and then builds the target's profiles **sequentially** +in one job — four builds on Windows/Linux and one on macOS. +Each lands in `$RUNNER_TEMP/oscan-release-binaries//` and is copied out before the next overwrites `target/release`. `target/` is -deliberately not cached: the same crate is built three times with mutually -exclusive feature sets. +deliberately not cached. ### Release job order @@ -85,7 +94,7 @@ exclusive feature sets. toolchain, build runtime archives, and prepare any sidecar inputs. Windows also resolves the pinned LLVM SDK/source, builds or restores the patched static LLVM/LLD toolchain, and prepares strict in-memory link inputs. It then - builds one compiler per backend and, for each backend, runs assemble → smoke + builds one compiler per profile and, for each profile, runs assemble → smoke → stage for upload. Windows additionally cuts the recommended LLVM MSI from the staged strict bundle. 3. `checksums` — collect every `.zip`/`.msi`/`.tar.gz`/`.tar.xz`, refuse @@ -98,19 +107,19 @@ exclusive feature sets. Run the workflow manually (Actions → "Oscan Release" → Run workflow) with a version and **`publish` cleared**. `prepare` then reports `should_publish=false`, the `publish` job is skipped, and the run still builds, -smokes and checksums all seven archives and the MSI — the supported way to +smokes and checksums all nine archives and the MSI — the supported way to rehearse a release. A tag push (`v*`) always publishes. ### Assembling and smoking one variant by hand Each variant can be produced locally with the same scripts CI uses, in the same -order. `-Backend` is mandatory everywhere, and only the canonical names are +order. `-Profile` is mandatory everywhere, and only the canonical names are accepted. This walkthrough produces the Windows LLVM package end to end; run it from the repository root on the target's own platform: ```powershell $target = 'windows-x86_64' -$backend = 'llvm' +$profile = 'llvm' $version = '1.2.3' # archive/bundle version, no leading 'v' $tag = "v$version" # what --version reports $manifest = "packaging/toolchains/$target.json" @@ -173,7 +182,9 @@ python scripts/release_tools.py prepare-inprocess-notices ` # 5. Build the strict compiler with static LLVM, in-process LLD and static CRT. Remove-Item Env:OSCAN_EMBED_ASSETS_DIR, Env:OSCAN_REQUIRE_EMBEDDED_ASSETS -ErrorAction SilentlyContinue -$env:OSCAN_DISTRIBUTION_BACKEND = $backend +Remove-Item Env:OSCAN_DISTRIBUTION_BACKEND -ErrorAction SilentlyContinue +$env:OSCAN_DISTRIBUTION_PROFILE = $profile +$env:OSCAN_DEFAULT_BACKEND = 'llvm' $env:OSCAN_VERSION = $tag $env:OSCAN_INPROCESS_TOOLCHAIN_DIR = (Resolve-Path "build/inprocess-toolchain/$target").Path $env:OSCAN_INPROCESS_ASSETS_DIR = (Resolve-Path "build/inprocess-assets/$target").Path @@ -183,20 +194,26 @@ cargo build --release --no-default-features ` # 6. Assemble that variant, passing only the inputs its component list declares. $archive = ./scripts/assemble-release.ps1 ` - -Target $target -Backend $backend -Version $version ` + -Target $target -Profile $profile -Version $version ` -BinaryPath target/release/oscan.exe ` -EmbeddedNoticesDir (Resolve-Path $noticeDir).Path ` -OutputDir build/release-artifacts | Select-Object -Last 1 -# 7. Smoke exactly that archive, as that backend. +# 7. Smoke exactly that archive, as that profile. ./scripts/smoke-release.ps1 ` - -Target $target -Backend $backend -Version $version ` + -Target $target -Profile $profile -Version $version ` -ArchivePath $archive ` - -ScratchDir "build/release-smoke/$target-$backend" + -ScratchDir "build/release-smoke/$target-$profile" ``` Variant differences: +* **`full`** — enable all three `backend-*` features. On Windows it uses the + same strict in-process inputs and static CRT as LLVM and also assembles with + `-ToolchainArchive`; build the `hosted` runtime archive in addition to step + 3's freestanding set. On Linux it uses the sidecar/runtime/provider inputs, + including the hosted runtime archive, and the C toolchain archive. Its + stamped default remains LLVM. * **`cranelift`** — reuse the base toolchain/runtime archives, run `prepare-embed-assets.ps1`, remove `OSCAN_INPROCESS_TOOLCHAIN_DIR`, `OSCAN_INPROCESS_ASSETS_DIR`, and `RUSTFLAGS`, then run @@ -215,8 +232,8 @@ Variant differences: resolve. * **macOS `c`** — build with `--features backend-c`; there is nothing pinned to fetch. The Unix wrappers use long options, for example - `assemble-release.sh --target macos-x86_64 --backend c ...` and - `smoke-release.sh --target macos-x86_64 --backend c ...`. + `assemble-release.sh --target macos-x86_64 --profile c ...` and + `smoke-release.sh --target macos-x86_64 --profile c ...`. Passing an input a variant's component list does not declare is an error, as is omitting one it does. `scripts/assemble-release.sh` / `smoke-release.sh` and the @@ -428,9 +445,9 @@ target tag, writes the same manifests, and embeds BearSSL under the same rules. Release assembly stages each archive/manifest pair at `build/runtime-archives//` inside sidecar object packages. Strict -Windows LLVM instead embeds the verified archive bytes in `oscan.exe` and -installs no runtime-archive directory. Runtime *sources* are never staged: -schema 2 packages ship no `native-runtime/` directory and no runtime builder, +Windows full and LLVM profiles instead embed the verified archive bytes in `oscan.exe` and +install no runtime-archive directory. Runtime *sources* are never staged: +schema 3 packages ship no `native-runtime/` directory and no runtime builder, because the shim is already compiled into every runtime archive (see below). Release smoke tests assert the appropriate assets survived staging, then compile and run the sample with the package's own backend — freestanding LLVM @@ -710,20 +727,21 @@ The Windows Cranelift release deliberately embeds none of that set: it ships it as `native-link/` and clears `OSCAN_EMBED_ASSETS_DIR`/`OSCAN_REQUIRE_EMBEDDED_ASSETS` before building. -The Windows LLVM release uses a separate strict path. The target manifest pins +The Windows full and LLVM releases use a separate strict path. The target manifest pins LLVM 22.1.0 SDK and source archives. `prepare-inprocess-toolchain-sources` verifies/extracts them, and `build-inprocess-toolchain.ps1` builds the patched static LLD bridge and collects the static LLVM/LLD libraries. `prepare-inprocess-link-assets` verifies and stages only the three runtime archives, six Win32 import libraries and compiler builtins for `build.rs`. -Building with `backend-llvm,inprocess-lld,static-llvm`, +The LLVM profile builds with `backend-llvm,inprocess-lld,static-llvm`; full adds +`backend-cranelift,backend-c`. With `OSCAN_INPROCESS_TOOLCHAIN_DIR`, `OSCAN_INPROCESS_ASSETS_DIR`, and `RUSTFLAGS="-C target-feature=+crt-static"` produces the single strict `oscan.exe`. The LLVM and llvm-mingw notices are installed under `LICENSES/embedded/`; all build inputs stay out of the package. **The release `package` job's step order** (one job per target; every expensive -input is prepared once and reused by that target's backends): +input is prepared once and reused by that target's profiles): 1. Checkout / Rust / Python setup. 2. **Resolve the pinned archives** (`resolve-archive.ps1`, once per target: @@ -734,20 +752,20 @@ input is prepared once and reused by that target's backends): 4. **Prepare each declared link profile.** `prepare-embed-assets` stages the sidecar set (13 files/≈85.4 MB on Windows, 1 file/≈2.78 MB on Linux). Windows additionally builds/restores static LLVM/LLD and runs - `prepare-inprocess-link-assets` for the strict LLVM variant. -5. **One contract-driven `cargo build` per backend**, stamped with - `OSCAN_DISTRIBUTION_BACKEND=` and copied to a per-backend path before the - next build overwrites `target/release`. The strict Windows LLVM build alone - enables `inprocess-lld,static-llvm` and static CRT; its environment is - cleared before the Cranelift and C builds. -6. **For each backend: assemble → smoke → stage for upload.** - `assemble-release.ps1` takes `-Backend` plus exactly the prepared inputs + `prepare-inprocess-link-assets` for the strict full and LLVM variants. +5. **One contract-driven `cargo build` per profile**, stamped with + `OSCAN_DISTRIBUTION_PROFILE=

` and `OSCAN_DEFAULT_BACKEND=`, then + copied to a per-profile path before the next build overwrites + `target/release`. Strict Windows full and LLVM enable + `inprocess-lld,static-llvm` and static CRT; that environment is cleared + before the sidecar/C builds. +6. **For each profile: assemble → smoke → stage for upload.** + `assemble-release.ps1` takes `-Profile` plus exactly the prepared inputs that variant's component list declares (`-EmbeddedNoticesDir` for strict - Windows LLVM; `-NativeLinkDir`/`-PrebuiltRuntimeArchiveDir` for sidecar + Windows full/LLVM; `-NativeLinkDir`/`-PrebuiltRuntimeArchiveDir` for sidecar object variants; `-ToolchainArchive` or `-LlvmProviderArchive` where - declared), so no package receives undeclared inputs. Only the C package's - `toolchain/` carries a compiler; LLVM and Cranelift freestanding final links - need no compiler executable at all. + declared), so no package receives undeclared inputs. Only full and C carry + a C compiler; slim LLVM and Cranelift freestanding final links need none. 7. **Cut the recommended MSI** from the staged Windows LLVM bundle directory (Windows only), so the installer contains exactly what the smoked archive contains. @@ -782,7 +800,7 @@ On Linux x86-64, freestanding LLVM and Cranelift object generation/final linking need no external compiler driver. LLVM object emission loads packaged LLVM 22 in-process, and the final link uses a fully static `x86_64-linux-musl-ld` binary from the pinned musl-cross toolchain. In a -schema-v2 release package that linker arrives as the verified +schema-3 release package that linker arrives as the verified `native-link/` sidecar and is used in place; in an optional embedded (dev/standalone) build it lives inside the binary and is extracted to a local cache at first use (see `docs/design/native-link-embedding.md` §10 for the @@ -819,11 +837,11 @@ A single Linux `oscan` binary only carries the linker for its own host target (`linux-x86_64`) — see `docs/design/native-link-embedding.md` §11.1 for why multi-target embedding is deliberately out of scope. -**Schema 2 packages ship no cross-target sidecars.** Each published package -contains only its own target's assets, and staging *refuses* a package that -contains a `cross-linkers/` directory: the earlier full-bundle model that -folded `cross-linkers//` into the `linux-x86_64` archive is gone along -with the full bundle itself. +**Schema 3 packages ship no cross-target sidecars.** Each published profile, +including `full`, contains only its own target's assets, and staging *refuses* +a package that contains a `cross-linkers/` directory. The current full profile +combines backends for one target; it does not revive the earlier bundle that +folded `cross-linkers//` into the `linux-x86_64` archive. The override mechanism the compiler exposes is unchanged, so cross-linking still works from user-supplied inputs. It needs a linker @@ -857,8 +875,8 @@ git tag v0.0.12 git push origin v0.0.12 ``` -The Release workflow then builds one compiler per backend for each target, -assembles and smokes all seven archives, cuts the recommended Windows LLVM +The Release workflow then builds one compiler per profile for each target, +assembles and smokes all nine archives, cuts the recommended Windows LLVM MSI, writes `SHA256SUMS` over everything, and publishes the result to GitHub Releases. To rehearse without publishing, use the manual workflow with `publish` cleared (see "Dry run without publishing" above). diff --git a/docs/spec/oscan-spec.md b/docs/spec/oscan-spec.md index acf44fa..122d058 100644 --- a/docs/spec/oscan-spec.md +++ b/docs/spec/oscan-spec.md @@ -34,14 +34,15 @@ ### Getting the Oscan Compiler -Oscan is distributed via [GitHub Releases](https://github.com/lucabol/Oscan/releases). Every published artifact is one **(platform, backend) package**: Windows and Linux publish `llvm`, `cranelift` and `c` packages, macOS publishes `c`, and Windows additionally publishes one recommended installer, the LLVM MSI. There is no combined package. An `llvm`/`cranelift` package contains the compiler and the verified direct-link/runtime inputs it needs — embedded in the strict Windows LLVM executable or installed as sidecars for the other object packages — and no C compiler, headers or sysroot. A `c` package contains the compiler and the pinned C toolchain it needs. +Oscan is distributed via [GitHub Releases](https://github.com/lucabol/Oscan/releases). Windows and Linux publish `full`, `llvm`, `cranelift`, and `c` profiles; macOS publishes `c`. Full contains one compiler with all three backends and a deterministic LLVM default. The slim profiles contain only their named backend. Windows additionally publishes the historical LLVM MSI; full remains archive-only during this transition. Slim `llvm`/`cranelift` packages contain verified direct-link/runtime inputs and no C compiler, headers, or sysroot. The `c` and `full` profiles carry the pinned C toolchain they need. #### Windows x86_64 **LLVM package (recommended):** -1. Download `oscan-vX.Y.Z-windows-x86_64-llvm.zip`, or run the recommended - installer `oscan-vX.Y.Z-windows-x86_64-llvm.msi` +1. Download `oscan-vX.Y.Z-windows-x86_64-llvm.zip`, the all-backend + `oscan-vX.Y.Z-windows-x86_64-full.zip`, another slim archive, or run the + recommended transition installer `oscan-vX.Y.Z-windows-x86_64-llvm.msi` 2. Verify exactly that asset against the release's `SHA256SUMS`, keeping the downloaded file's canonical name: ```powershell @@ -49,14 +50,14 @@ Oscan is distributed via [GitHub Releases](https://github.com/lucabol/Oscan/rele $expected = (Select-String -Path SHA256SUMS -Pattern "\s\*?$([regex]::Escape($asset))$").Line.Split(' ')[0] if ((Get-FileHash $asset -Algorithm SHA256).Hash.ToLowerInvariant() -ne $expected.ToLowerInvariant()) { throw "checksum mismatch" } ``` -3. Extract to a stable directory (e.g., `C:\Program Files\oscan\`) and add it - to your PATH, or run the bundled `install.ps1` -4. Verify: `oscan --version` +3. Extract and run the bundled `install.ps1`; use `-SetDefault` only when this + profile should replace an existing unqualified selection +4. Verify the qualified command, such as `oscan-full --version` -`oscan-vX.Y.Z-windows-x86_64-cranelift.zip` and +`oscan-vX.Y.Z-windows-x86_64-cranelift.zip`, `oscan-vX.Y.Z-windows-x86_64-c.zip` install the same way. No C compiler -installation is required for any of the three: the object packages need none, -and the C package bundles its own. +installation is required: slim object packages need none, while C and full +bundle one. **Components (llvm package):** - `oscan.exe` — the strict single-executable compiler: LLVM 22.1.0, patched @@ -77,22 +78,22 @@ retains the verified `native-link/` and runtime-archive sidecars. **LLVM package (recommended):** -1. Download `oscan-vX.Y.Z-linux-x86_64-llvm.tar.xz` and `SHA256SUMS`, keeping - the archive's canonical file name +1. Download `oscan-vX.Y.Z-linux-x86_64-llvm.tar.xz`, the all-backend + `oscan-vX.Y.Z-linux-x86_64-full.tar.xz`, or another slim profile plus + `SHA256SUMS`, keeping the archive's canonical file name 2. Verify exactly that asset, then extract: ```bash grep -E "[[:space:]]\*?oscan-vX.Y.Z-linux-x86_64-llvm\.tar\.xz$" SHA256SUMS | sha256sum -c - tar xf oscan-vX.Y.Z-linux-x86_64-llvm.tar.xz ``` -3. Move to a stable location: `mv oscan-*/ ~/.local/oscan/` or `/opt/oscan/` -4. On Debian/Ubuntu: `sudo apt-get install libedit2 libffi8 libxml2 libz3-4 libzstd1 zlib1g` -5. Add to PATH: `export PATH=$PATH:~/.local/oscan/` -6. Verify: `oscan --version` +3. Run the included `install.sh`; use `--set-default` only to change an existing selection +4. For LLVM/full on Debian/Ubuntu: `sudo apt-get install libedit2 libffi8 libxml2 libz3-4 libzstd1 zlib1g` +5. Verify a qualified command, such as `oscan-llvm --version` The packaged LLVM code generator requires glibc 2.34 or newer plus the listed host runtime libraries — but no installed C/Clang/LLVM toolchain. `oscan-vX.Y.Z-linux-x86_64-cranelift.tar.xz` needs neither, and -`oscan-vX.Y.Z-linux-x86_64-c.tar.xz` bundles its own C toolchain. +`oscan-vX.Y.Z-linux-x86_64-c.tar.xz` and full bundle a C toolchain. **Components (llvm package):** - `oscan` — the Oscan compiler, built with the LLVM backend only @@ -148,8 +149,8 @@ cargo build --release ``` Binary: `target/release/oscan` (or `oscan.exe` on Windows). A source build -contains all three backends; released packages are single-backend builds. -Ordinary variants use `--no-default-features --features backend-`; +and the full profile contain all three backends; slim packages contain one. +Slim variants use `--no-default-features --features backend-`; strict Windows LLVM uses `--features backend-llvm,inprocess-lld,static-llvm` with static CRT. @@ -204,10 +205,11 @@ oscan-vX.Y.Z-linux-x86_64-c/ ### Compiler Invocation -Once installed, invoke Oscan like this: +Once installed, invoke the managed default (`oscan`) or a stable qualified +profile command (`oscan-full`, `oscan-llvm`, `oscan-cranelift`, `oscan-c`): ```bash -oscan # Compile to executable (this package's backend) +oscan # Compile with the selected profile's default oscan --run # Compile and run immediately oscan -o # Emit C code only (`.c` extension) oscan --emit-c # Emit C code to stdout @@ -231,14 +233,19 @@ that use one. ### Upgrade and Uninstall -**Phase 1 releases** do not yet use system package managers. To upgrade or uninstall: +Archive installers use profile-specific version roots and atomically activate a +same-profile upgrade. Installing one profile never removes another or changes +an existing unqualified selector; `-SetDefault`/`--set-default` is explicit. -- **Upgrade (archive install):** Download the new package and extract it to the same location (or a new location and update your PATH) +- **Upgrade (archive install):** Run the newer profile's bundled installer; it replaces only that profile - **Upgrade (Windows MSI):** Run the newer MSI; it replaces the previous MSI install -- **Uninstall (archive install):** Remove the directory containing `oscan` and any `toolchain/`/`native-link/` it shipped, then remove it from your PATH +- **Uninstall (archive install):** Run that profile's bundled installer with `-Uninstall` or `--uninstall`; other profiles remain installed - **Uninstall (Windows MSI):** Use **Settings → Apps → Installed apps → Oscan → Uninstall**, or run `msiexec /x oscan-vX.Y.Z-windows-x86_64-llvm.msi /quiet`. Deleting the directory by hand leaves the product registered with Windows Installer. -Future releases may support WinGet, Homebrew, and Scoop for easier management. +On Windows, archive and MSI installers reject ambiguous ownership of the +unqualified `oscan` command by default. The MSI detects archive selectors owned +by the same Windows account; intentional coexistence requires the explicit +`OSCAN_ALLOW_ARCHIVE_CONFLICT=1` MSI property. --- @@ -2053,7 +2060,7 @@ oscan [OPTIONS] | `--emit-c` | Emit C-backend source to stdout. | | `--emit-llvm-ir` | Emit textual LLVM IR to stdout (alias: `--emit-llvm`). Requires the LLVM backend and stops before object emission and linking. | | `--libc` | Use hosted libc mode instead of freestanding mode. Enables access to standard C library functions. | -| `--backend ` | Explicitly select `llvm` (direct IR plus in-process LLVM object code), `cranelift` (direct Cranelift AOT object code), or `c` (portability/reference/source backend), limited to the backends the build contains. `native` is a deprecated alias for `cranelift`. Without this option, a released single-backend package uses its own backend; a multi-backend build prefers LLVM on supported Windows/Linux hosts when a compatible provider is available, then Cranelift, then C. | +| `--backend ` | Explicitly select `llvm` (direct IR plus in-process LLVM object code), `cranelift` (direct Cranelift AOT object code), or `c` (portability/reference/source backend), limited to the backends the build contains. `native` is a deprecated alias for `cranelift`. A slim release defaults to its backend, full defaults to LLVM, and an unstamped multi-backend development build probes LLVM, then Cranelift, then C. | | `--debuginfo ` | Debug information level: `none` (default) or `line-tables`. Line tables preserve Oscan file/line mappings for breakpoints, stepping, stack traces, and symbolization without changing optimization. Oscan does not generate or guarantee local-variable and type descriptions at this level; a C toolchain may include additional records. | | `--native-target ` | Object target for `--backend llvm` and `--backend cranelift`. Supported: `host` (default), `linux-x86_64`, `windows-x86_64`, `linux-aarch64`, `linux-riscv64`. Provider target capabilities are checked before LLVM emission. | | `--target ` | Cross-compile for target architecture (C backend only). Supported: `riscv64`, `wasi`. Without this flag, compiles for the host platform. | diff --git a/docs/technical-details.md b/docs/technical-details.md index 2af59fb..0d1c1a9 100644 --- a/docs/technical-details.md +++ b/docs/technical-details.md @@ -24,12 +24,13 @@ inside the compiler; it does not invoke `clang`, `llvm-as`, `opt`, or `llc`. ### Backend selection -Every release package contains exactly one backend and defaults to it. Asking a -release build for another backend produces an error that identifies the package -to install. +The `full` profile contains all three backends and defaults deterministically +to LLVM. Slim `llvm`, `cranelift`, and `c` profiles contain and default to only +their named backend. Asking a slim build for another backend produces an error +that identifies either the matching slim profile or `full`. -A default development build contains all three backends. Without an explicit -selector it chooses: +A default development build contains all three backends and probes for a +usable default. Without an explicit selector it chooses: 1. LLVM on a supported host when a compatible provider is available. 2. Cranelift when native object generation is available. @@ -74,34 +75,40 @@ separate future tiers. ## Release packages -Release contract schema 2 publishes one archive per platform/backend pair. It -does not publish a combined package. +Release contract schema 3 publishes one archive per platform/profile pair. +Windows and Linux have a combined `full` profile plus three slim profiles. +Package metadata is schema 2 and records `package_id`, profile, +`is_distribution`, available backends, deterministic default, components, and +component digests. -| Platform | Backend | Artifact | Additional host requirement | +| Platform | Profile | Artifact | Additional host requirement | |---|---|---|---| +| Windows x86_64 | `full` | `oscan-vX.Y.Z-windows-x86_64-full.zip` | None; all three backends and the C toolchain are included | | Windows x86_64 | `llvm` | `oscan-vX.Y.Z-windows-x86_64-llvm.msi` or `oscan-vX.Y.Z-windows-x86_64-llvm.zip` | None | | Windows x86_64 | `cranelift` | `oscan-vX.Y.Z-windows-x86_64-cranelift.zip` | None | | Windows x86_64 | `c` | `oscan-vX.Y.Z-windows-x86_64-c.zip` | None; the C toolchain is bundled | +| Linux x86_64 | `full` | `oscan-vX.Y.Z-linux-x86_64-full.tar.xz` | glibc 2.34+ and the LLVM provider's host libraries | | Linux x86_64 | `llvm` | `oscan-vX.Y.Z-linux-x86_64-llvm.tar.xz` | glibc 2.34+ and the provider's host libraries | | Linux x86_64 | `cranelift` | `oscan-vX.Y.Z-linux-x86_64-cranelift.tar.xz` | None | | Linux x86_64 | `c` | `oscan-vX.Y.Z-linux-x86_64-c.tar.xz` | None; the C toolchain is bundled | | macOS x86_64 | `c` | `oscan-vX.Y.Z-macos-x86_64-c.tar.gz` | Xcode Command Line Tools | -Object-backend packages contain the compiler, precompiled freestanding runtime +Slim object-backend packages contain the compiler, precompiled freestanding runtime archives, and the files needed for final linking. They do not contain a C compiler, C headers, or a sysroot. They therefore reject operations that need one, including C emission, hosted libc builds, explicit C sources, rebuilding the runtime, and generated C ABI shims. -The C packages contain the compiler and a pinned C toolchain on Windows and -Linux. The macOS package uses Apple Clang from Xcode Command Line Tools. +The full package combines those object inputs with the pinned C toolchain. C +slim packages contain only the compiler and C toolchain. The macOS package +uses Apple Clang from Xcode Command Line Tools. ### Package layout -A Windows object-backend package has this shape: +A Windows Cranelift slim package has this shape: ```text -oscan-vX.Y.Z-windows-x86_64-llvm/ +oscan-vX.Y.Z-windows-x86_64-cranelift/ oscan.exe native-link/ native-link-assets.json @@ -133,6 +140,32 @@ oscan-vX.Y.Z-windows-x86_64-c/ Toolchains and runtime archives are release artifacts rather than source files, so they are not committed to the Git repository. +### Installed profile layout + +Archive installs use one stable bin directory and isolated profile payloads: + +```text +/ + oscan # managed default selector + oscan-full + oscan-llvm + oscan-cranelift + oscan-c +/ + profiles/ + full//... + llvm//... +``` + +Windows uses `.cmd` shims; Unix uses symlinks. The first profile may create the +selector when none exists. Thereafter only `-SetDefault`/`--set-default` +changes it. A staged same-profile upgrade updates its qualified entry and then +removes the older payload; other profile roots are never touched. The existing +LLVM MSI remains the only MSI and keeps its historical product/UpgradeCode +identity during this transition. Archive and MSI installers reject ownership of +the same unqualified Windows command by default; the direct MSI check applies +to archive selectors owned by the same Windows account. + ## Runtime and final linking Oscan supports two runtime modes: diff --git a/packaging/toolchains/release-contract.json b/packaging/toolchains/release-contract.json index 67b62e6..6b3d2ec 100644 --- a/packaging/toolchains/release-contract.json +++ b/packaging/toolchains/release-contract.json @@ -1,31 +1,64 @@ { - "schema_version": 2, + "schema_version": 3, "install_surface": "github-releases", "toolchains_committed_to_git": false, "backends": { "llvm": { "cargo_feature": "backend-llvm", - "artifact_suffix": "llvm", "kind": "object", "description": "Direct in-process LLVM object code. No C compiler, no C source, no toolchain." }, "cranelift": { "cargo_feature": "backend-cranelift", - "artifact_suffix": "cranelift", "kind": "object", - "description": "Cranelift object code. No C compiler, no C source, no toolchain. 'native' is only a deprecated CLI alias for this backend and is never an artifact label." + "description": "Cranelift object code. No C compiler, no C source, no toolchain. 'native' is only a deprecated CLI alias." }, "c": { "cargo_feature": "backend-c", - "artifact_suffix": "c", "kind": "c-source", "description": "Portability/reference backend: emits C and needs a C toolchain to compile it." } }, + "profiles": { + "full": { + "artifact_suffix": "full", + "backends": [ + "llvm", + "cranelift", + "c" + ], + "default_backend": "llvm", + "description": "All backends in one compiler. Select explicitly with --backend; LLVM is the deterministic default." + }, + "llvm": { + "artifact_suffix": "llvm", + "backends": [ + "llvm" + ], + "default_backend": "llvm", + "description": "Slim LLVM-only compiler." + }, + "cranelift": { + "artifact_suffix": "cranelift", + "backends": [ + "cranelift" + ], + "default_backend": "cranelift", + "description": "Slim Cranelift-only compiler." + }, + "c": { + "artifact_suffix": "c", + "backends": [ + "c" + ], + "default_backend": "c", + "description": "Slim C-only compiler." + } + }, "components": { "compiler": { "position": "archive-root", - "description": "The feature-gated oscan binary, stamped with OSCAN_DISTRIBUTION_BACKEND." + "description": "The feature-gated oscan binary, stamped with OSCAN_DISTRIBUTION_PROFILE and OSCAN_DEFAULT_BACKEND." }, "direct_link_sidecar": { "position": "archive-root/native-link", @@ -35,8 +68,9 @@ }, "runtime_archives": { "position_template": "archive-root/build/runtime-archives/{target}", - "description": "Precompiled freestanding runtime archives plus their manifests. Object packages ship no hosted archive, no runtime sources, and no runtime builder.", + "description": "Precompiled runtime archives plus their manifests. Full packages include hosted and freestanding modes; slim object packages include freestanding modes only.", "allowed_profiles": [ + "hosted", "freestanding", "freestanding_gfx", "freestanding_core" @@ -86,7 +120,7 @@ }, "c_toolchain": { "position": "archive-root/toolchain", - "description": "The pinned, pruned C toolchain a C package needs to compile the C the backend emits." + "description": "The pinned, pruned C toolchain a package containing the C backend needs to compile emitted C." } }, "lookup_contract": { @@ -125,17 +159,47 @@ "binary_name": "oscan.exe", "archive_format": "zip", "toolchain_manifest": "windows-x86_64.json", - "backends": { + "profiles": { + "full": { + "archive_name_template": "oscan-v{version}-windows-x86_64-full.zip", + "archive_root_template": "oscan-v{version}-windows-x86_64-full", + "cargo_features": [ + "backend-llvm", + "backend-cranelift", + "backend-c", + "inprocess-lld", + "static-llvm" + ], + "rustflags": "-C target-feature=+crt-static", + "toolchain_free": false, + "link_mode": "inprocess", + "components": [ + "compiler", + "runtime_archives", + "c_toolchain" + ], + "embedded_notice_files": [ + "LLVM-LICENSE.txt", + "mingw-w64-COPYING.txt", + "mingw-w64-project-COPYING.txt", + "mingw-w64-runtime-COPYING.txt" + ], + "runtime_profiles": [ + "hosted", + "freestanding", + "freestanding_gfx", + "freestanding_core" + ] + }, "llvm": { "archive_name_template": "oscan-v{version}-windows-x86_64-llvm.zip", "archive_root_template": "oscan-v{version}-windows-x86_64-llvm", - "cargo_feature": "backend-llvm", - "extra_cargo_features": [ + "cargo_features": [ + "backend-llvm", "inprocess-lld", "static-llvm" ], "rustflags": "-C target-feature=+crt-static", - "distribution_backend": "llvm", "toolchain_free": true, "link_mode": "inprocess", "components": [ @@ -156,8 +220,9 @@ "cranelift": { "archive_name_template": "oscan-v{version}-windows-x86_64-cranelift.zip", "archive_root_template": "oscan-v{version}-windows-x86_64-cranelift", - "cargo_feature": "backend-cranelift", - "distribution_backend": "cranelift", + "cargo_features": [ + "backend-cranelift" + ], "toolchain_free": true, "link_mode": "sidecar", "components": [ @@ -174,8 +239,9 @@ "c": { "archive_name_template": "oscan-v{version}-windows-x86_64-c.zip", "archive_root_template": "oscan-v{version}-windows-x86_64-c", - "cargo_feature": "backend-c", - "distribution_backend": "c", + "cargo_features": [ + "backend-c" + ], "toolchain_free": false, "components": [ "compiler", @@ -189,12 +255,38 @@ "binary_name": "oscan", "archive_format": "tar.xz", "toolchain_manifest": "linux-x86_64.json", - "backends": { + "profiles": { + "full": { + "archive_name_template": "oscan-v{version}-linux-x86_64-full.tar.xz", + "archive_root_template": "oscan-v{version}-linux-x86_64-full", + "cargo_features": [ + "backend-llvm", + "backend-cranelift", + "backend-c" + ], + "toolchain_free": false, + "link_mode": "sidecar", + "components": [ + "compiler", + "direct_link_sidecar", + "runtime_archives", + "llvm_provider", + "c_toolchain" + ], + "llvm_provider_source": "toolchain-manifest", + "runtime_profiles": [ + "hosted", + "freestanding", + "freestanding_gfx", + "freestanding_core" + ] + }, "llvm": { "archive_name_template": "oscan-v{version}-linux-x86_64-llvm.tar.xz", "archive_root_template": "oscan-v{version}-linux-x86_64-llvm", - "cargo_feature": "backend-llvm", - "distribution_backend": "llvm", + "cargo_features": [ + "backend-llvm" + ], "toolchain_free": true, "link_mode": "sidecar", "components": [ @@ -213,8 +305,9 @@ "cranelift": { "archive_name_template": "oscan-v{version}-linux-x86_64-cranelift.tar.xz", "archive_root_template": "oscan-v{version}-linux-x86_64-cranelift", - "cargo_feature": "backend-cranelift", - "distribution_backend": "cranelift", + "cargo_features": [ + "backend-cranelift" + ], "toolchain_free": true, "link_mode": "sidecar", "components": [ @@ -231,8 +324,9 @@ "c": { "archive_name_template": "oscan-v{version}-linux-x86_64-c.tar.xz", "archive_root_template": "oscan-v{version}-linux-x86_64-c", - "cargo_feature": "backend-c", - "distribution_backend": "c", + "cargo_features": [ + "backend-c" + ], "toolchain_free": false, "components": [ "compiler", @@ -246,12 +340,13 @@ "binary_name": "oscan", "archive_format": "tar.gz", "note_file": "macos-phase1-note.md", - "backends": { + "profiles": { "c": { "archive_name_template": "oscan-v{version}-macos-x86_64-c.tar.gz", "archive_root_template": "oscan-v{version}-macos-x86_64-c", - "cargo_feature": "backend-c", - "distribution_backend": "c", + "cargo_features": [ + "backend-c" + ], "toolchain_free": false, "components": [ "compiler" diff --git a/packaging/windows/oscan.wxs b/packaging/windows/oscan.wxs index 1389ddd..5fa21c0 100644 --- a/packaging/windows/oscan.wxs +++ b/packaging/windows/oscan.wxs @@ -13,10 +13,34 @@ Scope="perMachine" Compressed="yes"> - + + + + + + + + + + + + + + + + + + + + @@ -25,6 +49,8 @@ + diff --git a/scripts/assemble-release.ps1 b/scripts/assemble-release.ps1 index 673d2ec..eabb384 100644 --- a/scripts/assemble-release.ps1 +++ b/scripts/assemble-release.ps1 @@ -3,19 +3,18 @@ param( [ValidateSet("windows-x86_64", "linux-x86_64", "macos-x86_64")] [string]$Target, - # Which backend variant to assemble. The canonical names are the only - # artifact labels; `native` is a deprecated CLI alias for cranelift and - # is never a package name. + # Which package profile to assemble. [Parameter(Mandatory = $true)] - [ValidateSet("llvm", "cranelift", "c")] - [string]$Backend, + [Alias("Backend")] + [ValidateSet("full", "llvm", "cranelift", "c")] + [string]$Profile, [Parameter(Mandatory = $true)] [string]$Version, # The feature-gated binary for this variant: built with # the exact feature set declared by the release contract and - # OSCAN_DISTRIBUTION_BACKEND=. + # OSCAN_DISTRIBUTION_PROFILE=. [Parameter(Mandatory = $true)] [string]$BinaryPath, @@ -25,7 +24,7 @@ param( # Prepared inputs. CI fetches/caches the pinned source archives, prepares # the native-link asset set, and builds the freestanding runtime archives - # once per target, then reuses them for every backend variant of that + # once per target, then reuses them for every package profile of that # target. Staging never downloads. [string]$PrebuiltRuntimeArchiveDir, @@ -84,35 +83,35 @@ if (-not $contract["variants"].ContainsKey($Target)) { throw "Release contract does not define target '$Target'." } $targetSpec = $contract["variants"][$Target] -if (-not $targetSpec["backends"].ContainsKey($Backend)) { - $known = ($targetSpec["backends"].Keys | Sort-Object) -join ", " - throw "Release contract does not define backend '$Backend' for target '$Target' (known: $known)." +if (-not $targetSpec["profiles"].ContainsKey($Profile)) { + $known = ($targetSpec["profiles"].Keys | Sort-Object) -join ", " + throw "Release contract does not define profile '$Profile' for target '$Target' (known: $known)." } -$variant = $targetSpec["backends"][$Backend] +$variant = $targetSpec["profiles"][$Profile] $components = @($variant["components"]) $runtimeProfiles = @($variant["runtime_profiles"]) # Sidecar object variants need a prepared native-link asset set. Strict # in-process variants embed their linker and runtime inputs in the binary. if ($components -contains "direct_link_sidecar" -and -not $NativeLinkDir) { - throw "Backend '$Backend' for '$Target' needs -NativeLinkDir (run prepare-embed-assets for this target first)." + throw "Profile '$Profile' for '$Target' needs -NativeLinkDir (run prepare-embed-assets for this target first)." } if ($variant["link_mode"] -eq "inprocess" -and -not $EmbeddedNoticesDir) { - throw "Backend '$Backend' for '$Target' needs -EmbeddedNoticesDir containing the notices for its statically linked and embedded inputs." + throw "Profile '$Profile' for '$Target' needs -EmbeddedNoticesDir containing the notices for its statically linked and embedded inputs." } if ($components -contains "c_toolchain" -and -not $ToolchainArchive) { - throw "Backend '$Backend' for '$Target' needs -ToolchainArchive (the pinned C toolchain source archive; resolve it with 'release_tools.py resolve-archive --component toolchain')." + throw "Profile '$Profile' for '$Target' needs -ToolchainArchive (the pinned C toolchain source archive; resolve it with 'release_tools.py resolve-archive --component toolchain')." } if ($components -contains "llvm_provider" -and $variant["llvm_provider_source"] -eq "toolchain-manifest" -and -not $LlvmProviderArchive) { - throw "Backend '$Backend' for '$Target' needs -LlvmProviderArchive (the pinned LLVM provider source archive; resolve it with 'release_tools.py resolve-archive --component llvm-provider')." + throw "Profile '$Profile' for '$Target' needs -LlvmProviderArchive (the pinned LLVM provider source archive; resolve it with 'release_tools.py resolve-archive --component llvm-provider')." } $runtimeArchiveDir = $null if ($components -contains "runtime_archives") { if (-not $PrebuiltRuntimeArchiveDir) { - throw "Backend '$Backend' for '$Target' needs -PrebuiltRuntimeArchiveDir containing its freestanding runtime archives ($($runtimeProfiles -join ', '))." + throw "Profile '$Profile' for '$Target' needs -PrebuiltRuntimeArchiveDir containing its freestanding runtime archives ($($runtimeProfiles -join ', '))." } if (-not (Test-Path -LiteralPath $PrebuiltRuntimeArchiveDir)) { throw "PrebuiltRuntimeArchiveDir '$PrebuiltRuntimeArchiveDir' does not exist." @@ -122,7 +121,7 @@ if ($components -contains "runtime_archives") { $stageArgs = @{ Target = $Target - Backend = $Backend + Profile = $Profile Version = $Version BinaryPath = $BinaryPath OutputDir = $OutputDir diff --git a/scripts/build-msi.ps1 b/scripts/build-msi.ps1 index 06063c0..3f4bb74 100644 --- a/scripts/build-msi.ps1 +++ b/scripts/build-msi.ps1 @@ -26,6 +26,14 @@ $BundleDir = (Resolve-Path -LiteralPath $BundleDir).Path if (-not (Test-Path (Join-Path $BundleDir "oscan.exe"))) { throw "Bundle directory must contain oscan.exe" } +$packageMetadataPath = Join-Path $BundleDir "oscan-package.json" +if (-not (Test-Path -LiteralPath $packageMetadataPath)) { + throw "Bundle directory must contain oscan-package.json" +} +$packageMetadata = Get-Content -LiteralPath $packageMetadataPath -Raw | ConvertFrom-Json +if ($packageMetadata.schema_version -ne 2 -or $packageMetadata.profile -ne "llvm") { + throw "The transition MSI is reserved for the schema-2 LLVM profile; got schema '$($packageMetadata.schema_version)' profile '$($packageMetadata.profile)'." +} if (-not (Test-Path $WxsPath)) { throw "WiX source not found: $WxsPath" } @@ -52,9 +60,9 @@ if (-not $HarvestOnly) { # Harvest the bundle's payload into a WiX fragment. # -# Schema 2 packages are backend-specific, so the payload is no longer always -# `toolchain/`: sidecar object packages ship `native-link/` and runtime -# archives, while strict Windows LLVM carries only its embedded-input notices. +# The transition MSI remains LLVM-only. The payload is no longer always +# `toolchain/`: the strict Windows LLVM profile carries its embedded-input +# notices and package metadata. # Every package ships `oscan-package.json` plus its LICENSES tree at the root. # Everything in the staged bundle is therefore harvested, except the two files # the .wxs declares itself and the archive's own install script, which has no diff --git a/scripts/install-dev.ps1 b/scripts/install-dev.ps1 index 063d2d8..0a9cba9 100644 --- a/scripts/install-dev.ps1 +++ b/scripts/install-dev.ps1 @@ -112,8 +112,10 @@ try { $installerArgs = @{ SourceDir = $Stage + Profile = 'dev' + SetDefault = $true } - if ($PSBoundParameters.ContainsKey('InstallDir')) { $installerArgs.InstallDir = $InstallDir } + if ($PSBoundParameters.ContainsKey('InstallDir')) { $installerArgs.InstallRoot = $InstallDir } if ($PSBoundParameters.ContainsKey('BinDir')) { $installerArgs.BinDir = $BinDir } if ($NoPathUpdate) { $installerArgs.NoPathUpdate = $true } diff --git a/scripts/install-latest.ps1 b/scripts/install-latest.ps1 index ec7ae8d..aa3ed87 100644 --- a/scripts/install-latest.ps1 +++ b/scripts/install-latest.ps1 @@ -1,32 +1,34 @@ <# .SYNOPSIS - Downloads and silently installs the latest backend-specific Oscan release - for Windows x86_64. + Downloads and installs a coexistence-safe Oscan package profile for + Windows x86_64. .DESCRIPTION - Every published Oscan artifact is one (target, backend) package. For - Windows x86_64 the release publishes exactly three archives — - oscan-vX.Y.Z-windows-x86_64-llvm.zip, ...-cranelift.zip and ...-c.zip — - plus one installer, the recommended LLVM MSI - (oscan-vX.Y.Z-windows-x86_64-llvm.msi). No combined all-backends package - is published. + Windows publishes a full package containing all backends and three slim + packages (LLVM, Cranelift, and C). Archive installs use profile-specific + roots and qualified commands, so installing one profile does not remove + another. The unqualified oscan command is created when no selector exists + and changes only when -SetDefault is supplied. This script queries the GitHub Releases API for lucabol/Oscan, picks the asset whose name is exactly the one the release contract derives from the - resolved tag and the requested backend, verifies its SHA-256 against the + resolved tag and the requested profile, verifies its SHA-256 against the release's SHA256SUMS file, and installs it without prompts. No fuzzy, suffix or version-drift matching is performed: if the exact name is not published, the script fails with the list of assets that are. - LLVM is the recommended backend and the default here. The zip flow is the - default install mode because it is per-user and does not require - administrator privileges; -Mode msi installs the recommended LLVM MSI - through msiexec /quiet instead (which may require elevation). + The slim LLVM package remains the transition default. Use -Profile full + for one compiler with --backend llvm|cranelift|c. The zip flow is the + default because it supports profile coexistence without administrator + privileges. The legacy LLVM MSI remains the only MSI and is intentionally + a separate, single-product install surface. + +.PARAMETER Profile + 'slim' (default) selects the package named by -Backend. 'full' installs + one compiler containing LLVM, Cranelift, and C. .PARAMETER Backend - 'llvm' (default, recommended), 'cranelift', or 'c'. The backend selects - the package: an llvm/cranelift package emits object code directly and - carries no C compiler; the c package carries the pinned C toolchain. + For -Profile slim: 'llvm' (default, recommended), 'cranelift', or 'c'. 'native' is accepted as the compiler's deprecated CLI alias spelling and resolves to the cranelift package; it is never an artifact name. @@ -42,7 +44,12 @@ published release. .PARAMETER InstallDir - Forwarded to install.ps1 when installing a zip. Ignored for MSI. + Root under which profile-specific payloads and the stable bin directory + are installed. Forwarded to install.ps1 for zip installs. Ignored for MSI. + +.PARAMETER SetDefault + Select the installed archive profile for the unqualified oscan command. + Without this switch, an existing selection is preserved. .PARAMETER NoPathUpdate Forwarded to install.ps1 when installing a zip. Ignored for MSI. @@ -56,13 +63,15 @@ iwr -useb https://raw.githubusercontent.com/lucabol/Oscan/master/scripts/install-latest.ps1 | iex .EXAMPLE - .\install-latest.ps1 -Backend cranelift + .\install-latest.ps1 -Profile full -SetDefault .EXAMPLE - .\install-latest.ps1 -Backend llvm -Mode msi + .\install-latest.ps1 -Profile slim -Backend cranelift #> [CmdletBinding()] param( + [ValidateSet('slim', 'full')] + [string]$Profile = 'slim', [ValidateSet('llvm', 'cranelift', 'c', 'native')] [string]$Backend = 'llvm', [ValidateSet('zip', 'msi')] @@ -70,8 +79,10 @@ param( [string]$Version, [string]$InstallDir, [string]$BinDir, + [switch]$SetDefault, [switch]$NoPathUpdate, - [switch]$SkipChecksum + [switch]$SkipChecksum, + [switch]$AllowMsiCommandConflict ) $ErrorActionPreference = 'Stop' @@ -80,10 +91,10 @@ $ProgressPreference = 'SilentlyContinue' $Repo = 'lucabol/Oscan' $ApiBase = "https://api.github.com/repos/$Repo/releases" -# The one Windows target this installer serves, and the only backend that +# The one Windows target this installer serves, and the only profile that # publishes an installer. $InstallerTarget = 'windows-x86_64' -$MsiBackend = 'llvm' +$MsiProfile = 'llvm' # Force TLS 1.2 for older PowerShell hosts try { @@ -110,27 +121,39 @@ function Resolve-OscanBackend { return $normalized } +function Resolve-OscanPackageProfile { + param( + [Parameter(Mandatory)][ValidateSet('slim', 'full')][string]$Profile, + [Parameter(Mandatory)][string]$Backend + ) + + if ($Profile -eq 'full') { + return 'full' + } + return Resolve-OscanBackend $Backend +} + function Get-OscanAssetName { <# - The exact canonical asset name for a (tag, backend, kind) triple, as + The exact canonical asset name for a (tag, profile, kind) triple, as rendered by packaging/toolchains/release-contract.json. Nothing here globs: an installer must never resolve to another backend's package. #> param( [Parameter(Mandatory)][string]$Tag, - [Parameter(Mandatory)][ValidateSet('llvm', 'cranelift', 'c')][string]$Backend, + [Parameter(Mandatory)][ValidateSet('full', 'llvm', 'cranelift', 'c')][string]$Profile, [Parameter(Mandatory)][ValidateSet('zip', 'msi')][string]$Kind ) - if ($Kind -eq 'msi' -and $Backend -ne $MsiBackend) { - throw "Only the recommended $MsiBackend package is published as an MSI; the $Backend package ships as a zip archive." + if ($Kind -eq 'msi' -and $Profile -ne $MsiProfile) { + throw "Only the recommended $MsiProfile profile is published as an MSI; the $Profile profile ships as a zip archive." } - return "oscan-$Tag-$InstallerTarget-$Backend.$Kind" + return "oscan-$Tag-$InstallerTarget-$Profile.$Kind" } function Select-OscanReleaseAsset { <# - Pick the exact canonical asset for this backend out of a release's + Pick the exact canonical asset for this profile out of a release's asset list. * Matching is by exact, contract/tag-derived name only. A release @@ -147,19 +170,19 @@ function Select-OscanReleaseAsset { param( [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Assets, [Parameter(Mandatory)][string]$Tag, - [Parameter(Mandatory)][ValidateSet('llvm', 'cranelift', 'c')][string]$Backend, + [Parameter(Mandatory)][ValidateSet('full', 'llvm', 'cranelift', 'c')][string]$Profile, [Parameter(Mandatory)][ValidateSet('zip', 'msi')][string]$Mode ) - if ($Mode -eq 'msi' -and $Backend -ne $MsiBackend) { - throw "-Mode msi is only available for the recommended $MsiBackend package. Re-run with '-Backend $MsiBackend -Mode msi', or install the $Backend package with '-Backend $Backend' (zip)." + if ($Mode -eq 'msi' -and $Profile -ne $MsiProfile) { + throw "-Mode msi is only available for the recommended $MsiProfile profile. Install '$Profile' with -Mode zip." } $wanted = @() if ($Mode -eq 'msi') { - $wanted += [PSCustomObject]@{ Kind = 'msi'; Name = (Get-OscanAssetName -Tag $Tag -Backend $Backend -Kind 'msi') } + $wanted += [PSCustomObject]@{ Kind = 'msi'; Name = (Get-OscanAssetName -Tag $Tag -Profile $Profile -Kind 'msi') } } - $wanted += [PSCustomObject]@{ Kind = 'zip'; Name = (Get-OscanAssetName -Tag $Tag -Backend $Backend -Kind 'zip') } + $wanted += [PSCustomObject]@{ Kind = 'zip'; Name = (Get-OscanAssetName -Tag $Tag -Profile $Profile -Kind 'zip') } foreach ($candidate in $wanted) { $match = @($Assets | Where-Object { $_.name -ieq $candidate.Name }) @@ -168,7 +191,7 @@ function Select-OscanReleaseAsset { } if ($match.Count -eq 1) { if ($candidate.Kind -ne $Mode) { - Write-Warning "Release $Tag publishes no $Backend MSI; installing $($match[0].name) instead." + Write-Warning "Release $Tag publishes no $Profile MSI; installing $($match[0].name) instead." } return [PSCustomObject]@{ Asset = $match[0]; Kind = $candidate.Kind } } @@ -176,7 +199,7 @@ function Select-OscanReleaseAsset { $published = @($Assets | Where-Object { $_.name } | ForEach-Object { $_.name } | Sort-Object) $expected = ($wanted | ForEach-Object { $_.Name }) -join ' or ' - throw "Release $Tag publishes no $expected. Asset names are derived from the release tag, and only that exact name is accepted. Windows releases ship one archive per backend (llvm, cranelift, c) and one recommended llvm MSI; there is no combined package. Published assets: $(if ($published) { $published -join ', ' } else { '(none)' })." + throw "Release $Tag publishes no $expected. Asset names are derived from the release tag, and only that exact name is accepted. Windows releases ship full, llvm, cranelift, and c archives plus one recommended llvm MSI. Published assets: $(if ($published) { $published -join ', ' } else { '(none)' })." } function Get-OscanExpectedChecksum { @@ -194,6 +217,43 @@ function Get-OscanExpectedChecksum { return $null } +function Assert-OscanArchiveBundle { + param( + [Parameter(Mandatory)][string]$BundleDir, + [Parameter(Mandatory)][ValidateSet('full', 'llvm', 'cranelift', 'c')][string]$ExpectedProfile + ) + + $metadataPath = Join-Path $BundleDir 'oscan-package.json' + if (-not (Test-Path -LiteralPath $metadataPath -PathType Leaf)) { + throw "The downloaded archive has no profile-aware oscan-package.json. Legacy archives contain a destructive flat installer and cannot be installed over coexistence-safe profiles; extract it as a portable bundle or choose a schema-3 release." + } + try { + $metadata = Get-Content -LiteralPath $metadataPath -Raw | ConvertFrom-Json + } catch { + throw "The downloaded archive has unreadable oscan-package.json: $($_.Exception.Message)" + } + if ($metadata.schema_version -ne 2 -or + $metadata.target -ne $InstallerTarget -or + $metadata.profile -ne $ExpectedProfile -or + $metadata.package_id -ne "oscan-$ExpectedProfile" -or + $metadata.is_distribution -ne $true) { + throw "The downloaded archive metadata does not identify schema-2 package 'oscan-$ExpectedProfile' for '$InstallerTarget'; refusing to run its installer." + } + $expectedBackends = if ($ExpectedProfile -eq 'full') { + @('llvm', 'cranelift', 'c') + } else { + @($ExpectedProfile) + } + if ((@($metadata.available_backends) -join ',') -ne ($expectedBackends -join ',')) { + throw "The downloaded archive's backend inventory does not match profile '$ExpectedProfile'." + } + $expectedDefault = if ($ExpectedProfile -eq 'full') { 'llvm' } else { $ExpectedProfile } + if ($metadata.default_backend -ne $expectedDefault) { + throw "The downloaded archive's default backend does not match profile '$ExpectedProfile'." + } + return $metadata +} + function Invoke-GitHubApi { param([Parameter(Mandatory)][string]$Url) $headers = @{ @@ -201,23 +261,29 @@ function Invoke-GitHubApi { 'Accept' = 'application/vnd.github+json' } if ($env:GITHUB_TOKEN) { - $headers['Authorization'] = "******" + $headers['Authorization'] = "Bearer $env:GITHUB_TOKEN" } Invoke-RestMethod -Uri $Url -Headers $headers } function Invoke-OscanInstallLatest { param( + [string]$Profile = 'slim', [string]$Backend = 'llvm', [string]$Mode = 'zip', [string]$Version, [string]$InstallDir, [string]$BinDir, + [switch]$SetDefault, [switch]$NoPathUpdate, - [switch]$SkipChecksum + [switch]$SkipChecksum, + [switch]$AllowMsiCommandConflict ) - $resolvedBackend = Resolve-OscanBackend $Backend + $packageProfile = Resolve-OscanPackageProfile -Profile $Profile -Backend $Backend + if ($Mode -eq 'msi' -and $SetDefault) { + throw "-SetDefault applies to coexistence-safe zip profiles. The legacy LLVM MSI owns its own unqualified PATH command." + } if ($Version) { $tag = if ($Version.StartsWith('v')) { $Version } else { "v$Version" } @@ -234,10 +300,10 @@ function Invoke-OscanInstallLatest { $selection = Select-OscanReleaseAsset ` -Assets @($release.assets) ` -Tag $tagName ` - -Backend $resolvedBackend ` + -Profile $packageProfile ` -Mode $Mode $asset = $selection.Asset - Write-Host "Selected the $resolvedBackend package: $($asset.name)" + Write-Host "Selected the $packageProfile profile: $($asset.name)" $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("oscan-install-" + [guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null @@ -271,31 +337,57 @@ function Invoke-OscanInstallLatest { } if ($selection.Kind -eq 'msi') { + $archiveRoot = if ($InstallDir) { + [System.IO.Path]::GetFullPath($InstallDir) + } else { + Join-Path $env:LOCALAPPDATA 'Programs\Oscan' + } + $archiveSelector = Join-Path $archiveRoot 'default-profile' + $archiveCommand = Join-Path (Join-Path $archiveRoot 'bin') 'oscan.cmd' + $legacyArchiveCommand = Join-Path $archiveRoot 'oscan.exe' + $archiveMarker = Join-Path $env:LOCALAPPDATA 'Programs\Oscan\archive-default' + if (-not $AllowMsiCommandConflict -and + ((Test-Path -LiteralPath $archiveSelector) -or + (Test-Path -LiteralPath $archiveCommand) -or + (Test-Path -LiteralPath $legacyArchiveCommand) -or + (Test-Path -LiteralPath $archiveMarker))) { + throw "A per-user archive command is installed under '$archiveRoot'. The legacy LLVM MSI also exports an unqualified oscan command, so PATH order would be ambiguous. Use the zip profile, remove the archive selector/legacy install first, or pass -AllowMsiCommandConflict to acknowledge the collision." + } Write-Host "Installing $($asset.name) silently via msiexec..." $logPath = Join-Path $tempRoot 'oscan-msi.log' + $msiArgs = @('/i', "`"$assetPath`"", '/quiet', '/norestart', '/l*v', "`"$logPath`"") + if ($AllowMsiCommandConflict) { + $msiArgs += 'OSCAN_ALLOW_ARCHIVE_CONFLICT=1' + } $proc = Start-Process -FilePath 'msiexec.exe' ` - -ArgumentList @('/i', "`"$assetPath`"", '/quiet', '/norestart', '/l*v', "`"$logPath`"") ` + -ArgumentList $msiArgs ` -Wait -PassThru if ($proc.ExitCode -ne 0) { throw "msiexec exited with code $($proc.ExitCode). See log: $logPath" } - Write-Host "Installed Oscan $tagName ($resolvedBackend) via MSI." + Write-Host "Installed Oscan $tagName ($packageProfile) via MSI." } else { $extractDir = Join-Path $tempRoot 'extract' Write-Host "Extracting $($asset.name)..." Expand-Archive -Path $assetPath -DestinationPath $extractDir -Force - $bundle = Get-ChildItem -Path $extractDir -Directory | Select-Object -First 1 - if (-not $bundle) { - throw "Extracted archive does not contain a bundle directory." + $bundles = @(Get-ChildItem -Path $extractDir -Directory) + if ($bundles.Count -ne 1) { + throw "Extracted archive must contain exactly one bundle directory; found $($bundles.Count)." } + $bundle = $bundles[0] + Assert-OscanArchiveBundle -BundleDir $bundle.FullName -ExpectedProfile $packageProfile | Out-Null $installScript = Join-Path $bundle.FullName 'install.ps1' if (-not (Test-Path $installScript)) { throw "install.ps1 not found in extracted bundle: $($bundle.FullName)" } $installArgs = @{} - if ($InstallDir) { $installArgs['InstallDir'] = $InstallDir } + if ($InstallDir) { $installArgs['InstallRoot'] = $InstallDir } if ($BinDir) { $installArgs['BinDir'] = $BinDir } + if ($SetDefault) { $installArgs['SetDefault'] = $true } if ($NoPathUpdate) { $installArgs['NoPathUpdate'] = $true } + if ($AllowMsiCommandConflict) { + $installArgs['AllowMsiCommandConflict'] = $true + } Write-Host "Running bundled installer..." & $installScript @installArgs } @@ -304,9 +396,11 @@ function Invoke-OscanInstallLatest { Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $tempRoot } - Write-Host "Installed the $resolvedBackend package. Open a new terminal and run: oscan --help" - if ($resolvedBackend -ne 'c') { - Write-Host "This package builds with --backend $resolvedBackend only; --backend c, --emit-c, -o *.c, --libc and --extra-c are refused by design." + Write-Host "Installed the $packageProfile profile. Qualified command: oscan-$packageProfile --help" + if ($packageProfile -eq 'full') { + Write-Host "Select LLVM, Cranelift, or C with --backend." + } elseif ($packageProfile -ne 'c') { + Write-Host "This slim profile builds with --backend $packageProfile only; C-specific modes are refused by design." } } @@ -314,11 +408,14 @@ function Invoke-OscanInstallLatest { # above without installing anything, so they can be unit-tested offline. if ($MyInvocation.InvocationName -ne '.') { Invoke-OscanInstallLatest ` + -Profile $Profile ` -Backend $Backend ` -Mode $Mode ` -Version $Version ` -InstallDir $InstallDir ` -BinDir $BinDir ` + -SetDefault:$SetDefault ` -NoPathUpdate:$NoPathUpdate ` - -SkipChecksum:$SkipChecksum + -SkipChecksum:$SkipChecksum ` + -AllowMsiCommandConflict:$AllowMsiCommandConflict } diff --git a/scripts/install-oscan.ps1 b/scripts/install-oscan.ps1 index 162fca7..95535a2 100644 --- a/scripts/install-oscan.ps1 +++ b/scripts/install-oscan.ps1 @@ -1,79 +1,778 @@ [CmdletBinding()] param( [string]$SourceDir = $PSScriptRoot, - [string]$InstallDir = (Join-Path $env:LOCALAPPDATA "Programs\oscan"), + [Alias("InstallDir")] + [string]$InstallRoot = (Join-Path $env:LOCALAPPDATA "Programs\Oscan"), [string]$BinDir, - [switch]$NoPathUpdate + [ValidateSet("full", "llvm", "cranelift", "c", "dev")] + [string]$Profile, + [switch]$SetDefault, + [switch]$Uninstall, + [switch]$NoPathUpdate, + [switch]$AllowMsiCommandConflict ) $ErrorActionPreference = "Stop" $ProgressPreference = "SilentlyContinue" function Add-UserPathEntry { - param([Parameter(Mandatory = $true)][string]$Entry) + param( + [Parameter(Mandatory = $true)][string]$Entry, + [string]$LegacyEntry + ) $current = [Environment]::GetEnvironmentVariable("Path", "User") - $parts = @() - if ($current) { - $parts = $current.Split(';', [System.StringSplitOptions]::RemoveEmptyEntries) + $parts = if ($current) { + @($current.Split(';', [System.StringSplitOptions]::RemoveEmptyEntries)) + } else { + @() } - if ($parts -contains $Entry) { - return + $filtered = @($parts | Where-Object { + $_ -ne $Entry -and (-not $LegacyEntry -or $_ -ne $LegacyEntry) + }) + [Environment]::SetEnvironmentVariable("Path", (@($Entry) + $filtered -join ';'), "User") + + $processParts = @($env:Path.Split(';', [System.StringSplitOptions]::RemoveEmptyEntries) | + Where-Object { $_ -ne $Entry -and (-not $LegacyEntry -or $_ -ne $LegacyEntry) }) + $env:Path = @($Entry) + $processParts -join ';' +} + +function Write-AtomicText { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Content + ) + + $parent = Split-Path -Parent $Path + New-Item -ItemType Directory -Path $parent -Force | Out-Null + $temporary = Join-Path $parent (".tmp-" + [guid]::NewGuid().ToString("N")) + try { + Set-Content -LiteralPath $temporary -Value $Content -Encoding ASCII -NoNewline + Move-Item -LiteralPath $temporary -Destination $Path -Force + } finally { + Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue } - $updated = @($parts + $Entry) -join ';' - [Environment]::SetEnvironmentVariable("Path", $updated, "User") - if (-not ($env:Path.Split(';', [System.StringSplitOptions]::RemoveEmptyEntries) -contains $Entry)) { - $env:Path = "$env:Path;$Entry" +} + +function Write-ProfileShim { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$RelativeExecutable + ) + + Write-AtomicText -Path $Path -Content "@echo off`r`n`"%~dp0$RelativeExecutable`" %*`r`n" +} + +function Write-DefaultShim { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$SelectedProfile + ) + + Write-AtomicText -Path $Path ` + -Content "@echo off`r`ncall `"%~dp0oscan-$SelectedProfile.cmd`" %*`r`n" +} + +function Restore-AtomicFile { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][bool]$Existed, + [AllowEmptyString()][string]$Content + ) + + if ($Existed) { + Write-AtomicText -Path $Path -Content $Content + } else { + Remove-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue + } +} + +function Ensure-ProfileJunction { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Target + ) + + if (Test-Path -LiteralPath $Path) { + $item = Get-Item -LiteralPath $Path -Force + if (-not $item.PSIsContainer -or + -not ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + throw "Profile-link path '$Path' already exists and is not a managed directory junction." + } + $existingTarget = [string]@($item.Target)[0] + if (-not [System.IO.Path]::IsPathRooted($existingTarget)) { + $existingTarget = Join-Path (Split-Path -Parent $Path) $existingTarget + } + if (-not [string]::Equals( + [System.IO.Path]::GetFullPath($existingTarget).TrimEnd('\'), + [System.IO.Path]::GetFullPath($Target).TrimEnd('\'), + [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Profile-link junction '$Path' points somewhere other than '$Target'." + } + return $false + } + + New-Item -ItemType Junction -Path $Path -Target $Target -ErrorAction Stop | Out-Null + return $true +} + +function Test-JunctionPointsTo { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Target + ) + + if (-not (Test-Path -LiteralPath $Path)) { + return $false + } + $item = Get-Item -LiteralPath $Path -Force + if (-not $item.PSIsContainer -or + -not ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + return $false + } + $existingTarget = [string]@($item.Target)[0] + if (-not [System.IO.Path]::IsPathRooted($existingTarget)) { + $existingTarget = Join-Path (Split-Path -Parent $Path) $existingTarget + } + return [string]::Equals( + [System.IO.Path]::GetFullPath($existingTarget).TrimEnd('\'), + [System.IO.Path]::GetFullPath($Target).TrimEnd('\'), + [System.StringComparison]::OrdinalIgnoreCase + ) +} + +function Test-LegacyProfileShimOwnedByRoot { + param( + [Parameter(Mandatory = $true)][string]$BinDir, + [Parameter(Mandatory = $true)][string]$InstallRoot, + [Parameter(Mandatory = $true)][string]$ProfilesRoot, + [Parameter(Mandatory = $true)][string]$Profile + ) + + $shim = Join-Path $BinDir "oscan-$Profile.cmd" + if (-not (Test-Path -LiteralPath $shim -PathType Leaf)) { + return $false + } + $content = Get-Content -LiteralPath $shim -Raw + $defaultBinDir = [System.IO.Path]::GetFullPath( + (Join-Path $InstallRoot "bin") + ).TrimEnd('\') + if ([string]::Equals( + $BinDir.TrimEnd('\'), + $defaultBinDir, + [System.StringComparison]::OrdinalIgnoreCase)) { + return $content.Contains("%~dp0..\profiles\$Profile\") + } + + $profileJunction = Join-Path $BinDir ".oscan-profiles" + return (Test-JunctionPointsTo -Path $profileJunction -Target $ProfilesRoot) -and + $content.Contains("%~dp0.oscan-profiles\$Profile\") +} + +function Get-SelectorOwnership { + param( + [Parameter(Mandatory = $true)][string]$CommandPath, + [Parameter(Mandatory = $true)][string]$OwnerPath, + [Parameter(Mandatory = $true)][string]$OwnerId, + [Parameter(Mandatory = $true)][bool]$LegacyOwned + ) + + if (Test-Path -LiteralPath $OwnerPath) { + $recordedOwner = (Get-Content -LiteralPath $OwnerPath -Raw).Trim() + if ($recordedOwner -eq $OwnerId) { + return "Current" + } + return "Foreign" + } + if (Test-Path -LiteralPath $CommandPath) { + if ($LegacyOwned) { + return "Current" + } + return "Unmanaged" + } + return "Absent" +} + +function Copy-InstallTree { + param( + [Parameter(Mandatory = $true)][string]$Source, + [Parameter(Mandatory = $true)][string]$Destination + ) + + New-Item -ItemType Directory -Path $Destination -Force | Out-Null + $robocopy = Get-Command robocopy.exe -ErrorAction SilentlyContinue + if ($robocopy) { + & $robocopy.Source $Source $Destination /MIR /NFL /NDL /NJH /NJS /NP | Out-Null + if ($LASTEXITCODE -gt 7) { + throw "robocopy failed while staging the install (exit code $LASTEXITCODE)." + } + } else { + Copy-Item (Join-Path $Source "*") -Destination $Destination -Recurse -Force + } +} + +function Test-OscanMsiInstalled { + $installer = New-Object -ComObject WindowsInstaller.Installer + try { + $products = @( + $installer.RelatedProducts("{F7A3B2C1-4D5E-6F78-9A0B-1C2D3E4F5A6B}") + ) + return $products.Count -gt 0 + } finally { + [void][System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($installer) + } +} + +function Enter-OscanInstallLock { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Name + ) + + New-Item -ItemType Directory -Path $Root -Force | Out-Null + $path = Join-Path $Root $Name + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while ($true) { + try { + return [System.IO.File]::Open( + $path, + [System.IO.FileMode]::OpenOrCreate, + [System.IO.FileAccess]::ReadWrite, + [System.IO.FileShare]::None + ) + } catch [System.IO.IOException] { + if ([DateTime]::UtcNow -ge $deadline) { + throw "Timed out waiting for another Oscan install or uninstall to finish at '$Root'." + } + Start-Sleep -Milliseconds 100 + } } } $SourceDir = [System.IO.Path]::GetFullPath($SourceDir) -$InstallDir = [System.IO.Path]::GetFullPath($InstallDir) -if (-not (Test-Path (Join-Path $SourceDir "oscan.exe"))) { - throw "Source bundle must contain oscan.exe" +$resolvedInstallRoot = [System.IO.Path]::GetFullPath($InstallRoot) +if ([System.IO.Path]::GetPathRoot($resolvedInstallRoot) -eq $resolvedInstallRoot) { + throw "Refusing to use a drive root as the Oscan install root: $InstallRoot" +} +$InstallRoot = $resolvedInstallRoot.TrimEnd([char[]]"\/") +if (-not $BinDir) { + $BinDir = Join-Path $InstallRoot "bin" +} +$resolvedBinDir = [System.IO.Path]::GetFullPath($BinDir) +$BinDir = if ([System.IO.Path]::GetPathRoot($resolvedBinDir) -eq $resolvedBinDir) { + $resolvedBinDir +} else { + $resolvedBinDir.TrimEnd([char[]]"\/") +} + +$installLockPath = Join-Path $InstallRoot ".install.lock" +$selectorLockPath = Join-Path $BinDir ".oscan-selectors.lock" +$installLock = Enter-OscanInstallLock -Root $InstallRoot -Name ".install.lock" +$selectorLock = $null +try { +$selectorLock = Enter-OscanInstallLock -Root $BinDir -Name ".oscan-selectors.lock" +$metadataPath = Join-Path $SourceDir "oscan-package.json" +$metadata = $null +if (Test-Path -LiteralPath $metadataPath) { + $metadata = Get-Content -LiteralPath $metadataPath -Raw | ConvertFrom-Json + if ($metadata.schema_version -ne 2) { + throw "Unsupported oscan-package.json schema '$($metadata.schema_version)'; expected 2." + } + if ($metadata.profile -notin @("full", "llvm", "cranelift", "c")) { + throw "Package metadata contains unknown profile '$($metadata.profile)'." + } + if ($Profile -and $Profile -ne $metadata.profile) { + throw "Requested profile '$Profile' does not match package profile '$($metadata.profile)'." + } + $Profile = [string]$metadata.profile + $version = [string]$metadata.version + if (-not $version) { + throw "Package metadata does not declare a version." + } + if ($metadata.package_id -ne "oscan-$Profile") { + throw "Package metadata package_id '$($metadata.package_id)' does not match profile '$Profile'." + } + if ($metadata.target -ne "windows-x86_64") { + throw "Package metadata target '$($metadata.target)' cannot be installed by the Windows x86_64 installer." + } + if ($metadata.is_distribution -ne $true) { + throw "Package metadata must identify a packaged distribution." + } + $expectedBackends = if ($Profile -eq "full") { + @("llvm", "cranelift", "c") + } else { + @($Profile) + } + $actualBackends = @($metadata.available_backends) + if (($actualBackends -join ",") -ne ($expectedBackends -join ",")) { + throw "Package metadata backends '$($actualBackends -join ", ")' do not match profile '$Profile'." + } + $expectedDefault = if ($Profile -eq "full") { "llvm" } else { $Profile } + if ($metadata.default_backend -ne $expectedDefault) { + throw "Package metadata default backend '$($metadata.default_backend)' does not match profile '$Profile' (expected '$expectedDefault')." + } + $expectedCompilerDigest = [string]$metadata.component_digests.'oscan.exe' + if ($expectedCompilerDigest -notmatch '^[0-9a-fA-F]{64}$') { + throw "Package metadata does not declare a valid SHA-256 digest for oscan.exe." + } +} elseif ($Uninstall -and $Profile) { + $version = $null +} elseif ($Profile -eq "dev") { + $version = "current" + $expectedCompilerDigest = $null +} else { + throw "Source bundle must contain schema-2 oscan-package.json; development installs must pass -Profile dev." } -if ([System.IO.Path]::GetPathRoot($InstallDir) -eq $InstallDir) { - throw "Refusing to install into a drive root: $InstallDir" +if (-not $Uninstall) { + if ($version -notmatch '^[A-Za-z0-9][A-Za-z0-9._+-]*$') { + throw "Package metadata contains unsafe version '$version'." + } + if (-not (Test-Path -LiteralPath (Join-Path $SourceDir "oscan.exe"))) { + throw "Source bundle must contain oscan.exe." + } + if ($expectedCompilerDigest) { + $actualCompilerDigest = (Get-FileHash -LiteralPath (Join-Path $SourceDir "oscan.exe") -Algorithm SHA256).Hash + if ($actualCompilerDigest -ne $expectedCompilerDigest) { + throw "oscan.exe digest mismatch: package metadata has $expectedCompilerDigest, actual is $actualCompilerDigest." + } + } } -if (Test-Path $InstallDir) { - Remove-Item $InstallDir -Recurse -Force +$profilesRoot = Join-Path $InstallRoot "profiles" +$profileRoot = Join-Path $profilesRoot $Profile +$qualifiedShim = Join-Path $BinDir "oscan-$Profile.cmd" +$defaultShim = Join-Path $BinDir "oscan.cmd" +$defaultState = Join-Path $InstallRoot "default-profile" +$archiveMarkerBase = Join-Path $env:LOCALAPPDATA "Programs\Oscan" +$archiveDefaultMarker = Join-Path $archiveMarkerBase "archive-default" +$archiveOwnerRoot = Join-Path $archiveMarkerBase "archive-defaults" +$archiveOwnerBytes = [System.Text.Encoding]::UTF8.GetBytes( + "$($InstallRoot.ToUpperInvariant())`n$($BinDir.ToUpperInvariant())" +) +$archiveOwnerHash = [System.Security.Cryptography.SHA256]::Create() +try { + $archiveOwnerName = -join ( + $archiveOwnerHash.ComputeHash($archiveOwnerBytes) | + ForEach-Object { $_.ToString("x2") } + ) +} finally { + $archiveOwnerHash.Dispose() +} +$archiveOwnerMarker = Join-Path $archiveOwnerRoot $archiveOwnerName +$selectorOwnerId = $archiveOwnerName +$qualifiedOwner = Join-Path $BinDir ".oscan-$Profile.owner" +$defaultOwner = Join-Path $BinDir ".oscan-default.owner" +$qualifiedLegacyOwned = Test-LegacyProfileShimOwnedByRoot ` + -BinDir $BinDir ` + -InstallRoot $InstallRoot ` + -ProfilesRoot $profilesRoot ` + -Profile $Profile +$qualifiedOwnership = Get-SelectorOwnership ` + -CommandPath $qualifiedShim ` + -OwnerPath $qualifiedOwner ` + -OwnerId $selectorOwnerId ` + -LegacyOwned $qualifiedLegacyOwned +$selected = if (Test-Path -LiteralPath $defaultState) { + (Get-Content -LiteralPath $defaultState -Raw).Trim() +} else { + "" } -New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null +if ($selected -and $selected -notin @("full", "llvm", "cranelift", "c", "dev")) { + throw "Default-profile state contains unknown profile '$selected'." +} +$selectedQualifiedOwnership = "Absent" +if ($selected) { + $selectedQualifiedShim = Join-Path $BinDir "oscan-$selected.cmd" + $selectedQualifiedOwner = Join-Path $BinDir ".oscan-$selected.owner" + $selectedLegacyOwned = Test-LegacyProfileShimOwnedByRoot ` + -BinDir $BinDir ` + -InstallRoot $InstallRoot ` + -ProfilesRoot $profilesRoot ` + -Profile $selected + $selectedQualifiedOwnership = Get-SelectorOwnership ` + -CommandPath $selectedQualifiedShim ` + -OwnerPath $selectedQualifiedOwner ` + -OwnerId $selectorOwnerId ` + -LegacyOwned $selectedLegacyOwned +} +$defaultLegacyOwned = $false +if ($selected -and + $selectedQualifiedOwnership -eq "Current" -and + (Test-Path -LiteralPath $defaultShim -PathType Leaf)) { + $defaultLegacyOwned = (Get-Content -LiteralPath $defaultShim -Raw). + Contains("oscan-$selected.cmd") +} +$defaultOwnership = Get-SelectorOwnership ` + -CommandPath $defaultShim ` + -OwnerPath $defaultOwner ` + -OwnerId $selectorOwnerId ` + -LegacyOwned $defaultLegacyOwned + +if ($Uninstall) { + $uninstallBackup = Join-Path $profilesRoot ( + ".$Profile-uninstall-" + [guid]::NewGuid().ToString("N") + ) + $payloadStagedForRemoval = $false + $qualifiedShimRemoved = $false + $qualifiedOwnerRemoved = $false + $defaultShimRemoved = $false + $defaultOwnerRemoved = $false + $stateRemoved = $false + $ownerMarkerRemoved = $false + $defaultMarkerRemoved = $false + $uninstallCommitted = $false -$robocopy = Get-Command robocopy.exe -ErrorAction SilentlyContinue -if ($robocopy) { - & $robocopy.Source $SourceDir $InstallDir /MIR /NFL /NDL /NJH /NJS /NP | Out-Null - if ($LASTEXITCODE -gt 7) { - throw "robocopy failed while installing to $InstallDir (exit code $LASTEXITCODE)." + $qualifiedExisted = Test-Path -LiteralPath $qualifiedShim -PathType Leaf + $qualifiedContent = if ($qualifiedExisted) { + Get-Content -LiteralPath $qualifiedShim -Raw + } else { "" } + $qualifiedOwnerExisted = Test-Path -LiteralPath $qualifiedOwner -PathType Leaf + $qualifiedOwnerContent = if ($qualifiedOwnerExisted) { + Get-Content -LiteralPath $qualifiedOwner -Raw + } else { "" } + $defaultExisted = Test-Path -LiteralPath $defaultShim -PathType Leaf + $defaultContent = if ($defaultExisted) { + Get-Content -LiteralPath $defaultShim -Raw + } else { "" } + $defaultOwnerExisted = Test-Path -LiteralPath $defaultOwner -PathType Leaf + $defaultOwnerContent = if ($defaultOwnerExisted) { + Get-Content -LiteralPath $defaultOwner -Raw + } else { "" } + $stateExisted = Test-Path -LiteralPath $defaultState -PathType Leaf + $stateContent = if ($stateExisted) { + Get-Content -LiteralPath $defaultState -Raw + } else { "" } + $ownerMarkerExisted = Test-Path -LiteralPath $archiveOwnerMarker -PathType Leaf + $ownerMarkerContent = if ($ownerMarkerExisted) { + Get-Content -LiteralPath $archiveOwnerMarker -Raw + } else { "" } + $defaultMarkerExisted = Test-Path -LiteralPath $archiveDefaultMarker -PathType Leaf + $defaultMarkerContent = if ($defaultMarkerExisted) { + Get-Content -LiteralPath $archiveDefaultMarker -Raw + } else { "" } + + try { + if (Test-Path -LiteralPath $profileRoot) { + Move-Item -LiteralPath $profileRoot -Destination $uninstallBackup -ErrorAction Stop + $payloadStagedForRemoval = $true + } + if ($qualifiedOwnership -eq "Current") { + if ($qualifiedExisted) { + Remove-Item -LiteralPath $qualifiedShim -Force -ErrorAction Stop + $qualifiedShimRemoved = $true + } + if ($qualifiedOwnerExisted) { + Remove-Item -LiteralPath $qualifiedOwner -Force -ErrorAction Stop + $qualifiedOwnerRemoved = $true + } + } + if ($selected -eq $Profile) { + if ($defaultOwnership -eq "Current") { + if ($defaultExisted) { + Remove-Item -LiteralPath $defaultShim -Force -ErrorAction Stop + $defaultShimRemoved = $true + } + if ($defaultOwnerExisted) { + Remove-Item -LiteralPath $defaultOwner -Force -ErrorAction Stop + $defaultOwnerRemoved = $true + } + } + if ($stateExisted) { + Remove-Item -LiteralPath $defaultState -Force -ErrorAction Stop + $stateRemoved = $true + } + if ($ownerMarkerExisted) { + Remove-Item -LiteralPath $archiveOwnerMarker -Force -ErrorAction Stop + $ownerMarkerRemoved = $true + } + $otherArchiveOwners = if (Test-Path -LiteralPath $archiveOwnerRoot) { + @(Get-ChildItem -LiteralPath $archiveOwnerRoot -File -ErrorAction Stop) + } else { + @() + } + if ($otherArchiveOwners.Count -eq 0 -and $defaultMarkerExisted) { + Remove-Item -LiteralPath $archiveDefaultMarker -Force -ErrorAction Stop + $defaultMarkerRemoved = $true + } + } + $uninstallCommitted = $true + } catch { + $uninstallError = $_ + $rollbackErrors = @() + if ($payloadStagedForRemoval -and (Test-Path -LiteralPath $uninstallBackup)) { + try { + Move-Item -LiteralPath $uninstallBackup -Destination $profileRoot -ErrorAction Stop + } catch { + $rollbackErrors += "restore profile payload: $($_.Exception.Message)" + } + } + foreach ($restore in @( + [pscustomobject]@{ Path = $qualifiedShim; Existed = $qualifiedExisted; Content = $qualifiedContent; Changed = $qualifiedShimRemoved }, + [pscustomobject]@{ Path = $qualifiedOwner; Existed = $qualifiedOwnerExisted; Content = $qualifiedOwnerContent; Changed = $qualifiedOwnerRemoved }, + [pscustomobject]@{ Path = $defaultShim; Existed = $defaultExisted; Content = $defaultContent; Changed = $defaultShimRemoved }, + [pscustomobject]@{ Path = $defaultOwner; Existed = $defaultOwnerExisted; Content = $defaultOwnerContent; Changed = $defaultOwnerRemoved }, + [pscustomobject]@{ Path = $defaultState; Existed = $stateExisted; Content = $stateContent; Changed = $stateRemoved }, + [pscustomobject]@{ Path = $archiveOwnerMarker; Existed = $ownerMarkerExisted; Content = $ownerMarkerContent; Changed = $ownerMarkerRemoved }, + [pscustomobject]@{ Path = $archiveDefaultMarker; Existed = $defaultMarkerExisted; Content = $defaultMarkerContent; Changed = $defaultMarkerRemoved } + )) { + if ($restore.Changed) { + try { + Restore-AtomicFile ` + -Path ([string]$restore.Path) ` + -Existed ([bool]$restore.Existed) ` + -Content ([string]$restore.Content) + } catch { + $rollbackErrors += "restore '$($restore.Path)': $($_.Exception.Message)" + } + } + } + if ($rollbackErrors.Count -gt 0) { + throw "Uninstalling profile '$Profile' failed: $($uninstallError.Exception.Message). Rollback also failed: $($rollbackErrors -join '; ')." + } + throw $uninstallError + } + if ($uninstallCommitted -and (Test-Path -LiteralPath $uninstallBackup)) { + try { + Remove-Item -LiteralPath $uninstallBackup -Recurse -Force -ErrorAction Stop + } catch { + Write-Warning "The profile was deactivated, but its staged payload could not be deleted from '$uninstallBackup': $($_.Exception.Message)" + } + } + if ($qualifiedOwnership -in @("Foreign", "Unmanaged")) { + Write-Warning "Preserved '$qualifiedShim' because it is not owned by install root '$InstallRoot'." + } + if ($selected -eq $Profile) { + Write-Warning "Removed the selected '$Profile' profile. The unqualified oscan command now has no default for this install root; reinstall or use -SetDefault with another profile." } + Write-Host "Uninstalled Oscan profile '$Profile'; other profiles were preserved." + return +} + +$selectedBeforeInstall = $selected +if ($qualifiedOwnership -in @("Foreign", "Unmanaged")) { + throw "Refusing to replace qualified command '$qualifiedShim' because it is not owned by install root '$InstallRoot'. Use a different -BinDir or uninstall its owning profile first." +} +if ($selectedBeforeInstall -and $defaultOwnership -notin @("Current")) { + throw "Default-profile state at '$defaultState' does not own the shared selector '$defaultShim'. Repair or remove the stale state before installing into this root." +} +$willSelectProfile = $SetDefault -or + (-not $selectedBeforeInstall -and -not (Test-Path -LiteralPath $defaultShim)) +if ($willSelectProfile -and $defaultOwnership -in @("Foreign", "Unmanaged")) { + throw "Refusing to replace default command '$defaultShim' because it is not owned by install root '$InstallRoot'. Uninstall its owning default or use a different -BinDir." +} +if ($willSelectProfile -and -not $AllowMsiCommandConflict) { + if (Test-OscanMsiInstalled) { + throw "The legacy LLVM MSI is installed and already owns an unqualified oscan command. Installing an archive default would make PATH order ambiguous. Install the qualified profile without -SetDefault after creating an archive selector explicitly, uninstall the MSI, or pass -AllowMsiCommandConflict to acknowledge the collision." + } +} + +$versionDir = Join-Path $profileRoot $version +$staged = Join-Path $profileRoot (".install-$version-" + [guid]::NewGuid().ToString("N")) +$backup = Join-Path $profileRoot (".backup-$version-" + [guid]::NewGuid().ToString("N")) +$qualifiedExisted = Test-Path -LiteralPath $qualifiedShim +$qualifiedContent = if ($qualifiedExisted) { + Get-Content -LiteralPath $qualifiedShim -Raw } else { - Copy-Item (Join-Path $SourceDir "*") -Destination $InstallDir -Recurse -Force + "" } +$qualifiedOwnerExisted = Test-Path -LiteralPath $qualifiedOwner +$qualifiedOwnerContent = if ($qualifiedOwnerExisted) { + Get-Content -LiteralPath $qualifiedOwner -Raw +} else { + "" +} +$defaultExisted = Test-Path -LiteralPath $defaultShim +$defaultContent = if ($defaultExisted) { + Get-Content -LiteralPath $defaultShim -Raw +} else { + "" +} +$defaultOwnerExisted = Test-Path -LiteralPath $defaultOwner +$defaultOwnerContent = if ($defaultOwnerExisted) { + Get-Content -LiteralPath $defaultOwner -Raw +} else { + "" +} +$stateExisted = Test-Path -LiteralPath $defaultState +$stateContent = if ($stateExisted) { + Get-Content -LiteralPath $defaultState -Raw +} else { + "" +} +$markerExisted = Test-Path -LiteralPath $archiveDefaultMarker +$markerContent = if ($markerExisted) { + Get-Content -LiteralPath $archiveDefaultMarker -Raw +} else { + "" +} +$ownerMarkerExisted = Test-Path -LiteralPath $archiveOwnerMarker +$ownerMarkerContent = if ($ownerMarkerExisted) { + Get-Content -LiteralPath $archiveOwnerMarker -Raw +} else { + "" +} +$payloadActivated = $false +$backupCreated = $false +$qualifiedChanged = $false +$defaultChanged = $false +$defaultOwnerChanged = $false +$markerChanged = $false +$profileJunctionCreated = $false +$committed = $false +New-Item -ItemType Directory -Path $profileRoot -Force | Out-Null +try { + Copy-InstallTree -Source $SourceDir -Destination $staged + if (-not (Test-Path -LiteralPath (Join-Path $staged "oscan.exe"))) { + throw "Staged profile is missing oscan.exe." + } + if ($expectedCompilerDigest) { + $stagedDigest = (Get-FileHash -LiteralPath (Join-Path $staged "oscan.exe") -Algorithm SHA256).Hash + if ($stagedDigest -ne $expectedCompilerDigest) { + throw "Staged oscan.exe digest mismatch: package metadata has $expectedCompilerDigest, actual is $stagedDigest." + } + } + if ($metadata -and -not (Test-Path -LiteralPath (Join-Path $staged "oscan-package.json"))) { + throw "Staged profile is missing oscan-package.json." + } + + if (Test-Path -LiteralPath $versionDir) { + $backupCreated = $true + Move-Item -LiteralPath $versionDir -Destination $backup + } + $payloadActivated = $true + Move-Item -LiteralPath $staged -Destination $versionDir -$InstalledExe = Join-Path $InstallDir "oscan.exe" -$PathEntry = $InstallDir -if ($BinDir) { - $BinDir = [System.IO.Path]::GetFullPath($BinDir) New-Item -ItemType Directory -Path $BinDir -Force | Out-Null - $ShimPath = Join-Path $BinDir "oscan.cmd" - $Shim = "@echo off`r`n`"$InstalledExe`" %*`r`n" - Set-Content -Path $ShimPath -Value $Shim -Encoding ASCII -NoNewline - $PathEntry = $BinDir + $defaultBinDir = [System.IO.Path]::GetFullPath((Join-Path $InstallRoot "bin")).TrimEnd('\') + if ([string]::Equals( + $BinDir.TrimEnd('\'), + $defaultBinDir, + [System.StringComparison]::OrdinalIgnoreCase)) { + $relativeExecutable = "..\profiles\$Profile\$version\oscan.exe" + } else { + $profileJunction = Join-Path $BinDir ".oscan-profiles" + $profileJunctionCreated = Ensure-ProfileJunction -Path $profileJunction -Target $profilesRoot + $relativeExecutable = ".oscan-profiles\$Profile\$version\oscan.exe" + } + $qualifiedChanged = $true + Write-ProfileShim -Path $qualifiedShim -RelativeExecutable $relativeExecutable + Write-AtomicText -Path $qualifiedOwner -Content "$selectorOwnerId`r`n" + + $selected = if (Test-Path -LiteralPath $defaultState) { + (Get-Content -LiteralPath $defaultState -Raw).Trim() + } else { + "" + } + $defaultExists = Test-Path -LiteralPath $defaultShim + if ($SetDefault -or (-not $selected -and -not $defaultExists)) { + $defaultChanged = $true + Write-DefaultShim -Path $defaultShim -SelectedProfile $Profile + Write-AtomicText -Path $defaultState -Content "$Profile`r`n" + $selected = $Profile + } + if ($selected -and + (Test-Path -LiteralPath $defaultShim) -and + ($defaultChanged -or $defaultOwnership -eq "Current")) { + $defaultOwnerChanged = $true + Write-AtomicText -Path $defaultOwner -Content "$selectorOwnerId`r`n" + $markerChanged = $true + Write-AtomicText -Path $archiveOwnerMarker -Content "archive-default-owner`r`n" + Write-AtomicText -Path $archiveDefaultMarker -Content "archive-default`r`n" + } + + $committed = $true + Get-ChildItem -LiteralPath $profileRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -ne $versionDir } | + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue +} catch { + $installError = $_ + if ($committed) { + throw $installError + } + $rollbackErrors = @() + if ($payloadActivated -and (Test-Path -LiteralPath $versionDir)) { + try { + Remove-Item -LiteralPath $versionDir -Recurse -Force -ErrorAction Stop + } catch { + $rollbackErrors += "remove new payload: $($_.Exception.Message)" + } + } + if ($backupCreated -and (Test-Path -LiteralPath $backup)) { + try { + Move-Item -LiteralPath $backup -Destination $versionDir -ErrorAction Stop + } catch { + $rollbackErrors += "restore previous payload: $($_.Exception.Message)" + } + } + if ($qualifiedChanged) { + try { + Restore-AtomicFile -Path $qualifiedShim -Existed $qualifiedExisted -Content $qualifiedContent + Restore-AtomicFile -Path $qualifiedOwner -Existed $qualifiedOwnerExisted -Content $qualifiedOwnerContent + } catch { + $rollbackErrors += "restore qualified command: $($_.Exception.Message)" + } + } + if ($defaultChanged) { + try { + Restore-AtomicFile -Path $defaultShim -Existed $defaultExisted -Content $defaultContent + Restore-AtomicFile -Path $defaultState -Existed $stateExisted -Content $stateContent + } catch { + $rollbackErrors += "restore default selector: $($_.Exception.Message)" + } + } + if ($defaultOwnerChanged) { + try { + Restore-AtomicFile -Path $defaultOwner -Existed $defaultOwnerExisted -Content $defaultOwnerContent + } catch { + $rollbackErrors += "restore default selector ownership: $($_.Exception.Message)" + } + } + if ($markerChanged) { + try { + Restore-AtomicFile -Path $archiveOwnerMarker -Existed $ownerMarkerExisted -Content $ownerMarkerContent + Restore-AtomicFile -Path $archiveDefaultMarker -Existed $markerExisted -Content $markerContent + } catch { + $rollbackErrors += "restore MSI-conflict marker: $($_.Exception.Message)" + } + } + if ($profileJunctionCreated -and (Test-Path -LiteralPath $profileJunction)) { + try { + Remove-Item -LiteralPath $profileJunction -Force -ErrorAction Stop + } catch { + $rollbackErrors += "remove profile-link junction: $($_.Exception.Message)" + } + } + if ($rollbackErrors.Count -gt 0) { + throw "Installing profile '$Profile' failed: $($installError.Exception.Message). Rollback also failed: $($rollbackErrors -join '; ')." + } + throw $installError +} finally { + Remove-Item -LiteralPath $staged -Recurse -Force -ErrorAction SilentlyContinue + if ($committed) { + Remove-Item -LiteralPath $backup -Recurse -Force -ErrorAction SilentlyContinue + } } +if ((Test-Path -LiteralPath (Join-Path $InstallRoot "oscan.exe")) -and + (Join-Path $InstallRoot "oscan.exe") -ne (Join-Path $SourceDir "oscan.exe")) { + Write-Warning "A legacy flat Oscan install remains at $InstallRoot\oscan.exe. The stable bin directory takes precedence on PATH; remove the legacy files after confirming the new profile works." +} if (-not $NoPathUpdate) { - Add-UserPathEntry -Entry $PathEntry + Add-UserPathEntry -Entry $BinDir -LegacyEntry $InstallRoot } -Write-Host "Installed Oscan to $InstallDir" -if (Test-Path (Join-Path $InstallDir "toolchain")) { - Write-Host "Bundled toolchain installed next to oscan.exe" -} -if ($BinDir) { - Write-Host "Shim available at $(Join-Path $BinDir 'oscan.cmd')" -} elseif ($NoPathUpdate) { - Write-Host "Add $InstallDir to PATH to run oscan globally." +Write-Host "Installed Oscan profile '$Profile' to $versionDir" +Write-Host "Qualified command: $(Join-Path $BinDir "oscan-$Profile.cmd")" +if ($selected -eq $Profile) { + Write-Host "Default command: $(Join-Path $BinDir "oscan.cmd") -> oscan-$Profile" } else { - Write-Host "User PATH updated with $PathEntry" + Write-Host "Default remains '$selected'. Re-run with -SetDefault to select '$Profile'." +} +} finally { + if ($selectorLock) { + $selectorLock.Dispose() + Remove-Item -LiteralPath $selectorLockPath -Force -ErrorAction SilentlyContinue + } + $installLock.Dispose() + Remove-Item -LiteralPath $installLockPath -Force -ErrorAction SilentlyContinue } diff --git a/scripts/install-oscan.sh b/scripts/install-oscan.sh index f16833c..d7d312f 100644 --- a/scripts/install-oscan.sh +++ b/scripts/install-oscan.sh @@ -2,66 +2,676 @@ set -eu SOURCE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" -INSTALL_DIR="$HOME/.local/oscan" +INSTALL_ROOT="$HOME/.local/share/oscan" BIN_DIR="$HOME/.local/bin" +PROFILE="" +VERSION="" +EXPECTED_COMPILER_DIGEST="" +SET_DEFAULT=0 +UNINSTALL=0 CREATE_LINK=1 +usage() { + echo "usage: $0 [--source-dir ] [--install-root ] [--bin-dir ] [--profile ] [--set-default] [--uninstall] [--no-bin-link]" >&2 +} + +fail() { + echo "$1" >&2 + exit 1 +} + while [ "$#" -gt 0 ]; do case "$1" in --source-dir) SOURCE_DIR="$2" shift 2 ;; - --install-dir) - INSTALL_DIR="$2" + --install-root|--install-dir) + INSTALL_ROOT="$2" shift 2 ;; --bin-dir) BIN_DIR="$2" shift 2 ;; + --profile) + PROFILE="$2" + shift 2 + ;; + --set-default) + SET_DEFAULT=1 + shift + ;; + --uninstall) + UNINSTALL=1 + shift + ;; --no-bin-link) CREATE_LINK=0 shift ;; *) - echo "usage: $0 [--source-dir ] [--install-dir ] [--bin-dir ] [--no-bin-link]" >&2 + usage exit 1 ;; esac done -if [ ! -f "$SOURCE_DIR/oscan" ]; then - echo "source bundle must contain an oscan binary" >&2 - exit 1 -fi - -case "$INSTALL_DIR" in +case "$INSTALL_ROOT" in ""|"/"|"$HOME"|".") - echo "refusing to install into unsafe directory '$INSTALL_DIR'" >&2 - exit 1 + fail "refusing to use unsafe Oscan install root '$INSTALL_ROOT'" ;; esac -rm -rf "$INSTALL_DIR" -mkdir -p "$INSTALL_DIR" -cp -RP "$SOURCE_DIR"/. "$INSTALL_DIR"/ -chmod +x "$INSTALL_DIR/oscan" -if [ -f "$INSTALL_DIR/install.sh" ]; then - chmod +x "$INSTALL_DIR/install.sh" +mkdir -p "$INSTALL_ROOT" +INSTALL_ROOT="$(CDPATH= cd -- "$INSTALL_ROOT" && pwd -P)" +INSTALL_LOCK="$INSTALL_ROOT/.install.lock" +LOCK_HELD=0 +BIN_LOCK="" +BIN_LOCK_HELD=0 + +release_install_lock() { + if [ "$BIN_LOCK_HELD" -eq 1 ]; then + rm -rf "$BIN_LOCK" + BIN_LOCK_HELD=0 + fi + if [ "$LOCK_HELD" -eq 1 ]; then + rm -rf "$INSTALL_LOCK" + LOCK_HELD=0 + fi +} + +cleanup_install_lock() { + _status="$?" + trap - EXIT HUP INT TERM + release_install_lock + exit "$_status" +} + +WAITED=0 +while ! mkdir "$INSTALL_LOCK" 2>/dev/null; do + if [ -f "$INSTALL_LOCK/pid" ]; then + LOCK_OWNER="$(sed -n '1p' "$INSTALL_LOCK/pid")" + case "$LOCK_OWNER" in + ""|*[!0-9]*) + rm -rf "$INSTALL_LOCK" + continue + ;; + *) + if ! kill -0 "$LOCK_OWNER" 2>/dev/null; then + rm -rf "$INSTALL_LOCK" + continue + fi + ;; + esac + fi + [ "$WAITED" -lt 30 ] || + fail "timed out waiting for another Oscan install or uninstall to finish at '$INSTALL_ROOT'" + WAITED=$((WAITED + 1)) + sleep 1 +done +printf '%s\n' "$$" > "$INSTALL_LOCK/pid" +LOCK_HELD=1 +trap cleanup_install_lock EXIT HUP INT TERM + +if [ "$CREATE_LINK" -eq 1 ] || [ "$UNINSTALL" -eq 1 ]; then + mkdir -p "$BIN_DIR" + BIN_DIR="$(CDPATH= cd -- "$BIN_DIR" && pwd -P)" + BIN_LOCK="$BIN_DIR/.oscan-selectors.lock" + WAITED=0 + while ! mkdir "$BIN_LOCK" 2>/dev/null; do + if [ -f "$BIN_LOCK/pid" ]; then + LOCK_OWNER="$(sed -n '1p' "$BIN_LOCK/pid")" + case "$LOCK_OWNER" in + ""|*[!0-9]*) + rm -rf "$BIN_LOCK" + continue + ;; + *) + if ! kill -0 "$LOCK_OWNER" 2>/dev/null; then + rm -rf "$BIN_LOCK" + continue + fi + ;; + esac + fi + [ "$WAITED" -lt 30 ] || + fail "timed out waiting for another Oscan selector update to finish at '$BIN_DIR'" + WAITED=$((WAITED + 1)) + sleep 1 + done + printf '%s\n' "$$" > "$BIN_LOCK/pid" + BIN_LOCK_HELD=1 +fi + +metadata_top_string() { + sed -n "s/^ \"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" \ + "$SOURCE_DIR/oscan-package.json" | sed -n '1p' +} + +metadata_component_digest() { + sed -n "s/^ \"$1\"[[:space:]]*:[[:space:]]*\"\([0-9a-fA-F]*\)\".*/\1/p" \ + "$SOURCE_DIR/oscan-package.json" | sed -n '1p' +} + +METADATA="$SOURCE_DIR/oscan-package.json" +if [ -f "$METADATA" ]; then + SCHEMA="$(sed -n 's/^ "schema_version"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$METADATA" | sed -n '1p')" + [ "$SCHEMA" = "2" ] || fail "unsupported oscan-package.json schema '$SCHEMA'; expected 2" + PACKAGE_PROFILE="$(metadata_top_string profile)" + PACKAGE_ID="$(metadata_top_string package_id)" + VERSION="$(metadata_top_string version)" + PACKAGE_TARGET="$(metadata_top_string target)" + DEFAULT_BACKEND="$(metadata_top_string default_backend)" + EXPECTED_COMPILER_DIGEST="$(metadata_component_digest oscan)" + case "$PACKAGE_PROFILE" in + full|llvm|cranelift|c) ;; + *) fail "package metadata contains unknown profile '$PACKAGE_PROFILE'" ;; + esac + if [ -n "$PROFILE" ] && [ "$PROFILE" != "$PACKAGE_PROFILE" ]; then + fail "requested profile '$PROFILE' does not match package profile '$PACKAGE_PROFILE'" + fi + PROFILE="$PACKAGE_PROFILE" + [ "$PACKAGE_ID" = "oscan-$PROFILE" ] || + fail "package metadata package_id '$PACKAGE_ID' does not match profile '$PROFILE'" + [ -n "$VERSION" ] || fail "package metadata does not declare a version" + grep -Eq '^ "is_distribution"[[:space:]]*:[[:space:]]*true' "$METADATA" || + fail "package metadata must identify a packaged distribution" + EXPECTED_DEFAULT="$PROFILE" + [ "$PROFILE" != "full" ] || EXPECTED_DEFAULT="llvm" + [ "$DEFAULT_BACKEND" = "$EXPECTED_DEFAULT" ] || + fail "package metadata default backend '$DEFAULT_BACKEND' does not match profile '$PROFILE' (expected '$EXPECTED_DEFAULT')" + case "$(uname -s)" in + Linux) EXPECTED_TARGET="linux-x86_64" ;; + Darwin) EXPECTED_TARGET="macos-x86_64" ;; + *) fail "unsupported installation host '$(uname -s)'" ;; + esac + case "$(uname -m)" in + x86_64|amd64) ;; + *) fail "package target '$EXPECTED_TARGET' requires an x86_64 host, not '$(uname -m)'" ;; + esac + [ "$PACKAGE_TARGET" = "$EXPECTED_TARGET" ] || + fail "package target '$PACKAGE_TARGET' cannot be installed on this host (expected '$EXPECTED_TARGET')" + case "$EXPECTED_COMPILER_DIGEST" in + *[!0-9a-fA-F]*|"") fail "package metadata does not declare a valid SHA-256 digest for oscan" ;; + esac + [ "${#EXPECTED_COMPILER_DIGEST}" -eq 64 ] || + fail "package metadata does not declare a valid SHA-256 digest for oscan" +elif [ "$UNINSTALL" -eq 1 ]; then + case "$PROFILE" in + full|llvm|cranelift|c|dev) ;; + *) fail "--uninstall requires --profile full|llvm|cranelift|c|dev when package metadata is unavailable" ;; + esac +elif [ "$PROFILE" = "dev" ]; then + VERSION="current" +else + fail "source bundle must contain schema-2 oscan-package.json; development installs must pass --profile dev" +fi + +if [ "$UNINSTALL" -eq 0 ]; then + case "$VERSION" in + ""|"."|".."|*[!A-Za-z0-9._+-]*) + fail "package metadata contains unsafe version '$VERSION'" + ;; + esac + [ -f "$SOURCE_DIR/oscan" ] || fail "source bundle must contain an oscan binary" + if [ -n "$EXPECTED_COMPILER_DIGEST" ]; then + if command -v sha256sum >/dev/null 2>&1; then + ACTUAL_COMPILER_DIGEST="$(sha256sum "$SOURCE_DIR/oscan")" + ACTUAL_COMPILER_DIGEST="${ACTUAL_COMPILER_DIGEST%% *}" + elif command -v shasum >/dev/null 2>&1; then + ACTUAL_COMPILER_DIGEST="$(shasum -a 256 "$SOURCE_DIR/oscan")" + ACTUAL_COMPILER_DIGEST="${ACTUAL_COMPILER_DIGEST%% *}" + else + fail "installing a release bundle requires sha256sum or shasum to verify oscan" + fi + [ "$ACTUAL_COMPILER_DIGEST" = "$EXPECTED_COMPILER_DIGEST" ] || + fail "oscan digest mismatch: package metadata has $EXPECTED_COMPILER_DIGEST, actual is $ACTUAL_COMPILER_DIGEST" + fi fi +PROFILES_ROOT="$INSTALL_ROOT/profiles" +PROFILE_ROOT="$PROFILES_ROOT/$PROFILE" +QUALIFIED_LINK="$BIN_DIR/oscan-$PROFILE" +QUALIFIED_OWNER="$BIN_DIR/.oscan-$PROFILE.owner" +DEFAULT_LINK="$BIN_DIR/oscan" +DEFAULT_OWNER="$BIN_DIR/.oscan-default.owner" +DEFAULT_STATE="$INSTALL_ROOT/default-profile" + +atomic_symlink() { + _target="$1" + _path="$2" + _temporary="$(dirname -- "$_path")/.tmp-$(basename -- "$_path")-$$" + rm -f "$_temporary" + if ! ln -s "$_target" "$_temporary"; then + rm -f "$_temporary" + return 1 + fi + if ! mv -f "$_temporary" "$_path"; then + rm -f "$_temporary" + return 1 + fi +} + +atomic_text() { + _text="$1" + _path="$2" + _temporary="$(dirname -- "$_path")/.tmp-$(basename -- "$_path")-$$" + printf '%s\n' "$_text" > "$_temporary" + mv -f "$_temporary" "$_path" +} + +selected_profile() { + if [ -f "$DEFAULT_STATE" ]; then + sed -n '1p' "$DEFAULT_STATE" + fi +} + +qualified_ownership() { + _profile="$1" + _link="$BIN_DIR/oscan-$_profile" + _owner="$BIN_DIR/.oscan-$_profile.owner" + if [ -e "$_link" ] && [ ! -L "$_link" ]; then + printf '%s\n' unmanaged + elif [ -f "$_owner" ]; then + if [ "$(sed -n '1p' "$_owner")" = "$INSTALL_ROOT" ]; then + printf '%s\n' current + else + printf '%s\n' foreign + fi + elif [ -L "$_link" ]; then + case "$(readlink "$_link")" in + "$PROFILES_ROOT/$_profile/"*) printf '%s\n' current ;; + *) printf '%s\n' foreign ;; + esac + elif [ -e "$_link" ]; then + printf '%s\n' unmanaged + else + printf '%s\n' absent + fi +} + +default_ownership() { + _selected="$1" + if [ -e "$DEFAULT_LINK" ] && [ ! -L "$DEFAULT_LINK" ]; then + printf '%s\n' unmanaged + elif [ -f "$DEFAULT_OWNER" ]; then + if [ "$(sed -n '1p' "$DEFAULT_OWNER")" = "$INSTALL_ROOT" ]; then + printf '%s\n' current + else + printf '%s\n' foreign + fi + elif [ -L "$DEFAULT_LINK" ]; then + if [ -n "$_selected" ] && + [ "$(readlink "$DEFAULT_LINK")" = "oscan-$_selected" ] && + [ "$(qualified_ownership "$_selected")" = current ]; then + printf '%s\n' current + else + printf '%s\n' foreign + fi + elif [ -e "$DEFAULT_LINK" ]; then + printf '%s\n' unmanaged + else + printf '%s\n' absent + fi +} + +SELECTED="$(selected_profile)" +case "$SELECTED" in + ""|full|llvm|cranelift|c|dev) ;; + *) fail "default-profile contains unknown profile '$SELECTED'" ;; +esac +QUALIFIED_OWNERSHIP="$(qualified_ownership "$PROFILE")" +DEFAULT_OWNERSHIP="$(default_ownership "$SELECTED")" + +if [ "$UNINSTALL" -eq 1 ]; then + UNINSTALL_BACKUP="$PROFILES_ROOT/.$PROFILE-uninstall-$$" + PAYLOAD_STAGED_FOR_REMOVAL=0 + QUALIFIED_LINK_REMOVED=0 + QUALIFIED_OWNER_REMOVED=0 + DEFAULT_LINK_REMOVED=0 + DEFAULT_OWNER_REMOVED=0 + STATE_REMOVED=0 + UNINSTALL_COMMITTED=0 + + QUALIFIED_EXISTED=0 + QUALIFIED_TARGET="" + if [ -L "$QUALIFIED_LINK" ]; then + QUALIFIED_EXISTED=1 + QUALIFIED_TARGET="$(readlink "$QUALIFIED_LINK")" + fi + QUALIFIED_OWNER_EXISTED=0 + QUALIFIED_OWNER_VALUE="" + if [ -f "$QUALIFIED_OWNER" ]; then + QUALIFIED_OWNER_EXISTED=1 + QUALIFIED_OWNER_VALUE="$(sed -n '1p' "$QUALIFIED_OWNER")" + fi + DEFAULT_EXISTED=0 + DEFAULT_TARGET="" + if [ -L "$DEFAULT_LINK" ]; then + DEFAULT_EXISTED=1 + DEFAULT_TARGET="$(readlink "$DEFAULT_LINK")" + fi + DEFAULT_OWNER_EXISTED=0 + DEFAULT_OWNER_VALUE="" + if [ -f "$DEFAULT_OWNER" ]; then + DEFAULT_OWNER_EXISTED=1 + DEFAULT_OWNER_VALUE="$(sed -n '1p' "$DEFAULT_OWNER")" + fi + STATE_EXISTED=0 + STATE_VALUE="$SELECTED" + [ ! -f "$DEFAULT_STATE" ] || STATE_EXISTED=1 + + rollback_uninstall() { + _status="$?" + trap - EXIT HUP INT TERM + _rollback_failed=0 + if [ "$UNINSTALL_COMMITTED" -eq 0 ]; then + if [ "$PAYLOAD_STAGED_FOR_REMOVAL" -eq 1 ] && + [ -e "$UNINSTALL_BACKUP" ]; then + mv "$UNINSTALL_BACKUP" "$PROFILE_ROOT" || { + echo "warning: could not restore profile payload '$PROFILE_ROOT'" >&2 + _rollback_failed=1 + } + fi + if [ "$QUALIFIED_LINK_REMOVED" -eq 1 ] && + [ "$QUALIFIED_EXISTED" -eq 1 ]; then + atomic_symlink "$QUALIFIED_TARGET" "$QUALIFIED_LINK" || { + echo "warning: could not restore '$QUALIFIED_LINK'" >&2 + _rollback_failed=1 + } + fi + if [ "$QUALIFIED_OWNER_REMOVED" -eq 1 ] && + [ "$QUALIFIED_OWNER_EXISTED" -eq 1 ]; then + atomic_text "$QUALIFIED_OWNER_VALUE" "$QUALIFIED_OWNER" || { + echo "warning: could not restore '$QUALIFIED_OWNER'" >&2 + _rollback_failed=1 + } + fi + if [ "$DEFAULT_LINK_REMOVED" -eq 1 ] && + [ "$DEFAULT_EXISTED" -eq 1 ]; then + atomic_symlink "$DEFAULT_TARGET" "$DEFAULT_LINK" || { + echo "warning: could not restore '$DEFAULT_LINK'" >&2 + _rollback_failed=1 + } + fi + if [ "$DEFAULT_OWNER_REMOVED" -eq 1 ] && + [ "$DEFAULT_OWNER_EXISTED" -eq 1 ]; then + atomic_text "$DEFAULT_OWNER_VALUE" "$DEFAULT_OWNER" || { + echo "warning: could not restore '$DEFAULT_OWNER'" >&2 + _rollback_failed=1 + } + fi + if [ "$STATE_REMOVED" -eq 1 ] && + [ "$STATE_EXISTED" -eq 1 ]; then + atomic_text "$STATE_VALUE" "$DEFAULT_STATE" || { + echo "warning: could not restore '$DEFAULT_STATE'" >&2 + _rollback_failed=1 + } + fi + fi + [ "$_rollback_failed" -eq 0 ] || + echo "warning: uninstall rollback was incomplete" >&2 + release_install_lock + [ "$_status" -ne 0 ] || _status=1 + exit "$_status" + } + trap rollback_uninstall EXIT HUP INT TERM + + if [ -e "$PROFILE_ROOT" ]; then + rm -rf "$UNINSTALL_BACKUP" + mv "$PROFILE_ROOT" "$UNINSTALL_BACKUP" + PAYLOAD_STAGED_FOR_REMOVAL=1 + fi + if [ "$QUALIFIED_OWNERSHIP" = current ]; then + if [ -L "$QUALIFIED_LINK" ]; then + rm -f "$QUALIFIED_LINK" + QUALIFIED_LINK_REMOVED=1 + fi + if [ -f "$QUALIFIED_OWNER" ]; then + rm -f "$QUALIFIED_OWNER" + QUALIFIED_OWNER_REMOVED=1 + fi + fi + if [ "$SELECTED" = "$PROFILE" ]; then + if [ "$DEFAULT_OWNERSHIP" = current ]; then + if [ -L "$DEFAULT_LINK" ]; then + rm -f "$DEFAULT_LINK" + DEFAULT_LINK_REMOVED=1 + fi + if [ -f "$DEFAULT_OWNER" ]; then + rm -f "$DEFAULT_OWNER" + DEFAULT_OWNER_REMOVED=1 + fi + fi + if [ -f "$DEFAULT_STATE" ]; then + rm -f "$DEFAULT_STATE" + STATE_REMOVED=1 + fi + fi + UNINSTALL_COMMITTED=1 + if [ "$PAYLOAD_STAGED_FOR_REMOVAL" -eq 1 ] && + ! rm -rf "$UNINSTALL_BACKUP"; then + echo "warning: the profile was deactivated, but its staged payload could not be deleted from '$UNINSTALL_BACKUP'" >&2 + fi + if [ "$QUALIFIED_OWNERSHIP" = foreign ] || + [ "$QUALIFIED_OWNERSHIP" = unmanaged ]; then + echo "warning: preserved '$QUALIFIED_LINK' because it is not owned by install root '$INSTALL_ROOT'" >&2 + fi + if [ "$SELECTED" = "$PROFILE" ]; then + echo "warning: removed the selected '$PROFILE' profile; the unqualified oscan command now has no default for this install root" >&2 + fi + trap - EXIT HUP INT TERM + release_install_lock + echo "Uninstalled Oscan profile '$PROFILE'; other profiles were preserved." + exit 0 +fi + +VERSION_DIR="$PROFILE_ROOT/$VERSION" +mkdir -p "$PROFILE_ROOT" +STAGED="$PROFILE_ROOT/.install-$VERSION-$$" +BACKUP="$PROFILE_ROOT/.backup-$VERSION-$$" +rm -rf "$STAGED" "$BACKUP" + +QUALIFIED_EXISTED=0 +QUALIFIED_TARGET="" +QUALIFIED_OWNER_EXISTED=0 +QUALIFIED_OWNER_VALUE="" +if [ "$CREATE_LINK" -eq 1 ]; then + case "$QUALIFIED_OWNERSHIP" in + foreign|unmanaged) + fail "refusing to replace command '$QUALIFIED_LINK' because it is not owned by install root '$INSTALL_ROOT'" + ;; + esac + if [ -L "$QUALIFIED_LINK" ]; then + QUALIFIED_EXISTED=1 + QUALIFIED_TARGET="$(readlink "$QUALIFIED_LINK")" + fi + if [ -f "$QUALIFIED_OWNER" ]; then + QUALIFIED_OWNER_EXISTED=1 + QUALIFIED_OWNER_VALUE="$(sed -n '1p' "$QUALIFIED_OWNER")" + fi +fi + +if [ "$CREATE_LINK" -eq 1 ] && + [ -n "$SELECTED" ] && + [ "$DEFAULT_OWNERSHIP" != current ]; then + fail "default-profile state at '$DEFAULT_STATE' does not own shared selector '$DEFAULT_LINK'" +fi +SET_DEFAULT_NOW=0 +if [ "$CREATE_LINK" -eq 1 ] && + { [ "$SET_DEFAULT" -eq 1 ] || { [ -z "$SELECTED" ] && [ ! -e "$DEFAULT_LINK" ] && [ ! -L "$DEFAULT_LINK" ]; }; }; then + SET_DEFAULT_NOW=1 +fi +if [ "$SET_DEFAULT_NOW" -eq 1 ]; then + case "$DEFAULT_OWNERSHIP" in + foreign|unmanaged) + fail "refusing to replace default command '$DEFAULT_LINK' because it is not owned by install root '$INSTALL_ROOT'" + ;; + esac +fi + +DEFAULT_EXISTED=0 +DEFAULT_TARGET="" +DEFAULT_OWNER_EXISTED=0 +DEFAULT_OWNER_VALUE="" +STATE_EXISTED=0 +STATE_VALUE="$SELECTED" +if [ "$SET_DEFAULT_NOW" -eq 1 ]; then + if [ -L "$DEFAULT_LINK" ]; then + DEFAULT_EXISTED=1 + DEFAULT_TARGET="$(readlink "$DEFAULT_LINK")" + elif [ -e "$DEFAULT_LINK" ]; then + fail "refusing to replace unmanaged command '$DEFAULT_LINK'" + fi + if [ -f "$DEFAULT_OWNER" ]; then + DEFAULT_OWNER_EXISTED=1 + DEFAULT_OWNER_VALUE="$(sed -n '1p' "$DEFAULT_OWNER")" + fi + [ ! -f "$DEFAULT_STATE" ] || STATE_EXISTED=1 +elif [ "$DEFAULT_OWNERSHIP" = current ] && [ -f "$DEFAULT_OWNER" ]; then + DEFAULT_OWNER_EXISTED=1 + DEFAULT_OWNER_VALUE="$(sed -n '1p' "$DEFAULT_OWNER")" +fi + +PAYLOAD_ACTIVATED=0 +BACKUP_CREATED=0 +QUALIFIED_CHANGED=0 +DEFAULT_CHANGED=0 +DEFAULT_OWNER_CHANGED=0 +COMMITTED=0 + +rollback_install() { + _status="$?" + trap - EXIT HUP INT TERM + if [ "$COMMITTED" -eq 0 ]; then + if [ "$PAYLOAD_ACTIVATED" -eq 1 ]; then + rm -rf "$VERSION_DIR" || + echo "warning: could not remove failed payload '$VERSION_DIR'" >&2 + fi + if [ "$BACKUP_CREATED" -eq 1 ] && [ -e "$BACKUP" ]; then + mv "$BACKUP" "$VERSION_DIR" || + echo "warning: could not restore previous payload from '$BACKUP'" >&2 + fi + if [ "$QUALIFIED_CHANGED" -eq 1 ]; then + rm -f "$QUALIFIED_LINK" "$QUALIFIED_OWNER" + if [ "$QUALIFIED_EXISTED" -eq 1 ]; then + atomic_symlink "$QUALIFIED_TARGET" "$QUALIFIED_LINK" || + echo "warning: could not restore '$QUALIFIED_LINK'" >&2 + fi + if [ "$QUALIFIED_OWNER_EXISTED" -eq 1 ]; then + atomic_text "$QUALIFIED_OWNER_VALUE" "$QUALIFIED_OWNER" || + echo "warning: could not restore '$QUALIFIED_OWNER'" >&2 + fi + fi + if [ "$DEFAULT_CHANGED" -eq 1 ]; then + rm -f "$DEFAULT_LINK" + if [ "$DEFAULT_EXISTED" -eq 1 ]; then + atomic_symlink "$DEFAULT_TARGET" "$DEFAULT_LINK" || + echo "warning: could not restore '$DEFAULT_LINK'" >&2 + fi + if [ "$STATE_EXISTED" -eq 1 ]; then + atomic_text "$STATE_VALUE" "$DEFAULT_STATE" || + echo "warning: could not restore '$DEFAULT_STATE'" >&2 + else + rm -f "$DEFAULT_STATE" + fi + fi + if [ "$DEFAULT_OWNER_CHANGED" -eq 1 ]; then + rm -f "$DEFAULT_OWNER" + if [ "$DEFAULT_OWNER_EXISTED" -eq 1 ]; then + atomic_text "$DEFAULT_OWNER_VALUE" "$DEFAULT_OWNER" || + echo "warning: could not restore '$DEFAULT_OWNER'" >&2 + fi + fi + fi + rm -rf "$STAGED" + if [ "$COMMITTED" -eq 1 ]; then + rm -rf "$BACKUP" + fi + if [ "$_status" -eq 0 ] && [ "$COMMITTED" -eq 0 ]; then + _status=1 + fi + release_install_lock + exit "$_status" +} +trap rollback_install EXIT HUP INT TERM + +mkdir -p "$STAGED" +cp -RP "$SOURCE_DIR"/. "$STAGED"/ +[ -f "$STAGED/oscan" ] || fail "staged profile is missing oscan" +if [ -n "$EXPECTED_COMPILER_DIGEST" ]; then + if command -v sha256sum >/dev/null 2>&1; then + STAGED_COMPILER_DIGEST="$(sha256sum "$STAGED/oscan")" + STAGED_COMPILER_DIGEST="${STAGED_COMPILER_DIGEST%% *}" + else + STAGED_COMPILER_DIGEST="$(shasum -a 256 "$STAGED/oscan")" + STAGED_COMPILER_DIGEST="${STAGED_COMPILER_DIGEST%% *}" + fi + [ "$STAGED_COMPILER_DIGEST" = "$EXPECTED_COMPILER_DIGEST" ] || + fail "staged oscan digest mismatch: package metadata has $EXPECTED_COMPILER_DIGEST, actual is $STAGED_COMPILER_DIGEST" +fi +if [ -f "$METADATA" ]; then + [ -f "$STAGED/oscan-package.json" ] || fail "staged profile is missing oscan-package.json" +fi +chmod +x "$STAGED/oscan" +[ ! -f "$STAGED/install.sh" ] || chmod +x "$STAGED/install.sh" + +if [ -e "$VERSION_DIR" ]; then + BACKUP_CREATED=1 + mv "$VERSION_DIR" "$BACKUP" +fi +PAYLOAD_ACTIVATED=1 +mv "$STAGED" "$VERSION_DIR" || fail "could not activate the staged '$PROFILE' profile" + if [ "$CREATE_LINK" -eq 1 ]; then mkdir -p "$BIN_DIR" - ln -sfn "$INSTALL_DIR/oscan" "$BIN_DIR/oscan" + QUALIFIED_CHANGED=1 + atomic_symlink "$VERSION_DIR/oscan" "$QUALIFIED_LINK" || + fail "could not activate the oscan-$PROFILE command" + atomic_text "$INSTALL_ROOT" "$QUALIFIED_OWNER" + + if [ "$SET_DEFAULT_NOW" -eq 1 ]; then + DEFAULT_CHANGED=1 + atomic_symlink "oscan-$PROFILE" "$DEFAULT_LINK" + atomic_text "$PROFILE" "$DEFAULT_STATE" + SELECTED="$PROFILE" + fi + if [ -n "$SELECTED" ] && + { [ "$SET_DEFAULT_NOW" -eq 1 ] || [ "$DEFAULT_OWNERSHIP" = current ]; }; then + DEFAULT_OWNER_CHANGED=1 + atomic_text "$INSTALL_ROOT" "$DEFAULT_OWNER" + fi fi -echo "Installed Oscan to $INSTALL_DIR" -if [ -d "$INSTALL_DIR/toolchain" ]; then - echo "Bundled toolchain installed next to oscan." +COMMITTED=1 +# The qualified link now selects the new payload. Remove old versions only +# after activation, so an interrupted copy cannot destroy the working profile. +# With --no-bin-link, retain older versions because an existing managed link +# may still reference one and the caller explicitly opted out of retargeting it. +if [ "$CREATE_LINK" -eq 1 ]; then + for OLD in "$PROFILE_ROOT"/*; do + [ -e "$OLD" ] || continue + [ "$OLD" = "$VERSION_DIR" ] || rm -rf "$OLD" + done +fi +rm -rf "$PROFILE_ROOT"/.install-* "$PROFILE_ROOT"/.backup-* +trap - EXIT HUP INT TERM +release_install_lock + +LEGACY_ROOT="$HOME/.local/oscan" +if [ -f "$LEGACY_ROOT/oscan" ] && [ "$LEGACY_ROOT" != "$INSTALL_ROOT" ]; then + echo "warning: legacy flat install remains at $LEGACY_ROOT; remove it after confirming the new profile works" >&2 fi + +echo "Installed Oscan profile '$PROFILE' to $VERSION_DIR" if [ "$CREATE_LINK" -eq 1 ]; then - echo "Symlink refreshed at $BIN_DIR/oscan" + echo "Qualified command: $QUALIFIED_LINK" + if [ "$SELECTED" = "$PROFILE" ]; then + echo "Default command: $DEFAULT_LINK -> oscan-$PROFILE" + else + echo "Default remains '$SELECTED'. Re-run with --set-default to select '$PROFILE'." + fi else - echo "Add $INSTALL_DIR to PATH to run oscan globally." + echo "Run $VERSION_DIR/oscan directly or add a qualified link yourself." fi diff --git a/scripts/release_tools.py b/scripts/release_tools.py index 81ab363..3a10eb3 100644 --- a/scripts/release_tools.py +++ b/scripts/release_tools.py @@ -39,6 +39,7 @@ # `cranelift` and never appears in an artifact name, package label, or # contract entry. CANONICAL_BACKENDS = ("llvm", "cranelift", "c") +CANONICAL_PROFILES = ("full", "llvm", "cranelift", "c") REQUIRED_COMPONENTS = ( "compiler", "direct_link_sidecar", @@ -50,11 +51,12 @@ # archive would need the host CRT, which is exactly the C-toolchain # dependency these packages exist to remove. FREESTANDING_PROFILES = ("freestanding", "freestanding_gfx", "freestanding_core") +RUNTIME_PROFILES = ("hosted", *FREESTANDING_PROFILES) # The published matrix. macOS is C-only: there is no Darwin object target # for either object backend. SUPPORTED_RELEASE_TARGETS = { - "windows-x86_64": ("llvm", "cranelift", "c"), - "linux-x86_64": ("llvm", "cranelift", "c"), + "windows-x86_64": CANONICAL_PROFILES, + "linux-x86_64": CANONICAL_PROFILES, "macos-x86_64": ("c",), } PACKAGE_METADATA_NAME = "oscan-package.json" @@ -185,28 +187,21 @@ def load_manifest(path: Path) -> dict: def load_release_contract(path: Path) -> dict: - """Load and fully validate the release contract (schema 2). - - Schema 2 replaces schema 1's single "full bundle per target" model with - an explicit target x backend variant matrix: every artifact this - repository publishes is one (target, backend) pair with its own archive - names, Cargo feature, distribution stamp, capability flag, and component - list. There is no `-full` bundle any more, and `native` is never an - artifact label (it survives only as a deprecated CLI alias). - """ + """Load and fully validate the profile-aware release contract (schema 3).""" try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: fail(f"cannot read release contract {path}: {exc}") - if data.get("schema_version") != 2: + if data.get("schema_version") != 3: fail( - f"unsupported release contract schema in {path}: expected schema_version 2 " - f"(target x backend variants), got {data.get('schema_version')!r}" + f"unsupported release contract schema in {path}: expected schema_version 3 " + f"(target x package profiles), got {data.get('schema_version')!r}" ) for key in ( "install_surface", "toolchains_committed_to_git", "backends", + "profiles", "components", "lookup_contract", "variants", @@ -219,6 +214,7 @@ def load_release_contract(path: Path) -> dict: fail("the release contract must keep toolchains out of git") _validate_contract_backends(path, data["backends"]) + _validate_contract_profiles(path, data) _validate_contract_components(path, data["components"]) lookup_contract = data["lookup_contract"] @@ -242,7 +238,7 @@ def _validate_contract_backends(path: Path, backends: dict) -> None: for name, spec in backends.items(): if not isinstance(spec, dict): fail(f"release contract backend '{name}' must be an object") - for key in ("cargo_feature", "artifact_suffix", "kind"): + for key in ("cargo_feature", "kind"): if key not in spec: fail(f"release contract backend '{name}' is missing '{key}'") if spec["cargo_feature"] != f"backend-{name}": @@ -250,13 +246,50 @@ def _validate_contract_backends(path: Path, backends: dict) -> None: f"release contract backend '{name}' must use cargo feature 'backend-{name}', " f"got '{spec['cargo_feature']}'" ) + if spec["kind"] not in ("object", "c-source"): + fail(f"release contract backend '{name}' has unknown kind '{spec['kind']}'") + + +def _validate_contract_profiles(path: Path, contract: dict) -> None: + profiles = contract["profiles"] + if sorted(profiles) != sorted(CANONICAL_PROFILES): + fail( + f"release contract {path} must declare exactly the canonical profiles " + f"{', '.join(CANONICAL_PROFILES)}, got {', '.join(sorted(profiles))}" + ) + for name, spec in profiles.items(): + if not isinstance(spec, dict): + fail(f"release contract profile '{name}' must be an object") + for key in ("artifact_suffix", "backends", "default_backend"): + if key not in spec: + fail(f"release contract profile '{name}' is missing '{key}'") if spec["artifact_suffix"] != name: fail( - f"release contract backend '{name}' must use artifact suffix '{name}', got " + f"release contract profile '{name}' must use artifact suffix '{name}', got " f"'{spec['artifact_suffix']}'" ) - if spec["kind"] not in ("object", "c-source"): - fail(f"release contract backend '{name}' has unknown kind '{spec['kind']}'") + available = spec["backends"] + if not isinstance(available, list) or not available: + fail(f"release contract profile '{name}' must declare a non-empty backend list") + unknown = [backend for backend in available if backend not in contract["backends"]] + if unknown: + fail( + f"release contract profile '{name}' declares unknown backend(s): " + f"{', '.join(unknown)}" + ) + expected = list(CANONICAL_BACKENDS) if name == "full" else [name] + if available != expected: + fail( + f"release contract profile '{name}' must contain backends {expected!r}, got " + f"{available!r}" + ) + if spec["default_backend"] not in available: + fail( + f"release contract profile '{name}' defaults to " + f"'{spec['default_backend']}', which it does not contain" + ) + if name == "full" and spec["default_backend"] != "llvm": + fail("release contract profile 'full' must use LLVM as its stable default backend") def _validate_contract_components(path: Path, components: dict) -> None: @@ -273,11 +306,10 @@ def _validate_contract_components(path: Path, components: dict) -> None: "runtime archives must be staged at archive-root/build/runtime-archives/{target}" ) allowed_profiles = components["runtime_archives"].get("allowed_profiles") - if allowed_profiles != list(FREESTANDING_PROFILES): + if allowed_profiles != list(RUNTIME_PROFILES): fail( "release contract runtime_archives.allowed_profiles must be " - f"{list(FREESTANDING_PROFILES)} (freestanding only: object packages ship no hosted " - "archive)" + f"{list(RUNTIME_PROFILES)}" ) for name in ("llvm_provider", "c_toolchain"): if components[name].get("position") != "archive-root/toolchain": @@ -293,7 +325,7 @@ def _validate_contract_variants(path: Path, contract: dict) -> None: for target, target_spec in variants.items(): if target not in SUPPORTED_RELEASE_TARGETS: fail(f"release contract declares unsupported target '{target}'") - for key in ("binary_name", "archive_format", "backends"): + for key in ("binary_name", "archive_format", "profiles"): if key not in target_spec: fail(f"release contract target '{target}' is missing '{key}'") if target_spec["archive_format"] not in ARCHIVE_SUFFIXES: @@ -301,48 +333,47 @@ def _validate_contract_variants(path: Path, contract: dict) -> None: f"unsupported archive format '{target_spec['archive_format']}' for target " f"'{target}'" ) - backends = target_spec["backends"] - if not backends: - fail(f"release contract target '{target}' declares no backends") - expected_backends = SUPPORTED_RELEASE_TARGETS[target] - if sorted(backends) != sorted(expected_backends): + profiles = target_spec["profiles"] + if not profiles: + fail(f"release contract target '{target}' declares no profiles") + expected_profiles = SUPPORTED_RELEASE_TARGETS[target] + if sorted(profiles) != sorted(expected_profiles): fail( - f"release contract target '{target}' must declare backends " - f"{', '.join(expected_backends)}, got {', '.join(sorted(backends))}" + f"release contract target '{target}' must declare profiles " + f"{', '.join(expected_profiles)}, got {', '.join(sorted(profiles))}" ) - for backend, variant in backends.items(): - _validate_contract_variant(path, contract, target, backend, variant) + for profile, variant in profiles.items(): + _validate_contract_variant(path, contract, target, profile, variant) name = variant["archive_name_template"] root = variant["archive_root_template"] if name in seen_archive_names: fail( f"release contract archive name '{name}' is used by both " - f"{seen_archive_names[name]} and {target}/{backend}" + f"{seen_archive_names[name]} and {target}/{profile}" ) if root in seen_roots: fail( f"release contract archive root '{root}' is used by both " - f"{seen_roots[root]} and {target}/{backend}" + f"{seen_roots[root]} and {target}/{profile}" ) - seen_archive_names[name] = f"{target}/{backend}" - seen_roots[root] = f"{target}/{backend}" + seen_archive_names[name] = f"{target}/{profile}" + seen_roots[root] = f"{target}/{profile}" def variant_build_features(variant: dict) -> list[str]: - return [variant["cargo_feature"], *variant.get("extra_cargo_features", [])] + return list(variant["cargo_features"]) def _validate_contract_variant( - path: Path, contract: dict, target: str, backend: str, variant: dict + path: Path, contract: dict, target: str, profile: str, variant: dict ) -> None: - where = f"release contract variant {target}/{backend}" - if backend not in contract["backends"]: - fail(f"{where} names a backend the contract does not declare") + where = f"release contract variant {target}/{profile}" + if profile not in contract["profiles"]: + fail(f"{where} names a profile the contract does not declare") for key in ( "archive_name_template", "archive_root_template", - "cargo_feature", - "distribution_backend", + "cargo_features", "toolchain_free", "components", "runtime_profiles", @@ -350,35 +381,29 @@ def _validate_contract_variant( if key not in variant: fail(f"{where} is missing '{key}'") - backend_spec = contract["backends"][backend] - if variant["cargo_feature"] != backend_spec["cargo_feature"]: - fail( - f"{where} declares cargo feature '{variant['cargo_feature']}', but backend " - f"'{backend}' is built with '{backend_spec['cargo_feature']}'" - ) - if variant["distribution_backend"] != backend: - fail( - f"{where} declares distribution backend '{variant['distribution_backend']}', which " - f"does not match the variant's own backend '{backend}'" - ) - extra_features = variant.get("extra_cargo_features", []) - if not isinstance(extra_features, list) or not all( - isinstance(feature, str) and feature for feature in extra_features - ): - fail(f"{where} extra_cargo_features must be a list of non-empty feature names") build_features = variant_build_features(variant) + if not isinstance(variant["cargo_features"], list) or not all( + isinstance(feature, str) and feature for feature in build_features + ): + fail(f"{where} cargo_features must be a list of non-empty feature names") if len(build_features) != len(set(build_features)): fail(f"{where} declares duplicate Cargo build features") - extra_backends = [ - feature - for feature in extra_features - if feature.startswith("backend-") and feature != variant["cargo_feature"] + profile_spec = contract["profiles"][profile] + available_backends = profile_spec["backends"] + expected_backend_features = [ + contract["backends"][backend]["cargo_feature"] for backend in available_backends + ] + declared_backend_features = [ + feature for feature in build_features if feature.startswith("backend-") ] - if extra_backends: + if declared_backend_features != expected_backend_features: fail( - f"{where} may build only backend '{backend}', but extra_cargo_features includes " - f"{', '.join(extra_backends)}" + f"{where} must build backend features {expected_backend_features!r}, got " + f"{declared_backend_features!r}" ) + extra_features = [ + feature for feature in build_features if not feature.startswith("backend-") + ] suffix = ARCHIVE_SUFFIXES[contract["variants"][target]["archive_format"]] for field in ("archive_name_template", "archive_root_template"): @@ -390,10 +415,10 @@ def _validate_contract_variant( f"{where} {field} uses 'native', which is only a deprecated CLI alias; the " "canonical artifact label is 'cranelift'" ) - if not variant["archive_name_template"].endswith(f"-{backend}{suffix}"): - fail(f"{where} archive_name_template must end with '-{backend}{suffix}'") - if not variant["archive_root_template"].endswith(f"-{backend}"): - fail(f"{where} archive_root_template must end with '-{backend}'") + if not variant["archive_name_template"].endswith(f"-{profile}{suffix}"): + fail(f"{where} archive_name_template must end with '-{profile}{suffix}'") + if not variant["archive_root_template"].endswith(f"-{profile}"): + fail(f"{where} archive_root_template must end with '-{profile}'") components = variant["components"] unknown = [name for name in components if name not in contract["components"]] @@ -405,31 +430,44 @@ def _validate_contract_variant( has_c_toolchain = "c_toolchain" in components if variant["toolchain_free"] and has_c_toolchain: fail(f"{where} claims toolchain_free but ships the c_toolchain component") - if backend == "c" and variant["toolchain_free"]: - fail(f"{where} is a C-backend package and can never be toolchain-free") - - profiles = variant["runtime_profiles"] - invalid = [profile for profile in profiles if profile not in FREESTANDING_PROFILES] + has_c_backend = "c" in available_backends + has_object_backend = any( + contract["backends"][backend]["kind"] == "object" for backend in available_backends + ) + if has_c_backend and variant["toolchain_free"]: + fail(f"{where} contains the C backend and can never be toolchain-free") + if has_c_backend and not target.startswith("macos") and not has_c_toolchain: + fail(f"{where} contains the C backend and must include the c_toolchain component") + + runtime_profiles = variant["runtime_profiles"] + invalid = [ + runtime_profile + for runtime_profile in runtime_profiles + if runtime_profile not in RUNTIME_PROFILES + ] if invalid: - fail(f"{where} lists non-freestanding runtime profile(s): {', '.join(invalid)}") + fail(f"{where} lists unknown runtime profile(s): {', '.join(invalid)}") - if contract["backends"][backend]["kind"] == "object": - if not variant["toolchain_free"]: + if has_object_backend: + if profile != "full" and not variant["toolchain_free"]: fail(f"{where} is an object package and must be toolchain-free") - if not profiles: + if not runtime_profiles: + fail(f"{where} must declare at least one freestanding runtime profile") + if not any(profile in FREESTANDING_PROFILES for profile in runtime_profiles): fail(f"{where} must declare at least one freestanding runtime profile") + if "hosted" in runtime_profiles and not has_c_backend: + fail(f"{where} is a slim object package and must not ship the hosted runtime") + if profile == "full" and "hosted" not in runtime_profiles: + fail(f"{where} must ship the hosted runtime for --libc object-backend builds") link_mode = variant.get("link_mode") if link_mode not in ("sidecar", "inprocess"): fail(f"{where} must declare link_mode 'sidecar' or 'inprocess'") expected_link_mode = ( - "inprocess" - if (target, backend) == ("windows-x86_64", "llvm") - else "sidecar" + "inprocess" if target == "windows-x86_64" and profile in ("full", "llvm") else "sidecar" ) if link_mode != expected_link_mode: fail( - f"{where} must use link_mode '{expected_link_mode}'; only the official " - "windows-x86_64/llvm package uses the strict in-process profile" + f"{where} must use link_mode '{expected_link_mode}' for this target/profile" ) if link_mode == "sidecar": for required in ("direct_link_sidecar", "runtime_archives"): @@ -438,8 +476,8 @@ def _validate_contract_variant( f"{where} uses sidecar linking and must include the '{required}' component" ) if extra_features or variant.get("rustflags"): - fail(f"{where} uses sidecar linking and must use only its backend Cargo feature") - if backend == "llvm": + fail(f"{where} uses sidecar linking and must use only backend Cargo features") + if "llvm" in available_backends: if "llvm_provider" not in components: fail(f"{where} must include the 'llvm_provider' component") source = variant.get("llvm_provider_source") @@ -455,9 +493,15 @@ def _validate_contract_variant( else: if target != "windows-x86_64": fail(f"{where} uses in-process linking, which is supported only on windows-x86_64") - if components != ["compiler"]: + expected_components = ( + ["compiler", "runtime_archives", "c_toolchain"] + if has_c_backend + else ["compiler"] + ) + if components != expected_components: fail( - f"{where} uses in-process linking and must package only the compiler component" + f"{where} uses in-process linking and must package components " + f"{expected_components!r}" ) notice_files = variant.get("embedded_notice_files") if ( @@ -491,12 +535,10 @@ def _validate_contract_variant( f"{where} embedded_notice_files must exactly match the pinned toolchain " f"notices {expected_notices!r}" ) - expected_features = ["inprocess-lld"] - if backend == "llvm": - expected_features.append("static-llvm") + expected_features = ["inprocess-lld", "static-llvm"] if extra_features != expected_features: fail( - f"{where} uses in-process linking and must declare extra_cargo_features " + f"{where} uses in-process linking and must declare non-backend cargo_features " f"{expected_features!r}" ) if variant.get("rustflags") != "-C target-feature=+crt-static": @@ -512,8 +554,12 @@ def _validate_contract_variant( fail( f"{where} is a C package and must not ship the '{forbidden}' component" ) - if profiles: + if runtime_profiles: fail(f"{where} is a C package and must not ship native runtime archives") + if extra_features: + fail( + f"{where} is a C package and must not declare non-backend cargo_features" + ) for forbidden in ("link_mode", "extra_cargo_features", "rustflags"): if forbidden in variant: fail(f"{where} is a C package and must not declare '{forbidden}'") @@ -535,9 +581,9 @@ def render_release_template(template: str, version: str, field_name: str) -> str def resolve_release_variant( - contract: dict, contract_path: Path, target: str, backend: str + contract: dict, contract_path: Path, target: str, profile: str ) -> dict: - """The fully resolved (target, backend) variant spec, or a hard error.""" + """The fully resolved (target, profile) variant spec, or a hard error.""" variants = contract["variants"] if target not in variants: fail( @@ -545,20 +591,33 @@ def resolve_release_variant( f"(known: {', '.join(sorted(variants))})" ) target_spec = variants[target] - if backend not in target_spec["backends"]: + if profile not in target_spec["profiles"]: fail( - f"release contract does not define backend '{backend}' for target '{target}' " - f"(known: {', '.join(sorted(target_spec['backends']))})" + f"release contract does not define profile '{profile}' for target '{target}' " + f"(known: {', '.join(sorted(target_spec['profiles']))})" ) - spec = dict(target_spec["backends"][backend]) + spec = dict(target_spec["profiles"][profile]) + profile_spec = contract["profiles"][profile] spec["target"] = target - spec["backend"] = backend + spec["profile"] = profile + # Compatibility aliases for internal helpers and third-party callers that + # consumed schema-2 resolved variants. + spec["backend"] = profile + spec["available_backends"] = list(profile_spec["backends"]) + spec["default_backend"] = profile_spec["default_backend"] + spec["distribution_backend"] = None if profile == "full" else profile spec["binary_name"] = target_spec["binary_name"] spec["archive_format"] = target_spec["archive_format"] spec["platform"] = target.split("-", 1)[0] - spec["backend_kind"] = contract["backends"][backend]["kind"] + spec["package_kind"] = ( + "full" if profile == "full" else contract["backends"][profile]["kind"] + ) + spec["backend_kind"] = spec["package_kind"] spec["build_features"] = variant_build_features(spec) + spec["cargo_feature"] = next( + feature for feature in spec["build_features"] if feature.startswith("backend-") + ) spec["rustflags"] = spec.get("rustflags", "") manifest_name = target_spec.get("toolchain_manifest") @@ -579,19 +638,22 @@ def resolve_release_variant( def release_variant_matrix(contract: dict) -> list[dict]: - """Every (target, backend) pair the contract publishes, in a stable order.""" + """Every (target, profile) pair the contract publishes, in stable order.""" matrix: list[dict] = [] for target in sorted(contract["variants"]): target_spec = contract["variants"][target] - for backend in sorted(target_spec["backends"]): - variant = target_spec["backends"][backend] + for profile in CANONICAL_PROFILES: + if profile not in target_spec["profiles"]: + continue + variant = target_spec["profiles"][profile] + profile_spec = contract["profiles"][profile] matrix.append( { "target": target, - "backend": backend, - "cargo_feature": variant["cargo_feature"], + "profile": profile, + "available_backends": list(profile_spec["backends"]), "build_features": variant_build_features(variant), - "distribution_backend": variant["distribution_backend"], + "default_backend": profile_spec["default_backend"], "toolchain_free": variant["toolchain_free"], "link_mode": variant.get("link_mode"), "archive_format": target_spec["archive_format"], @@ -1701,121 +1763,86 @@ def fetch_toolchain(manifest_path: Path, download_dir: Path, destination: Path) def write_install_readme(path: Path, variant: dict, asset_name: str) -> None: - """The package README: what this artifact is, and precisely what it can - and cannot do. Every claim here is enforced by the compiler itself (see - `backend::select` and `backend::no_toolchain`), so it must not overstate - a package's capabilities.""" + """Write a capability-accurate package README.""" target = variant["target"] platform, arch = target.split("-", 1) - backend = variant["backend"] + profile = variant["profile"] + available = variant["available_backends"] components = variant["components"] if platform == "windows": - install_hint = "Run install.ps1 from this directory, or keep this directory on PATH." + install_hint = ( + f"Run install.ps1 from this directory to install the '{profile}' profile and its " + f"'oscan-{profile}' command." + ) else: - install_hint = "Run install.sh from this directory, or copy oscan somewhere on PATH." + install_hint = ( + f"Run install.sh from this directory to install the '{profile}' profile and its " + f"'oscan-{profile}' command." + ) lines: list[str] = [ f"Oscan release asset: {asset_name}", f"Platform: {platform} {arch}", - f"Backend: {backend} (this package's only backend, and its default)", + f"Profile: {profile}", + f"Available backends: {', '.join(available)}", + f"Default backend: {variant['default_backend']}", "", install_hint, + "Installing this profile does not remove other profiles or change the unqualified " + "'oscan' command unless the installer is explicitly asked to select it.", "", "What this package contains", "--------------------------", + f" oscan compiled with --features {','.join(variant['build_features'])}.", ] - if variant["backend_kind"] == "object": - features = ",".join(variant["build_features"]) + + if variant["package_kind"] == "full": lines.append( - f" oscan compiled with --features {features}: it emits object " - "code directly and never writes or compiles C." + " One compiler exposes LLVM, Cranelift, and C through --backend; LLVM is the " + "deterministic implicit default." + ) + elif variant["package_kind"] == "object": + lines.append( + f" This slim package contains only the {profile} object backend and never writes or " + "compiles C." ) - if variant["link_mode"] == "inprocess": - lines.append( - " oscan contains the LLVM code generator, LLD linker, import libraries, compiler " - "builtins, and all freestanding runtime profiles in the executable itself." - ) - lines.append( - " No native-link/, build/runtime-archives/, toolchain/, provider DLL, linker " - "executable, extraction cache, or linker subprocess is required." - ) - lines.append( - " LICENSES/embedded/ contains the notices for the statically linked and embedded " - "LLVM/LLD and llvm-mingw inputs." - ) - else: - lines.append( - f" {NATIVE_LINK_DIR_NAME}/ the linker and its runtime libraries, verified " - f"against {NATIVE_LINK_DIR_NAME}/{NATIVE_LINK_MANIFEST_NAME} (SHA-256) before " - "every use." - ) - lines.append( - f" build/runtime-archives/{target}/ precompiled freestanding runtime archives " - f"({', '.join(variant['runtime_profiles'])})." - ) - if "llvm_provider" in components: - if variant.get("llvm_provider_source") == "direct-link-sidecar": - lines.append( - f" the LLVM code generator is the single verified " - f"{variant.get('llvm_provider_asset', 'libLLVM')} in {NATIVE_LINK_DIR_NAME}/ " - "— it is shared with the linker rather than duplicated." - ) - else: - lines.append( - " toolchain/ the packaged LLVM code generator only (no clang, no GCC, no " - "headers, no sysroot, no LLVM command-line tools)." - ) lines += [ "", "What this package does NOT do", "-----------------------------", - " --backend c, --emit-c and -o *.c are refused: this build has no C backend.", - " --libc is refused: the hosted runtime needs this machine's CRT/libm.", - " --extra-c/--extra-cflags are refused: there is no C compilation step.", - " extern functions with `str` parameters/returns need a generated C shim and are " - "refused for the same reason.", - " No C compiler is bundled, and none is searched for on PATH.", + " The C backend, C sources, C compiler, headers, sysroot, and hosted runtime are " + "not included.", + " Other backends report that the full or matching slim profile must be installed.", ] - if backend == "cranelift" and platform == "windows": - lines.append( - " libLLVM in this package is only LLD's runtime dependency; this build has no " - "LLVM backend (--backend llvm is not included)." - ) - if variant["link_mode"] == "inprocess": - lines.append( - " Named --extra-lib inputs are refused; pass an explicit archive path instead." - ) + else: + lines.append(" This slim package contains only the C backend.") + + if variant.get("link_mode") == "inprocess": lines.append( - f" Other backends are not included: --backend {'llvm' if backend == 'cranelift' else 'cranelift'} " - "and --backend c report which package to install instead." + " LLVM/LLD, native link inputs, and freestanding runtime profiles are embedded in " + "the compiler; LICENSES/embedded/ contains their notices." ) - else: + elif "direct_link_sidecar" in components: lines.append( - f" oscan compiled with --features {variant['cargo_feature']} only: it emits C and " - "needs a C toolchain to compile it." + f" {NATIVE_LINK_DIR_NAME}/ and build/runtime-archives/{target}/ contain the verified " + "native-link payload." ) - if "c_toolchain" in components: - lines.append( - " toolchain/ the pinned C toolchain this package uses; oscan finds it beside " - "its own executable, never on PATH." - ) - else: - lines.append( - f" No toolchain is bundled: {variant.get('required_host_toolchain', 'a host C toolchain')} " - "must be installed on this machine." - ) - lines += [ - "", - "What this package does NOT do", - "-----------------------------", - " --backend llvm and --backend cranelift are not included; each reports which " - "package to install instead.", - " No native-link sidecar, LLVM provider, or freestanding runtime archives are " - "shipped.", - ] - if variant.get("note_file"): - lines.append(f" See {variant['note_file']} for the macOS phase 1 note.") + if "runtime_archives" in components and "direct_link_sidecar" not in components: + lines.append( + f" build/runtime-archives/{target}/ contains hosted and compiler-driver runtime " + "archives used by the full profile." + ) + if "llvm_provider" in components: + lines.append(" toolchain/ contains the packaged LLVM code generator.") + if "c_toolchain" in components: + lines.append(" toolchain/ also contains the pinned C toolchain used by the C backend.") + elif variant.get("requires_host_compiler"): + lines.append( + f" {variant.get('required_host_toolchain', 'A host C toolchain')} must be installed." + ) + if variant.get("note_file"): + lines.append(f" See {variant['note_file']} for the macOS phase 1 note.") lines += [ "", @@ -1837,13 +1864,14 @@ def write_package_metadata( """Machine-readable record of what this package is, mirroring what the packaged compiler reports through `oscan --version`.""" metadata = { - "schema_version": 1, + "schema_version": 2, + "package_id": f"oscan-{variant['profile']}", "version": version, "target": variant["target"], - "backend": variant["backend"], - "available_backends": [variant["backend"]], - "default_backend": variant["distribution_backend"], - "cargo_feature": variant["cargo_feature"], + "profile": variant["profile"], + "is_distribution": True, + "available_backends": list(variant["available_backends"]), + "default_backend": variant["default_backend"], "cargo_features": list(variant["build_features"]), "toolchain_free": variant["toolchain_free"], "link_mode": variant.get("link_mode"), @@ -2244,24 +2272,23 @@ def stage_direct_link_sidecar( } -def stage_freestanding_runtime_archives( +def stage_runtime_archives( bundle_dir: Path, variant: dict, runtime_archive_dir: Path ) -> dict: - """Stage exactly the freestanding archive/manifest pairs this variant - declares — no hosted archive, no runtime sources, no runtime builder.""" + """Stage exactly the archive/manifest pairs this variant declares.""" target = variant["target"] runtime_contract = load_runtime_archive_contract(RUNTIME_ARCHIVE_CONTRACT_PATH) destination = bundle_dir / "build" / "runtime-archives" / target digests: dict = {} for profile in variant["runtime_profiles"]: - if profile not in FREESTANDING_PROFILES: - fail(f"runtime profile '{profile}' is not a freestanding profile") + if profile not in RUNTIME_PROFILES: + fail(f"unknown runtime profile '{profile}'") mode_spec = runtime_contract["modes"][profile] archive_path = runtime_archive_dir / mode_spec["archive_name"] manifest_path = runtime_archive_dir / mode_spec["manifest_name"] if not archive_path.is_file() or not manifest_path.is_file(): fail( - f"freestanding runtime {profile} archive pair for '{target}' is missing from " + f"runtime {profile} archive pair for '{target}' is missing from " f"{runtime_archive_dir}; build it before staging the release" ) try: @@ -2936,7 +2963,10 @@ def reject_removed_staging_inputs(args: argparse.Namespace) -> None: def stage_release(args: argparse.Namespace) -> int: contract_path = Path(args.contract).resolve() contract = load_release_contract(contract_path) - variant = resolve_release_variant(contract, contract_path, args.target, args.backend) + profile = getattr(args, "profile", None) or getattr(args, "backend", None) + if profile is None: + fail("stage-release requires --profile") + variant = resolve_release_variant(contract, contract_path, args.target, profile) target = variant["target"] platform = variant["platform"] version = args.version @@ -3014,7 +3044,7 @@ def stage_release(args: argparse.Namespace) -> int: else REPO_ROOT / "build" / "runtime-archives" / target ) component_digests.update( - stage_freestanding_runtime_archives(bundle_dir, variant, runtime_archive_dir) + stage_runtime_archives(bundle_dir, variant, runtime_archive_dir) ) provider_info = None @@ -3056,9 +3086,9 @@ def stage_release(args: argparse.Namespace) -> int: component_digests, ) - if variant["backend_kind"] == "object": + if variant["package_kind"] == "object": assert_object_package_is_toolchain_free(bundle_dir, variant) - else: + elif variant["package_kind"] == "c-source": assert_c_package_has_no_object_payload(bundle_dir, variant) archive_path = output_dir / archive_name @@ -3083,12 +3113,12 @@ def stage_release(args: argparse.Namespace) -> int: def ci_target_matrix(contract: dict, version: str) -> list[dict]: """The release workflow's package matrix: one entry per *target*. - Deliberately not one entry per (target, backend): every backend variant + Deliberately not one entry per (target, profile): every profile variant of a target reuses the same pinned toolchain download, the same freestanding runtime archives, and the same native-link sidecar, so - fanning out per backend would repeat the most expensive work in the - release three times per platform. The per-target entry therefore carries - the canonical backend list, and the job builds/assembles/smokes them + fanning out per profile would repeat the most expensive work in the + release once per profile. The per-target entry therefore carries + the canonical profile list, and the job builds/assembles/smokes them sequentially. """ entries: list[dict] = [] @@ -3098,16 +3128,16 @@ def ci_target_matrix(contract: dict, version: str) -> list[dict]: platform = target.split("-", 1)[0] if platform not in CI_RUNNERS: fail(f"release contract target '{target}' has no CI runner mapping") - declared = target_spec["backends"] - unknown = sorted(set(declared) - set(CANONICAL_BACKENDS)) + declared = target_spec["profiles"] + unknown = sorted(set(declared) - set(CANONICAL_PROFILES)) if unknown: fail( - f"release contract target '{target}' declares non-canonical backend(s): " + f"release contract target '{target}' declares non-canonical profile(s): " f"{', '.join(unknown)}" ) - backends = [backend for backend in CANONICAL_BACKENDS if backend in declared] - if not backends: - fail(f"release contract target '{target}' declares no backends") + profiles = [profile for profile in CANONICAL_PROFILES if profile in declared] + if not profiles: + fail(f"release contract target '{target}' declares no profiles") archives: list[str] = [] runtime_profiles: list[str] = [] @@ -3115,17 +3145,17 @@ def ci_target_matrix(contract: dict, version: str) -> list[dict]: needs_provider_archive = False needs_native_link = False needs_inprocess_toolchain = False - for backend in backends: - variant = declared[backend] + for profile in profiles: + variant = declared[profile] archive_name = render_release_template( variant["archive_name_template"], version, "archive_name_template" ) if archive_name in rendered_archives: fail( f"release archive name '{archive_name}' is produced by both " - f"{rendered_archives[archive_name]} and {target}/{backend}" + f"{rendered_archives[archive_name]} and {target}/{profile}" ) - rendered_archives[archive_name] = f"{target}/{backend}" + rendered_archives[archive_name] = f"{target}/{profile}" archives.append(archive_name) components = variant["components"] if "c_toolchain" in components: @@ -3156,7 +3186,7 @@ def ci_target_matrix(contract: dict, version: str) -> list[dict]: "os": CI_RUNNERS[platform], "binary_name": target_spec["binary_name"], "binary_path": f"target/release/{target_spec['binary_name']}", - "backends": ",".join(backends), + "profiles": ",".join(profiles), "archives": ",".join(archives), "runtime_profiles": ",".join(runtime_profiles), "needs_base_toolchain": "true" if needs_base_toolchain else "false", @@ -3165,7 +3195,7 @@ def ci_target_matrix(contract: dict, version: str) -> list[dict]: "needs_inprocess_toolchain": ( "true" if needs_inprocess_toolchain else "false" ), - "msi_backend": "llvm" if platform == "windows" and "llvm" in backends else "", + "msi_profile": "llvm" if platform == "windows" and "llvm" in profiles else "", } ) if not entries: @@ -3188,13 +3218,14 @@ def expected_package_metadata(variant: dict, version: str) -> dict: being described by whatever it happens to contain. """ return { - "schema_version": 1, + "schema_version": 2, + "package_id": f"oscan-{variant['profile']}", "version": version, "target": variant["target"], - "backend": variant["backend"], - "available_backends": [variant["backend"]], - "default_backend": variant["distribution_backend"], - "cargo_feature": variant["cargo_feature"], + "profile": variant["profile"], + "is_distribution": True, + "available_backends": list(variant["available_backends"]), + "default_backend": variant["default_backend"], "cargo_features": list(variant["build_features"]), "toolchain_free": variant["toolchain_free"], "link_mode": variant.get("link_mode"), @@ -3290,7 +3321,7 @@ def _verify_sidecar_component(root: Path, variant: dict) -> dict: def _verify_runtime_archive_component(root: Path, variant: dict) -> None: - """Exactly the declared freestanding archive/manifest pairs, at the one + """Exactly the declared runtime archive/manifest pairs, at the one fixed executable-relative location a packaged compiler looks in.""" target = variant["target"] runtime_contract = load_runtime_archive_contract(RUNTIME_ARCHIVE_CONTRACT_PATH) @@ -3412,7 +3443,7 @@ def _verify_c_toolchain_component(root: Path, variant: dict, contract: dict) -> for relative in llvm_provider_staged_paths(manifest) if (toolchain_root / safe_relative_path(relative)).exists() ) - if excluded: + if excluded and "llvm_provider" not in variant["components"]: fail( f"{variant['target']}/{variant['backend']} is a C package and must not ship the " f"separately overlaid LLVM provider payload: {', '.join(excluded)}" @@ -3473,6 +3504,12 @@ def _verify_inprocess_package_payload(root: Path, variant: dict) -> None: if (notice_root / safe_relative_path(relative)).stat().st_size == 0: fail(f"embedded-input notice is empty: LICENSES/embedded/{relative}") + # The Windows full profile additionally carries the independently + # verified C toolchain. Exact single-executable closure remains a slim + # LLVM invariant; component verifiers cover the full profile's union. + if variant["profile"] == "full": + return + expected_files = { variant["binary_name"], PACKAGE_METADATA_NAME, @@ -3566,7 +3603,7 @@ def verify_package_layout( root: Path, contract_path: Path, target: str, - backend: str, + profile: str, version: str | None = None, archive: Path | None = None, expect_archive_root_name: bool = False, @@ -3579,7 +3616,7 @@ def verify_package_layout( be tested hermetically against staged fixture packages. """ contract = load_release_contract(contract_path) - variant = resolve_release_variant(contract, contract_path, target, backend) + variant = resolve_release_variant(contract, contract_path, target, profile) if not root.is_dir(): fail(f"package root '{root}' is not a directory") @@ -3624,12 +3661,14 @@ def verify_package_layout( _verify_c_toolchain_component(root, variant, contract) _verify_absent_components(root, variant) - if variant["backend_kind"] == "object": + if variant["package_kind"] == "object": _verify_object_package_payload(root, variant) if variant.get("link_mode") == "inprocess": _verify_inprocess_package_payload(root, variant) - else: + elif variant["package_kind"] == "c-source": _verify_c_package_payload(root, variant) + elif variant.get("link_mode") == "inprocess": + _verify_inprocess_package_payload(root, variant) if variant.get("note_file") and not (root / Path(variant["note_file"]).name).is_file(): fail(f"package is missing the contract note file '{variant['note_file']}'") @@ -3637,17 +3676,20 @@ def verify_package_layout( def verify_package_layout_command(args: argparse.Namespace) -> int: + profile = getattr(args, "profile", None) or getattr(args, "backend", None) + if profile is None: + fail("verify-package-layout requires --profile") metadata = verify_package_layout( Path(args.root).resolve(), Path(args.contract).resolve(), args.target, - args.backend, + profile, version=args.version, archive=Path(args.archive).resolve() if args.archive else None, expect_archive_root_name=args.stage == "extracted", ) print( - f"{args.target}/{args.backend} {args.stage} package layout OK " + f"{args.target}/{profile} {args.stage} package layout OK " f"(version {metadata['version']}, components: {', '.join(metadata['components'])})" ) return 0 @@ -3658,10 +3700,11 @@ def list_variants_command(args: argparse.Namespace) -> int: matrix = release_variant_matrix(contract) if args.target: matrix = [entry for entry in matrix if entry["target"] == args.target] - if args.backend: - matrix = [entry for entry in matrix if entry["backend"] == args.backend] + profile = getattr(args, "profile", None) or getattr(args, "backend", None) + if profile: + matrix = [entry for entry in matrix if entry["profile"] == profile] if not matrix: - fail("no release variant matches the requested target/backend") + fail("no release variant matches the requested target/profile") print(json.dumps(matrix, indent=2, sort_keys=True)) return 0 @@ -3670,7 +3713,7 @@ def validate_contract_command(args: argparse.Namespace) -> int: contract_path = Path(args.contract).resolve() contract = load_release_contract(contract_path) count = len(release_variant_matrix(contract)) - print(f"{contract_path}: schema 2 OK, {count} variants") + print(f"{contract_path}: schema 3 OK, {count} variants") return 0 @@ -5216,10 +5259,12 @@ def build_parser() -> argparse.ArgumentParser: stage = subparsers.add_parser("stage-release") stage.add_argument("--target", required=True) stage.add_argument( + "--profile", "--backend", + dest="profile", required=True, - choices=list(CANONICAL_BACKENDS), - help="which backend variant to package ('native' is only a deprecated CLI alias for cranelift and is never an artifact label)", + choices=list(CANONICAL_PROFILES), + help="which package profile to stage (--backend is a compatibility spelling)", ) stage.add_argument("--contract", default=str(CONTRACT_PATH)) stage.add_argument("--version", required=True) @@ -5342,22 +5387,34 @@ def build_parser() -> argparse.ArgumentParser: variants = subparsers.add_parser( "list-variants", - help="print the target x backend release matrix this contract publishes", + help="print the target x profile release matrix this contract publishes", ) variants.add_argument("--contract", default=str(CONTRACT_PATH)) variants.add_argument("--target", default=None) - variants.add_argument("--backend", default=None, choices=list(CANONICAL_BACKENDS)) + variants.add_argument( + "--profile", + "--backend", + dest="profile", + default=None, + choices=list(CANONICAL_PROFILES), + ) variants.set_defaults(func=list_variants_command) verify_layout = subparsers.add_parser( "verify-package-layout", help=( - "assert an extracted or installed package is exactly the (target, backend) variant " + "assert an extracted or installed package is exactly the (target, profile) variant " "the contract describes: metadata fields, component presence, and component absence" ), ) verify_layout.add_argument("--target", required=True) - verify_layout.add_argument("--backend", required=True, choices=list(CANONICAL_BACKENDS)) + verify_layout.add_argument( + "--profile", + "--backend", + dest="profile", + required=True, + choices=list(CANONICAL_PROFILES), + ) verify_layout.add_argument( "--root", required=True, diff --git a/scripts/smoke-release.ps1 b/scripts/smoke-release.ps1 index a85001b..fd0ff93 100644 --- a/scripts/smoke-release.ps1 +++ b/scripts/smoke-release.ps1 @@ -4,12 +4,11 @@ param( [ValidateSet("windows-x86_64", "linux-x86_64", "macos-x86_64")] [string]$Target, - # Which backend-specific package this archive is. The canonical names are - # the only artifact labels; 'native' is a deprecated CLI alias for - # cranelift and is never a package name. + # Which package profile this archive is. [Parameter(Mandatory = $true)] - [ValidateSet("llvm", "cranelift", "c")] - [string]$Backend, + [Alias("Backend")] + [ValidateSet("full", "llvm", "cranelift", "c")] + [string]$Profile, [Parameter(Mandatory = $true, ParameterSetName = "Archive")] [string]$ArchivePath, @@ -30,8 +29,8 @@ param( # Variant-aware release smoke test. # -# Every published artifact is one (target, backend) pair, so this script -# takes the backend explicitly and checks *that* variant's promises: +# Every published artifact is one (target, profile) pair, so this script +# takes the profile explicitly and checks *that* variant's promises: # # * the archive is the contract's archive (name, suffix, archive root); # * the package contains exactly the components the contract declares and @@ -45,6 +44,7 @@ param( # refuse — by name, with no fallback — everything they do not contain. $ErrorActionPreference = "Stop" +$Backend = $Profile $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path if ($PSCmdlet.ParameterSetName -eq "Archive") { @@ -64,21 +64,26 @@ if (-not $ContractPath) { $platform = $Target.Split("-", 2)[0] $contract = Get-Content $ContractPath -Raw | ConvertFrom-Json -AsHashtable -if ($contract["schema_version"] -ne 2) { - throw "Release contract $ContractPath is not schema 2 (got '$($contract["schema_version"])')." +if ($contract["schema_version"] -ne 3) { + throw "Release contract $ContractPath is not schema 3 (got '$($contract["schema_version"])')." } if (-not $contract["variants"].ContainsKey($Target)) { $known = ($contract["variants"].Keys | Sort-Object) -join ", " throw "Release contract does not define target '$Target' (known: $known)." } $targetSpec = $contract["variants"][$Target] -if (-not $targetSpec["backends"].ContainsKey($Backend)) { - $known = ($targetSpec["backends"].Keys | Sort-Object) -join ", " - throw "Release contract does not define backend '$Backend' for target '$Target' (known: $known)." -} -$variant = $targetSpec["backends"][$Backend] -$backendKind = [string]$contract["backends"][$Backend]["kind"] +if (-not $targetSpec["profiles"].ContainsKey($Profile)) { + $known = ($targetSpec["profiles"].Keys | Sort-Object) -join ", " + throw "Release contract does not define profile '$Profile' for target '$Target' (known: $known)." +} +$variant = $targetSpec["profiles"][$Profile] +$profileSpec = $contract["profiles"][$Profile] +$availableBackends = @($profileSpec["backends"]) +$defaultBackend = [string]$profileSpec["default_backend"] +$backendKind = if ($Profile -eq "full") { "full" } else { [string]$contract["backends"][$Profile]["kind"] } +$isFullPackage = $Profile -eq "full" $isObjectPackage = $backendKind -eq "object" +$isObjectCapable = $availableBackends -contains "llvm" -or $availableBackends -contains "cranelift" $linkMode = [string]($variant["link_mode"] ?? "") $usesSidecar = $linkMode -eq "sidecar" $requiresHostCompiler = [bool]($variant["requires_host_compiler"] ?? $false) @@ -249,7 +254,7 @@ function Get-NoHostToolPath { return "$BlockDir$([System.IO.Path]::PathSeparator)$SavedPath" } -$BlockedHostToolDir = New-BlockedHostToolDir -ScratchDir $ScratchDir -IncludeLinkers:$isObjectPackage +$BlockedHostToolDir = New-BlockedHostToolDir -ScratchDir $ScratchDir -IncludeLinkers:$isObjectCapable function Invoke-PackagedOscan { <# @@ -540,7 +545,7 @@ if ($PSCmdlet.ParameterSetName -eq "Archive") { $layoutArgs = @( $ReleaseTools, "verify-package-layout", "--target", $Target, - "--backend", $Backend, + "--profile", $Profile, "--root", $BundleDir, "--stage", "extracted", "--archive", $ArchivePath, @@ -554,30 +559,32 @@ if ($PSCmdlet.ParameterSetName -eq "Archive") { throw "Extracted $Target/$Backend package does not match the release contract." } - $InstallDir = Join-Path $ScratchDir "install" + $packageMetadata = Get-Content (Join-Path $BundleDir "oscan-package.json") -Raw | ConvertFrom-Json + $InstallRoot = Join-Path $ScratchDir "install" + $InstallDir = Join-Path (Join-Path (Join-Path $InstallRoot "profiles") $Profile) ([string]$packageMetadata.version) $BinDir = Join-Path $ScratchDir "bin" if ($platform -eq "windows") { # install.ps1 is a PowerShell script with its own error handling; it # throws on failure, and $LASTEXITCODE here belongs to the robocopy it # ran internally (1 means "files copied"), so it must not be read. - & (Join-Path $BundleDir "install.ps1") -InstallDir $InstallDir -BinDir $BinDir -NoPathUpdate + & (Join-Path $BundleDir "install.ps1") -InstallRoot $InstallRoot -BinDir $BinDir -NoPathUpdate # Compiles run the installed executable by absolute path. Inside a # no-host-tool body PATH is the blocker directory alone, which leaves # no interpreter for a .cmd shim. The shim is still exercised by the # --version check below with the ordinary PATH. $OscanCommand = Join-Path $InstallDir $binaryName - $OscanShim = Join-Path $BinDir "oscan.cmd" + $OscanShim = Join-Path $BinDir "oscan-$Profile.cmd" if (-not (Test-Path -LiteralPath $OscanShim -PathType Leaf)) { - throw "install.ps1 did not create the oscan.cmd shim in $BinDir" + throw "install.ps1 did not create the oscan-$Profile.cmd shim in $BinDir" } } else { - & sh (Join-Path $BundleDir "install.sh") --source-dir $BundleDir --install-dir $InstallDir --bin-dir $BinDir + & sh (Join-Path $BundleDir "install.sh") --source-dir $BundleDir --install-root $InstallRoot --bin-dir $BinDir if ($LASTEXITCODE -ne 0) { throw "install.sh failed for $Target/$Backend" } # The bin-directory symlink, so every invocation also proves the # compiler resolves its package through its real executable path. - $OscanCommand = Join-Path $BinDir "oscan" + $OscanCommand = Join-Path $BinDir "oscan-$Profile" $OscanShim = $OscanCommand } } else { @@ -592,7 +599,7 @@ if (-not (Test-Path -LiteralPath $OscanCommand)) { $installedLayoutArgs = @( $ReleaseTools, "verify-package-layout", "--target", $Target, - "--backend", $Backend, + "--profile", $Profile, "--root", $InstallDir, "--stage", "installed", "--contract", $ContractPath @@ -618,11 +625,12 @@ if ($LASTEXITCODE -ne 0) { throw "Packaged 'oscan --version' failed:`n$versionText" } Set-Content -LiteralPath $VersionLog -Value $versionText +$backendList = $availableBackends -join ", " foreach ($expected in @( - "(?m)^backends: $Backend[ \t\r]*$", - "(?m)^default-backend: $Backend[ \t\r]*$", - "(?m)^distribution: $Backend[ \t\r]*$", - "(?m)^toolchain-free: $(if ($isObjectPackage) { 'yes' } else { 'no' })[ \t\r]*$" + "(?m)^backends: $([regex]::Escape($backendList))[ \t\r]*$", + "(?m)^default-backend: $defaultBackend[ \t\r]*$", + "(?m)^distribution: $Profile[ \t\r]*$", + "(?m)^toolchain-free: $(if ([bool]$variant['toolchain_free']) { 'yes' } else { 'no' })[ \t\r]*$" )) { if ($versionText -notmatch $expected) { throw "Packaged 'oscan --version' does not report /$expected/:`n$versionText" @@ -642,7 +650,7 @@ fn! main() { '@ $exeSuffix = if ($platform -eq "windows") { ".exe" } else { "" } -if ($isObjectPackage -and $platform -eq "windows") { +if ($isObjectCapable -and $platform -eq "windows") { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal]::new($identity) $isWindowsAdministrator = $principal.IsInRole( @@ -656,7 +664,85 @@ if ($isObjectPackage -and $platform -eq "windows") { # runner is elevated. $ElevatedOptIn = if ($isWindowsAdministrator) { @("--allow-elevated-native-link") } else { @() } -if ($isObjectPackage) { +if ($isFullPackage) { + # The full profile must exercise its deterministic default and every + # explicitly selectable backend from the installed package alone. + $defaultOutput = Join-Path $ScratchDir "hello-default$exeSuffix" + $defaultLog = Join-Path $ScratchDir "default.stderr.txt" + $defaultArgs = @("--verbose") + $ElevatedOptIn + @($SampleSource, "-o", $defaultOutput) + $exitCode = Invoke-PackagedOscan -Arguments $defaultArgs -LogPath $defaultLog -BlockHostTools + if ($exitCode -ne 0) { + throw "Packaged full-profile default compile failed:`n$(Get-LogText -LogPath $defaultLog)" + } + Assert-LogMatches -LogPath $defaultLog -Pattern "(?m)^\[verbose\] $defaultBackend backend target:" ` + -What "Packaged full-profile default compile" + if ($usesSidecar) { + Assert-PackagedSidecarAssets -LogPath $defaultLog ` + -SidecarRoot (Join-Path $InstallDir $sidecarDirName) ` + -ManifestName $sidecarManifestName ` + -What "Packaged full-profile default compile" + } else { + Assert-PackagedInProcessLinking -LogPath $defaultLog ` + -What "Packaged full-profile default compile" + } + Assert-ProgramRuns -ExePath $defaultOutput -What "Packaged full-profile default compile" + + foreach ($selectedBackend in $availableBackends) { + $output = Join-Path $ScratchDir "hello-$selectedBackend$exeSuffix" + $log = Join-Path $ScratchDir "$selectedBackend.stderr.txt" + $backendOptIn = if ($selectedBackend -eq "c") { @() } else { $ElevatedOptIn } + $arguments = @("--verbose", "--backend", $selectedBackend) + $backendOptIn + + @($SampleSource, "-o", $output) + $exitCode = Invoke-PackagedOscan -Arguments $arguments -LogPath $log -BlockHostTools + if ($exitCode -ne 0) { + throw "Packaged full-profile '--backend $selectedBackend' compile failed:`n$(Get-LogText -LogPath $log)" + } + if ($selectedBackend -eq "c") { + Assert-LogMatches -LogPath $log -Pattern "Compiling with .+ \(bundled" ` + -What "Packaged full-profile C compile" + } else { + Assert-LogMatches -LogPath $log ` + -Pattern "(?m)^\[verbose\] $selectedBackend backend target:" ` + -What "Packaged full-profile $selectedBackend compile" + if ($usesSidecar) { + Assert-PackagedSidecarAssets -LogPath $log ` + -SidecarRoot (Join-Path $InstallDir $sidecarDirName) ` + -ManifestName $sidecarManifestName ` + -What "Packaged full-profile $selectedBackend compile" + } else { + Assert-PackagedInProcessLinking -LogPath $log ` + -What "Packaged full-profile $selectedBackend compile" + } + } + Assert-ProgramRuns -ExePath $output -What "Packaged full-profile $selectedBackend compile" + } + + $hostedOutput = Join-Path $ScratchDir "hello-hosted$exeSuffix" + $hostedLog = Join-Path $ScratchDir "hosted.stderr.txt" + $hostedArgs = @("--verbose", "--libc") + $ElevatedOptIn + + @($SampleSource, "-o", $hostedOutput) + $exitCode = Invoke-PackagedOscan -Arguments $hostedArgs -LogPath $hostedLog -BlockHostTools + if ($exitCode -ne 0) { + throw "Packaged full-profile '--libc' compile failed:`n$(Get-LogText -LogPath $hostedLog)" + } + Assert-LogMatches -LogPath $hostedLog -Pattern "Linking hosted executable with .+ \(bundled\)" ` + -What "Packaged full-profile hosted compile" + Assert-ProgramRuns -ExePath $hostedOutput -What "Packaged full-profile hosted compile" + + $extraCSource = Join-Path $ScratchDir "extra.c" + Set-Content -Path $extraCSource -Encoding ASCII -Value "int oscan_smoke_extra(void) { return 0; }" + $extraOutput = Join-Path $ScratchDir "hello-extra-c$exeSuffix" + $extraLog = Join-Path $ScratchDir "extra-c.stderr.txt" + $extraArgs = @("--verbose", "--extra-c", $extraCSource) + $ElevatedOptIn + + @($SampleSource, "-o", $extraOutput) + $exitCode = Invoke-PackagedOscan -Arguments $extraArgs -LogPath $extraLog -BlockHostTools + if ($exitCode -ne 0) { + throw "Packaged full-profile '--extra-c' compile failed:`n$(Get-LogText -LogPath $extraLog)" + } + Assert-LogMatches -LogPath $extraLog -Pattern "Linking freestanding executable with .+ \(bundled\)" ` + -What "Packaged full-profile extra-C compile" + Assert-ProgramRuns -ExePath $extraOutput -What "Packaged full-profile extra-C compile" +} elseif ($isObjectPackage) { # 1. The default backend: a distribution build defaults to the one # backend it ships, deterministically and without probing. $defaultOutput = Join-Path $ScratchDir "hello-default$exeSuffix" @@ -752,7 +838,7 @@ if ($isObjectPackage) { -ExpectedPatterns @( "the c backend is not included in this compiler build", "this build includes: $Backend", - "archive name ends in '-c'" + "archive name ends in '-full' or '-c'" ) ` -What "'--backend c' in the $Target/$Backend package" @@ -761,7 +847,7 @@ if ($isObjectPackage) { -OutputPath (Join-Path $ScratchDir "refused-other$exeSuffix") ` -ExpectedPatterns @( "the $otherObjectBackend backend is not included in this compiler build", - "archive name ends in '-$otherObjectBackend'" + "archive name ends in '-full' or '-$otherObjectBackend'" ) ` -What "'--backend $otherObjectBackend' in the $Target/$Backend package" @@ -832,7 +918,7 @@ if ($isObjectPackage) { -ExpectedPatterns @( "the $missing backend is not included in this compiler build", "this build includes: c", - "archive name ends in '-$missing'" + "archive name ends in '-full' or '-$missing'" ) ` -What "'--backend $missing' in the $Target/$Backend package" } diff --git a/scripts/smoke-release.sh b/scripts/smoke-release.sh index 647d31d..24fc3f1 100644 --- a/scripts/smoke-release.sh +++ b/scripts/smoke-release.sh @@ -1,7 +1,7 @@ #!/usr/bin/env sh # Variant-aware release smoke test (POSIX shell port of smoke-release.ps1). # -# Every published artifact is one (target, backend) pair, so --backend is +# Every published artifact is one (target, profile) pair, so --profile is # mandatory here too. The package layout/metadata assertions are delegated to # 'release_tools.py verify-package-layout', which the PowerShell smoke test # also runs, so both check identical facts about the same contract; what @@ -12,14 +12,14 @@ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" REPO_ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)" TARGET="" -BACKEND="" +PROFILE="" ARCHIVE_PATH="" VERSION="" SCRATCH_DIR="" CONTRACT_PATH="$REPO_ROOT/packaging/toolchains/release-contract.json" usage() { - echo "usage: $0 --target --backend --archive [--version ] [--scratch-dir ] [--contract ]" >&2 + echo "usage: $0 --target --profile --archive [--version ] [--scratch-dir ] [--contract ]" >&2 } fail() { @@ -33,8 +33,8 @@ while [ "$#" -gt 0 ]; do TARGET="$2" shift 2 ;; - --backend) - BACKEND="$2" + --profile|--backend) + PROFILE="$2" shift 2 ;; --archive) @@ -61,15 +61,15 @@ while [ "$#" -gt 0 ]; do done [ -n "$TARGET" ] || { echo "missing --target" >&2; usage; exit 1; } -[ -n "$BACKEND" ] || { echo "missing --backend (llvm|cranelift|c)" >&2; usage; exit 1; } +[ -n "$PROFILE" ] || { echo "missing --profile (full|llvm|cranelift|c)" >&2; usage; exit 1; } [ -n "$ARCHIVE_PATH" ] || { echo "missing --archive" >&2; usage; exit 1; } -case "$BACKEND" in - llvm|cranelift|c) ;; +case "$PROFILE" in + full|llvm|cranelift|c) ;; native) - fail "'native' is a deprecated CLI alias for the cranelift backend and is never a package label; pass --backend cranelift" + fail "'native' is a deprecated CLI alias for the cranelift backend and is never a package label; pass --profile cranelift" ;; *) - fail "unknown backend '$BACKEND' (expected llvm, cranelift or c)" + fail "unknown profile '$PROFILE' (expected full, llvm, cranelift or c)" ;; esac case "$TARGET" in @@ -81,7 +81,7 @@ esac ARCHIVE_PATH="$(CDPATH= cd -- "$(dirname -- "$ARCHIVE_PATH")" && pwd)/$(basename -- "$ARCHIVE_PATH")" if [ -z "$SCRATCH_DIR" ]; then - SCRATCH_DIR="$REPO_ROOT/target/release-smoke/$TARGET-$BACKEND" + SCRATCH_DIR="$REPO_ROOT/target/release-smoke/$TARGET-$PROFILE" fi PYTHON="$(command -v python3 || command -v python || true)" @@ -89,28 +89,28 @@ PYTHON="$(command -v python3 || command -v python || true)" # Resolve exactly the facts this variant promises, from the contract. eval "$( - "$PYTHON" - "$CONTRACT_PATH" "$TARGET" "$BACKEND" <<'PY' + "$PYTHON" - "$CONTRACT_PATH" "$TARGET" "$PROFILE" <<'PY' import json import shlex import sys -contract_path, target, backend = sys.argv[1], sys.argv[2], sys.argv[3] +contract_path, target, profile = sys.argv[1], sys.argv[2], sys.argv[3] with open(contract_path, encoding="utf-8") as handle: contract = json.load(handle) -if contract.get("schema_version") != 2: +if contract.get("schema_version") != 3: raise SystemExit( - f"release contract {contract_path} is not schema 2 " + f"release contract {contract_path} is not schema 3 " f"(got {contract.get('schema_version')!r})" ) target_spec = contract["variants"].get(target) if target_spec is None: raise SystemExit(f"release contract does not define target '{target}'") -variant = target_spec["backends"].get(backend) +variant = target_spec["profiles"].get(profile) if variant is None: - known = ", ".join(sorted(target_spec["backends"])) + known = ", ".join(sorted(target_spec["profiles"])) raise SystemExit( - f"release contract does not define backend '{backend}' for target '{target}' " + f"release contract does not define profile '{profile}' for target '{target}' " f"(known: {known})" ) @@ -121,13 +121,20 @@ def emit(name: str, value: str) -> None: emit("ARCHIVE_FORMAT", target_spec["archive_format"]) emit("BINARY_NAME", target_spec["binary_name"]) -emit("BACKEND_KIND", contract["backends"][backend]["kind"]) +emit("BACKEND_KIND", "full" if profile == "full" else contract["backends"][profile]["kind"]) +emit("AVAILABLE_BACKENDS", " ".join(contract["profiles"][profile]["backends"])) +emit("BACKEND_LIST", ", ".join(contract["profiles"][profile]["backends"])) +emit("DEFAULT_BACKEND", contract["profiles"][profile]["default_backend"]) emit("COMPONENTS", ",".join(variant["components"])) emit("RUNTIME_PROFILES", ",".join(variant["runtime_profiles"])) emit("REQUIRES_HOST_COMPILER", "1" if variant.get("requires_host_compiler") else "0") +emit("TOOLCHAIN_FREE", "yes" if variant.get("toolchain_free") else "no") PY )" +# Compatibility variable for the slim-profile checks below. +BACKEND="$PROFILE" + case "$ARCHIVE_FORMAT" in zip) EXPECTED_SUFFIX=".zip" ;; tar.gz) EXPECTED_SUFFIX=".tar.gz" ;; @@ -144,6 +151,13 @@ if [ "$BACKEND_KIND" = "object" ]; then else IS_OBJECT_PACKAGE=0 fi +if [ "$BACKEND_KIND" = "full" ]; then + IS_FULL_PACKAGE=1 + IS_OBJECT_CAPABLE=1 +else + IS_FULL_PACKAGE=0 + IS_OBJECT_CAPABLE="$IS_OBJECT_PACKAGE" +fi rm -rf "$SCRATCH_DIR" mkdir -p "$SCRATCH_DIR/extract" @@ -167,7 +181,7 @@ SCRUBBED="-u OSCAN_NO_TOOLCHAIN -u OSCAN_CC -u OSCAN_TOOLCHAIN_DIR -u OSCAN_LLVM BLOCK_DIR="$SCRATCH_DIR/blocked-host-tools" mkdir -p "$BLOCK_DIR" BLOCKED_TOOLS="cc gcc g++ clang clang++ x86_64-linux-musl-gcc" -if [ "$IS_OBJECT_PACKAGE" -eq 1 ]; then +if [ "$IS_OBJECT_CAPABLE" -eq 1 ]; then BLOCKED_TOOLS="$BLOCKED_TOOLS ld ld.lld lld x86_64-linux-musl-ld" fi for NAME in $BLOCKED_TOOLS; do @@ -261,32 +275,34 @@ BUNDLE_COUNT="$(find "$SCRATCH_DIR/extract" -mindepth 1 -maxdepth 1 -type d | wc BUNDLE_DIR="$(find "$SCRATCH_DIR/extract" -mindepth 1 -maxdepth 1 -type d)" set -- "$SCRIPT_DIR/release_tools.py" verify-package-layout \ - --target "$TARGET" --backend "$BACKEND" \ + --target "$TARGET" --profile "$PROFILE" \ --root "$BUNDLE_DIR" --stage extracted \ --archive "$ARCHIVE_PATH" --contract "$CONTRACT_PATH" if [ -n "$VERSION" ]; then set -- "$@" --version "$VERSION" fi -"$PYTHON" "$@" || fail "extracted $TARGET/$BACKEND package does not match the release contract" +"$PYTHON" "$@" || fail "extracted $TARGET/$PROFILE package does not match the release contract" # --- install ----------------------------------------------------------------- -INSTALL_DIR="$SCRATCH_DIR/install" +INSTALL_ROOT="$SCRATCH_DIR/install" BIN_DIR="$SCRATCH_DIR/bin" -sh "$BUNDLE_DIR/install.sh" --source-dir "$BUNDLE_DIR" --install-dir "$INSTALL_DIR" --bin-dir "$BIN_DIR" +PACKAGE_VERSION="$("$PYTHON" -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["version"])' "$BUNDLE_DIR/oscan-package.json")" +INSTALL_DIR="$INSTALL_ROOT/profiles/$PROFILE/$PACKAGE_VERSION" +sh "$BUNDLE_DIR/install.sh" --source-dir "$BUNDLE_DIR" --install-root "$INSTALL_ROOT" --bin-dir "$BIN_DIR" -OSCAN_COMMAND="$BIN_DIR/$BINARY_NAME" +OSCAN_COMMAND="$BIN_DIR/oscan-$PROFILE" [ -x "$OSCAN_COMMAND" ] || OSCAN_COMMAND="$INSTALL_DIR/$BINARY_NAME" [ -x "$OSCAN_COMMAND" ] || fail "installed oscan command was not found under $BIN_DIR or $INSTALL_DIR" SAVED_PATH="$PATH" set -- "$SCRIPT_DIR/release_tools.py" verify-package-layout \ - --target "$TARGET" --backend "$BACKEND" \ + --target "$TARGET" --profile "$PROFILE" \ --root "$INSTALL_DIR" --stage installed --contract "$CONTRACT_PATH" if [ -n "$VERSION" ]; then set -- "$@" --version "$VERSION" fi -"$PYTHON" "$@" || fail "installed $TARGET/$BACKEND package does not match the release contract" +"$PYTHON" "$@" || fail "installed $TARGET/$PROFILE package does not match the release contract" # --- identity ---------------------------------------------------------------- @@ -295,15 +311,10 @@ VERSION_LOG="$SCRATCH_DIR/version.txt" cat "$VERSION_LOG" >&2 fail "packaged 'oscan --version' failed" } -if [ "$IS_OBJECT_PACKAGE" -eq 1 ]; then - EXPECTED_TOOLCHAIN_FREE="yes" -else - EXPECTED_TOOLCHAIN_FREE="no" -fi -assert_matches "$VERSION_LOG" "^backends: $BACKEND\$" "packaged 'oscan --version'" -assert_matches "$VERSION_LOG" "^default-backend: $BACKEND\$" "packaged 'oscan --version'" -assert_matches "$VERSION_LOG" "^distribution: $BACKEND\$" "packaged 'oscan --version'" -assert_matches "$VERSION_LOG" "^toolchain-free: $EXPECTED_TOOLCHAIN_FREE\$" "packaged 'oscan --version'" +assert_matches "$VERSION_LOG" "^backends: $BACKEND_LIST\$" "packaged 'oscan --version'" +assert_matches "$VERSION_LOG" "^default-backend: $DEFAULT_BACKEND\$" "packaged 'oscan --version'" +assert_matches "$VERSION_LOG" "^distribution: $PROFILE\$" "packaged 'oscan --version'" +assert_matches "$VERSION_LOG" "^toolchain-free: $TOOLCHAIN_FREE\$" "packaged 'oscan --version'" if [ -n "$VERSION" ]; then grep -qF "$VERSION" "$VERSION_LOG" || { cat "$VERSION_LOG" >&2 @@ -313,7 +324,66 @@ fi # --- behaviour --------------------------------------------------------------- -if [ "$IS_OBJECT_PACKAGE" -eq 1 ]; then +if [ "$IS_FULL_PACKAGE" -eq 1 ]; then + DEFAULT_OUTPUT="$SCRATCH_DIR/hello-default" + DEFAULT_LOG="$SCRATCH_DIR/default.stderr.txt" + run_packaged "$DEFAULT_LOG" 0 -- --verbose "$SAMPLE_SOURCE" -o "$DEFAULT_OUTPUT" + [ "$STATUS" -eq 0 ] || { + cat "$DEFAULT_LOG" >&2 + fail "packaged full-profile default compile failed" + } + assert_matches "$DEFAULT_LOG" "^\[verbose\] $DEFAULT_BACKEND backend target:" \ + "packaged full-profile default compile" + assert_matches "$DEFAULT_LOG" "^\[verbose\] native-link assets: sidecar \(" \ + "packaged full-profile default compile" + assert_program_runs "$DEFAULT_OUTPUT" "packaged full-profile default compile" + + for SELECTED_BACKEND in $AVAILABLE_BACKENDS; do + OUTPUT="$SCRATCH_DIR/hello-$SELECTED_BACKEND" + LOG="$SCRATCH_DIR/$SELECTED_BACKEND.stderr.txt" + run_packaged "$LOG" 0 -- --verbose --backend "$SELECTED_BACKEND" \ + "$SAMPLE_SOURCE" -o "$OUTPUT" + [ "$STATUS" -eq 0 ] || { + cat "$LOG" >&2 + fail "packaged full-profile '--backend $SELECTED_BACKEND' compile failed" + } + if [ "$SELECTED_BACKEND" = "c" ]; then + assert_matches "$LOG" "Compiling with .+ \(bundled" \ + "packaged full-profile C compile" + else + assert_matches "$LOG" "^\[verbose\] $SELECTED_BACKEND backend target:" \ + "packaged full-profile $SELECTED_BACKEND compile" + assert_matches "$LOG" "^\[verbose\] native-link assets: sidecar \(" \ + "packaged full-profile $SELECTED_BACKEND compile" + fi + assert_program_runs "$OUTPUT" "packaged full-profile $SELECTED_BACKEND compile" + done + + HOSTED_OUTPUT="$SCRATCH_DIR/hello-hosted" + HOSTED_LOG="$SCRATCH_DIR/hosted.stderr.txt" + run_packaged "$HOSTED_LOG" 0 -- --verbose --libc "$SAMPLE_SOURCE" -o "$HOSTED_OUTPUT" + [ "$STATUS" -eq 0 ] || { + cat "$HOSTED_LOG" >&2 + fail "packaged full-profile '--libc' compile failed" + } + assert_matches "$HOSTED_LOG" "Linking hosted executable with .+ \(bundled\)" \ + "packaged full-profile hosted compile" + assert_program_runs "$HOSTED_OUTPUT" "packaged full-profile hosted compile" + + EXTRA_C_SOURCE="$SCRATCH_DIR/extra.c" + printf 'int oscan_smoke_extra(void) { return 0; }\n' > "$EXTRA_C_SOURCE" + EXTRA_OUTPUT="$SCRATCH_DIR/hello-extra-c" + EXTRA_LOG="$SCRATCH_DIR/extra-c.stderr.txt" + run_packaged "$EXTRA_LOG" 0 -- --verbose --extra-c "$EXTRA_C_SOURCE" \ + "$SAMPLE_SOURCE" -o "$EXTRA_OUTPUT" + [ "$STATUS" -eq 0 ] || { + cat "$EXTRA_LOG" >&2 + fail "packaged full-profile '--extra-c' compile failed" + } + assert_matches "$EXTRA_LOG" "Linking freestanding executable with .+ \(bundled\)" \ + "packaged full-profile extra-C compile" + assert_program_runs "$EXTRA_OUTPUT" "packaged full-profile extra-C compile" +elif [ "$IS_OBJECT_PACKAGE" -eq 1 ]; then # 1. The default backend: a distribution build defaults to the one # backend it ships, deterministically and without probing. DEFAULT_OUTPUT="$SCRATCH_DIR/hello-default" @@ -376,7 +446,7 @@ if [ "$IS_OBJECT_PACKAGE" -eq 1 ]; then assert_matches "$REFUSED_LOG" "the c backend is not included in this compiler build" \ "'--backend c' refusal" assert_matches "$REFUSED_LOG" "this build includes: $BACKEND" "'--backend c' refusal" - assert_matches "$REFUSED_LOG" "archive name ends in '-c'" "'--backend c' refusal" + assert_matches "$REFUSED_LOG" "archive name ends in '-full' or '-c'" "'--backend c' refusal" REFUSED_OUTPUT="$SCRATCH_DIR/refused-other" REFUSED_LOG="$SCRATCH_DIR/refused-other-backend.stderr.txt" @@ -386,7 +456,7 @@ if [ "$IS_OBJECT_PACKAGE" -eq 1 ]; then assert_matches "$REFUSED_LOG" \ "the $OTHER_OBJECT_BACKEND backend is not included in this compiler build" \ "'--backend $OTHER_OBJECT_BACKEND' refusal" - assert_matches "$REFUSED_LOG" "archive name ends in '-$OTHER_OBJECT_BACKEND'" \ + assert_matches "$REFUSED_LOG" "archive name ends in '-full' or '-$OTHER_OBJECT_BACKEND'" \ "'--backend $OTHER_OBJECT_BACKEND' refusal" REFUSED_OUTPUT="$SCRATCH_DIR/refused-libc" @@ -463,9 +533,9 @@ else "the $MISSING backend is not included in this compiler build" \ "'--backend $MISSING' refusal" assert_matches "$REFUSED_LOG" "this build includes: c" "'--backend $MISSING' refusal" - assert_matches "$REFUSED_LOG" "archive name ends in '-$MISSING'" \ + assert_matches "$REFUSED_LOG" "archive name ends in '-full' or '-$MISSING'" \ "'--backend $MISSING' refusal" done fi -echo "Release smoke test passed for $TARGET/$BACKEND ($ARCHIVE_PATH)" +echo "Release smoke test passed for $TARGET/$PROFILE ($ARCHIVE_PATH)" diff --git a/scripts/stage-release.ps1 b/scripts/stage-release.ps1 index 3c6e576..921aafc 100644 --- a/scripts/stage-release.ps1 +++ b/scripts/stage-release.ps1 @@ -4,8 +4,9 @@ param( [string]$Target, [Parameter(Mandatory = $true)] - [ValidateSet("llvm", "cranelift", "c")] - [string]$Backend, + [Alias("Backend")] + [ValidateSet("full", "llvm", "cranelift", "c")] + [string]$Profile, [Parameter(Mandatory = $true)] [string]$Version, @@ -86,7 +87,7 @@ $pythonArgs = @( $tool, "stage-release", "--target", $Target, - "--backend", $Backend, + "--profile", $Profile, "--version", $Version, "--binary", $BinaryPath, "--output-dir", $OutputDir, diff --git a/scripts/stage-release.sh b/scripts/stage-release.sh index f84a94c..e3337bc 100644 --- a/scripts/stage-release.sh +++ b/scripts/stage-release.sh @@ -10,14 +10,14 @@ BINARY_PATH="" OUTPUT_DIR="$REPO_ROOT/target/release-artifacts" CONTRACT_PATH="$REPO_ROOT/packaging/toolchains/release-contract.json" RUNTIME_ARCHIVE_DIR="" -BACKEND="" +PROFILE="" NATIVE_LINK_DIR="" EMBEDDED_NOTICES_DIR="" TOOLCHAIN_ARCHIVE="" LLVM_PROVIDER_ARCHIVE="" usage() { - echo "usage: $0 --target --backend --version --binary [--output-dir ] [--contract ] [--runtime-archive-dir ] [--native-link-dir ] [--embedded-notices-dir ] [--toolchain-archive ] [--llvm-provider-archive ]" >&2 + echo "usage: $0 --target --profile --version --binary [--output-dir ] [--contract ] [--runtime-archive-dir ] [--native-link-dir ] [--embedded-notices-dir ] [--toolchain-archive ] [--llvm-provider-archive ]" >&2 } while [ "$#" -gt 0 ]; do @@ -46,8 +46,8 @@ while [ "$#" -gt 0 ]; do RUNTIME_ARCHIVE_DIR="$2" shift 2 ;; - --backend) - BACKEND="$2" + --profile|--backend) + PROFILE="$2" shift 2 ;; --native-link-dir) @@ -82,13 +82,13 @@ while [ "$#" -gt 0 ]; do done [ -n "$TARGET" ] || { echo "missing --target" >&2; exit 1; } -[ -n "$BACKEND" ] || { echo "missing --backend (llvm|cranelift|c)" >&2; exit 1; } +[ -n "$PROFILE" ] || { echo "missing --profile (full|llvm|cranelift|c)" >&2; exit 1; } [ -n "$VERSION" ] || { echo "missing --version" >&2; exit 1; } [ -n "$BINARY_PATH" ] || { echo "missing --binary" >&2; exit 1; } set -- "$SCRIPT_DIR/release_tools.py" stage-release \ --target "$TARGET" \ - --backend "$BACKEND" \ + --profile "$PROFILE" \ --version "$VERSION" \ --binary "$BINARY_PATH" \ --output-dir "$OUTPUT_DIR" \ diff --git a/scripts/test_install_latest.py b/scripts/test_install_latest.py index 657ce15..be3a544 100644 --- a/scripts/test_install_latest.py +++ b/scripts/test_install_latest.py @@ -1,9 +1,7 @@ """Offline regressions for ``scripts/install-latest.ps1``. -The Windows quick installer is the first thing most users run, and release -contract schema 2 publishes one archive per (target, backend) pair plus a -single recommended LLVM MSI — never a combined package. So two kinds of -check live here: +The Windows quick installer exposes the full package and every slim profile, +while retaining a single recommended LLVM MSI. Two kinds of check live here: * static — the script's parameters, defaults and asset-name templates are read out of its text, so the contract's canonical names stay the ones the @@ -19,6 +17,7 @@ import json import shutil import subprocess +import tempfile import unittest import release_tools as rt @@ -37,16 +36,16 @@ def installer_text() -> str: def contract_windows_archives() -> dict[str, str]: - """The exact Windows archive name the contract renders per backend.""" + """The exact Windows archive name the contract renders per profile.""" contract = rt.load_release_contract(rt.CONTRACT_PATH) - backends = contract["variants"][WINDOWS_TARGET]["backends"] + profiles = contract["variants"][WINDOWS_TARGET]["profiles"] return { - backend: spec["archive_name_template"].replace("{version}", TAG.lstrip("v")) - for backend, spec in backends.items() + profile: spec["archive_name_template"].replace("{version}", TAG.lstrip("v")) + for profile, spec in profiles.items() } -def run_selection(assets: list[str], backend: str, mode: str, tag: str = TAG) -> dict: +def run_selection(assets: list[str], profile: str, mode: str, tag: str = TAG) -> dict: """Dot-source the installer and run one asset selection offline.""" asset_json = json.dumps([{"name": name} for name in assets]) script = f""" @@ -54,7 +53,7 @@ def run_selection(assets: list[str], backend: str, mode: str, tag: str = TAG) -> . '{INSTALLER.as_posix()}' $assets = @('{asset_json.replace("'", "''")}' | ConvertFrom-Json) try {{ - $selection = Select-OscanReleaseAsset -Assets $assets -Tag '{tag}' -Backend '{backend}' -Mode '{mode}' + $selection = Select-OscanReleaseAsset -Assets $assets -Tag '{tag}' -Profile '{profile}' -Mode '{mode}' @{{ ok = $true; name = $selection.Asset.name; kind = $selection.Kind }} | ConvertTo-Json -Compress }} catch {{ @{{ ok = $false; error = $_.Exception.Message }} | ConvertTo-Json -Compress @@ -79,7 +78,8 @@ class InstallerContractTests(unittest.TestCase): def setUp(self) -> None: self.text = installer_text() - def test_backend_is_a_first_class_parameter_defaulting_to_llvm(self) -> None: + def test_slim_llvm_remains_the_transition_default(self) -> None: + self.assertRegex(self.text, r"\[string\]\$Profile\s*=\s*'slim'") self.assertRegex(self.text, r"\[string\]\$Backend\s*=\s*'llvm'") self.assertRegex(self.text, r"ValidateSet\('llvm',\s*'cranelift',\s*'c',\s*'native'\)") @@ -87,8 +87,8 @@ def test_the_zip_flow_stays_the_default_install_mode(self) -> None: self.assertRegex(self.text, r"\[string\]\$Mode\s*=\s*'zip'") def test_asset_names_are_rendered_exactly_like_the_release_contract(self) -> None: - # One template, no globbing: 'oscan--windows-x86_64-.'. - self.assertIn('return "oscan-$Tag-$InstallerTarget-$Backend.$Kind"', self.text) + # One template, no globbing: 'oscan--windows-x86_64-.'. + self.assertIn('return "oscan-$Tag-$InstallerTarget-$Profile.$Kind"', self.text) self.assertIn("$InstallerTarget = 'windows-x86_64'", self.text) self.assertNotIn("'*windows-x86_64", self.text) @@ -100,15 +100,21 @@ def test_no_suffix_or_version_drift_matching_survives(self) -> None: self.assertNotIn(smell, self.text) self.assertIn("$_.name -ieq $candidate.Name", self.text) - def test_no_combined_full_package_is_assumed_anywhere(self) -> None: - self.assertNotIn("full.zip", self.text) - self.assertNotIn("x86_64-full", self.text) - self.assertIn("there is no combined package", self.text) + def test_full_is_a_first_class_package_choice(self) -> None: + self.assertRegex(self.text, r"ValidateSet\('slim',\s*'full'\)") + self.assertIn("return 'full'", self.text) + self.assertIn("Select LLVM, Cranelift, or C with --backend.", self.text) def test_only_the_llvm_package_is_installed_from_an_msi(self) -> None: - self.assertIn("$MsiBackend = 'llvm'", self.text) + self.assertIn("$MsiProfile = 'llvm'", self.text) self.assertIn("-Mode msi is only available for the recommended", self.text) + def test_archive_default_selection_is_explicit_and_msi_collision_is_detected(self) -> None: + self.assertRegex(self.text, r"\[switch\]\$SetDefault") + self.assertIn("$installArgs['SetDefault']", self.text) + self.assertIn("A per-user archive command is installed", self.text) + self.assertIn("OSCAN_ALLOW_ARCHIVE_CONFLICT=1", self.text) + def test_checksum_verification_and_tag_selection_are_preserved(self) -> None: self.assertIn("SHA256SUMS", self.text) self.assertIn("Checksum mismatch for", self.text) @@ -117,6 +123,13 @@ def test_checksum_verification_and_tag_selection_are_preserved(self) -> None: self.assertIn("$ApiBase/latest", self.text) self.assertRegex(self.text, r"\[switch\]\$SkipChecksum") + def test_github_token_uses_a_supported_authorization_header(self) -> None: + self.assertIn( + '$headers[\'Authorization\'] = "Bearer $env:GITHUB_TOKEN"', + self.text, + ) + self.assertNotIn('"******"', self.text) + def test_a_missing_checksum_file_fails_closed(self) -> None: self.assertIn("publishes no SHA256SUMS", self.text) # The old behaviour — warn and install anyway — must not return. @@ -133,11 +146,11 @@ def test_the_deprecated_native_spelling_is_only_a_compatibility_alias(self) -> N def test_dot_sourcing_defines_the_functions_without_installing(self) -> None: self.assertIn("if ($MyInvocation.InvocationName -ne '.') {", self.text) - def test_every_windows_contract_archive_is_reachable_by_backend(self) -> None: + def test_every_windows_contract_archive_is_reachable_by_profile(self) -> None: archives = contract_windows_archives() - self.assertEqual(sorted(archives), ["c", "cranelift", "llvm"]) - for backend, archive in archives.items(): - self.assertEqual(archive, f"oscan-{TAG}-{WINDOWS_TARGET}-{backend}.zip") + self.assertEqual(sorted(archives), ["c", "cranelift", "full", "llvm"]) + for profile, archive in archives.items(): + self.assertEqual(archive, f"oscan-{TAG}-{WINDOWS_TARGET}-{profile}.zip") @unittest.skipUnless(POWERSHELL, "pwsh is required for installer behaviour tests") @@ -146,6 +159,7 @@ class InstallerSelectionBehaviourTests(unittest.TestCase): def setUp(self) -> None: self.assets = [ + f"oscan-{TAG}-windows-x86_64-full.zip", f"oscan-{TAG}-windows-x86_64-llvm.zip", f"oscan-{TAG}-windows-x86_64-llvm.msi", f"oscan-{TAG}-windows-x86_64-cranelift.zip", @@ -163,13 +177,13 @@ def test_the_default_backend_resolves_to_the_llvm_zip(self) -> None: self.assertEqual(result["name"], f"oscan-{TAG}-windows-x86_64-llvm.zip") self.assertEqual(result["kind"], "zip") - def test_each_backend_selects_its_own_exact_archive(self) -> None: - for backend in ("llvm", "cranelift", "c"): - with self.subTest(backend=backend): - result = run_selection(self.assets, backend, "zip") + def test_each_profile_selects_its_own_exact_archive(self) -> None: + for profile in ("full", "llvm", "cranelift", "c"): + with self.subTest(profile=profile): + result = run_selection(self.assets, profile, "zip") self.assertTrue(result["ok"], result) self.assertEqual( - result["name"], f"oscan-{TAG}-windows-x86_64-{backend}.zip" + result["name"], f"oscan-{TAG}-windows-x86_64-{profile}.zip" ) def test_msi_mode_prefers_the_recommended_llvm_installer(self) -> None: @@ -185,10 +199,10 @@ def test_msi_mode_falls_back_to_the_exact_llvm_zip(self) -> None: self.assertEqual(result["name"], f"oscan-{TAG}-windows-x86_64-llvm.zip") self.assertEqual(result["kind"], "zip") - def test_msi_mode_is_refused_for_the_backends_that_publish_none(self) -> None: - for backend in ("cranelift", "c"): - with self.subTest(backend=backend): - result = run_selection(self.assets, backend, "msi") + def test_msi_mode_is_refused_for_the_profiles_that_publish_none(self) -> None: + for profile in ("full", "cranelift", "c"): + with self.subTest(profile=profile): + result = run_selection(self.assets, profile, "msi") self.assertFalse(result["ok"], result) self.assertIn("only available for the recommended llvm", result["error"]) @@ -197,12 +211,12 @@ def test_a_missing_backend_package_is_an_actionable_error(self) -> None: result = run_selection(assets, "cranelift", "zip") self.assertFalse(result["ok"], result) self.assertIn(f"oscan-{TAG}-windows-x86_64-cranelift.zip", result["error"]) - self.assertIn("one archive per backend", result["error"]) + self.assertIn("full, llvm, cranelift, and c archives", result["error"]) - def test_a_legacy_full_archive_never_satisfies_a_backend_request(self) -> None: + def test_a_legacy_unsuffixed_archive_never_satisfies_a_profile_request(self) -> None: result = run_selection( [ - f"oscan-{TAG}-windows-x86_64-full.zip", + f"oscan-{TAG}-windows-x86_64.zip", f"oscan-{TAG}-windows-x86_64.msi", "SHA256SUMS", ], @@ -210,7 +224,7 @@ def test_a_legacy_full_archive_never_satisfies_a_backend_request(self) -> None: "zip", ) self.assertFalse(result["ok"], result) - self.assertIn("there is no combined package", result["error"]) + self.assertIn(f"oscan-{TAG}-windows-x86_64-llvm.zip", result["error"]) def test_a_backend_request_never_matches_another_backend(self) -> None: # Only the C archive is published: llvm and cranelift must fail @@ -294,6 +308,20 @@ def test_the_native_alias_resolves_to_the_cranelift_package(self) -> None: self.assertIn("deprecated", output) self.assertIn("cranelift", output) + def test_full_profile_resolves_without_a_slim_backend(self) -> None: + script = ( + f"$ErrorActionPreference='Stop'; . '{INSTALLER.as_posix()}'; " + "Resolve-OscanPackageProfile -Profile full -Backend llvm" + ) + completed = subprocess.run( + [POWERSHELL, "-NoProfile", "-NonInteractive", "-Command", script], + capture_output=True, + text=True, + check=True, + cwd=REPO_ROOT, + ) + self.assertEqual(completed.stdout.strip(), "full") + def test_an_unknown_backend_is_rejected(self) -> None: script = ( f"$ErrorActionPreference='Stop'; . '{INSTALLER.as_posix()}'; " @@ -328,6 +356,55 @@ def test_checksum_lookup_matches_the_selected_asset_only(self) -> None: ) self.assertEqual(completed.stdout.split(), ["bbbb"]) + def test_profile_aware_bundle_preflight_accepts_matching_metadata(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + bundle = Path(tmp) + metadata = { + "schema_version": 2, + "package_id": "oscan-full", + "target": WINDOWS_TARGET, + "profile": "full", + "is_distribution": True, + "available_backends": ["llvm", "cranelift", "c"], + "default_backend": "llvm", + } + (bundle / "oscan-package.json").write_text( + json.dumps(metadata), encoding="utf-8" + ) + script = ( + f"$ErrorActionPreference='Stop'; . '{INSTALLER.as_posix()}'; " + f"Assert-OscanArchiveBundle -BundleDir '{bundle.as_posix()}' " + "-ExpectedProfile full | Out-Null; 'ok'" + ) + completed = subprocess.run( + [POWERSHELL, "-NoProfile", "-NonInteractive", "-Command", script], + capture_output=True, + text=True, + cwd=REPO_ROOT, + ) + self.assertEqual( + completed.returncode, 0, completed.stdout + completed.stderr + ) + self.assertEqual(completed.stdout.strip(), "ok") + + def test_legacy_bundle_preflight_is_refused_before_its_installer_runs(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + script = ( + f"$ErrorActionPreference='Stop'; . '{INSTALLER.as_posix()}'; " + "try { " + f"Assert-OscanArchiveBundle -BundleDir '{Path(tmp).as_posix()}' " + "-ExpectedProfile llvm " + "} catch { $_.Exception.Message }" + ) + completed = subprocess.run( + [POWERSHELL, "-NoProfile", "-NonInteractive", "-Command", script], + capture_output=True, + text=True, + check=True, + cwd=REPO_ROOT, + ) + self.assertIn("destructive flat installer", completed.stdout) + @unittest.skipUnless(POWERSHELL, "pwsh is required to parse the installer") class InstallerSyntaxTests(unittest.TestCase): @@ -367,15 +444,17 @@ def test_the_readme_shows_the_backend_flag_for_every_backend(self) -> None: def test_technical_details_list_the_exact_release_archive_names(self) -> None: contract = rt.load_release_contract(rt.CONTRACT_PATH) for target, spec in contract["variants"].items(): - for backend in spec["backends"]: - name = f"oscan-vX.Y.Z-{target}-{backend}" + for profile in spec["profiles"]: + name = f"oscan-vX.Y.Z-{target}-{profile}" self.assertIn( - name, self.technical, f"{target}/{backend} is undocumented" + name, self.technical, f"{target}/{profile} is undocumented" ) - def test_the_package_docs_make_no_full_bundle_claim(self) -> None: - self.assertNotRegex(self.technical, r"-full\.(zip|tar\.[gx]z)") - self.assertNotIn("x86_64-full", self.technical) + def test_the_package_docs_describe_full_and_coexisting_profiles(self) -> None: + self.assertIn("oscan-vX.Y.Z-windows-x86_64-full.zip", self.technical) + self.assertIn("oscan-vX.Y.Z-linux-x86_64-full.tar.xz", self.technical) + self.assertIn("oscan-full", self.technical) + self.assertIn("oscan-llvm", self.technical) def test_the_package_docs_name_the_single_recommended_msi(self) -> None: self.assertIn("oscan-vX.Y.Z-windows-x86_64-llvm.msi", self.technical) @@ -413,6 +492,12 @@ def test_msi_and_archive_uninstall_paths_are_documented_separately(self) -> None self.assertIn("Uninstall (archive install)", self.guide) self.assertIn("Uninstall (Windows MSI)", self.guide) + def test_default_selection_and_profile_isolation_are_documented(self) -> None: + for text in (self.guide, self.technical, self.spec): + self.assertIn("SetDefault", text) + self.assertIn("oscan-full", text) + self.assertIn("oscan-llvm", text) + def test_the_provider_search_roots_include_the_sidecar(self) -> None: for name, text in ( ("docs/technical-details.md", self.technical), diff --git a/scripts/test_installers.py b/scripts/test_installers.py new file mode 100644 index 0000000..dd7a09d --- /dev/null +++ b/scripts/test_installers.py @@ -0,0 +1,630 @@ +"""Offline coexistence tests for the archive installers.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import threading +import time +import unittest + + +REPO_ROOT = Path(__file__).resolve().parent.parent +WINDOWS_INSTALLER = REPO_ROOT / "scripts" / "install-oscan.ps1" +UNIX_INSTALLER = REPO_ROOT / "scripts" / "install-oscan.sh" +POWERSHELL = shutil.which("pwsh") or shutil.which("powershell") +SH = shutil.which("sh") + + +def make_bundle(root: Path, profile: str, version: str, windows: bool) -> Path: + bundle = root / f"{profile}-{version}" + bundle.mkdir(parents=True) + binary = bundle / ("oscan.exe" if windows else "oscan") + binary.write_bytes( + b"fake windows executable" + if windows + else b"#!/bin/sh\nprintf '%s\\n' fake-oscan\n" + ) + if not windows: + binary.chmod(0o755) + metadata = { + "schema_version": 2, + "package_id": f"oscan-{profile}", + "profile": profile, + "version": version, + "target": "windows-x86_64" if windows else "linux-x86_64", + "is_distribution": True, + "available_backends": ( + ["llvm", "cranelift", "c"] if profile == "full" else [profile] + ), + "default_backend": "llvm" if profile == "full" else profile, + "component_digests": { + binary.name: hashlib.sha256(binary.read_bytes()).hexdigest(), + "c_toolchain": {"version": "nested-toolchain-version"}, + }, + } + (bundle / "oscan-package.json").write_text( + json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return bundle + + +@unittest.skipUnless( + POWERSHELL and os.name == "nt", "Windows PowerShell is required" +) +class WindowsInstallerCoexistenceTests(unittest.TestCase): + def invoke( + self, + bundle: Path, + install_root: Path | str, + bin_dir: Path | str, + *extra: str, + succeeds: bool = True, + ) -> subprocess.CompletedProcess[str]: + command = [ + POWERSHELL, + "-NoProfile", + "-NonInteractive", + "-File", + str(WINDOWS_INSTALLER), + "-SourceDir", + str(bundle), + "-InstallRoot", + str(install_root), + "-BinDir", + str(bin_dir), + "-NoPathUpdate", + "-AllowMsiCommandConflict", + *extra, + ] + env = os.environ.copy() + install_path = Path(str(install_root).rstrip("\\/")) + env["ProgramW6432"] = str(install_path.parent / "no-msi") + env["LOCALAPPDATA"] = str(install_path.parent / "local-app-data") + completed = subprocess.run( + command, capture_output=True, text=True, env=env + ) + if succeeds: + self.assertEqual( + completed.returncode, 0, completed.stdout + completed.stderr + ) + else: + self.assertNotEqual(completed.returncode, 0) + return completed + + def test_profiles_coexist_upgrade_independently_and_preserve_default(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + install_root = root / "install" + bin_dir = root / "bin" + llvm_v1 = make_bundle(root, "llvm", "1.0.0", True) + c_v1 = make_bundle(root, "c", "1.0.0", True) + llvm_v2 = make_bundle(root, "llvm", "2.0.0", True) + archive_marker = ( + root / "local-app-data" / "Programs" / "Oscan" / "archive-default" + ) + + self.invoke(llvm_v1, install_root, bin_dir) + self.assertTrue(archive_marker.is_file()) + self.invoke(c_v1, install_root, bin_dir) + self.assertTrue((bin_dir / "oscan-llvm.cmd").is_file()) + self.assertTrue((bin_dir / "oscan-c.cmd").is_file()) + self.assertEqual( + (install_root / "default-profile").read_text().strip(), "llvm" + ) + + self.invoke(c_v1, install_root, bin_dir, "-SetDefault") + self.invoke(llvm_v2, install_root, bin_dir) + self.assertTrue( + (install_root / "profiles" / "llvm" / "2.0.0" / "oscan.exe").is_file() + ) + self.assertFalse((install_root / "profiles" / "llvm" / "1.0.0").exists()) + self.assertTrue( + (install_root / "profiles" / "c" / "1.0.0" / "oscan.exe").is_file() + ) + self.assertEqual( + (install_root / "default-profile").read_text().strip(), "c" + ) + + self.invoke(c_v1, install_root, bin_dir, "-Uninstall") + self.assertFalse((install_root / "profiles" / "c").exists()) + self.assertTrue((install_root / "profiles" / "llvm").is_dir()) + self.assertFalse((bin_dir / "oscan.cmd").exists()) + self.assertFalse((install_root / "default-profile").exists()) + self.assertFalse(archive_marker.exists()) + + def test_invalid_repair_does_not_remove_working_profile(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + install_root = root / "install" + bin_dir = root / "bin" + llvm = make_bundle(root, "llvm", "1.0.0", True) + self.invoke(llvm, install_root, bin_dir) + + broken = make_bundle(root, "llvm", "2.0.0", True) + (broken / "oscan.exe").unlink() + self.invoke(broken, install_root, bin_dir, succeeds=False) + self.assertTrue( + (install_root / "profiles" / "llvm" / "1.0.0" / "oscan.exe").is_file() + ) + + def test_explicit_profile_can_uninstall_a_damaged_bundle(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + install_root = root / "install" + bin_dir = root / "bin" + bundle = make_bundle(root, "llvm", "1.0.0", True) + self.invoke(bundle, install_root, bin_dir) + shutil.rmtree(bundle) + empty_source = root / "empty" + empty_source.mkdir() + self.invoke( + empty_source, + install_root, + bin_dir, + "-Profile", + "llvm", + "-Uninstall", + ) + self.assertFalse((install_root / "profiles" / "llvm").exists()) + + def test_activation_failure_rolls_back_a_same_version_repair(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + install_root = root / "install" + bin_dir = root / "bin" + bundle = make_bundle(root, "llvm", "1.0.0", True) + self.invoke(bundle, install_root, bin_dir) + invalid_bin_dir = root / "not-a-directory" + invalid_bin_dir.write_text("occupied", encoding="utf-8") + self.invoke( + bundle, + install_root, + invalid_bin_dir, + succeeds=False, + ) + self.assertTrue( + (install_root / "profiles" / "llvm" / "1.0.0" / "oscan.exe").is_file() + ) + self.assertTrue((bin_dir / "oscan-llvm.cmd").is_file()) + + def test_foreign_install_root_cannot_replace_or_remove_shared_selectors(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + first_root = root / "first" + second_root = root / "second" + bin_dir = root / "bin" + llvm = make_bundle(root, "llvm", "1.0.0", True) + c_bundle = make_bundle(root, "c", "1.0.0", True) + + self.invoke(llvm, first_root, bin_dir) + qualified_before = (bin_dir / "oscan-llvm.cmd").read_bytes() + default_before = (bin_dir / "oscan.cmd").read_bytes() + + self.invoke(llvm, second_root, bin_dir, succeeds=False) + self.invoke(c_bundle, second_root, bin_dir, "-SetDefault", succeeds=False) + self.invoke( + root / "missing-source", + second_root, + bin_dir, + "-Profile", + "llvm", + "-Uninstall", + ) + + self.assertEqual((bin_dir / "oscan-llvm.cmd").read_bytes(), qualified_before) + self.assertEqual((bin_dir / "oscan.cmd").read_bytes(), default_before) + self.assertTrue( + (first_root / "profiles" / "llvm" / "1.0.0" / "oscan.exe").is_file() + ) + + def test_selector_failure_rolls_back_a_staged_uninstall(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + install_root = root / "install" + bin_dir = root / "bin" + bundle = make_bundle(root, "llvm", "1.0.0", True) + self.invoke(bundle, install_root, bin_dir) + qualified = bin_dir / "oscan-llvm.cmd" + + with qualified.open("rb"): + self.invoke( + bundle, + install_root, + bin_dir, + "-Uninstall", + succeeds=False, + ) + + self.assertTrue( + (install_root / "profiles" / "llvm" / "1.0.0" / "oscan.exe").is_file() + ) + self.assertTrue(qualified.is_file()) + self.assertTrue((bin_dir / "oscan.cmd").is_file()) + self.assertEqual( + (install_root / "default-profile").read_text().strip(), "llvm" + ) + + def test_unicode_install_root_uses_an_ascii_relative_shim(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "unicod\u00e9" + install_root = root / "install" + bin_dir = root / "commands" + bundle = make_bundle(root, "llvm", "1.0.0", True) + self.invoke(bundle, install_root, bin_dir) + shim = (bin_dir / "oscan-llvm.cmd").read_bytes() + self.assertTrue(all(byte < 128 for byte in shim), shim) + self.assertIn(b"%~dp0.oscan-profiles", shim) + + def test_uninstall_keeps_the_msi_marker_for_another_install_root(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + first_root = root / "first" + second_root = root / "second" + bundle = make_bundle(root, "llvm", "1.0.0", True) + self.invoke(bundle, first_root, first_root / "bin") + self.invoke(bundle, second_root, second_root / "bin") + marker = ( + root / "local-app-data" / "Programs" / "Oscan" / "archive-default" + ) + self.invoke( + bundle, + first_root, + first_root / "bin", + "-Uninstall", + ) + self.assertTrue(marker.is_file()) + + def test_failed_uninstall_preserves_commands_and_selector(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + install_root = root / "install" + bin_dir = root / "bin" + bundle = make_bundle(root, "llvm", "1.0.0", True) + self.invoke(bundle, install_root, bin_dir) + executable = install_root / "profiles" / "llvm" / "1.0.0" / "oscan.exe" + with executable.open("rb"): + self.invoke( + bundle, + install_root, + bin_dir, + "-Uninstall", + succeeds=False, + ) + self.assertTrue(executable.is_file()) + self.assertTrue((bin_dir / "oscan-llvm.cmd").is_file()) + self.assertTrue((bin_dir / "oscan.cmd").is_file()) + self.assertEqual( + (install_root / "default-profile").read_text().strip(), "llvm" + ) + + def test_equivalent_root_spellings_share_marker_ownership(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + install_root = root / "install" + bin_dir = root / "bin" + bundle = make_bundle(root, "llvm", "1.0.0", True) + self.invoke( + bundle, + f"{install_root}{os.sep}", + f"{bin_dir}{os.sep}", + ) + marker = ( + root / "local-app-data" / "Programs" / "Oscan" / "archive-default" + ) + self.invoke(bundle, install_root, bin_dir, "-Uninstall") + self.assertFalse(marker.exists()) + + def test_install_waits_for_an_in_progress_operation(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + install_root = root / "install" + bin_dir = root / "bin" + bundle = make_bundle(root, "llvm", "1.0.0", True) + lock_path = install_root / ".install.lock" + ready = root / "lock-ready" + escaped_root = str(install_root).replace("'", "''") + escaped_ready = str(ready).replace("'", "''") + escaped_lock = str(lock_path).replace("'", "''") + holder = subprocess.Popen( + [ + POWERSHELL, + "-NoProfile", + "-NonInteractive", + "-Command", + ( + f"$root = '{escaped_root}'; $ready = '{escaped_ready}'; " + "New-Item -ItemType Directory -Path $root -Force | Out-Null; " + "$stream = [IO.File]::Open(" + f"'{escaped_lock}', " + "[IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, " + "[IO.FileShare]::None); " + "Set-Content -LiteralPath $ready -Value ready; " + "Start-Sleep -Seconds 2; $stream.Dispose()" + ), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + deadline = time.monotonic() + 5 + while not ready.exists() and time.monotonic() < deadline: + time.sleep(0.05) + self.assertTrue(ready.exists(), "lock holder did not start") + started = time.monotonic() + self.invoke(bundle, install_root, bin_dir) + self.assertGreaterEqual(time.monotonic() - started, 1.0) + stdout, stderr = holder.communicate(timeout=5) + self.assertEqual(holder.returncode, 0, stdout + stderr) + + +@unittest.skipUnless(SH and os.name != "nt", "a native POSIX shell is required") +class UnixInstallerCoexistenceTests(unittest.TestCase): + def invoke( + self, + bundle: Path, + install_root: Path, + bin_dir: Path, + *extra: str, + succeeds: bool = True, + env: dict[str, str] | None = None, + ) -> subprocess.CompletedProcess[str]: + command = [ + SH, + str(UNIX_INSTALLER), + "--source-dir", + str(bundle), + "--install-root", + str(install_root), + "--bin-dir", + str(bin_dir), + *extra, + ] + completed = subprocess.run(command, capture_output=True, text=True, env=env) + if succeeds: + self.assertEqual( + completed.returncode, 0, completed.stdout + completed.stderr + ) + else: + self.assertNotEqual(completed.returncode, 0) + return completed + + def test_profiles_coexist_upgrade_independently_and_preserve_default(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + install_root = root / "install" + bin_dir = root / "bin" + full_v1 = make_bundle(root, "full", "1.0.0", False) + llvm_v1 = make_bundle(root, "llvm", "1.0.0", False) + full_v2 = make_bundle(root, "full", "2.0.0", False) + + self.invoke(full_v1, install_root, bin_dir) + self.invoke(llvm_v1, install_root, bin_dir) + self.assertTrue((bin_dir / "oscan-full").is_symlink()) + self.assertTrue((bin_dir / "oscan-llvm").is_symlink()) + self.assertEqual((install_root / "default-profile").read_text().strip(), "full") + + self.invoke(llvm_v1, install_root, bin_dir, "--set-default") + self.invoke(full_v2, install_root, bin_dir) + self.assertTrue( + (install_root / "profiles" / "full" / "2.0.0" / "oscan").is_file() + ) + self.assertFalse((install_root / "profiles" / "full" / "1.0.0").exists()) + self.assertTrue( + (install_root / "profiles" / "llvm" / "1.0.0" / "oscan").is_file() + ) + self.assertEqual((install_root / "default-profile").read_text().strip(), "llvm") + self.assertEqual((bin_dir / "oscan").readlink(), Path("oscan-llvm")) + + self.invoke(llvm_v1, install_root, bin_dir, "--uninstall") + self.assertFalse((install_root / "profiles" / "llvm").exists()) + self.assertTrue((install_root / "profiles" / "full").is_dir()) + self.assertFalse((bin_dir / "oscan").exists()) + + def test_invalid_repair_does_not_remove_working_profile(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + install_root = root / "install" + bin_dir = root / "bin" + c_bundle = make_bundle(root, "c", "1.0.0", False) + self.invoke(c_bundle, install_root, bin_dir) + broken = make_bundle(root, "c", "2.0.0", False) + (broken / "oscan").unlink() + self.invoke(broken, install_root, bin_dir, succeeds=False) + self.assertTrue( + (install_root / "profiles" / "c" / "1.0.0" / "oscan").is_file() + ) + + def test_explicit_profile_can_uninstall_a_damaged_bundle(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + install_root = root / "install" + bin_dir = root / "bin" + bundle = make_bundle(root, "c", "1.0.0", False) + self.invoke(bundle, install_root, bin_dir) + shutil.rmtree(bundle) + empty_source = root / "empty" + empty_source.mkdir() + self.invoke( + empty_source, + install_root, + bin_dir, + "--profile", + "c", + "--uninstall", + ) + self.assertFalse((install_root / "profiles" / "c").exists()) + + def test_activation_failure_rolls_back_a_same_version_repair(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + install_root = root / "install" + bin_dir = root / "bin" + bundle = make_bundle(root, "c", "1.0.0", False) + self.invoke(bundle, install_root, bin_dir) + invalid_bin_dir = root / "not-a-directory" + invalid_bin_dir.write_text("occupied", encoding="utf-8") + self.invoke( + bundle, + install_root, + invalid_bin_dir, + succeeds=False, + ) + self.assertTrue( + (install_root / "profiles" / "c" / "1.0.0" / "oscan").is_file() + ) + self.assertTrue((bin_dir / "oscan-c").is_symlink()) + + def test_shared_bin_preserves_selectors_owned_by_other_install_roots(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + first_root = root / "first" + second_root = root / "second" + bin_dir = root / "bin" + llvm = make_bundle(root, "llvm", "1.0.0", False) + c_bundle = make_bundle(root, "c", "1.0.0", False) + + self.invoke(llvm, first_root, bin_dir) + self.invoke(c_bundle, second_root, bin_dir) + self.assertEqual((bin_dir / "oscan").readlink(), Path("oscan-llvm")) + + self.invoke(c_bundle, second_root, bin_dir, "--set-default", succeeds=False) + self.invoke(c_bundle, second_root, bin_dir, "--uninstall") + self.invoke( + root / "missing-source", + second_root, + bin_dir, + "--profile", + "llvm", + "--uninstall", + ) + + self.assertTrue((bin_dir / "oscan-llvm").is_symlink()) + self.assertEqual((bin_dir / "oscan").readlink(), Path("oscan-llvm")) + self.assertTrue( + (first_root / "profiles" / "llvm" / "1.0.0" / "oscan").is_file() + ) + + def test_selector_failure_rolls_back_a_staged_uninstall(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + install_root = root / "install" + bin_dir = root / "bin" + bundle = make_bundle(root, "c", "1.0.0", False) + self.invoke(bundle, install_root, bin_dir) + + fake_bin = root / "fake-bin" + fake_bin.mkdir() + fake_rm = fake_bin / "rm" + real_rm = shutil.which("rm") + self.assertIsNotNone(real_rm) + fake_rm.write_text( + "#!/bin/sh\n" + 'if [ "$#" -eq 2 ] && [ "$1" = "-f" ] && ' + '[ "$2" = "$FAIL_RM_PATH" ]; then exit 1; fi\n' + f"exec {real_rm} \"$@\"\n", + encoding="utf-8", + ) + fake_rm.chmod(0o755) + env = os.environ.copy() + env["PATH"] = f"{fake_bin}{os.pathsep}{env['PATH']}" + env["FAIL_RM_PATH"] = str(bin_dir / "oscan-c") + + self.invoke( + bundle, + install_root, + bin_dir, + "--uninstall", + succeeds=False, + env=env, + ) + + self.assertTrue( + (install_root / "profiles" / "c" / "1.0.0" / "oscan").is_file() + ) + self.assertTrue((bin_dir / "oscan-c").is_symlink()) + self.assertTrue((bin_dir / "oscan").is_symlink()) + self.assertEqual( + (install_root / "default-profile").read_text().strip(), "c" + ) + + def test_no_bin_link_preserves_payloads_referenced_by_existing_links(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + install_root = root / "install" + bin_dir = root / "bin" + v1 = make_bundle(root, "c", "1.0.0", False) + v2 = make_bundle(root, "c", "2.0.0", False) + self.invoke(v1, install_root, bin_dir) + old_target = (bin_dir / "oscan-c").readlink() + self.invoke(v2, install_root, bin_dir, "--no-bin-link") + self.assertEqual((bin_dir / "oscan-c").readlink(), old_target) + self.assertTrue( + (install_root / "profiles" / "c" / "1.0.0" / "oscan").is_file() + ) + self.assertTrue( + (install_root / "profiles" / "c" / "2.0.0" / "oscan").is_file() + ) + + def test_rejects_a_non_x86_64_host(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + bundle = make_bundle(root, "c", "1.0.0", False) + fake_bin = root / "fake-bin" + fake_bin.mkdir() + fake_uname = fake_bin / "uname" + fake_uname.write_text( + "#!/bin/sh\n" + 'case "$1" in\n' + " -s) printf '%s\\n' Linux ;;\n" + " -m) printf '%s\\n' aarch64 ;;\n" + "esac\n", + encoding="utf-8", + ) + fake_uname.chmod(0o755) + env = os.environ.copy() + env["PATH"] = f"{fake_bin}{os.pathsep}{env['PATH']}" + completed = subprocess.run( + [ + SH, + str(UNIX_INSTALLER), + "--source-dir", + str(bundle), + "--install-root", + str(root / "install"), + "--bin-dir", + str(root / "bin"), + ], + capture_output=True, + text=True, + env=env, + ) + self.assertNotEqual(completed.returncode, 0) + self.assertIn("requires an x86_64 host", completed.stderr) + + def test_install_waits_for_an_in_progress_operation(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + install_root = root / "install" + bin_dir = root / "bin" + bundle = make_bundle(root, "c", "1.0.0", False) + lock = install_root / ".install.lock" + lock.mkdir(parents=True) + (lock / "pid").write_text(f"{os.getpid()}\n", encoding="ascii") + release = threading.Timer(1.2, shutil.rmtree, args=(lock,)) + release.start() + try: + started = time.monotonic() + self.invoke(bundle, install_root, bin_dir) + self.assertGreaterEqual(time.monotonic() - started, 1.0) + finally: + release.cancel() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_release_packaging.py b/scripts/test_release_packaging.py index 619776d..1de68ce 100644 --- a/scripts/test_release_packaging.py +++ b/scripts/test_release_packaging.py @@ -1,4 +1,4 @@ -"""Release contract v2 + variant-aware packaging tests. +"""Release contract v3 + profile-aware packaging tests. Everything here is offline and hermetic: each test builds a complete fake package input set (tiny binaries, a tiny native-link asset set, tiny @@ -33,9 +33,11 @@ TOOLCHAINS = rt.REPO_ROOT / "packaging" / "toolchains" ALL_VARIANTS = ( + ("windows-x86_64", "full"), ("windows-x86_64", "llvm"), ("windows-x86_64", "cranelift"), ("windows-x86_64", "c"), + ("linux-x86_64", "full"), ("linux-x86_64", "llvm"), ("linux-x86_64", "cranelift"), ("linux-x86_64", "c"), @@ -390,7 +392,7 @@ def _write_native_link(self) -> None: def _write_embedded_notices(self) -> None: self.embedded_notices_dir.mkdir(parents=True, exist_ok=True) contract = json.loads(self.contract_path.read_text(encoding="utf-8")) - variant = contract["variants"].get(self.target, {}).get("backends", {}).get("llvm", {}) + variant = contract["variants"].get(self.target, {}).get("profiles", {}).get("llvm", {}) for name in variant.get("embedded_notice_files", []): destination = self.embedded_notices_dir / rt.safe_relative_path(name) destination.parent.mkdir(parents=True, exist_ok=True) @@ -402,7 +404,7 @@ def _write_runtime_archives(self) -> None: self.runtime_dir.mkdir(parents=True, exist_ok=True) contract = rt.load_runtime_archive_contract(rt.RUNTIME_ARCHIVE_CONTRACT_PATH) toolchain = self._runtime_toolchain() - for profile in rt.FREESTANDING_PROFILES: + for profile in rt.RUNTIME_PROFILES: mode_spec = contract["modes"][profile] archive = self.runtime_dir / mode_spec["archive_name"] archive.write_bytes(f"{profile} archive".encode()) @@ -419,11 +421,6 @@ def _write_runtime_archives(self) -> None: (self.runtime_dir / mode_spec["manifest_name"]).write_text( json.dumps(manifest), encoding="utf-8" ) - # A hosted pair exists in the prepared directory and must never be - # staged into an object package. - hosted = self.runtime_dir / "libosc_runtime_hosted.a" - hosted.write_bytes(b"hosted archive") - def _runtime_toolchain(self) -> dict: """The pinned provenance block the runtime-archive contract requires. @@ -456,10 +453,10 @@ def _runtime_toolchain(self) -> dict: } -def stage_namespace(fixture: PackagingFixture, backend: str, output_dir: Path, **overrides): +def stage_namespace(fixture: PackagingFixture, profile: str, output_dir: Path, **overrides): kwargs = { "target": fixture.target, - "backend": backend, + "profile": profile, "version": "9.9.9", "binary": str(fixture.binary), "output_dir": str(output_dir), @@ -468,7 +465,7 @@ def stage_namespace(fixture: PackagingFixture, backend: str, output_dir: Path, * "native_link_dir": str(fixture.native_link_dir), "embedded_notices_dir": ( str(fixture.embedded_notices_dir) - if fixture.target == "windows-x86_64" and backend == "llvm" + if fixture.target == "windows-x86_64" and profile in ("full", "llvm") else None ), "toolchain_archive": str(fixture.toolchain_archive) @@ -487,18 +484,18 @@ class ContractMatrixTests(unittest.TestCase): def setUp(self) -> None: self.contract = rt.load_release_contract(rt.CONTRACT_PATH) - def test_the_published_matrix_is_exactly_seven_variants(self) -> None: + def test_the_published_matrix_contains_full_and_slim_profiles(self) -> None: matrix = rt.release_variant_matrix(self.contract) self.assertEqual( - sorted((entry["target"], entry["backend"]) for entry in matrix), + sorted((entry["target"], entry["profile"]) for entry in matrix), sorted(ALL_VARIANTS), ) def test_archive_names_are_canonical_and_unique(self) -> None: names = set() - for target, backend in ALL_VARIANTS: + for target, profile in ALL_VARIANTS: variant = rt.resolve_release_variant( - self.contract, rt.CONTRACT_PATH, target, backend + self.contract, rt.CONTRACT_PATH, target, profile ) name = rt.render_release_template( variant["archive_name_template"], "1.2.3", "archive_name_template" @@ -507,20 +504,44 @@ def test_archive_names_are_canonical_and_unique(self) -> None: variant["archive_root_template"], "1.2.3", "archive_root_template" ) suffix = rt.ARCHIVE_SUFFIXES[variant["archive_format"]] - self.assertEqual(name, f"oscan-v1.2.3-{target}-{backend}{suffix}") - self.assertEqual(root, f"oscan-v1.2.3-{target}-{backend}") - self.assertNotIn("full", name) + self.assertEqual(name, f"oscan-v1.2.3-{target}-{profile}{suffix}") + self.assertEqual(root, f"oscan-v1.2.3-{target}-{profile}") self.assertNotIn("native", name) self.assertNotIn(name, names) names.add(name) - def test_object_variants_are_toolchain_free_and_c_variants_are_not(self) -> None: - for target, backend in ALL_VARIANTS: + def test_full_profiles_are_complete_and_slim_profiles_stay_slim(self) -> None: + for target, profile in ALL_VARIANTS: variant = rt.resolve_release_variant( - self.contract, rt.CONTRACT_PATH, target, backend + self.contract, rt.CONTRACT_PATH, target, profile ) - with self.subTest(target=target, backend=backend): - if backend == "c": + with self.subTest(target=target, profile=profile): + if profile == "full": + self.assertEqual(variant["available_backends"], ["llvm", "cranelift", "c"]) + self.assertEqual(variant["default_backend"], "llvm") + self.assertFalse(variant["toolchain_free"]) + self.assertIn("c_toolchain", variant["components"]) + self.assertIn("runtime_archives", variant["components"]) + self.assertEqual( + variant["runtime_profiles"], + [ + "hosted", + "freestanding", + "freestanding_gfx", + "freestanding_core", + ], + ) + if variant["link_mode"] == "sidecar": + self.assertIn("direct_link_sidecar", variant["components"]) + self.assertIn("runtime_archives", variant["components"]) + self.assertIn("llvm_provider", variant["components"]) + else: + self.assertEqual(target, "windows-x86_64") + self.assertEqual( + variant["components"], + ["compiler", "runtime_archives", "c_toolchain"], + ) + elif profile == "c": self.assertFalse(variant["toolchain_free"]) self.assertNotIn("direct_link_sidecar", variant["components"]) self.assertNotIn("runtime_archives", variant["components"]) @@ -537,7 +558,7 @@ def test_object_variants_are_toolchain_free_and_c_variants_are_not(self) -> None ) self.assertNotIn("hosted", variant["runtime_profiles"]) else: - self.assertEqual((target, backend), ("windows-x86_64", "llvm")) + self.assertEqual((target, profile), ("windows-x86_64", "llvm")) self.assertTrue(variant["toolchain_free"]) self.assertEqual(variant["link_mode"], "inprocess") self.assertEqual(variant["components"], ["compiler"]) @@ -561,6 +582,10 @@ def test_only_sidecar_llvm_carries_a_provider(self) -> None: self.contract, rt.CONTRACT_PATH, "linux-x86_64", "llvm" ) self.assertEqual(linux_llvm["llvm_provider_source"], "toolchain-manifest") + linux_full = rt.resolve_release_variant( + self.contract, rt.CONTRACT_PATH, "linux-x86_64", "full" + ) + self.assertEqual(linux_full["llvm_provider_source"], "toolchain-manifest") for target in ("windows-x86_64", "linux-x86_64"): cranelift = rt.resolve_release_variant( self.contract, rt.CONTRACT_PATH, target, "cranelift" @@ -569,14 +594,14 @@ def test_only_sidecar_llvm_carries_a_provider(self) -> None: def test_macos_is_c_only(self) -> None: self.assertEqual( - sorted(self.contract["variants"]["macos-x86_64"]["backends"]), ["c"] + sorted(self.contract["variants"]["macos-x86_64"]["profiles"]), ["c"] ) with self.assertRaises(SystemExit): rt.resolve_release_variant( self.contract, rt.CONTRACT_PATH, "macos-x86_64", "llvm" ) - def test_unknown_target_or_backend_is_refused(self) -> None: + def test_unknown_target_or_profile_is_refused(self) -> None: with self.assertRaises(SystemExit): rt.resolve_release_variant( self.contract, rt.CONTRACT_PATH, "freebsd-x86_64", "c" @@ -609,7 +634,7 @@ def test_the_repository_contract_validates(self) -> None: def test_schema_1_is_refused(self) -> None: message = self._reject(lambda data: data.update(schema_version=1)) - self.assertIn("schema_version 2", message) + self.assertIn("schema_version 3", message) def test_an_invalid_backend_name_is_refused(self) -> None: def mutate(data: dict) -> None: @@ -619,19 +644,33 @@ def mutate(data: dict) -> None: def test_a_mismatched_cargo_feature_is_refused(self) -> None: def mutate(data: dict) -> None: - data["variants"]["linux-x86_64"]["backends"]["llvm"]["cargo_feature"] = "backend-c" + data["variants"]["linux-x86_64"]["profiles"]["llvm"]["cargo_features"][0] = "backend-c" - self.assertIn("cargo feature", self._reject(mutate)) + self.assertIn("backend features", self._reject(mutate)) + + def test_a_c_variant_rejects_non_backend_cargo_features(self) -> None: + def mutate(data: dict) -> None: + data["variants"]["windows-x86_64"]["profiles"]["c"][ + "cargo_features" + ].append("static-llvm") + + self.assertIn("non-backend cargo_features", self._reject(mutate)) def test_a_mismatched_distribution_default_is_refused(self) -> None: def mutate(data: dict) -> None: - data["variants"]["linux-x86_64"]["backends"]["llvm"]["distribution_backend"] = "c" + data["profiles"]["llvm"]["default_backend"] = "c" + + self.assertIn("does not contain", self._reject(mutate)) + + def test_full_must_keep_llvm_as_its_stable_default(self) -> None: + def mutate(data: dict) -> None: + data["profiles"]["full"]["default_backend"] = "cranelift" - self.assertIn("distribution backend", self._reject(mutate)) + self.assertIn("stable default backend", self._reject(mutate)) def test_a_toolchain_free_variant_with_a_c_toolchain_is_refused(self) -> None: def mutate(data: dict) -> None: - data["variants"]["linux-x86_64"]["backends"]["cranelift"]["components"].append( + data["variants"]["linux-x86_64"]["profiles"]["cranelift"]["components"].append( "c_toolchain" ) @@ -639,7 +678,7 @@ def mutate(data: dict) -> None: def test_an_object_variant_without_its_sidecar_is_refused(self) -> None: def mutate(data: dict) -> None: - variant = data["variants"]["windows-x86_64"]["backends"]["cranelift"] + variant = data["variants"]["windows-x86_64"]["profiles"]["cranelift"] variant["components"] = [ name for name in variant["components"] if name != "direct_link_sidecar" ] @@ -648,7 +687,7 @@ def mutate(data: dict) -> None: def test_an_object_variant_without_runtime_archives_is_refused(self) -> None: def mutate(data: dict) -> None: - variant = data["variants"]["linux-x86_64"]["backends"]["llvm"] + variant = data["variants"]["linux-x86_64"]["profiles"]["llvm"] variant["components"] = [ name for name in variant["components"] if name != "runtime_archives" ] @@ -658,54 +697,60 @@ def mutate(data: dict) -> None: def test_a_c_variant_carrying_object_payload_is_refused(self) -> None: for extra in ("direct_link_sidecar", "llvm_provider", "runtime_archives"): def mutate(data: dict, extra=extra) -> None: - data["variants"]["windows-x86_64"]["backends"]["c"]["components"].append(extra) + data["variants"]["windows-x86_64"]["profiles"]["c"]["components"].append(extra) with self.subTest(component=extra): self.assertIn("C package", self._reject(mutate)) def test_a_cranelift_variant_with_a_provider_is_refused(self) -> None: def mutate(data: dict) -> None: - data["variants"]["windows-x86_64"]["backends"]["cranelift"]["components"].append( + data["variants"]["windows-x86_64"]["profiles"]["cranelift"]["components"].append( "llvm_provider" ) self.assertIn("must not ship an LLVM provider", self._reject(mutate)) - def test_a_hosted_runtime_profile_is_refused(self) -> None: + def test_a_slim_object_profile_refuses_the_hosted_runtime(self) -> None: def mutate(data: dict) -> None: - data["variants"]["linux-x86_64"]["backends"]["cranelift"]["runtime_profiles"].append( + data["variants"]["linux-x86_64"]["profiles"]["cranelift"]["runtime_profiles"].append( "hosted" ) - self.assertIn("non-freestanding", self._reject(mutate)) + self.assertIn("slim object package", self._reject(mutate)) + + def test_a_full_profile_requires_the_hosted_runtime(self) -> None: + def mutate(data: dict) -> None: + data["variants"]["linux-x86_64"]["profiles"]["full"][ + "runtime_profiles" + ].remove("hosted") + + self.assertIn("must ship the hosted runtime", self._reject(mutate)) def test_inprocess_linking_requires_the_exact_strict_features(self) -> None: def mutate(data: dict) -> None: - data["variants"]["windows-x86_64"]["backends"]["llvm"][ - "extra_cargo_features" - ] = ["inprocess-lld"] + data["variants"]["windows-x86_64"]["profiles"]["llvm"]["cargo_features"] = [ + "backend-llvm", + "inprocess-lld", + ] - self.assertIn("extra_cargo_features", self._reject(mutate)) + self.assertIn("non-backend cargo_features", self._reject(mutate)) def test_inprocess_linking_requires_static_crt(self) -> None: def mutate(data: dict) -> None: - data["variants"]["windows-x86_64"]["backends"]["llvm"]["rustflags"] = "" + data["variants"]["windows-x86_64"]["profiles"]["llvm"]["rustflags"] = "" self.assertIn("+crt-static", self._reject(mutate)) - def test_only_windows_llvm_can_use_the_inprocess_profile(self) -> None: + def test_cranelift_cannot_use_the_inprocess_profile(self) -> None: def mutate(data: dict) -> None: - variant = data["variants"]["windows-x86_64"]["backends"]["cranelift"] + variant = data["variants"]["windows-x86_64"]["profiles"]["cranelift"] variant["link_mode"] = "inprocess" - self.assertIn( - "only the official windows-x86_64/llvm package", - self._reject(mutate), - ) + self.assertIn("must use link_mode 'sidecar'", self._reject(mutate)) def test_strict_notice_list_must_match_the_pinned_toolchain_manifest(self) -> None: def mutate(data: dict) -> None: - data["variants"]["windows-x86_64"]["backends"]["llvm"][ + data["variants"]["windows-x86_64"]["profiles"]["llvm"][ "embedded_notice_files" ] = ["LLVM-LICENSE.txt", "invented-notice.txt"] @@ -713,7 +758,7 @@ def mutate(data: dict) -> None: def test_sidecar_variant_cannot_claim_embedded_notices(self) -> None: def mutate(data: dict) -> None: - data["variants"]["windows-x86_64"]["backends"]["cranelift"][ + data["variants"]["windows-x86_64"]["profiles"]["cranelift"][ "embedded_notice_files" ] = ["LLVM-LICENSE.txt", "mingw-w64-COPYING.txt"] @@ -721,15 +766,15 @@ def mutate(data: dict) -> None: def test_duplicate_archive_names_cannot_pass_validation(self) -> None: def mutate(data: dict) -> None: - data["variants"]["linux-x86_64"]["backends"]["llvm"]["archive_name_template"] = data[ + data["variants"]["linux-x86_64"]["profiles"]["llvm"]["archive_name_template"] = data[ "variants" - ]["linux-x86_64"]["backends"]["cranelift"]["archive_name_template"] + ]["linux-x86_64"]["profiles"]["cranelift"]["archive_name_template"] # The per-variant suffix rule catches the attempt first; the\n # cross-variant uniqueness check behind it is the backstop.\n self.assertIn("must end with '-llvm.tar.xz'", self._reject(mutate)) def test_a_native_label_is_refused(self) -> None: def mutate(data: dict) -> None: - variant = data["variants"]["windows-x86_64"]["backends"]["cranelift"] + variant = data["variants"]["windows-x86_64"]["profiles"]["cranelift"] variant["archive_name_template"] = "oscan-v{version}-windows-x86_64-native.zip" self.assertIn("deprecated CLI alias", self._reject(mutate)) @@ -740,11 +785,11 @@ def mutate(data: dict) -> None: self.assertIn("unsupported target", self._reject(mutate)) - def test_a_target_missing_a_declared_backend_is_refused(self) -> None: + def test_a_target_missing_a_declared_profile_is_refused(self) -> None: def mutate(data: dict) -> None: - del data["variants"]["linux-x86_64"]["backends"]["cranelift"] + del data["variants"]["linux-x86_64"]["profiles"]["cranelift"] - self.assertIn("must declare backends", self._reject(mutate)) + self.assertIn("must declare profiles", self._reject(mutate)) class VariantStagingTests(unittest.TestCase): @@ -774,14 +819,36 @@ def test_every_variant_stages_with_the_expected_contents(self) -> None: (bundle / rt.PACKAGE_METADATA_NAME).read_text(encoding="utf-8") ) self.assertEqual(metadata["target"], target) - self.assertEqual(metadata["backend"], backend) - self.assertEqual(metadata["default_backend"], backend) - self.assertEqual(metadata["available_backends"], [backend]) - self.assertEqual(metadata["cargo_feature"], f"backend-{backend}") + self.assertEqual(metadata["profile"], backend) + self.assertEqual(metadata["package_id"], f"oscan-{backend}") + expected_backends = ( + ["llvm", "cranelift", "c"] if backend == "full" else [backend] + ) + self.assertEqual(metadata["available_backends"], expected_backends) + self.assertEqual( + metadata["default_backend"], "llvm" if backend == "full" else backend + ) + for available in expected_backends: + self.assertIn(f"backend-{available}", metadata["cargo_features"]) self.assertEqual(metadata["version"], "9.9.9") self.assertIn(binary_name, metadata["component_digests"]) - if backend == "c": + if backend == "full": + self.assertFalse(metadata["toolchain_free"]) + self.assertTrue((bundle / "toolchain").is_dir()) + self.assertTrue(metadata["requirements"]["bundled_c_toolchain"]) + if metadata["link_mode"] == "sidecar": + self.assertTrue((bundle / "native-link").is_dir()) + self.assertTrue((bundle / "build").is_dir()) + self.assertIn("llvm_provider", metadata["component_digests"]) + else: + self.assertEqual(target, "windows-x86_64") + self.assertFalse((bundle / "native-link").exists()) + self.assertTrue((bundle / "build").is_dir()) + notices = bundle / "LICENSES" / "embedded" + for name in metadata["embedded_notice_files"]: + self.assertTrue((notices / name).is_file()) + elif backend == "c": self.assertFalse((bundle / "native-link").exists()) self.assertFalse((bundle / "build").exists()) self.assertFalse(metadata["toolchain_free"]) @@ -941,29 +1008,33 @@ def test_package_readme_states_what_each_variant_can_and_cannot_do(self) -> None tmp = Path(tmp_name) bundle, _ = self._stage(tmp, "windows-x86_64", "cranelift") readme = (bundle / "README-install.txt").read_text(encoding="utf-8") - self.assertIn("Backend: cranelift", readme) - self.assertIn("--libc is refused", readme) - self.assertIn("--extra-c", readme) - self.assertIn("--backend c, --emit-c and -o *.c are refused", readme) - self.assertIn("only LLD's runtime dependency", readme) + self.assertIn("Profile: cranelift", readme) + self.assertIn("Available backends: cranelift", readme) + self.assertIn("contains only the cranelift object backend", readme) + self.assertIn("does not remove other profiles", readme) bundle, _ = self._stage(tmp, "windows-x86_64", "llvm") readme = (bundle / "README-install.txt").read_text(encoding="utf-8") - self.assertIn("LLD linker", readme) - self.assertIn("No native-link/", readme) + self.assertIn("Profile: llvm", readme) + self.assertIn("native link inputs", readme) self.assertIn("LICENSES/embedded/", readme) - self.assertIn("Named --extra-lib inputs are refused", readme) + + bundle, _ = self._stage(tmp, "windows-x86_64", "full") + readme = (bundle / "README-install.txt").read_text(encoding="utf-8") + self.assertIn("Profile: full", readme) + self.assertIn("Available backends: llvm, cranelift, c", readme) + self.assertIn("One compiler exposes LLVM, Cranelift, and C", readme) + self.assertIn("pinned C toolchain", readme) bundle, _ = self._stage(tmp, "linux-x86_64", "c") readme = (bundle / "README-install.txt").read_text(encoding="utf-8") - self.assertIn("Backend: c", readme) + self.assertIn("Profile: c", readme) self.assertIn("pinned C toolchain", readme) - self.assertNotIn("--libc is refused", readme) bundle, _ = self._stage(tmp, "macos-x86_64", "c") readme = (bundle / "README-install.txt").read_text(encoding="utf-8") self.assertIn("apple-clt", readme) - self.assertIn("No toolchain is bundled", readme) + self.assertIn("must be installed", readme) def test_a_sidecar_digest_mismatch_fails_staging(self) -> None: with tempfile.TemporaryDirectory() as tmp_name: @@ -1904,7 +1975,7 @@ def test_preparing_strict_notices_uses_manifest_declared_files(self) -> None: ) expected = rt.load_release_contract(fixture.contract_path)["variants"][ "windows-x86_64" - ]["backends"]["llvm"]["embedded_notice_files"] + ]["profiles"]["llvm"]["embedded_notice_files"] self.assertEqual(names, expected) self.assertEqual( sorted(path.name for path in output.iterdir()), @@ -2007,12 +2078,13 @@ def test_gzip_header_carries_no_clock_or_file_name(self) -> None: class WrapperInterfaceTests(unittest.TestCase): - def test_powershell_wrappers_require_a_backend(self) -> None: + def test_powershell_wrappers_require_a_profile(self) -> None: for name in ("stage-release.ps1", "assemble-release.ps1"): script = (rt.REPO_ROOT / "scripts" / name).read_text(encoding="utf-8") with self.subTest(script=name): - self.assertIn('[ValidateSet("llvm", "cranelift", "c")]', script) - self.assertIn("[string]$Backend", script) + self.assertIn('[ValidateSet("full", "llvm", "cranelift", "c")]', script) + self.assertIn("[string]$Profile", script) + self.assertIn('[Alias("Backend")]', script) self.assertNotIn("CrossLinkerSidecarDir", script) def test_powershell_wrappers_take_archives_and_refuse_directories(self) -> None: @@ -2025,10 +2097,10 @@ def test_powershell_wrappers_take_archives_and_refuse_directories(self) -> None: self.assertIn("-ToolchainDir has been removed", script) self.assertIn("-LlvmProviderDir has been removed", script) - def test_shell_wrapper_requires_a_backend(self) -> None: + def test_shell_wrapper_requires_a_profile(self) -> None: script = (rt.REPO_ROOT / "scripts" / "stage-release.sh").read_text(encoding="utf-8") - self.assertIn("--backend", script) - self.assertIn('missing --backend', script) + self.assertIn("--profile", script) + self.assertIn('missing --profile', script) self.assertNotIn("--cross-linker-sidecar-dir", script) def test_shell_wrapper_takes_archives_and_refuses_directories(self) -> None: diff --git a/scripts/test_release_tools_runtime_archive.py b/scripts/test_release_tools_runtime_archive.py index c4269e5..573b063 100644 --- a/scripts/test_release_tools_runtime_archive.py +++ b/scripts/test_release_tools_runtime_archive.py @@ -597,9 +597,7 @@ def test_freestanding_runtime_pairs_are_staged(self): ) bundle_dir = self.scratch / "bundle" - rt.stage_freestanding_runtime_archives( - bundle_dir, target_spec, runtime_archive_dir - ) + rt.stage_runtime_archives(bundle_dir, target_spec, runtime_archive_dir) # Object packages ship precompiled archives only: no runtime # sources, and therefore no runtime builder, come along. @@ -647,9 +645,7 @@ def test_freestanding_runtime_pairs_are_staged_linux(self): ) bundle_dir = self.scratch / "bundle" - rt.stage_freestanding_runtime_archives( - bundle_dir, target_spec, runtime_archive_dir - ) + rt.stage_runtime_archives(bundle_dir, target_spec, runtime_archive_dir) # Object packages ship precompiled archives only: no runtime # sources, and therefore no runtime builder, come along. @@ -714,7 +710,7 @@ def test_staging_rejects_a_non_pinned_windows_runtime_toolchain(self): ) with self.assertRaises(SystemExit) as ctx: - rt.stage_freestanding_runtime_archives( + rt.stage_runtime_archives( self.scratch / "bundle", target_spec, runtime_archive_dir, @@ -756,7 +752,7 @@ def test_staging_rejects_a_non_pinned_linux_runtime_toolchain(self): ) with self.assertRaises(SystemExit) as ctx: - rt.stage_freestanding_runtime_archives( + rt.stage_runtime_archives( self.scratch / "bundle", target_spec, runtime_archive_dir, diff --git a/scripts/test_release_workflow.py b/scripts/test_release_workflow.py index 5367862..8ed7384 100644 --- a/scripts/test_release_workflow.py +++ b/scripts/test_release_workflow.py @@ -1,4 +1,4 @@ -"""Release/CI workflow regressions for release contract schema 2. +"""Release/CI workflow regressions for release contract schema 3. Two kinds of check live here: @@ -55,28 +55,28 @@ def setUp(self) -> None: self.contract = rt.load_release_contract(rt.CONTRACT_PATH) self.matrix = rt.ci_target_matrix(self.contract, "1.2.3") - def test_one_job_per_target_not_per_target_and_backend(self) -> None: + def test_one_job_per_target_not_per_target_and_profile(self) -> None: # Expensive per-target work must happen once, so the matrix is keyed - # on the target and carries the backend list. + # on the target and carries the profile list. self.assertEqual( [entry["target"] for entry in self.matrix], ["linux-x86_64", "macos-x86_64", "windows-x86_64"], ) variants = rt.release_variant_matrix(self.contract) self.assertEqual( - sum(len(entry["backends"].split(",")) for entry in self.matrix), + sum(len(entry["profiles"].split(",")) for entry in self.matrix), len(variants), ) - def test_backends_are_listed_in_canonical_order(self) -> None: + def test_profiles_are_listed_in_canonical_order(self) -> None: for entry in self.matrix: with self.subTest(target=entry["target"]): - backends = entry["backends"].split(",") + profiles = entry["profiles"].split(",") self.assertEqual( - backends, - [name for name in rt.CANONICAL_BACKENDS if name in backends], + profiles, + [name for name in rt.CANONICAL_PROFILES if name in profiles], ) - self.assertNotIn("native", backends) + self.assertNotIn("native", profiles) def test_each_entry_carries_its_runner_and_binary(self) -> None: expected = { @@ -103,7 +103,7 @@ def test_prepared_inputs_follow_the_declared_components(self) -> None: self.assertEqual(entry["needs_native_link"], "true") self.assertEqual( entry["runtime_profiles"].split(","), - list(rt.FREESTANDING_PROFILES), + list(rt.RUNTIME_PROFILES), ) # Only Linux overlays a separately pinned provider archive; Windows @@ -114,7 +114,7 @@ def test_prepared_inputs_follow_the_declared_components(self) -> None: self.assertEqual(windows["needs_inprocess_toolchain"], "true") # macOS is C-only and relies on the host Apple CLT: nothing to fetch. - self.assertEqual(macos["backends"], "c") + self.assertEqual(macos["profiles"], "c") self.assertEqual(macos["needs_base_toolchain"], "false") self.assertEqual(macos["needs_native_link"], "false") self.assertEqual(macos["needs_provider_archive"], "false") @@ -125,34 +125,36 @@ def test_archive_names_are_rendered_and_globally_unique(self) -> None: names = [name for entry in self.matrix for name in entry["archives"].split(",")] self.assertEqual(len(names), len(set(names))) self.assertIn("oscan-v1.2.3-windows-x86_64-llvm.zip", names) + self.assertIn("oscan-v1.2.3-windows-x86_64-full.zip", names) + self.assertIn("oscan-v1.2.3-linux-x86_64-full.tar.xz", names) self.assertIn("oscan-v1.2.3-linux-x86_64-cranelift.tar.xz", names) self.assertIn("oscan-v1.2.3-macos-x86_64-c.tar.gz", names) for name in names: self.assertNotIn("{version}", name) def test_exactly_one_recommended_msi_is_requested(self) -> None: - msi = [entry for entry in self.matrix if entry["msi_backend"]] + msi = [entry for entry in self.matrix if entry["msi_profile"]] self.assertEqual([entry["target"] for entry in msi], ["windows-x86_64"]) - self.assertEqual(msi[0]["msi_backend"], "llvm") + self.assertEqual(msi[0]["msi_profile"], "llvm") - def test_a_contract_with_a_non_canonical_backend_is_refused(self) -> None: + def test_a_contract_with_a_non_canonical_profile_is_refused(self) -> None: contract = json.loads(rt.CONTRACT_PATH.read_text(encoding="utf-8")) - target = contract["variants"]["linux-x86_64"]["backends"] + target = contract["variants"]["linux-x86_64"]["profiles"] target["native"] = target.pop("cranelift") with self.assertRaises(SystemExit) as caught: rt.ci_target_matrix(contract, "1.2.3") - self.assertIn("non-canonical backend", str(caught.exception)) + self.assertIn("non-canonical profile", str(caught.exception)) - def test_a_target_with_no_backends_is_refused(self) -> None: + def test_a_target_with_no_profiles_is_refused(self) -> None: contract = json.loads(rt.CONTRACT_PATH.read_text(encoding="utf-8")) - contract["variants"]["macos-x86_64"]["backends"] = {} + contract["variants"]["macos-x86_64"]["profiles"] = {} with self.assertRaises(SystemExit) as caught: rt.ci_target_matrix(contract, "1.2.3") - self.assertIn("declares no backends", str(caught.exception)) + self.assertIn("declares no profiles", str(caught.exception)) def test_colliding_archive_names_are_refused(self) -> None: contract = json.loads(rt.CONTRACT_PATH.read_text(encoding="utf-8")) - windows = contract["variants"]["windows-x86_64"]["backends"] + windows = contract["variants"]["windows-x86_64"]["profiles"] windows["cranelift"]["archive_name_template"] = windows["llvm"][ "archive_name_template" ] @@ -170,15 +172,22 @@ def test_the_matrix_comes_from_the_contract(self) -> None: self.assertIn("release_tools.py validate-contract", self.workflow) self.assertIn("fromJson(needs.prepare.outputs.matrix)", self.workflow) - def test_every_backend_is_built_with_its_contract_features(self) -> None: - self.assertIn("$features = @([string]$variant['cargo_feature'])", self.workflow) - self.assertIn("$features += @($variant['extra_cargo_features'])", self.workflow) + def test_every_profile_is_built_with_its_contract_features(self) -> None: + self.assertIn("$features = @($variant['cargo_features'])", self.workflow) self.assertIn("$featureList = $features -join ','", self.workflow) self.assertIn( "cargo build --release --no-default-features --features $featureList", self.workflow, ) - self.assertIn("$env:OSCAN_DISTRIBUTION_BACKEND = $backend", self.workflow) + self.assertIn("$env:OSCAN_DISTRIBUTION_PROFILE = $profile", self.workflow) + self.assertIn( + "$env:OSCAN_DEFAULT_BACKEND = [string]$profileSpec['default_backend']", + self.workflow, + ) + self.assertIn( + "Remove-Item Env:OSCAN_DISTRIBUTION_BACKEND", + self.workflow, + ) self.assertIn("$env:OSCAN_VERSION = $env:RELEASE_TAG", self.workflow) # Each build overwrites target/release, so the binary has to be moved # aside before the next backend is built. @@ -238,9 +247,9 @@ def test_staging_consumes_verified_archives_not_prepared_directories(self) -> No self.assertNotIn("ToolchainDir =", self.workflow) self.assertNotIn("LlvmProviderDir", self.workflow) - def test_every_archive_is_smoked_with_its_backend_before_upload(self) -> None: + def test_every_archive_is_smoked_with_its_profile_before_upload(self) -> None: self.assertIn("./scripts/smoke-release.ps1", self.workflow) - self.assertRegex(self.workflow, r"-Backend \$backend") + self.assertRegex(self.workflow, r"-Profile \$profile") self.assertRegex(self.workflow, r"-ArchivePath \$archivePath") self.assertRegex(self.workflow, r"-Version \$version") smoke_index = self.workflow.index("./scripts/smoke-release.ps1") @@ -265,11 +274,11 @@ def test_the_package_set_is_checked_against_the_contract(self) -> None: self.assertIn(message, self.workflow) def test_only_the_recommended_llvm_msi_is_built(self) -> None: - self.assertIn("if: matrix.msi_backend != ''", self.workflow) - self.assertIn("$backend = $env:RELEASE_MSI_BACKEND", self.workflow) + self.assertIn("if: matrix.msi_profile != ''", self.workflow) + self.assertIn("$profile = $env:RELEASE_MSI_PROFILE", self.workflow) self.assertIn("$msiPath = Join-Path $uploadDir \"$bundleName.msi\"", self.workflow) # Built from the bundle the assemble step staged and smoked. - self.assertIn("The staged $target/$backend bundle is missing at $bundleDir", self.workflow) + self.assertIn("The staged $target/$profile bundle is missing at $bundleDir", self.workflow) def test_the_installed_msi_payload_is_smoked_before_upload(self) -> None: self.assertIn("Start-Process -FilePath msiexec.exe", self.workflow) @@ -324,6 +333,18 @@ def test_the_all_backend_jobs_are_kept(self) -> None: self.assertIn(job, self.workflow) self.assertIn('python -m unittest discover -s scripts -p "test_*.py"', self.workflow) + def test_the_strict_full_profile_is_tested_before_release(self) -> None: + self.assertIn("full-distribution:", self.workflow) + self.assertIn("OSCAN_DISTRIBUTION_PROFILE: full", self.workflow) + self.assertIn("OSCAN_DEFAULT_BACKEND: llvm", self.workflow) + self.assertIn('grep -qx "distribution: full"', self.workflow) + + def test_windows_runs_archive_installer_coexistence_tests(self) -> None: + self.assertIn( + "python -m unittest scripts.test_installers.WindowsInstallerCoexistenceTests", + self.workflow, + ) + @unittest.skipIf(yaml is None, "PyYAML is not available in this environment") class WorkflowStructureTests(unittest.TestCase): @@ -338,7 +359,7 @@ def test_release_workflow_is_structurally_valid(self) -> None: self.assertEqual(package["strategy"]["matrix"], "${{ fromJson(needs.prepare.outputs.matrix) }}") self.assertFalse(package["strategy"]["fail-fast"]) self.assertEqual(package["runs-on"], "${{ matrix.os }}") - for name in ("RELEASE_TARGET", "RELEASE_BACKENDS", "RELEASE_VERSION", "RELEASE_TAG"): + for name in ("RELEASE_TARGET", "RELEASE_PROFILES", "RELEASE_VERSION", "RELEASE_TAG"): self.assertIn(name, package["env"]) self.assertEqual( workflow["jobs"]["publish"]["needs"], ["prepare", "package", "checksums"] @@ -349,6 +370,8 @@ def test_ci_workflow_is_structurally_valid(self) -> None: job = workflow["jobs"]["backend-distributions"] self.assertEqual(job["strategy"]["matrix"]["backend"], list(rt.CANONICAL_BACKENDS)) self.assertFalse(job["strategy"]["fail-fast"]) + full = workflow["jobs"]["full-distribution"] + self.assertEqual(full["runs-on"], "ubuntu-latest") if __name__ == "__main__": diff --git a/scripts/test_smoke_release.py b/scripts/test_smoke_release.py index 3a6811a..9e7faff 100644 --- a/scripts/test_smoke_release.py +++ b/scripts/test_smoke_release.py @@ -6,7 +6,7 @@ ``release_tools.py verify-package-layout``, which is exercised directly against real staged fixture packages (and against packages deliberately broken in one way each); and -* how the scripts are *driven* — the mandatory backend, archives rather +* how the scripts are *driven* — the mandatory package profile, archives rather than directories, and the environment scrubbing that makes a passing smoke test mean "this works from the package alone". @@ -77,9 +77,18 @@ def test_every_published_variant_verifies(self) -> None: archive=archive, expect_archive_root_name=True, ) - self.assertEqual(metadata["backend"], backend) - self.assertEqual(metadata["default_backend"], backend) - self.assertEqual(metadata["available_backends"], [backend]) + self.assertEqual(metadata["profile"], backend) + self.assertEqual(metadata["package_id"], f"oscan-{backend}") + expected = ( + ["llvm", "cranelift", "c"] + if backend == "full" + else [backend] + ) + self.assertEqual(metadata["available_backends"], expected) + self.assertEqual( + metadata["default_backend"], + "llvm" if backend == "full" else backend, + ) def test_the_command_line_entry_point_verifies_a_real_package(self) -> None: with tempfile.TemporaryDirectory() as tmp_name: @@ -91,7 +100,7 @@ def test_the_command_line_entry_point_verifies_a_real_package(self) -> None: str(REPO_ROOT / "scripts" / "release_tools.py"), "verify-package-layout", "--target", "linux-x86_64", - "--backend", "llvm", + "--profile", "llvm", "--root", str(bundle), "--stage", "extracted", "--archive", str(archive), @@ -261,12 +270,13 @@ class PowerShellSmokeInterfaceTests(unittest.TestCase): def setUp(self) -> None: self.script = SMOKE_POWERSHELL.read_text(encoding="utf-8") - def test_the_backend_is_mandatory_and_canonical(self) -> None: - self.assertIn('[ValidateSet("llvm", "cranelift", "c")]', self.script) + def test_the_profile_is_mandatory_and_canonical(self) -> None: + self.assertIn('[ValidateSet("full", "llvm", "cranelift", "c")]', self.script) self.assertRegex( self.script, r"\[Parameter\(Mandatory = \$true\)\]\s*\n\s*" - r'\[ValidateSet\("llvm", "cranelift", "c"\)\]\s*\n\s*\[string\]\$Backend', + r'\[Alias\("Backend"\)\]\s*\n\s*' + r'\[ValidateSet\("full", "llvm", "cranelift", "c"\)\]\s*\n\s*\[string\]\$Profile', ) def test_it_takes_an_archive_and_refuses_a_directory(self) -> None: @@ -331,7 +341,7 @@ def test_windows_compiles_run_the_installed_executable_not_the_shim(self) -> Non # packaged compile must not need one; the shim is still covered by # the --version check, which runs with the ordinary PATH. self.assertIn("$OscanCommand = Join-Path $InstallDir $binaryName", self.script) - self.assertIn('$OscanShim = Join-Path $BinDir "oscan.cmd"', self.script) + self.assertIn('$OscanShim = Join-Path $BinDir "oscan-$Profile.cmd"', self.script) self.assertIn("$versionText = (& $OscanShim --version", self.script) self.assertIn("& $script:OscanCommand @Arguments", self.script) @@ -341,14 +351,22 @@ def test_it_asserts_the_selected_backend_link_mode_and_provider(self) -> None: # The native-link assertion checks *which* sidecar was used, so a # generic "the word sidecar appears" match must not creep back in. self.assertNotIn(r"^\[verbose\] native-link assets: sidecar \(", self.script) - self.assertEqual(self.script.count("Assert-PackagedSidecarAssets -LogPath"), 2) - self.assertEqual(self.script.count("Assert-PackagedInProcessLinking -LogPath"), 2) + self.assertGreaterEqual(self.script.count("Assert-PackagedSidecarAssets -LogPath"), 2) + self.assertGreaterEqual(self.script.count("Assert-PackagedInProcessLinking -LogPath"), 2) self.assertIn("\nfunction Assert-PackagedInProcessLinking {", self.script) self.assertNotIn("\n function Assert-PackagedInProcessLinking {", self.script) self.assertIn("-SidecarRoot (Join-Path $InstallDir $sidecarDirName)", self.script) self.assertIn("LLVM code generator: statically-linked-llvm-", self.script) self.assertIn('if ($usesSidecar)', self.script) + def test_full_profile_smokes_default_and_every_explicit_backend(self) -> None: + self.assertIn("if ($isFullPackage)", self.script) + self.assertIn("foreach ($selectedBackend in $availableBackends)", self.script) + self.assertIn('"--backend", $selectedBackend', self.script) + self.assertIn('"--verbose", "--libc"', self.script) + self.assertIn('"--verbose", "--extra-c", $extraCSource', self.script) + self.assertIn("Linking hosted executable with .+ \\(bundled\\)", self.script) + def test_it_asserts_the_version_metadata_block(self) -> None: for line in ("backends", "default-backend", "distribution", "toolchain-free"): with self.subTest(line=line): @@ -371,7 +389,7 @@ def test_object_packages_refuse_what_they_do_not_contain(self) -> None: "the c backend is not included in this compiler build", "refuses --libc", "refuses --extra-c", - "archive name ends in '-c'", + "archive name ends in '-full' or '-c'", ): with self.subTest(expectation=expectation): self.assertIn(expectation, self.script) @@ -623,7 +641,7 @@ def setUp(self) -> None: self.script = SMOKE_SHELL.read_text(encoding="utf-8") def test_it_keeps_the_same_contract_driven_interface(self) -> None: - for flag in ("--target", "--backend", "--archive", "--version", "--contract"): + for flag in ("--target", "--profile", "--archive", "--version", "--contract"): with self.subTest(flag=flag): self.assertIn(flag, self.script) stages = re.findall(r"--stage (\w+)", self.script) @@ -643,12 +661,13 @@ def test_it_scrubs_the_same_overrides(self) -> None: self.assertIn(f"-u {name}", self.script) self.assertNotIn("OSCAN_RUNTIME_ARCHIVE_DIR=", self.script) - def test_it_asserts_the_same_backend_facts(self) -> None: - self.assertIn("^backends: $BACKEND\\$", self.script) - self.assertIn("^distribution: $BACKEND\\$", self.script) - self.assertIn("^toolchain-free: $EXPECTED_TOOLCHAIN_FREE\\$", self.script) + def test_it_asserts_the_same_profile_facts(self) -> None: + self.assertIn("^backends: $BACKEND_LIST\\$", self.script) + self.assertIn("^distribution: $PROFILE\\$", self.script) + self.assertIn("^toolchain-free: $TOOLCHAIN_FREE\\$", self.script) self.assertIn("native-link assets: sidecar", self.script) self.assertIn("the c backend is not included in this compiler build", self.script) + self.assertIn("for SELECTED_BACKEND in $AVAILABLE_BACKENDS", self.script) @unittest.skipIf(SHELL is None, "no POSIX shell is available in this environment") def test_the_script_is_syntactically_valid(self) -> None: @@ -658,14 +677,14 @@ def test_the_script_is_syntactically_valid(self) -> None: self.assertEqual(result.returncode, 0, result.stdout + result.stderr) @unittest.skipIf(SHELL is None, "no POSIX shell is available in this environment") - def test_it_refuses_to_run_without_a_backend(self) -> None: + def test_it_refuses_to_run_without_a_profile(self) -> None: result = subprocess.run( [SHELL, str(SMOKE_SHELL), "--target", "linux-x86_64", "--archive", "x.tar.xz"], capture_output=True, text=True, ) self.assertNotEqual(result.returncode, 0) - self.assertIn("missing --backend", result.stderr) + self.assertIn("missing --profile", result.stderr) @unittest.skipIf(SHELL is None, "no POSIX shell is available in this environment") def test_it_refuses_the_deprecated_alias_as_a_package_label(self) -> None: @@ -673,7 +692,7 @@ def test_it_refuses_the_deprecated_alias_as_a_package_label(self) -> None: [ SHELL, str(SMOKE_SHELL), "--target", "linux-x86_64", - "--backend", "native", + "--profile", "native", "--archive", "x.tar.xz", ], capture_output=True, @@ -689,7 +708,7 @@ def test_it_refuses_a_directory_instead_of_an_archive(self) -> None: [ SHELL, str(SMOKE_SHELL), "--target", "linux-x86_64", - "--backend", "llvm", + "--profile", "llvm", "--archive", tmp_name, ], capture_output=True, @@ -710,6 +729,13 @@ def test_the_wix_source_and_harvester_agree_on_the_component_group(self) -> None self.assertNotIn("ToolchainFiles", wxs) self.assertNotIn("ToolchainFiles", harvester) + def test_msi_checks_archive_ownership_after_app_search(self) -> None: + wxs = WXS.read_text(encoding="utf-8") + self.assertIn('Name="archive-default"', wxs) + self.assertIn('Name="oscan.exe"', wxs) + self.assertIn("OSCAN_ALLOW_ARCHIVE_CONFLICT", wxs) + self.assertEqual(wxs.count(''), 2) + @unittest.skipIf(POWERSHELL is None, "pwsh is not available in this environment") def test_the_strict_bundle_harvests_metadata_and_notices_only(self) -> None: with tempfile.TemporaryDirectory() as tmp_name: diff --git a/src/backend/distribution_contract.rs b/src/backend/distribution_contract.rs index 2065256..63898bd 100644 --- a/src/backend/distribution_contract.rs +++ b/src/backend/distribution_contract.rs @@ -1,24 +1,37 @@ -// The distribution-stamp contract. +// The packaged-distribution contract. // -// `OSCAN_DISTRIBUTION_BACKEND=llvm|cranelift|c` marks a build as *the* -// packaged compiler for one backend. Two places have to agree on exactly -// what that means, so they share this one dependency-free file: +// A distribution has two independent facts: // -// * `build.rs` `include!`s it and enforces the contract at build time, so -// a mismatched pair fails the build with a named reason instead of -// producing a compiler that cannot run its own default backend; and -// * the compiler itself (`crate::backend::select`) compiles it as a normal -// module, reads the stamp through it, and unit-tests the rules below. +// * OSCAN_DISTRIBUTION_PROFILE says whether this is a packaged build and +// which package profile it belongs to (`full`, `llvm`, `cranelift`, or `c`); +// * OSCAN_DEFAULT_BACKEND says which compiled-in backend an invocation uses +// when no CLI/output flag selects one. // -// Because `build.rs` includes this file verbatim, nothing here may refer -// to the crate, to cargo features, or to any dependency. +// OSCAN_DISTRIBUTION_BACKEND remains a compatibility input for existing +// single-backend build scripts. It is normalized to the equivalent profile +// and default. Two places share this dependency-free file: +// +// * build.rs validates and exports the normalized values at build time; and +// * crate::backend::select reads those exported values and unit-tests the +// selection contract. +// +// Because build.rs includes this file verbatim, nothing here may refer to the +// crate, Cargo features, or any dependency. -/// Every backend name a stamp may carry, in canonical order. +/// Every backend name, in canonical order. pub const DISTRIBUTION_BACKEND_NAMES: [&str; 3] = ["llvm", "cranelift", "c"]; -/// Normalize a raw stamp value. `None` means "unset": an ordinary -/// development build with no forced default. Surrounding whitespace and -/// letter case are not meaningful. +/// Every packaged profile, in canonical order. +pub const DISTRIBUTION_PROFILE_NAMES: [&str; 4] = ["full", "llvm", "cranelift", "c"]; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DistributionConfig { + pub profile: Option, + pub default_backend: Option, +} + +/// Normalize an environment value. `None` means unset; surrounding whitespace +/// and letter case are not meaningful. pub fn normalize_distribution_stamp(raw: &str) -> Option { let trimmed = raw.trim().to_ascii_lowercase(); if trimmed.is_empty() { @@ -28,21 +41,36 @@ pub fn normalize_distribution_stamp(raw: &str) -> Option { } } -/// Validate a raw stamp against the backends this build actually enables. +fn validate_backend_name(value: &str, variable: &str) -> Result<(), String> { + if DISTRIBUTION_BACKEND_NAMES.contains(&value) { + Ok(()) + } else { + Err(format!( + "{variable}='{value}' is not a valid backend name (expected one of: {})", + DISTRIBUTION_BACKEND_NAMES.join(", ") + )) + } +} + +/// Validate and normalize the complete distribution configuration. /// -/// * `Ok(None)` — no stamp: an ordinary development build. -/// * `Ok(Some(name))` — a valid single-backend distribution build. -/// * `Err(message)` — an actionable reason the pair is not a distribution -/// build at all. +/// An empty configuration is an ordinary development build. A default backend +/// without a profile is also allowed for deterministic custom/development +/// builds, but it does not enable strict packaged lookup. Packaged profiles +/// always require a default: /// -/// A distribution build must enable **exactly one** backend, and it must -/// be the stamped one. "At least one" is not enough: a stamp that merely -/// picks a default out of several compiled-in backends would produce an -/// artifact whose name promises a single-backend package while still -/// carrying the others, which is precisely the confusion the stamp exists -/// to prevent. -#[allow(dead_code)] // `build.rs` is the primary caller; the crate unit-tests it. -pub fn validate_distribution_stamp(raw: &str, enabled: &[&str]) -> Result, String> { +/// * `full` enables all three backends and defaults to LLVM; +/// * `llvm`, `cranelift`, and `c` enable exactly the named backend. +/// +/// The legacy `OSCAN_DISTRIBUTION_BACKEND` input is accepted only for a +/// matching single-backend build and must agree with any new variables. +#[allow(dead_code)] // build.rs is the primary caller; the crate unit-tests it. +pub fn validate_distribution_config( + profile_raw: &str, + default_raw: &str, + legacy_backend_raw: &str, + enabled: &[&str], +) -> Result { if enabled.is_empty() { return Err( "no backend feature is enabled: build with at least one of backend-llvm, \ @@ -50,35 +78,114 @@ pub fn validate_distribution_stamp(raw: &str, enabled: &[&str]) -> Result