diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4165023..9d19d2a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -125,7 +125,8 @@ jobs: RELEASE_RUNTIME_PROFILES: ${{ matrix.runtime_profiles }} RELEASE_BINARY_NAME: ${{ matrix.binary_name }} RELEASE_BINARY_PATH: ${{ matrix.binary_path }} - RELEASE_MSI_PROFILE: ${{ matrix.msi_profile }} + RELEASE_MSI_PROFILES: ${{ matrix.msi_profiles }} + RELEASE_MSIS: ${{ matrix.msis }} RELEASE_NEEDS_PROVIDER_ARCHIVE: ${{ matrix.needs_provider_archive }} RELEASE_NEEDS_INPROCESS_TOOLCHAIN: ${{ matrix.needs_inprocess_toolchain }} RELEASE_VERSION: ${{ needs.prepare.outputs.version }} @@ -531,56 +532,100 @@ jobs: Write-Host "Packaged and smoked: $($produced -join ', ')" $LASTEXITCODE = 0 - # One installer, for the recommended package. The MSI is cut from the - # 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_profile != '' + # Each Windows profile gets an independent MSI family cut from the exact + # staged bundle that was already packaged and smoked above. + - name: Build and smoke Windows MSI installers + if: matrix.msi_profiles != '' shell: pwsh run: | $target = $env:RELEASE_TARGET - $profile = $env:RELEASE_MSI_PROFILE $version = $env:RELEASE_VERSION + $profiles = $env:RELEASE_MSI_PROFILES.Split( + ',', [System.StringSplitOptions]::RemoveEmptyEntries + ) + $expected = $env:RELEASE_MSIS.Split( + ',', [System.StringSplitOptions]::RemoveEmptyEntries + ) + if ($profiles.Count -ne $expected.Count) { + throw "MSI profile/name matrix length mismatch: $($profiles.Count) profiles, $($expected.Count) names." + } $contract = Get-Content 'packaging/toolchains/release-contract.json' -Raw | ConvertFrom-Json -AsHashtable - $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/$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 - $msiPath = Join-Path $uploadDir "$bundleName.msi" - & ./scripts/build-msi.ps1 -BundleDir $bundleDir -Version $version -OutputPath $msiPath - 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-$profile" + $msiScratch = Join-Path $env:RUNNER_TEMP "oscan-release-msi/$target" if (Test-Path -LiteralPath $msiScratch) { Remove-Item -LiteralPath $msiScratch -Recurse -Force } $adminRoot = Join-Path $msiScratch 'admin' New-Item -ItemType Directory -Path $adminRoot -Force | Out-Null - $msiLog = Join-Path $msiScratch 'administrative-extract.log' - $process = Start-Process -FilePath msiexec.exe -ArgumentList @( - '/a', "`"$msiPath`"", '/qn', "TARGETDIR=`"$adminRoot`"", - '/l*v', "`"$msiLog`"" - ) -Wait -PassThru - if ($process.ExitCode -ne 0) { - Get-Content -LiteralPath $msiLog -Tail 100 - throw "Administrative extraction of $msiPath failed with exit code $($process.ExitCode)." + $produced = @() + + foreach ($profile in $profiles) { + $variant = $contract['variants'][$target]['profiles'][$profile] + $bundleName = ([string]$variant['archive_root_template']).Replace('{version}', $version) + $bundleDir = Join-Path (Join-Path $outputDir 'stage') $bundleName + if (-not (Test-Path -LiteralPath $bundleDir -PathType Container)) { + throw "The staged $target/$profile bundle is missing at $bundleDir; the MSI must be built from the bundle that was packaged and smoked." + } + $msiName = ([string]$variant['msi_name_template']).Replace('{version}', $version) + if ($produced -contains $msiName) { + throw "Two Windows profiles claim the same MSI name '$msiName'." + } + $msiPath = Join-Path $uploadDir $msiName + & ./scripts/build-msi.ps1 ` + -BundleDir $bundleDir ` + -Version $version ` + -OutputPath $msiPath + if (-not (Test-Path -LiteralPath $msiPath -PathType Leaf)) { + throw "The MSI build produced no installer at $msiPath." + } + + $msiLog = Join-Path $msiScratch "administrative-extract-$profile.log" + $process = Start-Process -FilePath msiexec.exe -ArgumentList @( + '/a', "`"$msiPath`"", '/qn', "TARGETDIR=`"$adminRoot`"", + '/l*v', "`"$msiLog`"" + ) -Wait -PassThru + if ($process.ExitCode -ne 0) { + Get-Content -LiteralPath $msiLog -Tail 100 + throw "Administrative extraction of $msiPath failed with exit code $($process.ExitCode)." + } + + $installedRoot = Join-Path $adminRoot "PFiles64\Oscan\profiles\$profile\$version" + & ./scripts/smoke-release.ps1 ` + -Target $target ` + -Profile $profile ` + -InstalledPackageDir $installedRoot ` + -Version $version ` + -ScratchDir (Join-Path $msiScratch "smoke-$profile") + if ($LASTEXITCODE -ne 0) { + throw "The MSI payload smoke failed for $target/$profile." + } + + $qualifiedCommand = Join-Path $adminRoot "PFiles64\Oscan\bin\oscan-$profile.cmd" + if (-not (Test-Path -LiteralPath $qualifiedCommand -PathType Leaf)) { + throw "The $profile MSI did not install its qualified command." + } + & $qualifiedCommand --version | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "The administratively extracted command '$qualifiedCommand' failed." + } + $produced += $msiName + } + + $missing = @($expected | Where-Object { $produced -notcontains $_ }) + $unexpected = @($produced | Where-Object { $expected -notcontains $_ }) + if ($missing.Count -gt 0 -or $unexpected.Count -gt 0) { + throw "MSI set differs from the release contract. Missing: $($missing -join ', '); unexpected: $($unexpected -join ', ')." + } + $selector = Join-Path $adminRoot 'PFiles64\Oscan\bin\oscan.cmd' + if (-not (Test-Path -LiteralPath $selector -PathType Leaf)) { + throw "The coinstalled MSI image has no shared oscan selector." } - $installedRoot = Join-Path $adminRoot 'PFiles64\Oscan' - & ./scripts/smoke-release.ps1 ` - -Target $target ` - -Profile $profile ` - -InstalledPackageDir $installedRoot ` - -Version $version ` - -ScratchDir (Join-Path $msiScratch 'smoke') + & $selector --version | Out-Null if ($LASTEXITCODE -ne 0) { - throw "The MSI payload smoke failed for $target/$profile." + throw "The coinstalled MSI selector failed." } - name: Upload packaged artifacts diff --git a/README.md b/README.md index 0c3eb0e..c9ddc63 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,16 @@ 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` | LLVM remains the transition default; `-full.zip` includes all backends | +| Windows x86_64 | `oscan-vX.Y.Z-windows-x86_64-llvm.msi` or `.zip` | Every profile has both formats; `-full.msi`/`.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 provide `full`, `llvm`, `cranelift`, and `c` profiles. Full contains one backend-neutral compiler; the others are smaller slim packages. +Windows publishes both `.msi` and `.zip` for all four profiles. Profile MSIs +upgrade independently and may be installed together. If the legacy flat LLVM +MSI is installed, run the current LLVM MSI once to migrate it before installing +another MSI profile. Each release includes `SHA256SUMS`; keep the downloaded file's original name and verify it before installing. The [installation guide](docs/guide.md#installation) has diff --git a/docs/guide.md b/docs/guide.md index 5025b06..b2a0ccd 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -39,9 +39,10 @@ list and what each package contains. **Windows x86_64:** -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` +1. Download the recommended slim + `oscan-vX.Y.Z-windows-x86_64-llvm.msi` (or `.zip`), or choose the matching + `-full`, `-cranelift`, or `-c` asset. Every profile is published as both + `.msi` and `.zip`. 2. Verify exactly that asset against the release's `SHA256SUMS` (filter the file to the asset you downloaded rather than checking every line): ```powershell @@ -49,8 +50,8 @@ 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 and run the included `install.ps1`; pass `-SetDefault` to change an - existing unqualified `oscan` selection. For the MSI, just run it. +3. For a ZIP, extract and run `install.ps1`; pass `-SetDefault` to change an + existing unqualified `oscan` selection. For an MSI, just run it. 4. Verify the qualified command, for example `oscan-full --version` `scripts/install-latest.ps1 -Profile full -SetDefault` installs the all-backend @@ -153,17 +154,34 @@ and preserves every other profile. `-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. - -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. +- **Upgrade (Windows MSI):** Run the newer MSI for that profile. `Oscan Full`, + `Oscan LLVM`, `Oscan Cranelift`, and `Oscan C` are independent product + families, so upgrading one preserves the others. +- **Uninstall (Windows MSI):** Use **Settings → Apps → Installed apps** and + uninstall the named profile, or run + `msiexec /x oscan-vX.Y.Z-windows-x86_64-.msi /quiet`. Do **not** + delete its directory manually. + +MSIs install isolated payloads under +`Program Files\Oscan\profiles\\` and expose +`oscan-full`, `oscan-llvm`, `oscan-cranelift`, and `oscan-c` through a shared +`Program Files\Oscan\bin`. Their reference-counted `oscan` selector chooses the +first installed command in this order: full, LLVM, Cranelift, C; uninstalling a +profile falls through to the next one. The LLVM family retains the historical +UpgradeCode so a new LLVM MSI upgrades the old single-product MSI. + +The quick installer and MSI detect a per-user archive selector for the same +Windows account and a matching profile-qualified archive command. They 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. +The pre-profile LLVM MSI installed `oscan.exe` directly under +`Program Files\Oscan`. Run the current LLVM MSI once to migrate that product +before installing Full, Cranelift, or C; those MSI families block while the +legacy executable could shadow the shared selector. + --- ## Basics diff --git a/docs/releasing.md b/docs/releasing.md index 398b630..50c34b4 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -6,7 +6,7 @@ Release builds are handled by GitHub Actions workflows. Two manual workflows mus `packaging/toolchains/release-contract.json` (`schema_version: 3`) is the single source of truth, and every published artifact is one **(target, -profile) pair**. A release therefore publishes **nine archives, one MSI and +profile) pair**. A release therefore publishes **nine archives, four MSIs and one `SHA256SUMS`** — and nothing else: | Target | Profile | Archive | Components staged | @@ -21,11 +21,12 @@ one `SHA256SUMS`** — and nothing else: | `linux-x86_64` | `c` | `oscan-v{version}-linux-x86_64-c.tar.xz` | compiler, pinned C toolchain | | `macos-x86_64` | `c` | `oscan-v{version}-macos-x86_64-c.tar.gz` | compiler only (uses the host's Apple Command Line Tools) | -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 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 +Each Windows row also publishes a matching `.msi`, cut from the *already staged +and smoked* bundle directory. MSI names, product names, and distinct +UpgradeCodes are declared by the contract; the LLVM UpgradeCode remains the +historical one for in-place migration. The other families block while the +legacy flat LLVM executable remains, forcing that migration before their shared +selector can be shadowed. `native` is never an artifact label — it survives only as the compiler's deprecated `--backend` alias for `cranelift`. @@ -95,11 +96,12 @@ deliberately not cached. 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 profile and, for each profile, runs assemble → smoke - → stage for upload. Windows additionally cuts the recommended LLVM MSI from - the staged strict bundle. + → stage for upload. Windows additionally cuts and administratively + coextracts all four profile MSIs from those staged bundles, then smokes each + extracted payload and its qualified command. 3. `checksums` — collect every `.zip`/`.msi`/`.tar.gz`/`.tar.xz`, refuse duplicate asset names, and write one `SHA256SUMS` over all of them. -4. `publish` — only when publishing is enabled; uploads the archives, the MSI +4. `publish` — only when publishing is enabled; uploads the archives, the MSIs and `SHA256SUMS` to the GitHub release. ### Dry run without publishing @@ -107,7 +109,7 @@ deliberately not cached. 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 nine archives and the MSI — the supported way to +smokes and checksums all nine archives and four MSIs — the supported way to rehearse a release. A tag push (`v*`) always publishes. ### Assembling and smoking one variant by hand @@ -766,9 +768,9 @@ input is prepared once and reused by that target's profiles): object variants; `-ToolchainArchive` or `-LlvmProviderArchive` where 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. +7. **Cut all four Windows profile MSIs** from their staged bundle directories, + coextract them into one administrative image, and smoke every isolated + payload plus the shared/qualified command layout. CI (`ci.yml`) keeps its main `linux` and `windows` jobs building *without* `OSCAN_EMBED_ASSETS_DIR`, so the dev/external-toolchain path stays covered. @@ -876,7 +878,7 @@ git push origin v0.0.12 ``` 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 +assembles and smokes all nine archives, cuts all four Windows profile MSIs, +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 122d058..e6b9bc8 100644 --- a/docs/spec/oscan-spec.md +++ b/docs/spec/oscan-spec.md @@ -34,15 +34,15 @@ ### Getting the Oscan Compiler -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. +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 publishes every profile as both a ZIP and an independently upgradable, coinstallable MSI. 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`, 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` +1. Download `oscan-vX.Y.Z-windows-x86_64-llvm.zip` or `.msi`, the all-backend + `oscan-vX.Y.Z-windows-x86_64-full.zip` or `.msi`, or the matching + Cranelift/C asset 2. Verify exactly that asset against the release's `SHA256SUMS`, keeping the downloaded file's canonical name: ```powershell @@ -238,14 +238,21 @@ same-profile upgrade. Installing one profile never removes another or changes an existing unqualified selector; `-SetDefault`/`--set-default` is explicit. - **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 +- **Upgrade (Windows MSI):** Run the newer same-profile MSI; each profile is an independent product family and preserves the others - **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. +- **Uninstall (Windows MSI):** Use **Settings → Apps → Installed apps** to remove the named Oscan profile, or run `msiexec /x oscan-vX.Y.Z-windows-x86_64-.msi /quiet`. Deleting the directory by hand leaves the product registered with Windows Installer. + +Windows profile MSIs install isolated payloads and qualified commands. Their +shared, reference-counted `oscan` selector chooses the first available profile +in `full`, `llvm`, `cranelift`, `c` order and falls through when one is +uninstalled. The LLVM MSI keeps the historical UpgradeCode so it upgrades the +legacy LLVM-only product. Other profile MSIs require that migration first so +the old flat executable cannot shadow the shared selector. 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. +unqualified `oscan` command or a matching profile-qualified command by default. +The MSI detects archive commands owned by the same Windows account; intentional +coexistence requires the explicit `OSCAN_ALLOW_ARCHIVE_CONFLICT=1` MSI property. --- diff --git a/docs/technical-details.md b/docs/technical-details.md index 0d1c1a9..2454b67 100644 --- a/docs/technical-details.md +++ b/docs/technical-details.md @@ -83,10 +83,10 @@ component digests. | 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 | `full` | `oscan-vX.Y.Z-windows-x86_64-full.msi` or `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 | +| Windows x86_64 | `cranelift` | `oscan-vX.Y.Z-windows-x86_64-cranelift.msi` or `oscan-vX.Y.Z-windows-x86_64-cranelift.zip` | None | +| Windows x86_64 | `c` | `oscan-vX.Y.Z-windows-x86_64-c.msi` or `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 | @@ -157,14 +157,19 @@ Archive installs use one stable bin directory and isolated profile payloads: 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. +Windows uses `.cmd` shims; Unix uses symlinks. Archive installs preserve an +existing selector unless `-SetDefault`/`--set-default` changes it. Their staged +same-profile upgrades update only that qualified entry and payload. + +Windows also publishes four coinstallable MSI product families. They use +distinct UpgradeCodes and payload directories, a qualified command per profile, +and identical reference-counted PATH/selector components. The MSI selector +falls through in `full`, `llvm`, `cranelift`, `c` order as profiles are +uninstalled. The LLVM family preserves the historical UpgradeCode to upgrade +legacy LLVM installs in place. Other MSI families require that migration first +so the old flat executable cannot shadow the shared selector. Archive and MSI +installers reject ownership of the same unqualified or profile-qualified +Windows command by default. ## Runtime and final linking diff --git a/packaging/toolchains/release-contract.json b/packaging/toolchains/release-contract.json index 6b3d2ec..ea1d5a6 100644 --- a/packaging/toolchains/release-contract.json +++ b/packaging/toolchains/release-contract.json @@ -163,6 +163,9 @@ "full": { "archive_name_template": "oscan-v{version}-windows-x86_64-full.zip", "archive_root_template": "oscan-v{version}-windows-x86_64-full", + "msi_name_template": "oscan-v{version}-windows-x86_64-full.msi", + "msi_product_name": "Oscan Full", + "msi_upgrade_code": "2D90D756-5B6A-46EA-B70A-2AE1FBFF986D", "cargo_features": [ "backend-llvm", "backend-cranelift", @@ -194,6 +197,9 @@ "llvm": { "archive_name_template": "oscan-v{version}-windows-x86_64-llvm.zip", "archive_root_template": "oscan-v{version}-windows-x86_64-llvm", + "msi_name_template": "oscan-v{version}-windows-x86_64-llvm.msi", + "msi_product_name": "Oscan LLVM", + "msi_upgrade_code": "F7A3B2C1-4D5E-6F78-9A0B-1C2D3E4F5A6B", "cargo_features": [ "backend-llvm", "inprocess-lld", @@ -220,6 +226,9 @@ "cranelift": { "archive_name_template": "oscan-v{version}-windows-x86_64-cranelift.zip", "archive_root_template": "oscan-v{version}-windows-x86_64-cranelift", + "msi_name_template": "oscan-v{version}-windows-x86_64-cranelift.msi", + "msi_product_name": "Oscan Cranelift", + "msi_upgrade_code": "F3F0AE9A-0CBC-48EC-8CE3-6E2962F0DD7A", "cargo_features": [ "backend-cranelift" ], @@ -239,6 +248,9 @@ "c": { "archive_name_template": "oscan-v{version}-windows-x86_64-c.zip", "archive_root_template": "oscan-v{version}-windows-x86_64-c", + "msi_name_template": "oscan-v{version}-windows-x86_64-c.msi", + "msi_product_name": "Oscan C", + "msi_upgrade_code": "F9A6AE6D-5718-482F-9D3B-780F8CCB062F", "cargo_features": [ "backend-c" ], diff --git a/packaging/windows/oscan.wxs b/packaging/windows/oscan.wxs index 5fa21c0..e1006ea 100644 --- a/packaging/windows/oscan.wxs +++ b/packaging/windows/oscan.wxs @@ -4,16 +4,19 @@ + Version = semantic version (e.g. 0.0.1) + Profile = full, llvm, cranelift, or c + ProductName / UpgradeCode = profile-specific MSI identity + ProfileShimPath / SelectorShimPath = generated command shims --> - - + @@ -31,9 +34,34 @@ + + + + + + + + + + + + + - + + + + @@ -50,35 +78,58 @@ + Value="$(var.ProductName) profile. MSI profiles coexist through isolated payloads and qualified commands." /> - - - + + + Value="$(var.ProductName) has been installed. Open a new terminal and run 'oscan-$(var.Profile) --help'." /> - + - + + + + + + + + - - - + + - - - + + - - - + + + + + + + + + + + @@ -86,7 +137,7 @@ harvested dynamically by scripts/build-msi.ps1 --> - + diff --git a/scripts/build-msi.ps1 b/scripts/build-msi.ps1 index 3f4bb74..2f2ac54 100644 --- a/scripts/build-msi.ps1 +++ b/scripts/build-msi.ps1 @@ -18,6 +18,7 @@ param( $ErrorActionPreference = "Stop" $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $WxsPath = Join-Path $RepoRoot "packaging/windows/oscan.wxs" +$ContractPath = Join-Path $RepoRoot "packaging/toolchains/release-contract.json" if (-not (Test-Path $BundleDir)) { throw "Bundle directory not found: $BundleDir" @@ -31,8 +32,40 @@ 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 ($packageMetadata.schema_version -ne 2) { + throw "The MSI requires schema-2 package metadata; got schema '$($packageMetadata.schema_version)'." +} +$Profile = [string]$packageMetadata.profile +if ($Profile -notin @("full", "llvm", "cranelift", "c")) { + throw "The package metadata has unsupported MSI profile '$Profile'." +} +if ($packageMetadata.package_id -ne "oscan-$Profile") { + throw "Package metadata package_id '$($packageMetadata.package_id)' does not match profile '$Profile'." +} +if ($packageMetadata.target -ne "windows-x86_64") { + throw "The MSI builder requires a windows-x86_64 package; got '$($packageMetadata.target)'." +} +if ($packageMetadata.is_distribution -ne $true) { + throw "The MSI builder requires distribution package metadata." +} +if (-not (Test-Path -LiteralPath $ContractPath -PathType Leaf)) { + throw "Release contract not found: $ContractPath" +} +$releaseContract = Get-Content -LiteralPath $ContractPath -Raw | ConvertFrom-Json -AsHashtable +$profileSpec = $releaseContract["variants"]["windows-x86_64"]["profiles"][$Profile] +if (-not $profileSpec) { + throw "The release contract does not declare Windows profile '$Profile'." +} +foreach ($field in @("msi_name_template", "msi_product_name", "msi_upgrade_code")) { + if (-not $profileSpec[$field]) { + throw "The release contract Windows profile '$Profile' is missing '$field'." + } +} +$ProductName = [string]$profileSpec["msi_product_name"] +$UpgradeCode = ([guid]([string]$profileSpec["msi_upgrade_code"])).ToString().ToUpperInvariant() +if ($Profile -eq "llvm" -and + $UpgradeCode -ne "F7A3B2C1-4D5E-6F78-9A0B-1C2D3E4F5A6B") { + throw "The LLVM MSI must preserve its historical UpgradeCode for in-place migration." } if (-not (Test-Path $WxsPath)) { throw "WiX source not found: $WxsPath" @@ -44,6 +77,12 @@ if (-not $HarvestOnly) { if (-not $OutputPath) { throw "-OutputPath is required when building an MSI" } + if ($Version -ne [string]$packageMetadata.version) { + throw "MSI version '$Version' does not match package metadata version '$($packageMetadata.version)'." + } + if ($Version -notmatch '^\d+\.\d+\.\d+$') { + throw "MSI version '$Version' must contain exactly three numeric components." + } # Install WiX dotnet tool if not present $wixCmd = Get-Command wix -ErrorAction SilentlyContinue @@ -60,13 +99,9 @@ if (-not $HarvestOnly) { # Harvest the bundle's payload into a WiX fragment. # -# 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 -# meaning inside an MSI (and mirrors/deletes its destination when run). +# Every profile MSI is cut from its already staged package. The payload differs +# by profile, so harvest everything except files declared directly by the .wxs +# and the archive install script, which has no meaning inside an MSI. $RootFileExclusions = @("oscan.exe", "README-install.txt", "install.ps1") if (-not $HarvestPath) { $HarvestPath = Join-Path ([System.IO.Path]::GetTempPath()) "oscan-bundle-harvest.wxs" @@ -104,9 +139,8 @@ function Add-DirectoryContent { $compId = "comp_$($Counter.Value)" $fileId = "file_$($Counter.Value)" $Counter.Value++ - $guid = [guid]::NewGuid().ToString().ToUpper() $relativePath = $item.FullName.Substring($BundleDir.Length).TrimStart('\', '/').Replace('/', '\') - $Builder.AppendLine("$pad") | Out-Null + $Builder.AppendLine("$pad") | Out-Null $Builder.AppendLine("$pad ") | Out-Null $Builder.AppendLine("$pad") | Out-Null $Components.Value += $compId @@ -132,7 +166,7 @@ if ($payloadDirectories.Count -eq 0 -and $rootFiles.Count -eq 0) { - + '@) | Out-Null $counterRef = [ref]$counter @@ -148,8 +182,7 @@ if ($payloadDirectories.Count -eq 0 -and $rootFiles.Count -eq 0) { $compId = "comp_$($counterRef.Value)" $fileId = "file_$($counterRef.Value)" $counterRef.Value++ - $guid = [guid]::NewGuid().ToString().ToUpper() - $sb.AppendLine(" ") | Out-Null + $sb.AppendLine(" ") | Out-Null $sb.AppendLine(" ") | Out-Null $sb.AppendLine(" ") | Out-Null $componentIdsRef.Value += $compId @@ -173,24 +206,66 @@ if ($HarvestOnly) { return } -# Build the MSI -Write-Host "Building MSI..." +# Build one product-family MSI. Profile payloads live under distinct folders; +# shared selector/PATH components are identical and reference-counted by MSI. +Write-Host "Building $ProductName MSI..." $parentDir = Split-Path $OutputPath -Parent if (-not (Test-Path $parentDir)) { New-Item -ItemType Directory -Path $parentDir -Force | Out-Null } +$shimDir = Join-Path ([System.IO.Path]::GetTempPath()) "oscan-msi-$([guid]::NewGuid().ToString('N'))" +$profileShimPath = Join-Path $shimDir "oscan-$Profile.cmd" +$selectorShimPath = Join-Path $shimDir "oscan.cmd" +New-Item -ItemType Directory -Path $shimDir -Force | Out-Null +try { + $profileShim = @" +@echo off +"%~dp0..\profiles\$Profile\$Version\oscan.exe" %* +@exit /b %ERRORLEVEL% +"@ + $selectorShim = @' +@echo off +if exist "%~dp0oscan-full.cmd" goto full +if exist "%~dp0oscan-llvm.cmd" goto llvm +if exist "%~dp0oscan-cranelift.cmd" goto cranelift +if exist "%~dp0oscan-c.cmd" goto c +echo Oscan has no installed MSI profile. 1>&2 +exit /b 1 +:full +call "%~dp0oscan-full.cmd" %* +exit /b %ERRORLEVEL% +:llvm +call "%~dp0oscan-llvm.cmd" %* +exit /b %ERRORLEVEL% +:cranelift +call "%~dp0oscan-cranelift.cmd" %* +exit /b %ERRORLEVEL% +:c +call "%~dp0oscan-c.cmd" %* +exit /b %ERRORLEVEL% +'@ + Set-Content -LiteralPath $profileShimPath -Value $profileShim -Encoding Ascii + Set-Content -LiteralPath $selectorShimPath -Value $selectorShim -Encoding Ascii -wix build $WxsPath $HarvestWxs ` - -arch x64 ` - -acceptEula wix7 ` - -ext WixToolset.UI.wixext ` - -b $RepoRoot ` - -d "BundleDir=$BundleDir" ` - -d "Version=$Version" ` - -out $OutputPath + wix build $WxsPath $HarvestWxs ` + -arch x64 ` + -acceptEula wix7 ` + -ext WixToolset.UI.wixext ` + -b $RepoRoot ` + -d "BundleDir=$BundleDir" ` + -d "Version=$Version" ` + -d "Profile=$Profile" ` + -d "ProductName=$ProductName" ` + -d "UpgradeCode=$UpgradeCode" ` + -d "ProfileShimPath=$profileShimPath" ` + -d "SelectorShimPath=$selectorShimPath" ` + -out $OutputPath -if ($LASTEXITCODE -ne 0) { - throw "WiX build failed" + if ($LASTEXITCODE -ne 0) { + throw "WiX build failed for profile '$Profile'" + } +} finally { + Remove-Item -LiteralPath $shimDir -Recurse -Force -ErrorAction SilentlyContinue } Write-Host "MSI created: $OutputPath" diff --git a/scripts/install-latest.ps1 b/scripts/install-latest.ps1 index aa3ed87..da562d5 100644 --- a/scripts/install-latest.ps1 +++ b/scripts/install-latest.ps1 @@ -17,11 +17,11 @@ suffix or version-drift matching is performed: if the exact name is not published, the script fails with the list of assets that are. - 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. + The slim LLVM package remains the default. Use -Profile full for one + compiler with --backend llvm|cranelift|c. The zip flow is the default + because it installs without administrator privileges. MSI mode is + available for every profile; each MSI has an independent upgrade family, + isolated payload, and qualified command. .PARAMETER Profile 'slim' (default) selects the package named by -Backend. 'full' installs @@ -35,9 +35,9 @@ .PARAMETER Mode 'zip' (default): download the backend's exact zip and run its bundled install.ps1. - 'msi': download the recommended LLVM MSI and install it via msiexec - /quiet. Only the llvm backend publishes an MSI; if that exact MSI is not - published, the script falls back to the exact llvm zip and nothing else. + 'msi': download the requested profile's MSI and install it via msiexec + /quiet. If that exact MSI is not published, the script falls back only to + the exact same-profile zip. .PARAMETER Version Optional explicit version tag (e.g. 'v0.5.0'). Defaults to the latest @@ -91,10 +91,8 @@ $ProgressPreference = 'SilentlyContinue' $Repo = 'lucabol/Oscan' $ApiBase = "https://api.github.com/repos/$Repo/releases" -# The one Windows target this installer serves, and the only profile that -# publishes an installer. +# The one Windows target this installer serves. $InstallerTarget = 'windows-x86_64' -$MsiProfile = 'llvm' # Force TLS 1.2 for older PowerShell hosts try { @@ -145,9 +143,6 @@ function Get-OscanAssetName { [Parameter(Mandatory)][ValidateSet('zip', 'msi')][string]$Kind ) - 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-$Profile.$Kind" } @@ -161,11 +156,8 @@ function Select-OscanReleaseAsset { not something to guess around: no suffix, glob or version-drift matching happens here, so another backend's package, a source archive, or a legacy combined archive can never be selected. - * llvm + -Mode msi prefers the exact LLVM MSI and falls back only to - the exact LLVM zip when a release published no installer. - * cranelift and c always resolve to their own exact zip; they have - no MSI, and -Mode msi is refused rather than silently installing - the LLVM package. + * -Mode msi prefers the exact same-profile MSI and falls back only to + that profile's exact zip when a release published no installer. #> param( [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Assets, @@ -174,10 +166,6 @@ function Select-OscanReleaseAsset { [Parameter(Mandatory)][ValidateSet('zip', 'msi')][string]$Mode ) - 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 -Profile $Profile -Kind 'msi') } @@ -199,7 +187,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 full, llvm, cranelift, and c archives plus one recommended llvm MSI. 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 profiles as both ZIP archives and MSIs. Published assets: $(if ($published) { $published -join ', ' } else { '(none)' })." } function Get-OscanExpectedChecksum { @@ -282,7 +270,7 @@ function Invoke-OscanInstallLatest { $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." + throw "-SetDefault applies to archive profiles. MSI profiles use the shared deterministic selector and qualified commands." } if ($Version) { @@ -344,14 +332,28 @@ function Invoke-OscanInstallLatest { } $archiveSelector = Join-Path $archiveRoot 'default-profile' $archiveCommand = Join-Path (Join-Path $archiveRoot 'bin') 'oscan.cmd' + $archiveProfileCommand = Join-Path (Join-Path $archiveRoot 'bin') "oscan-$packageProfile.cmd" $legacyArchiveCommand = Join-Path $archiveRoot 'oscan.exe' $archiveMarker = Join-Path $env:LOCALAPPDATA 'Programs\Oscan\archive-default' + $archiveProfileMarker = Join-Path $env:LOCALAPPDATA "Programs\Oscan\archive-profile-$packageProfile" + $programFiles64 = if ($env:ProgramW6432) { + $env:ProgramW6432 + } else { + $env:ProgramFiles + } + $legacyMsiCommand = Join-Path $programFiles64 'Oscan\oscan.exe' + if ($packageProfile -ne 'llvm' -and + (Test-Path -LiteralPath $legacyMsiCommand -PathType Leaf)) { + throw "The legacy flat Oscan LLVM MSI is still installed at '$legacyMsiCommand'. Install the current LLVM MSI first to migrate it, or uninstall the legacy product before adding the $packageProfile profile." + } if (-not $AllowMsiCommandConflict -and ((Test-Path -LiteralPath $archiveSelector) -or (Test-Path -LiteralPath $archiveCommand) -or + (Test-Path -LiteralPath $archiveProfileCommand) -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." + (Test-Path -LiteralPath $archiveMarker) -or + (Test-Path -LiteralPath $archiveProfileMarker))) { + throw "A per-user archive command is installed under '$archiveRoot'. MSI profiles export an unqualified selector and the same profile-qualified command, so PATH order would be ambiguous. Remove the conflicting archive command, or pass -AllowMsiCommandConflict to acknowledge the collision." } Write-Host "Installing $($asset.name) silently via msiexec..." $logPath = Join-Path $tempRoot 'oscan-msi.log' diff --git a/scripts/install-oscan.ps1 b/scripts/install-oscan.ps1 index 95535a2..aaeab2b 100644 --- a/scripts/install-oscan.ps1 +++ b/scripts/install-oscan.ps1 @@ -211,13 +211,22 @@ function Copy-InstallTree { } } -function Test-OscanMsiInstalled { +function Get-InstalledOscanMsiProfiles { + $families = [ordered]@{ + "full" = "{2D90D756-5B6A-46EA-B70A-2AE1FBFF986D}" + "llvm" = "{F7A3B2C1-4D5E-6F78-9A0B-1C2D3E4F5A6B}" + "cranelift" = "{F3F0AE9A-0CBC-48EC-8CE3-6E2962F0DD7A}" + "c" = "{F9A6AE6D-5718-482F-9D3B-780F8CCB062F}" + } $installer = New-Object -ComObject WindowsInstaller.Installer try { - $products = @( - $installer.RelatedProducts("{F7A3B2C1-4D5E-6F78-9A0B-1C2D3E4F5A6B}") - ) - return $products.Count -gt 0 + $installed = @() + foreach ($family in $families.GetEnumerator()) { + if (@($installer.RelatedProducts($family.Value)).Count -gt 0) { + $installed += $family.Key + } + } + return $installed } finally { [void][System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($installer) } @@ -249,6 +258,39 @@ function Enter-OscanInstallLock { } } +function Enter-WindowsInstallerExecutionLock { + param([int]$TimeoutSeconds = 30) + + $createdNew = $false + try { + $mutex = [System.Threading.Mutex]::new( + $true, + "Global\_MSIExecute", + [ref]$createdNew + ) + } catch { + throw "Could not open the Windows Installer execution mutex: $($_.Exception.Message)" + } + if ($createdNew) { + return $mutex + } + + $acquired = $false + try { + $acquired = $mutex.WaitOne([TimeSpan]::FromSeconds($TimeoutSeconds)) + } catch [System.Threading.AbandonedMutexException] { + $acquired = $true + } catch { + $mutex.Dispose() + throw + } + if (-not $acquired) { + $mutex.Dispose() + throw "Timed out waiting for another Windows Installer operation to finish." + } + return $mutex +} + $SourceDir = [System.IO.Path]::GetFullPath($SourceDir) $resolvedInstallRoot = [System.IO.Path]::GetFullPath($InstallRoot) if ([System.IO.Path]::GetPathRoot($resolvedInstallRoot) -eq $resolvedInstallRoot) { @@ -269,6 +311,9 @@ $installLockPath = Join-Path $InstallRoot ".install.lock" $selectorLockPath = Join-Path $BinDir ".oscan-selectors.lock" $installLock = Enter-OscanInstallLock -Root $InstallRoot -Name ".install.lock" $selectorLock = $null +$msiExecutionLock = $null +$markerLock = $null +$markerLockPath = $null try { $selectorLock = Enter-OscanInstallLock -Root $BinDir -Name ".oscan-selectors.lock" $metadataPath = Join-Path $SourceDir "oscan-package.json" @@ -338,13 +383,17 @@ if (-not $Uninstall) { } } +$archiveMarkerBase = Join-Path $env:LOCALAPPDATA "Programs\Oscan" +$msiExecutionLock = Enter-WindowsInstallerExecutionLock +$markerLockPath = Join-Path $archiveMarkerBase ".archive-markers.lock" +$markerLock = Enter-OscanInstallLock -Root $archiveMarkerBase -Name ".archive-markers.lock" $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" +$archiveProfileMarker = Join-Path $archiveMarkerBase "archive-profile-$Profile" $archiveOwnerRoot = Join-Path $archiveMarkerBase "archive-defaults" $archiveOwnerBytes = [System.Text.Encoding]::UTF8.GetBytes( "$($InstallRoot.ToUpperInvariant())`n$($BinDir.ToUpperInvariant())" @@ -420,6 +469,7 @@ if ($Uninstall) { $stateRemoved = $false $ownerMarkerRemoved = $false $defaultMarkerRemoved = $false + $profileMarkerChanged = $false $uninstallCommitted = $false $qualifiedExisted = Test-Path -LiteralPath $qualifiedShim -PathType Leaf @@ -450,6 +500,10 @@ if ($Uninstall) { $defaultMarkerContent = if ($defaultMarkerExisted) { Get-Content -LiteralPath $archiveDefaultMarker -Raw } else { "" } + $profileMarkerExisted = Test-Path -LiteralPath $archiveProfileMarker -PathType Leaf + $profileMarkerContent = if ($profileMarkerExisted) { + Get-Content -LiteralPath $archiveProfileMarker -Raw + } else { "" } try { if (Test-Path -LiteralPath $profileRoot) { @@ -466,6 +520,23 @@ if ($Uninstall) { $qualifiedOwnerRemoved = $true } } + $profileMarkerOwners = @( + $profileMarkerContent -split '\r?\n' | + Where-Object { $_ -match '^[0-9a-f]{64}$' } + ) + if ($profileMarkerOwners -contains $selectorOwnerId) { + $remainingProfileOwners = @( + $profileMarkerOwners | Where-Object { $_ -ne $selectorOwnerId } + ) + $profileMarkerChanged = $true + if ($remainingProfileOwners.Count -gt 0) { + Write-AtomicText -Path $archiveProfileMarker -Content ( + ($remainingProfileOwners -join "`r`n") + "`r`n" + ) + } else { + Remove-Item -LiteralPath $archiveProfileMarker -Force -ErrorAction Stop + } + } if ($selected -eq $Profile) { if ($defaultOwnership -eq "Current") { if ($defaultExisted) { @@ -513,7 +584,8 @@ if ($Uninstall) { [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 } + [pscustomobject]@{ Path = $archiveDefaultMarker; Existed = $defaultMarkerExisted; Content = $defaultMarkerContent; Changed = $defaultMarkerRemoved }, + [pscustomobject]@{ Path = $archiveProfileMarker; Existed = $profileMarkerExisted; Content = $profileMarkerContent; Changed = $profileMarkerChanged } )) { if ($restore.Changed) { try { @@ -560,9 +632,16 @@ $willSelectProfile = $SetDefault -or 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." +if (-not $AllowMsiCommandConflict) { + $installedMsiProfiles = @() + if ($willSelectProfile -or $Profile -in @("full", "llvm", "cranelift", "c")) { + $installedMsiProfiles = @(Get-InstalledOscanMsiProfiles) + } + if ($installedMsiProfiles -contains $Profile) { + throw "The Oscan $Profile MSI already owns the qualified oscan-$Profile command. Uninstall that MSI, choose a different archive profile, or pass -AllowMsiCommandConflict to acknowledge ambiguous PATH ownership." + } + if ($willSelectProfile -and $installedMsiProfiles.Count -gt 0) { + throw "An Oscan profile MSI is installed and already owns an unqualified oscan command. Installing an archive default would make PATH order ambiguous. Install the qualified archive profile without -SetDefault, uninstall the MSI profiles, or pass -AllowMsiCommandConflict to acknowledge the collision." } } @@ -611,12 +690,19 @@ $ownerMarkerContent = if ($ownerMarkerExisted) { } else { "" } +$profileMarkerExisted = Test-Path -LiteralPath $archiveProfileMarker +$profileMarkerContent = if ($profileMarkerExisted) { + Get-Content -LiteralPath $archiveProfileMarker -Raw +} else { + "" +} $payloadActivated = $false $backupCreated = $false $qualifiedChanged = $false $defaultChanged = $false $defaultOwnerChanged = $false $markerChanged = $false +$profileMarkerChanged = $false $profileJunctionCreated = $false $committed = $false New-Item -ItemType Directory -Path $profileRoot -Force | Out-Null @@ -657,6 +743,16 @@ try { $qualifiedChanged = $true Write-ProfileShim -Path $qualifiedShim -RelativeExecutable $relativeExecutable Write-AtomicText -Path $qualifiedOwner -Content "$selectorOwnerId`r`n" + $profileMarkerOwners = @( + $profileMarkerContent -split '\r?\n' | + Where-Object { $_ -match '^[0-9a-f]{64}$' } + ) + if ($profileMarkerOwners -notcontains $selectorOwnerId) { + $profileMarkerChanged = $true + Write-AtomicText -Path $archiveProfileMarker -Content ( + (($profileMarkerOwners + $selectorOwnerId) -join "`r`n") + "`r`n" + ) + } $selected = if (Test-Path -LiteralPath $defaultState) { (Get-Content -LiteralPath $defaultState -Raw).Trim() @@ -735,6 +831,13 @@ try { $rollbackErrors += "restore MSI-conflict marker: $($_.Exception.Message)" } } + if ($profileMarkerChanged) { + try { + Restore-AtomicFile -Path $archiveProfileMarker -Existed $profileMarkerExisted -Content $profileMarkerContent + } catch { + $rollbackErrors += "restore profile MSI-conflict marker: $($_.Exception.Message)" + } + } if ($profileJunctionCreated -and (Test-Path -LiteralPath $profileJunction)) { try { Remove-Item -LiteralPath $profileJunction -Force -ErrorAction Stop @@ -769,6 +872,19 @@ if ($selected -eq $Profile) { Write-Host "Default remains '$selected'. Re-run with -SetDefault to select '$Profile'." } } finally { + if ($markerLock) { + $markerLock.Dispose() + Remove-Item -LiteralPath $markerLockPath -Force -ErrorAction SilentlyContinue + } + if ($msiExecutionLock) { + try { + $msiExecutionLock.ReleaseMutex() + } catch { + Write-Warning "Could not release the Windows Installer execution mutex: $($_.Exception.Message)" + } finally { + $msiExecutionLock.Dispose() + } + } if ($selectorLock) { $selectorLock.Dispose() Remove-Item -LiteralPath $selectorLockPath -Force -ErrorAction SilentlyContinue diff --git a/scripts/release_tools.py b/scripts/release_tools.py index 3a10eb3..8332fc5 100644 --- a/scripts/release_tools.py +++ b/scripts/release_tools.py @@ -19,6 +19,7 @@ import urllib.error import urllib.parse import urllib.request +import uuid import zipfile from pathlib import Path, PurePosixPath @@ -52,6 +53,8 @@ # dependency these packages exist to remove. FREESTANDING_PROFILES = ("freestanding", "freestanding_gfx", "freestanding_core") RUNTIME_PROFILES = ("hosted", *FREESTANDING_PROFILES) +MSI_FIELDS = ("msi_name_template", "msi_product_name", "msi_upgrade_code") +LEGACY_LLVM_MSI_UPGRADE_CODE = "F7A3B2C1-4D5E-6F78-9A0B-1C2D3E4F5A6B" # The published matrix. macOS is C-only: there is no Darwin object target # for either object backend. SUPPORTED_RELEASE_TARGETS = { @@ -322,6 +325,9 @@ def _validate_contract_variants(path: Path, contract: dict) -> None: fail(f"release contract {path} declares no variants") seen_archive_names: dict[str, str] = {} seen_roots: dict[str, str] = {} + seen_msi_names: dict[str, str] = {} + seen_msi_products: dict[str, str] = {} + seen_msi_upgrade_codes: dict[str, str] = {} for target, target_spec in variants.items(): if target not in SUPPORTED_RELEASE_TARGETS: fail(f"release contract declares unsupported target '{target}'") @@ -358,6 +364,22 @@ def _validate_contract_variants(path: Path, contract: dict) -> None: ) seen_archive_names[name] = f"{target}/{profile}" seen_roots[root] = f"{target}/{profile}" + if "msi_name_template" in variant: + where = f"{target}/{profile}" + msi_name = variant["msi_name_template"] + product_name = variant["msi_product_name"] + upgrade_code = variant["msi_upgrade_code"].upper() + for value, seen, label in ( + (msi_name, seen_msi_names, "MSI name"), + (product_name.casefold(), seen_msi_products, "MSI product name"), + (upgrade_code, seen_msi_upgrade_codes, "MSI UpgradeCode"), + ): + if value in seen: + fail( + f"release contract {label} '{value}' is used by both " + f"{seen[value]} and {where}" + ) + seen[value] = where def variant_build_features(variant: dict) -> list[str]: @@ -420,6 +442,37 @@ def _validate_contract_variant( if not variant["archive_root_template"].endswith(f"-{profile}"): fail(f"{where} archive_root_template must end with '-{profile}'") + declared_msi_fields = [field for field in MSI_FIELDS if field in variant] + if target == "windows-x86_64": + missing_msi_fields = [field for field in MSI_FIELDS if field not in variant] + if missing_msi_fields: + fail(f"{where} is missing MSI field(s): {', '.join(missing_msi_fields)}") + msi_template = variant["msi_name_template"] + if "{version}" not in msi_template: + fail(f"{where} msi_name_template must include '{{version}}'") + if not msi_template.endswith(f"-{profile}.msi"): + fail(f"{where} msi_name_template must end with '-{profile}.msi'") + if not isinstance(variant["msi_product_name"], str) or not variant[ + "msi_product_name" + ].startswith("Oscan "): + fail(f"{where} msi_product_name must be a non-empty 'Oscan ...' name") + try: + upgrade_code = uuid.UUID(variant["msi_upgrade_code"]) + except (AttributeError, TypeError, ValueError): + fail(f"{where} msi_upgrade_code must be a GUID") + if str(upgrade_code).upper() != variant["msi_upgrade_code"]: + fail(f"{where} msi_upgrade_code must use canonical uppercase GUID syntax") + if ( + profile == "llvm" + and variant["msi_upgrade_code"] != LEGACY_LLVM_MSI_UPGRADE_CODE + ): + fail( + f"{where} msi_upgrade_code must preserve the legacy LLVM MSI " + f"UpgradeCode {LEGACY_LLVM_MSI_UPGRADE_CODE}" + ) + elif declared_msi_fields: + fail(f"{where} is not a Windows package and must not declare MSI fields") + components = variant["components"] unknown = [name for name in components if name not in contract["components"]] if unknown: @@ -3140,6 +3193,8 @@ def ci_target_matrix(contract: dict, version: str) -> list[dict]: fail(f"release contract target '{target}' declares no profiles") archives: list[str] = [] + msi_profiles: list[str] = [] + msi_names: list[str] = [] runtime_profiles: list[str] = [] needs_toolchain_archive = False needs_provider_archive = False @@ -3157,6 +3212,13 @@ def ci_target_matrix(contract: dict, version: str) -> list[dict]: ) rendered_archives[archive_name] = f"{target}/{profile}" archives.append(archive_name) + if "msi_name_template" in variant: + msi_profiles.append(profile) + msi_names.append( + render_release_template( + variant["msi_name_template"], version, "msi_name_template" + ) + ) components = variant["components"] if "c_toolchain" in components: needs_toolchain_archive = True @@ -3188,6 +3250,8 @@ def ci_target_matrix(contract: dict, version: str) -> list[dict]: "binary_path": f"target/release/{target_spec['binary_name']}", "profiles": ",".join(profiles), "archives": ",".join(archives), + "msi_profiles": ",".join(msi_profiles), + "msis": ",".join(msi_names), "runtime_profiles": ",".join(runtime_profiles), "needs_base_toolchain": "true" if needs_base_toolchain else "false", "needs_native_link": "true" if needs_native_link else "false", @@ -3195,7 +3259,6 @@ def ci_target_matrix(contract: dict, version: str) -> list[dict]: "needs_inprocess_toolchain": ( "true" if needs_inprocess_toolchain else "false" ), - "msi_profile": "llvm" if platform == "windows" and "llvm" in profiles else "", } ) if not entries: diff --git a/scripts/test_install_latest.py b/scripts/test_install_latest.py index be3a544..172157f 100644 --- a/scripts/test_install_latest.py +++ b/scripts/test_install_latest.py @@ -1,7 +1,7 @@ """Offline regressions for ``scripts/install-latest.ps1``. -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: +The Windows quick installer exposes full and slim profiles as both archives and +independent MSI families. 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 @@ -105,14 +105,18 @@ def test_full_is_a_first_class_package_choice(self) -> None: 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("$MsiProfile = 'llvm'", self.text) - self.assertIn("-Mode msi is only available for the recommended", self.text) + def test_every_profile_can_be_installed_from_an_msi(self) -> None: + self.assertNotIn("$MsiProfile", self.text) + self.assertIn("MSI mode is", self.text) + self.assertIn("available for every profile", self.text) - def test_archive_default_selection_is_explicit_and_msi_collision_is_detected(self) -> None: + def test_archive_commands_and_legacy_msi_collisions_are_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-$packageProfile.cmd"', self.text) + self.assertIn('"Programs\\Oscan\\archive-profile-$packageProfile"', self.text) + self.assertIn("The legacy flat Oscan LLVM MSI is still installed", self.text) self.assertIn("OSCAN_ALLOW_ARCHIVE_CONFLICT=1", self.text) def test_checksum_verification_and_tag_selection_are_preserved(self) -> None: @@ -160,10 +164,13 @@ class InstallerSelectionBehaviourTests(unittest.TestCase): def setUp(self) -> None: self.assets = [ f"oscan-{TAG}-windows-x86_64-full.zip", + f"oscan-{TAG}-windows-x86_64-full.msi", f"oscan-{TAG}-windows-x86_64-llvm.zip", f"oscan-{TAG}-windows-x86_64-llvm.msi", f"oscan-{TAG}-windows-x86_64-cranelift.zip", + f"oscan-{TAG}-windows-x86_64-cranelift.msi", f"oscan-{TAG}-windows-x86_64-c.zip", + f"oscan-{TAG}-windows-x86_64-c.msi", f"oscan-{TAG}-linux-x86_64-llvm.tar.xz", f"oscan-{TAG}-linux-x86_64-cranelift.tar.xz", f"oscan-{TAG}-macos-x86_64-c.tar.gz", @@ -186,32 +193,38 @@ def test_each_profile_selects_its_own_exact_archive(self) -> None: result["name"], f"oscan-{TAG}-windows-x86_64-{profile}.zip" ) - def test_msi_mode_prefers_the_recommended_llvm_installer(self) -> None: - result = run_selection(self.assets, "llvm", "msi") - self.assertTrue(result["ok"], result) - self.assertEqual(result["name"], f"oscan-{TAG}-windows-x86_64-llvm.msi") - self.assertEqual(result["kind"], "msi") + def test_msi_mode_selects_each_profiles_exact_installer(self) -> None: + for profile in ("full", "llvm", "cranelift", "c"): + with self.subTest(profile=profile): + result = run_selection(self.assets, profile, "msi") + self.assertTrue(result["ok"], result) + self.assertEqual( + result["name"], + f"oscan-{TAG}-windows-x86_64-{profile}.msi", + ) + self.assertEqual(result["kind"], "msi") - def test_msi_mode_falls_back_to_the_exact_llvm_zip(self) -> None: + def test_msi_mode_falls_back_to_the_exact_same_profile_zip(self) -> None: assets = [name for name in self.assets if not name.endswith(".msi")] - result = run_selection(assets, "llvm", "msi") - self.assertTrue(result["ok"], result) - 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_profiles_that_publish_none(self) -> None: - for profile in ("full", "cranelift", "c"): + for profile in ("full", "llvm", "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"]) + result = run_selection(assets, profile, "msi") + self.assertTrue(result["ok"], result) + self.assertEqual( + result["name"], + f"oscan-{TAG}-windows-x86_64-{profile}.zip", + ) + self.assertEqual(result["kind"], "zip") def test_a_missing_backend_package_is_an_actionable_error(self) -> None: assets = [name for name in self.assets if "windows-x86_64-cranelift" not in name] 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("full, llvm, cranelift, and c archives", result["error"]) + self.assertIn( + "full, llvm, cranelift, and c profiles as both ZIP archives and MSIs", + result["error"], + ) def test_a_legacy_unsuffixed_archive_never_satisfies_a_profile_request(self) -> None: result = run_selection( @@ -456,8 +469,13 @@ def test_the_package_docs_describe_full_and_coexisting_profiles(self) -> None: 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) + def test_the_package_docs_name_every_profile_msi(self) -> None: + for profile in rt.CANONICAL_PROFILES: + with self.subTest(profile=profile): + self.assertIn( + f"oscan-vX.Y.Z-windows-x86_64-{profile}.msi", + self.technical, + ) self.assertNotIn("oscan-vX.Y.Z-windows-x86_64.msi", self.technical) def test_checksum_commands_verify_exactly_the_downloaded_asset(self) -> None: diff --git a/scripts/test_installers.py b/scripts/test_installers.py index dd7a09d..0367e8a 100644 --- a/scripts/test_installers.py +++ b/scripts/test_installers.py @@ -108,10 +108,18 @@ def test_profiles_coexist_upgrade_independently_and_preserve_default(self) -> No archive_marker = ( root / "local-app-data" / "Programs" / "Oscan" / "archive-default" ) + llvm_marker = ( + root / "local-app-data" / "Programs" / "Oscan" / "archive-profile-llvm" + ) + c_marker = ( + root / "local-app-data" / "Programs" / "Oscan" / "archive-profile-c" + ) self.invoke(llvm_v1, install_root, bin_dir) self.assertTrue(archive_marker.is_file()) + self.assertTrue(llvm_marker.is_file()) self.invoke(c_v1, install_root, bin_dir) + self.assertTrue(c_marker.is_file()) self.assertTrue((bin_dir / "oscan-llvm.cmd").is_file()) self.assertTrue((bin_dir / "oscan-c.cmd").is_file()) self.assertEqual( @@ -137,6 +145,8 @@ def test_profiles_coexist_upgrade_independently_and_preserve_default(self) -> No self.assertFalse((bin_dir / "oscan.cmd").exists()) self.assertFalse((install_root / "default-profile").exists()) self.assertFalse(archive_marker.exists()) + self.assertFalse(c_marker.exists()) + self.assertTrue(llvm_marker.is_file()) def test_invalid_repair_does_not_remove_working_profile(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -272,6 +282,14 @@ def test_uninstall_keeps_the_msi_marker_for_another_install_root(self) -> None: marker = ( root / "local-app-data" / "Programs" / "Oscan" / "archive-default" ) + profile_marker = ( + root + / "local-app-data" + / "Programs" + / "Oscan" + / "archive-profile-llvm" + ) + self.assertEqual(len(profile_marker.read_text().splitlines()), 2) self.invoke( bundle, first_root, @@ -279,6 +297,7 @@ def test_uninstall_keeps_the_msi_marker_for_another_install_root(self) -> None: "-Uninstall", ) self.assertTrue(marker.is_file()) + self.assertEqual(len(profile_marker.read_text().splitlines()), 1) def test_failed_uninstall_preserves_commands_and_selector(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -362,6 +381,44 @@ def test_install_waits_for_an_in_progress_operation(self) -> None: stdout, stderr = holder.communicate(timeout=5) self.assertEqual(holder.returncode, 0, stdout + stderr) + def test_archive_install_waits_for_windows_installer_execution(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) + ready = root / "mutex-ready" + escaped_ready = str(ready).replace("'", "''") + holder = subprocess.Popen( + [ + POWERSHELL, + "-NoProfile", + "-NonInteractive", + "-Command", + ( + "$created = $false; " + "$mutex = [Threading.Mutex]::new(" + "$true, 'Global\\_MSIExecute', [ref]$created); " + "if (-not $created) { $mutex.WaitOne() | Out-Null }; " + f"Set-Content -LiteralPath '{escaped_ready}' -Value ready; " + "Start-Sleep -Seconds 2; " + "$mutex.ReleaseMutex(); $mutex.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(), "MSI mutex 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): diff --git a/scripts/test_release_workflow.py b/scripts/test_release_workflow.py index 8ed7384..db72bca 100644 --- a/scripts/test_release_workflow.py +++ b/scripts/test_release_workflow.py @@ -132,10 +132,48 @@ def test_archive_names_are_rendered_and_globally_unique(self) -> None: 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_profile"]] - self.assertEqual([entry["target"] for entry in msi], ["windows-x86_64"]) - self.assertEqual(msi[0]["msi_profile"], "llvm") + def test_every_windows_profile_has_an_msi(self) -> None: + by_target = {entry["target"]: entry for entry in self.matrix} + windows = by_target["windows-x86_64"] + self.assertEqual(windows["msi_profiles"].split(","), list(rt.CANONICAL_PROFILES)) + self.assertEqual( + windows["msis"].split(","), + [ + f"oscan-v1.2.3-windows-x86_64-{profile}.msi" + for profile in rt.CANONICAL_PROFILES + ], + ) + for target in ("linux-x86_64", "macos-x86_64"): + with self.subTest(target=target): + self.assertEqual(by_target[target]["msi_profiles"], "") + self.assertEqual(by_target[target]["msis"], "") + + def test_every_windows_profile_requires_a_complete_msi_identity(self) -> None: + contract = json.loads(rt.CONTRACT_PATH.read_text(encoding="utf-8")) + del contract["variants"]["windows-x86_64"]["profiles"]["c"][ + "msi_upgrade_code" + ] + with self.assertRaises(SystemExit) as caught: + rt._validate_contract_variants(rt.CONTRACT_PATH, contract) + self.assertIn("missing MSI field", str(caught.exception)) + + def test_msi_upgrade_codes_are_unique_product_families(self) -> None: + contract = json.loads(rt.CONTRACT_PATH.read_text(encoding="utf-8")) + windows = contract["variants"]["windows-x86_64"]["profiles"] + windows["c"]["msi_upgrade_code"] = windows["llvm"]["msi_upgrade_code"] + with self.assertRaises(SystemExit) as caught: + rt._validate_contract_variants(rt.CONTRACT_PATH, contract) + self.assertIn("MSI UpgradeCode", str(caught.exception)) + self.assertIn("used by both", str(caught.exception)) + + def test_llvm_msi_preserves_its_legacy_upgrade_family(self) -> None: + contract = json.loads(rt.CONTRACT_PATH.read_text(encoding="utf-8")) + contract["variants"]["windows-x86_64"]["profiles"]["llvm"][ + "msi_upgrade_code" + ] = "E954CEAE-F3A4-451A-B122-95D84BAA4F83" + with self.assertRaises(SystemExit) as caught: + rt._validate_contract_variants(rt.CONTRACT_PATH, contract) + self.assertIn("preserve the legacy LLVM MSI UpgradeCode", str(caught.exception)) def test_a_contract_with_a_non_canonical_profile_is_refused(self) -> None: contract = json.loads(rt.CONTRACT_PATH.read_text(encoding="utf-8")) @@ -273,18 +311,22 @@ def test_the_package_set_is_checked_against_the_contract(self) -> None: with self.subTest(message=message): self.assertIn(message, self.workflow) - def test_only_the_recommended_llvm_msi_is_built(self) -> None: - 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) + def test_every_windows_profile_msi_is_built(self) -> None: + self.assertIn("if: matrix.msi_profiles != ''", self.workflow) + self.assertIn("$profiles = $env:RELEASE_MSI_PROFILES.Split(", self.workflow) + self.assertIn("foreach ($profile in $profiles)", self.workflow) + self.assertIn("$variant['msi_name_template']", self.workflow) + self.assertIn("MSI set differs from the release contract", self.workflow) # Built from the bundle the assemble step staged and smoked. 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) self.assertIn("'/a'", self.workflow) - self.assertIn("'PFiles64\\Oscan'", self.workflow) + self.assertIn('"PFiles64\\Oscan\\profiles\\$profile\\$version"', self.workflow) self.assertIn("-InstalledPackageDir $installedRoot", self.workflow) + self.assertIn('"PFiles64\\Oscan\\bin\\oscan-$profile.cmd"', self.workflow) + self.assertIn("'PFiles64\\Oscan\\bin\\oscan.cmd'", self.workflow) build_index = self.workflow.index("& ./scripts/build-msi.ps1") extract_index = self.workflow.index("Start-Process -FilePath msiexec.exe") installed_smoke_index = self.workflow.index("-InstalledPackageDir $installedRoot") diff --git a/scripts/test_smoke_release.py b/scripts/test_smoke_release.py index 9e7faff..2e912cc 100644 --- a/scripts/test_smoke_release.py +++ b/scripts/test_smoke_release.py @@ -719,7 +719,7 @@ def test_it_refuses_a_directory_instead_of_an_archive(self) -> None: class MsiHarvestTests(unittest.TestCase): - """The recommended MSI is cut from the strict compiler-only LLVM bundle.""" + """Every Windows profile MSI is cut from its matching staged bundle.""" def test_the_wix_source_and_harvester_agree_on_the_component_group(self) -> None: wxs = WXS.read_text(encoding="utf-8") @@ -733,9 +733,83 @@ 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('Property Id="OSCAN_ARCHIVE_PROFILE"', wxs) + self.assertIn('Name="archive-profile-$(var.Profile)"', wxs) + self.assertIn('Property Id="OSCAN_ARCHIVE_PROFILE_COMMAND"', wxs) + self.assertIn('Name="oscan-$(var.Profile).cmd"', wxs) self.assertIn("OSCAN_ALLOW_ARCHIVE_CONFLICT", wxs) self.assertEqual(wxs.count(''), 2) + def test_non_llvm_msi_requires_legacy_layout_migration(self) -> None: + wxs = WXS.read_text(encoding="utf-8") + self.assertIn('Property Id="OSCAN_LEGACY_MSI"', wxs) + self.assertIn('', wxs) + self.assertIn("Install the current Oscan LLVM MSI first", wxs) + + def test_profile_msis_have_isolated_products_and_shared_commands(self) -> None: + contract = rt.load_release_contract(rt.CONTRACT_PATH) + windows = contract["variants"]["windows-x86_64"]["profiles"] + self.assertEqual(list(windows), list(rt.CANONICAL_PROFILES)) + self.assertEqual( + len({variant["msi_upgrade_code"] for variant in windows.values()}), + len(rt.CANONICAL_PROFILES), + ) + wxs = WXS.read_text(encoding="utf-8") + self.assertIn('UpgradeCode="$(var.UpgradeCode)"', wxs) + self.assertIn('Name="oscan-$(var.Profile).cmd"', wxs) + self.assertIn('Name="oscan.cmd"', wxs) + self.assertIn('Directory Id="ProfileFolder" Name="$(var.Profile)"', wxs) + self.assertIn('Directory Id="InstallFolder" Name="$(var.Version)"', wxs) + self.assertIn('', wxs) + self.assertNotIn("WIXUI_INSTALLDIR", wxs) + self.assertIn("F62BD086-496A-46AA-904B-36E149101397", wxs) + self.assertIn("AA340A99-32F7-4689-8EC2-D48B7503A478", wxs) + + def test_archive_installer_detects_every_msi_family(self) -> None: + contract = rt.load_release_contract(rt.CONTRACT_PATH) + installer = (REPO_ROOT / "scripts" / "install-oscan.ps1").read_text( + encoding="utf-8" + ) + for profile, variant in contract["variants"]["windows-x86_64"][ + "profiles" + ].items(): + with self.subTest(profile=profile): + self.assertIn(variant["msi_upgrade_code"], installer) + self.assertIn("$installedMsiProfiles -contains $Profile", installer) + + @unittest.skipIf(POWERSHELL is None, "pwsh is not available in this environment") + def test_every_windows_profile_is_accepted_by_the_msi_harvester(self) -> None: + with tempfile.TemporaryDirectory() as tmp_name: + tmp = Path(tmp_name) + for profile in rt.CANONICAL_PROFILES: + with self.subTest(profile=profile): + bundle, _, _ = stage_fixture_package( + tmp, "windows-x86_64", profile + ) + harvest = tmp / f"{profile}-harvest.wxs" + result = subprocess.run( + [ + POWERSHELL, + "-NoProfile", + "-File", + str(BUILD_MSI), + "-BundleDir", + str(bundle), + "-HarvestOnly", + "-HarvestPath", + str(harvest), + ], + capture_output=True, + text=True, + ) + self.assertEqual( + result.returncode, 0, result.stdout + result.stderr + ) + self.assertIn( + '', + harvest.read_text(encoding="utf-8"), + ) + @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: