From a703e9586f014300a1be9192e842a583e0dd9311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Tue, 23 Jun 2026 09:56:36 -0400 Subject: [PATCH 1/2] Expose flexible apphost package assets Add neutral MSBuild metadata for resolving packaged multi-pwsh apphost launchers by RID, generate an apphost manifest during package creation, and document private build-time consumption for downstream SDK packages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 26 ++++- .../Devolutions.MultiPwsh.Cli.AppHost.targets | 49 ++++++++- .../Devolutions.MultiPwsh.Cli.csproj | 14 +-- .../Devolutions.MultiPwsh.Cli.props | 5 + .../Devolutions.MultiPwsh.Cli.targets | 16 +-- nuget/Devolutions.MultiPwsh.Cli/README.md | 24 ++++- scripts/Build-NativeNuGetPackages.ps1 | 100 +++++++++++++++++- tests/Invoke-AppHostNuGetPackageSmokeTest.ps1 | 24 ++++- 8 files changed, 237 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index e324bba..2b489ac 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,31 @@ See [docs/host-and-venv.md](docs/host-and-venv.md) for host shims, local replace ## CLI NuGet package and AppHost mode -`Devolutions.MultiPwsh.Cli` packages RID-specific `multi-pwsh` binaries for .NET projects. It also includes opt-in MSBuild targets for downstream packages that need to copy `multi-pwsh` as a replacement apphost. AppHost mode is inert by default. +`Devolutions.MultiPwsh.Cli` packages RID-specific `multi-pwsh` binaries under `runtimes//native/` for .NET projects. It also exposes neutral MSBuild metadata for downstream packages that need to consume the same binaries as PowerShell apphosts. The package supplies only the native launcher; downstream packages must place it beside their own `pwsh.dll` and `pwsh.runtimeconfig.json`. + +Package authors can consume the launchers privately and map them into their own package layout: + +```xml + + + + + + false + + + + + + tools/apphost/%(RuntimeIdentifier)/%(AppHostFileName) + + + +``` + +`@(MultiPwshAppHostAsset)` includes the source path as the item identity plus metadata such as `RuntimeIdentifier`, `NativeFileName`, `AppHostFileName`, `PackageRelativePath`, and `PackageId`. The package also sets `MultiPwshAppHostSupportedRuntimeIdentifiers` and `MultiPwshAppHostManifestPath`. + +For a simple single-RID project, AppHost mode can copy the selected binary directly to build and publish output. AppHost mode is inert by default. ```xml diff --git a/nuget/Devolutions.MultiPwsh.Cli/Devolutions.MultiPwsh.Cli.AppHost.targets b/nuget/Devolutions.MultiPwsh.Cli/Devolutions.MultiPwsh.Cli.AppHost.targets index 45e90d8..7a8bac8 100644 --- a/nuget/Devolutions.MultiPwsh.Cli/Devolutions.MultiPwsh.Cli.AppHost.targets +++ b/nuget/Devolutions.MultiPwsh.Cli/Devolutions.MultiPwsh.Cli.AppHost.targets @@ -1,5 +1,36 @@ - + + + $(MSBuildThisFileDirectory)..\ + $(MultiPwshAppHostPackageRoot)runtimes\ + $(MultiPwshAppHostPackageRoot)build\Devolutions.MultiPwsh.Cli.AppHostManifest.json + win-x64;win-arm64;linux-x64;linux-arm64;osx-x64;osx-arm64 + + + + <_MultiPwshAppHostAssetDefinition Include="win-x64;win-arm64"> + multi-pwsh.exe + pwsh.exe + + <_MultiPwshAppHostAssetDefinition Include="linux-x64;linux-arm64;osx-x64;osx-arm64"> + multi-pwsh + pwsh + + + + %(_MultiPwshAppHostAssetDefinition.Identity) + %(_MultiPwshAppHostAssetDefinition.NativeFileName) + %(_MultiPwshAppHostAssetDefinition.AppHostFileName) + runtimes/%(_MultiPwshAppHostAssetDefinition.Identity)/native/%(_MultiPwshAppHostAssetDefinition.NativeFileName) + Devolutions.MultiPwsh.Cli + + + + + <_MultiPwshAppHostResolvedRuntimeIdentifier Condition="'$(MultiPwshAppHostRuntimeIdentifier)' != ''">$(MultiPwshAppHostRuntimeIdentifier) <_MultiPwshAppHostResolvedRuntimeIdentifier Condition="'$(_MultiPwshAppHostResolvedRuntimeIdentifier)' == '' And '$(PowerShellSDKAppHostRuntimeIdentifier)' != ''">$(PowerShellSDKAppHostRuntimeIdentifier) @@ -18,14 +49,22 @@ <_MultiPwshAppHostOutputBaseName Condition="'$(_MultiPwshAppHostOutputBaseName)' == ''">multi-pwsh <_MultiPwshAppHostOutputName Condition="'$(MultiPwshAppHostOutputName)' != ''">$(MultiPwshAppHostOutputName) <_MultiPwshAppHostOutputName Condition="'$(_MultiPwshAppHostOutputName)' == ''">$(_MultiPwshAppHostOutputBaseName)$(_MultiPwshAppHostExeSuffix) - $(MSBuildThisFileDirectory)..\runtimes\$(_MultiPwshAppHostResolvedRuntimeIdentifier)\native\$(_MultiPwshAppHostNativeFileName) - + + <_MultiPwshResolvedAppHostAsset Include="@(MultiPwshAppHostAsset)" + Condition="'%(MultiPwshAppHostAsset.RuntimeIdentifier)' == '$(_MultiPwshAppHostResolvedRuntimeIdentifier)'" /> + + + + + + @(_MultiPwshResolvedAppHostAsset) + - + $(_MultiPwshAppHostResolvedRuntimeIdentifier) $(_MultiPwshAppHostOutputName) diff --git a/nuget/Devolutions.MultiPwsh.Cli/Devolutions.MultiPwsh.Cli.csproj b/nuget/Devolutions.MultiPwsh.Cli/Devolutions.MultiPwsh.Cli.csproj index f5bf4a9..881c6cb 100644 --- a/nuget/Devolutions.MultiPwsh.Cli/Devolutions.MultiPwsh.Cli.csproj +++ b/nuget/Devolutions.MultiPwsh.Cli/Devolutions.MultiPwsh.Cli.csproj @@ -19,27 +19,29 @@ git false README.md + $(MSBuildThisFileDirectory)..\..\artifacts\cli\multi-pwsh - + runtimes\win-x64\native\multi-pwsh.exe - + runtimes\win-arm64\native\multi-pwsh.exe - + runtimes\linux-x64\native - + runtimes\linux-arm64\native - + runtimes\osx-x64\native - + runtimes\osx-arm64\native + diff --git a/nuget/Devolutions.MultiPwsh.Cli/Devolutions.MultiPwsh.Cli.props b/nuget/Devolutions.MultiPwsh.Cli/Devolutions.MultiPwsh.Cli.props index fdddeaa..25cff23 100644 --- a/nuget/Devolutions.MultiPwsh.Cli/Devolutions.MultiPwsh.Cli.props +++ b/nuget/Devolutions.MultiPwsh.Cli/Devolutions.MultiPwsh.Cli.props @@ -3,5 +3,10 @@ false true true + true + $(MSBuildThisFileDirectory)..\ + $(MultiPwshAppHostPackageRoot)runtimes\ + $(MultiPwshAppHostPackageRoot)build\Devolutions.MultiPwsh.Cli.AppHostManifest.json + win-x64;win-arm64;linux-x64;linux-arm64;osx-x64;osx-arm64 diff --git a/nuget/Devolutions.MultiPwsh.Cli/Devolutions.MultiPwsh.Cli.targets b/nuget/Devolutions.MultiPwsh.Cli/Devolutions.MultiPwsh.Cli.targets index 6342248..5a47952 100644 --- a/nuget/Devolutions.MultiPwsh.Cli/Devolutions.MultiPwsh.Cli.targets +++ b/nuget/Devolutions.MultiPwsh.Cli/Devolutions.MultiPwsh.Cli.targets @@ -1,5 +1,9 @@ - + + true + + + runtimes\win-x64\native\multi-pwsh.exe PreserveNewest @@ -10,7 +14,7 @@ - + runtimes\win-arm64\native\multi-pwsh.exe PreserveNewest @@ -21,7 +25,7 @@ - + runtimes\linux-x64\native\multi-pwsh PreserveNewest @@ -32,7 +36,7 @@ - + runtimes\linux-arm64\native\multi-pwsh PreserveNewest @@ -43,7 +47,7 @@ - + runtimes\osx-x64\native\multi-pwsh PreserveNewest @@ -54,7 +58,7 @@ - + runtimes\osx-arm64\native\multi-pwsh PreserveNewest diff --git a/nuget/Devolutions.MultiPwsh.Cli/README.md b/nuget/Devolutions.MultiPwsh.Cli/README.md index bbf0692..64f737e 100644 --- a/nuget/Devolutions.MultiPwsh.Cli/README.md +++ b/nuget/Devolutions.MultiPwsh.Cli/README.md @@ -2,7 +2,29 @@ `Devolutions.MultiPwsh.Cli` ships RID-specific `multi-pwsh` native binaries for .NET projects. It also includes opt-in AppHost MSBuild targets for projects that need to copy the same binary as a PowerShell replacement apphost. -The normal CLI payload is copied under `runtimes//native/` for build and publish outputs. AppHost mode is inert by default; set `MultiPwshAppHostEnabled=true` to copy the selected RID binary as `multi-pwsh`, `pwsh`, or another explicit file name. +The normal CLI payload is copied under `runtimes//native/` for build and publish outputs. Package authors can disable that content copy and consume neutral apphost metadata instead: + +```xml + + + + + + false + + + + + + tools/apphost/%(RuntimeIdentifier)/%(AppHostFileName) + + + +``` + +`@(MultiPwshAppHostAsset)` includes the source path as the item identity plus `RuntimeIdentifier`, `NativeFileName`, `AppHostFileName`, `PackageRelativePath`, and `PackageId` metadata. The package also provides `MultiPwshAppHostSupportedRuntimeIdentifiers` and `MultiPwshAppHostManifestPath`. + +AppHost mode is inert by default; set `MultiPwshAppHostEnabled=true` to copy the selected RID binary as `multi-pwsh`, `pwsh`, or another explicit file name. ```xml diff --git a/scripts/Build-NativeNuGetPackages.ps1 b/scripts/Build-NativeNuGetPackages.ps1 index b557e61..f66a737 100644 --- a/scripts/Build-NativeNuGetPackages.ps1 +++ b/scripts/Build-NativeNuGetPackages.ps1 @@ -85,6 +85,49 @@ function Resolve-RustTarget { } } +function Resolve-AppHostFileName { + param([Parameter(Mandatory)][string]$RuntimeIdentifier) + + if ($RuntimeIdentifier.StartsWith('win-', [System.StringComparison]::OrdinalIgnoreCase)) { + 'pwsh.exe' + } + else { + 'pwsh' + } +} + +function New-AppHostManifest { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$PackageId, + [Parameter(Mandatory)][string]$PackageVersion, + [Parameter(Mandatory)][string[]]$RuntimeIdentifiers + ) + + $assets = foreach ($rid in $RuntimeIdentifiers) { + $target = Resolve-RustTarget -RuntimeIdentifier $rid + [ordered]@{ + runtimeIdentifier = $rid + packageRelativePath = "runtimes/$rid/native/$($target['BinaryName'])" + nativeFileName = $target['BinaryName'] + appHostFileName = Resolve-AppHostFileName -RuntimeIdentifier $rid + } + } + + $manifest = [ordered]@{ + packageId = $PackageId + packageVersion = $PackageVersion + supportedRuntimeIdentifiers = $RuntimeIdentifiers + requiredAdjacentPayloadFiles = @('pwsh.dll', 'pwsh.runtimeconfig.json') + notes = 'This package supplies only the native PowerShell apphost executable. Consumers must place it beside their own PowerShell managed payload.' + assets = @($assets) + } + + $manifestDirectory = Split-Path -Path $Path -Parent + New-Item -Path $manifestDirectory -ItemType Directory -Force | Out-Null + $manifest | ConvertTo-Json -Depth 5 | Set-Content -Path $Path -Encoding utf8 +} + function Resolve-PackageProject { param([Parameter(Mandatory)][string]$Package) @@ -95,6 +138,7 @@ function Resolve-PackageProject { Project = Join-Path $repoRoot 'nuget\Devolutions.MultiPwsh.Cli\Devolutions.MultiPwsh.Cli.csproj' FixedEntries = @( 'build/Devolutions.MultiPwsh.Cli.targets', + 'build/Devolutions.MultiPwsh.Cli.AppHostManifest.json', 'buildTransitive/Devolutions.MultiPwsh.Cli.props', 'buildTransitive/Devolutions.MultiPwsh.Cli.targets', 'README.md' @@ -135,6 +179,7 @@ function Assert-NupkgContents { param( [Parameter(Mandatory)][string]$PackagePath, [Parameter(Mandatory)][hashtable]$PackageInfo, + [Parameter(Mandatory)][string]$PackageVersion, [Parameter(Mandatory)][string[]]$ExpectedRuntimeIdentifiers ) @@ -162,6 +207,56 @@ function Assert-NupkgContents { throw "Expected package entry '$entry' was not found in $PackagePath" } } + + $manifestEntryName = 'build/Devolutions.MultiPwsh.Cli.AppHostManifest.json' + $manifestEntry = $archive.GetEntry($manifestEntryName) + if ($null -eq $manifestEntry) { + throw "Expected package manifest '$manifestEntryName' was not found in $PackagePath" + } + + $reader = [System.IO.StreamReader]::new($manifestEntry.Open()) + try { + $manifest = $reader.ReadToEnd() | ConvertFrom-Json + } + finally { + $reader.Dispose() + } + + if ($manifest.packageId -ne $PackageInfo['Id']) { + throw "Package manifest packageId mismatch: expected '$($PackageInfo['Id'])', got '$($manifest.packageId)'" + } + + if ($manifest.packageVersion -ne $PackageVersion) { + throw "Package manifest packageVersion mismatch: expected '$PackageVersion', got '$($manifest.packageVersion)'" + } + + $manifestRids = @($manifest.supportedRuntimeIdentifiers) + $manifestAssets = @($manifest.assets) + foreach ($rid in $ExpectedRuntimeIdentifiers) { + if ($manifestRids -notcontains $rid) { + throw "Package manifest is missing supported RID '$rid'" + } + + $target = Resolve-RustTarget -RuntimeIdentifier $rid + $asset = $manifestAssets | Where-Object { $_.runtimeIdentifier -eq $rid } | Select-Object -First 1 + if ($null -eq $asset) { + throw "Package manifest is missing an asset for RID '$rid'" + } + + $expectedPackageRelativePath = "runtimes/$rid/native/$($target['BinaryName'])" + if ($asset.packageRelativePath -ne $expectedPackageRelativePath) { + throw "Package manifest asset path mismatch for '$rid': expected '$expectedPackageRelativePath', got '$($asset.packageRelativePath)'" + } + + if ($asset.nativeFileName -ne $target['BinaryName']) { + throw "Package manifest native file mismatch for '$rid': expected '$($target['BinaryName'])', got '$($asset.nativeFileName)'" + } + + $expectedAppHostFileName = Resolve-AppHostFileName -RuntimeIdentifier $rid + if ($asset.appHostFileName -ne $expectedAppHostFileName) { + throw "Package manifest apphost file mismatch for '$rid': expected '$expectedAppHostFileName', got '$($asset.appHostFileName)'" + } + } } finally { $archive.Dispose() @@ -228,6 +323,8 @@ foreach ($rid in $RuntimeIdentifiers) { if (-not $NoPack) { foreach ($package in $Packages) { $packageInfo = Resolve-PackageProject -Package $package + New-AppHostManifest -Path (Join-Path $StagingRoot 'apphost-manifest.json') -PackageId $packageInfo['Id'] -PackageVersion $Version -RuntimeIdentifiers $RuntimeIdentifiers + Invoke-NativeCommand -FilePath dotnet -ArgumentList @( 'pack', $packageInfo['Project'], @@ -235,6 +332,7 @@ if (-not $NoPack) { $Configuration, '-o', $OutputRoot, + "/p:MultiPwshCliStagingRoot=$StagingRoot", "/p:Version=$Version", '/p:ContinuousIntegrationBuild=true' ) @@ -242,6 +340,6 @@ if (-not $NoPack) { $packagePath = Join-Path $OutputRoot "$($packageInfo['Id']).$Version.nupkg" Assert-FileExists -Path $packagePath Set-NupkgUnixExecutablePermissions -PackagePath $packagePath - Assert-NupkgContents -PackagePath $packagePath -PackageInfo $packageInfo -ExpectedRuntimeIdentifiers $RuntimeIdentifiers + Assert-NupkgContents -PackagePath $packagePath -PackageInfo $packageInfo -PackageVersion $Version -ExpectedRuntimeIdentifiers $RuntimeIdentifiers } } diff --git a/tests/Invoke-AppHostNuGetPackageSmokeTest.ps1 b/tests/Invoke-AppHostNuGetPackageSmokeTest.ps1 index 5b6e496..790a3ca 100644 --- a/tests/Invoke-AppHostNuGetPackageSmokeTest.ps1 +++ b/tests/Invoke-AppHostNuGetPackageSmokeTest.ps1 @@ -150,13 +150,23 @@ try { false true $outputName + false - + + + <_CurrentRidAppHostAsset Include="@(MultiPwshAppHostAsset)" Condition="'%(MultiPwshAppHostAsset.RuntimeIdentifier)' == '$RuntimeIdentifier'" /> + + + + + + + @@ -184,6 +194,7 @@ try { net8.0 $RuntimeIdentifier false + false @@ -203,6 +214,17 @@ try { } } + $runtimeNativeName = if ($RuntimeIdentifier.StartsWith('win-', [System.StringComparison]::OrdinalIgnoreCase)) { + 'multi-pwsh.exe' + } + else { + 'multi-pwsh' + } + $unexpectedRuntimeNativePath = Join-Path $inertOutputDir "runtimes\$RuntimeIdentifier\native\$runtimeNativeName" + if (Test-Path -Path $unexpectedRuntimeNativePath -PathType Leaf) { + throw "Runtime native content copy should be disabled, but found unexpected output: $unexpectedRuntimeNativePath" + } + Invoke-CheckedCommand -FilePath dotnet -ArgumentList @('restore', $projectPath, '--configfile', $nugetConfig) Invoke-CheckedCommand -FilePath dotnet -ArgumentList @('build', $projectPath, '--no-restore', '-c', $Configuration) Invoke-CheckedCommand -FilePath dotnet -ArgumentList @('publish', $projectPath, '--no-restore', '-c', $Configuration) From 7f2f69ff9e9bbd1bcde3f85e3e4f6f0affabc3c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Tue, 23 Jun 2026 10:05:50 -0400 Subject: [PATCH 2/2] Bump version to 0.14.0 Update crate versions, Cargo.lock, release examples, and NuGet package reference examples for the 0.14.0 release. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 4 ++-- README.md | 10 +++++----- crates/multi-pwsh/Cargo.toml | 2 +- crates/pwsh-host/Cargo.toml | 2 +- docs/host-and-venv.md | 2 +- nuget/Devolutions.MultiPwsh.Cli/README.md | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f102afe..7d6dde9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -780,7 +780,7 @@ dependencies = [ [[package]] name = "multi-pwsh" -version = "0.13.0" +version = "0.14.0" dependencies = [ "flate2", "home", @@ -929,7 +929,7 @@ dependencies = [ [[package]] name = "pwsh-host" -version = "0.13.0" +version = "0.14.0" dependencies = [ "base64 0.13.1", "cfg-if 0.1.10", diff --git a/README.md b/README.md index 2b489ac..9cc82e4 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,14 @@ curl -fsSL https://github.com/Devolutions/multi-pwsh/releases/latest/download/in irm https://github.com/Devolutions/multi-pwsh/releases/latest/download/install-multi-pwsh.ps1 | iex ``` -Install a specific tag (example `v0.13.0`): +Install a specific tag (example `v0.14.0`): ```bash -curl -fsSL https://github.com/Devolutions/multi-pwsh/releases/download/v0.13.0/install-multi-pwsh.sh | bash -s -- v0.13.0 +curl -fsSL https://github.com/Devolutions/multi-pwsh/releases/download/v0.14.0/install-multi-pwsh.sh | bash -s -- v0.14.0 ``` ```powershell -& ([scriptblock]::Create((irm https://github.com/Devolutions/multi-pwsh/releases/download/v0.13.0/install-multi-pwsh.ps1))) -Version v0.13.0 +& ([scriptblock]::Create((irm https://github.com/Devolutions/multi-pwsh/releases/download/v0.14.0/install-multi-pwsh.ps1))) -Version v0.14.0 ``` Uninstall bootstrap scripts: @@ -283,7 +283,7 @@ Package authors can consume the launchers privately and map them into their own ```xml - + @@ -305,7 +305,7 @@ For a simple single-RID project, AppHost mode can copy the selected binary direc ```xml - + diff --git a/crates/multi-pwsh/Cargo.toml b/crates/multi-pwsh/Cargo.toml index 267a8b2..f2280ed 100644 --- a/crates/multi-pwsh/Cargo.toml +++ b/crates/multi-pwsh/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "multi-pwsh" -version = "0.13.0" +version = "0.14.0" edition = "2018" license = "MIT/Apache-2.0" homepage = "https://github.com/Devolutions/multi-pwsh" diff --git a/crates/pwsh-host/Cargo.toml b/crates/pwsh-host/Cargo.toml index 84ef26a..30e37be 100644 --- a/crates/pwsh-host/Cargo.toml +++ b/crates/pwsh-host/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pwsh-host" -version = "0.13.0" +version = "0.14.0" edition = "2018" license = "MIT/Apache-2.0" homepage = "https://github.com/Devolutions/pwsh-host-rs" diff --git a/docs/host-and-venv.md b/docs/host-and-venv.md index 9b03c6d..e39993d 100644 --- a/docs/host-and-venv.md +++ b/docs/host-and-venv.md @@ -32,7 +32,7 @@ Typical downstream vendored-SDK usage: ```xml - + diff --git a/nuget/Devolutions.MultiPwsh.Cli/README.md b/nuget/Devolutions.MultiPwsh.Cli/README.md index 64f737e..b9a6f2e 100644 --- a/nuget/Devolutions.MultiPwsh.Cli/README.md +++ b/nuget/Devolutions.MultiPwsh.Cli/README.md @@ -6,7 +6,7 @@ The normal CLI payload is copied under `runtimes//native/` for build and pu ```xml - + @@ -28,7 +28,7 @@ AppHost mode is inert by default; set `MultiPwshAppHostEnabled=true` to copy the ```xml - +