diff --git a/.github/workflows/cli-packages.yml b/.github/workflows/cli-packages.yml new file mode 100644 index 0000000..2f50202 --- /dev/null +++ b/.github/workflows/cli-packages.yml @@ -0,0 +1,296 @@ +name: CLI NuGet Packages + +on: + push: + tags-ignore: + - 'v*' + pull_request: + workflow_dispatch: + inputs: + dry-run: + description: Build and package only; skip publish/release actions. + required: false + default: true + type: boolean + +permissions: + contents: read + +env: + DOTNET_NOLOGO: true + CARGO_TERM_COLOR: always + +jobs: + rust-cli: + name: Rust CLI (${{ matrix.rid }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - rid: linux-x64 + os: ubuntu-latest + rust-target: x86_64-unknown-linux-gnu + - rid: linux-arm64 + os: ubuntu-latest + rust-target: aarch64-unknown-linux-gnu + - rid: win-x64 + os: windows-latest + rust-target: x86_64-pc-windows-msvc + - rid: win-arm64 + os: windows-latest + rust-target: aarch64-pc-windows-msvc + - rid: osx-x64 + os: macos-14 + rust-target: x86_64-apple-darwin + - rid: osx-arm64 + os: macos-14 + rust-target: aarch64-apple-darwin + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Rust toolchain + shell: pwsh + run: | + rustup toolchain install stable --profile minimal --target "${{ matrix.rust-target }}" + rustup default stable + + - name: Install Linux ARM64 linker + if: matrix.rid == 'linux-arm64' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + + - name: Configure static MSVC runtime + if: contains(matrix.rust-target, 'windows-msvc') + shell: pwsh + run: | + "RUSTFLAGS=-C target-feature=+crt-static" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Build and stage Rust CLI + shell: pwsh + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + run: | + pwsh -NoLogo -NoProfile -File (Resolve-Path 'scripts\Build-CliNativeNuGetPackages.ps1') ` + -Package Rust ` + -RustRuntimeIdentifiers '${{ matrix.rid }}' ` + -NoPack ` + -Clean + + - name: Upload Rust CLI artifact + uses: actions/upload-artifact@v7 + with: + name: rust-cli-${{ matrix.rid }} + path: artifacts/cli/rust/${{ matrix.rid }}/* + if-no-files-found: error + + dotnet-cli: + name: .NET NativeAOT CLI (${{ matrix.rid }}) + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + rid: + - win-x64 + - win-arm64 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Build and stage .NET NativeAOT CLI + shell: pwsh + run: | + pwsh -NoLogo -NoProfile -File (Resolve-Path 'scripts\Build-CliNativeNuGetPackages.ps1') ` + -Package DotNet ` + -DotNetRuntimeIdentifiers '${{ matrix.rid }}' ` + -NoPack ` + -Clean + + - name: Upload .NET CLI artifact + uses: actions/upload-artifact@v7 + with: + name: dotnet-cli-${{ matrix.rid }} + path: artifacts/cli/dotnet-nativeaot/${{ matrix.rid }}/* + if-no-files-found: error + + package: + name: Pack and smoke test + runs-on: ubuntu-latest + needs: + - rust-cli + - dotnet-cli + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Download CLI artifacts + uses: actions/download-artifact@v8 + with: + path: artifacts/download + + - name: Arrange staged artifacts + shell: pwsh + run: | + $Mappings = @{ + 'rust-cli-linux-x64' = 'artifacts/cli/rust/linux-x64' + 'rust-cli-linux-arm64' = 'artifacts/cli/rust/linux-arm64' + 'rust-cli-win-x64' = 'artifacts/cli/rust/win-x64' + 'rust-cli-win-arm64' = 'artifacts/cli/rust/win-arm64' + 'rust-cli-osx-x64' = 'artifacts/cli/rust/osx-x64' + 'rust-cli-osx-arm64' = 'artifacts/cli/rust/osx-arm64' + 'dotnet-cli-win-x64' = 'artifacts/cli/dotnet-nativeaot/win-x64' + 'dotnet-cli-win-arm64' = 'artifacts/cli/dotnet-nativeaot/win-arm64' + } + + foreach ($mapping in $Mappings.GetEnumerator()) { + $source = Join-Path 'artifacts/download' $mapping.Key + if (-not (Test-Path -Path $source)) { + throw "Downloaded artifact directory not found: $source" + } + + New-Item -Path $mapping.Value -ItemType Directory -Force | Out-Null + Copy-Item -Path (Join-Path $source '*') -Destination $mapping.Value -Recurse -Force + } + + - name: Pack CLI NuGet packages + shell: pwsh + run: | + pwsh -NoLogo -NoProfile -File (Resolve-Path 'scripts\Build-CliNativeNuGetPackages.ps1') -NoBuild -Clean:$false + + - name: Smoke test PackageReference copy targets + shell: pwsh + run: | + $packageSource = (Resolve-Path 'artifacts/cli-nuget').Path + $smokeRoot = Join-Path $env:RUNNER_TEMP 'pinget-cli-package-smoke' + $env:NUGET_PACKAGES = Join-Path $env:RUNNER_TEMP 'pinget-cli-package-smoke-nuget-cache' + New-Item -Path $smokeRoot -ItemType Directory -Force | Out-Null + New-Item -Path $env:NUGET_PACKAGES -ItemType Directory -Force | Out-Null + $rustPackage = Get-ChildItem -Path $packageSource -Filter 'Devolutions.Pinget.Cli.Rust.*.nupkg' | Select-Object -First 1 + $dotNetPackage = Get-ChildItem -Path $packageSource -Filter 'Devolutions.Pinget.Cli.DotNet.*.nupkg' | Select-Object -First 1 + if (($null -eq $rustPackage) -or ($null -eq $dotNetPackage)) { + throw 'Smoke test packages were not found.' + } + $rustVersion = $rustPackage.BaseName.Substring('Devolutions.Pinget.Cli.Rust.'.Length) + $dotNetVersion = $dotNetPackage.BaseName.Substring('Devolutions.Pinget.Cli.DotNet.'.Length) + + dotnet new console --framework net10.0 --output $smokeRoot + if ($LASTEXITCODE -ne 0) { throw 'Smoke project creation failed.' } + + $projectPath = Join-Path $smokeRoot 'pinget-cli-package-smoke.csproj' + $project = Get-Content -Path $projectPath -Raw + $project = $project -replace '', @" + + pinget-rust.exe + pinget-dotnet.exe + + + + + + + "@ + Set-Content -Path $projectPath -Value $project -Encoding utf8 + + $nugetConfig = Join-Path $smokeRoot 'NuGet.Config' + @" + + + + + + + + + "@ | Set-Content -Path $nugetConfig -Encoding utf8 + + dotnet build $projectPath --configfile $nugetConfig + if ($LASTEXITCODE -ne 0) { throw 'Smoke project build failed.' } + + $expectedFiles = @( + 'bin/Debug/net10.0/runtimes/win-x64/native/pinget.exe', + 'bin/Debug/net10.0/runtimes/win-x64/native/pinget-rust.exe', + 'bin/Debug/net10.0/runtimes/win-x64/native/pinget-dotnet.exe', + 'bin/Debug/net10.0/runtimes/win-x64/native/e_sqlite3.dll', + 'bin/Debug/net10.0/runtimes/linux-x64/native/pinget' + ) + + foreach ($relativePath in $expectedFiles) { + $path = Join-Path $smokeRoot $relativePath + if (-not (Test-Path -Path $path -PathType Leaf)) { + throw "Expected smoke output file was not found: $path" + } + } + + - name: Smoke test dotnet tool package + shell: pwsh + run: | + $packageSource = (Resolve-Path 'artifacts/cli-nuget').Path + $toolPackage = Get-ChildItem -Path $packageSource -Filter 'Devolutions.Pinget.Tool.*.nupkg' | Select-Object -First 1 + if ($null -eq $toolPackage) { + throw 'Devolutions.Pinget.Tool package was not found.' + } + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead($toolPackage.FullName) + try { + $entries = @($archive.Entries | ForEach-Object { $_.FullName }) + $nuspecEntry = $archive.Entries | Where-Object { $_.FullName -match '\.nuspec$' } | Select-Object -First 1 + if ($null -eq $nuspecEntry) { + throw "Unable to find nuspec entry in $($toolPackage.Name)." + } + + $reader = [System.IO.StreamReader]::new($nuspecEntry.Open()) + try { + $nuspec = $reader.ReadToEnd() + } + finally { + $reader.Dispose() + } + + if ($nuspec -notmatch '\native` | +| `Devolutions.Pinget.Cli.DotNet` | C# CLI | Trimmed NativeAOT executable assets named `pinget.exe`, plus required native sidecars, starting with Windows x64 and arm64 | + +These packages are intended for `PackageReference` consumption and copy the executable assets into the consuming project's output/publish output through MSBuild targets. They are not `dotnet tool` packages; use `Devolutions.Pinget.Tool` when you want to install the CLI with `dotnet tool install`. + +```powershell +dotnet add package Devolutions.Pinget.Cli.Rust +dotnet add package Devolutions.Pinget.Cli.DotNet +``` + +Both packages use `pinget[.exe]` inside the NuGet package. If a project intentionally references both implementations, set package-specific output names to add distinguishable copies in the build output: + +```xml + + pinget.exe + pinget-managed.exe + +``` + ## Non-goals Pinget intentionally excludes: diff --git a/dotnet/src/Devolutions.Pinget.Cli/Devolutions.Pinget.Cli.csproj b/dotnet/src/Devolutions.Pinget.Cli/Devolutions.Pinget.Cli.csproj index deb666b..e98a569 100644 --- a/dotnet/src/Devolutions.Pinget.Cli/Devolutions.Pinget.Cli.csproj +++ b/dotnet/src/Devolutions.Pinget.Cli/Devolutions.Pinget.Cli.csproj @@ -15,6 +15,33 @@ enable pinget Devolutions.Pinget.Cli + Pinget CLI + Pinget + Pinget CLI + Copyright 2021-2026 Devolutions Inc. + true + pinget + Devolutions.Pinget.Tool + pinget;winget;package-manager;cli;dotnet-tool + README.md + true + + + + + + + + + + + + + diff --git a/dotnet/src/Devolutions.Pinget.Cli/Program.cs b/dotnet/src/Devolutions.Pinget.Cli/Program.cs index fa43cc4..4548497 100644 --- a/dotnet/src/Devolutions.Pinget.Cli/Program.cs +++ b/dotnet/src/Devolutions.Pinget.Cli/Program.cs @@ -5,7 +5,7 @@ using Devolutions.Pinget.Core; using YamlDotNet.Serialization; -const string Version = "0.3.0"; +const string Version = "0.4.0"; const string UpgradeUnsupportedWarning = "Upgrading packages is not supported on this platform; no changes were made."; if (args.Length == 1 && (string.Equals(args[0], "--version", StringComparison.OrdinalIgnoreCase) || string.Equals(args[0], "-v", StringComparison.OrdinalIgnoreCase))) diff --git a/dotnet/src/Devolutions.Pinget.Cli/README.md b/dotnet/src/Devolutions.Pinget.Cli/README.md new file mode 100644 index 0000000..30ac0a5 --- /dev/null +++ b/dotnet/src/Devolutions.Pinget.Cli/README.md @@ -0,0 +1,26 @@ +# Devolutions.Pinget.Tool + +`Devolutions.Pinget.Tool` installs the C# Pinget CLI as a framework-dependent .NET tool. + +## Install globally + +```powershell +dotnet tool install -g Devolutions.Pinget.Tool +``` + +## Install in a local tool manifest + +```powershell +dotnet new tool-manifest +dotnet tool install Devolutions.Pinget.Tool +dotnet tool run pinget -- --help +``` + +## Run + +```powershell +pinget --help +pinget --info +``` + +This package uses the C# Pinget implementation and requires a compatible .NET runtime on the target machine. It is separate from the `Devolutions.Pinget.Cli.Rust` and `Devolutions.Pinget.Cli.DotNet` packages, which are build-time `PackageReference` packages that copy prebuilt executables into another project's output. diff --git a/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1 b/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1 index e7d520d..5c32fbf 100644 --- a/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1 +++ b/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'Devolutions.Pinget.Client.psm1' - ModuleVersion = '0.3.0' + ModuleVersion = '0.4.0' CompatiblePSEditions = @('Desktop', 'Core') GUID = 'c6d1b5f2-5ccd-4771-9480-25caad7c58bd' Author = 'Devolutions' diff --git a/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PowerShellEngineVersion.cs b/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PowerShellEngineVersion.cs index b0feeae..84b1cab 100644 --- a/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PowerShellEngineVersion.cs +++ b/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PowerShellEngineVersion.cs @@ -2,5 +2,5 @@ namespace Devolutions.Pinget.PowerShell.Engine; public static class PowerShellEngineVersion { - public const string Current = "0.3.0"; + public const string Current = "0.4.0"; } diff --git a/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.csproj b/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.csproj new file mode 100644 index 0000000..c45a78c --- /dev/null +++ b/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.csproj @@ -0,0 +1,40 @@ + + + + 0.4.0 + Devolutions Inc. + Devolutions + Devolutions.Pinget.Cli.DotNet + pinget;winget;package-manager;cli;dotnet;nativeaot + Prebuilt trimmed NativeAOT .NET Pinget CLI executable for use from .NET projects. + netstandard2.0 + true + false + false + true + false + true + MIT + https://github.com/Devolutions/pinget + https://github.com/Devolutions/pinget.git + git + false + + + + + runtimes\win-x64\native\pinget.exe + + + runtimes\win-x64\native\e_sqlite3.dll + + + runtimes\win-arm64\native\pinget.exe + + + runtimes\win-arm64\native\e_sqlite3.dll + + + + + diff --git a/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.targets b/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.targets new file mode 100644 index 0000000..9572bbd --- /dev/null +++ b/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.targets @@ -0,0 +1,43 @@ + + + pinget.exe + + + + + runtimes\win-x64\native\$(DevolutionsPingetDotNetCliWindowsOutputName) + PreserveNewest + PreserveNewest + Included + false + false + + + runtimes\win-x64\native\e_sqlite3.dll + PreserveNewest + PreserveNewest + Included + false + false + + + + + + runtimes\win-arm64\native\$(DevolutionsPingetDotNetCliWindowsOutputName) + PreserveNewest + PreserveNewest + Included + false + false + + + runtimes\win-arm64\native\e_sqlite3.dll + PreserveNewest + PreserveNewest + Included + false + false + + + diff --git a/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.csproj b/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.csproj new file mode 100644 index 0000000..dd25847 --- /dev/null +++ b/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.csproj @@ -0,0 +1,46 @@ + + + + 0.4.0 + Devolutions Inc. + Devolutions + Devolutions.Pinget.Cli.Rust + pinget;winget;package-manager;cli;rust;native + Prebuilt Rust Pinget CLI executable for use from .NET projects. + netstandard2.0 + true + false + false + true + false + true + MIT + https://github.com/Devolutions/pinget + https://github.com/Devolutions/pinget.git + git + false + + + + + runtimes\win-x64\native\pinget.exe + + + runtimes\win-arm64\native\pinget.exe + + + runtimes\linux-x64\native + + + runtimes\linux-arm64\native + + + runtimes\osx-x64\native + + + runtimes\osx-arm64\native + + + + + diff --git a/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.targets b/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.targets new file mode 100644 index 0000000..53334e0 --- /dev/null +++ b/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.targets @@ -0,0 +1,72 @@ + + + pinget.exe + pinget + + + + + runtimes\win-x64\native\$(DevolutionsPingetRustCliWindowsOutputName) + PreserveNewest + PreserveNewest + Included + false + false + + + + + + runtimes\win-arm64\native\$(DevolutionsPingetRustCliWindowsOutputName) + PreserveNewest + PreserveNewest + Included + false + false + + + + + + runtimes\linux-x64\native\$(DevolutionsPingetRustCliUnixOutputName) + PreserveNewest + PreserveNewest + Included + false + false + + + + + + runtimes\linux-arm64\native\$(DevolutionsPingetRustCliUnixOutputName) + PreserveNewest + PreserveNewest + Included + false + false + + + + + + runtimes\osx-x64\native\$(DevolutionsPingetRustCliUnixOutputName) + PreserveNewest + PreserveNewest + Included + false + false + + + + + + runtimes\osx-arm64\native\$(DevolutionsPingetRustCliUnixOutputName) + PreserveNewest + PreserveNewest + Included + false + false + + + diff --git a/rust/crates/pinget-cli/Cargo.toml b/rust/crates/pinget-cli/Cargo.toml index 362b032..e515ccc 100644 --- a/rust/crates/pinget-cli/Cargo.toml +++ b/rust/crates/pinget-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pinget-cli" -version = "0.3.0" +version = "0.4.0" edition = "2024" [lints] @@ -13,7 +13,7 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.102" clap = { version = "4.6.1", features = ["derive"] } -pinget-core = { version = "0.3.1", path = "../pinget-core" } +pinget-core = { version = "0.4.0", path = "../pinget-core" } chrono = "0.4.44" dirs = "6.0" jsonschema = "0.30" @@ -27,3 +27,6 @@ sha2 = "0.11.0" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console"] } winreg = "0.55.0" + +[target.'cfg(windows)'.build-dependencies] +winresource = "0.1.30" diff --git a/rust/crates/pinget-cli/build.rs b/rust/crates/pinget-cli/build.rs new file mode 100644 index 0000000..949f068 --- /dev/null +++ b/rust/crates/pinget-cli/build.rs @@ -0,0 +1,28 @@ +#[cfg(windows)] +fn main() { + let version = env!("CARGO_PKG_VERSION"); + let version_string = format!("{version}.0"); + let version_info = version + .split('.') + .chain(std::iter::repeat("0")) + .take(4) + .map(|part| part.parse::().expect("package version segment must fit in u16")) + .fold(0u64, |acc, part| (acc << 16) | u64::from(part)); + + let mut resource = winresource::WindowsResource::new(); + resource + .set("CompanyName", "Devolutions Inc.") + .set("FileDescription", "Pinget CLI") + .set("FileVersion", &version_string) + .set("InternalName", "pinget") + .set("LegalCopyright", "Copyright 2021-2026 Devolutions Inc.") + .set("OriginalFilename", "pinget.exe") + .set("ProductName", "Pinget") + .set("ProductVersion", &version_string) + .set_version_info(winresource::VersionInfo::FILEVERSION, version_info) + .set_version_info(winresource::VersionInfo::PRODUCTVERSION, version_info); + resource.compile().expect("failed to compile Windows version resource"); +} + +#[cfg(not(windows))] +fn main() {} diff --git a/rust/crates/pinget-com/Cargo.toml b/rust/crates/pinget-com/Cargo.toml index 120d6c4..e22a5b7 100644 --- a/rust/crates/pinget-com/Cargo.toml +++ b/rust/crates/pinget-com/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pinget-com" -version = "0.3.0" +version = "0.4.0" edition = "2024" description = "Windows-only native COM bridge for Pinget backed by pinget-core." license = "MIT" diff --git a/rust/crates/pinget-core/Cargo.toml b/rust/crates/pinget-core/Cargo.toml index 2acd108..7226887 100644 --- a/rust/crates/pinget-core/Cargo.toml +++ b/rust/crates/pinget-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pinget-core" -version = "0.3.1" +version = "0.4.0" edition = "2024" description = "Pure Rust Pinget core library that works directly with source caches, REST endpoints, and installed package state without COM." license = "MIT" diff --git a/scripts/Build-CliNativeNuGetPackages.ps1 b/scripts/Build-CliNativeNuGetPackages.ps1 new file mode 100644 index 0000000..52e88ac --- /dev/null +++ b/scripts/Build-CliNativeNuGetPackages.ps1 @@ -0,0 +1,548 @@ +param( + [ValidateSet('All', 'Rust', 'DotNet', 'Tool')] + [string]$Package = 'All', + + [string]$Version, + + [string]$Configuration = 'Release', + + [string]$StagingRoot, + + [string]$OutputRoot, + + [string[]]$RustRuntimeIdentifiers = @('win-x64', 'win-arm64', 'linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64'), + + [string[]]$DotNetRuntimeIdentifiers = @('win-x64', 'win-arm64'), + + [switch]$NoBuild, + + [switch]$NoPack, + + [switch]$Clean +) + +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +if ([string]::IsNullOrWhiteSpace($StagingRoot)) { + $StagingRoot = Join-Path $repoRoot 'artifacts\cli' +} +elseif (-not [System.IO.Path]::IsPathRooted($StagingRoot)) { + $StagingRoot = Join-Path $repoRoot $StagingRoot +} + +if ([string]::IsNullOrWhiteSpace($OutputRoot)) { + $OutputRoot = Join-Path $repoRoot 'artifacts\cli-nuget' +} +elseif (-not [System.IO.Path]::IsPathRooted($OutputRoot)) { + $OutputRoot = Join-Path $repoRoot $OutputRoot +} + +function Invoke-NativeCommand { + param( + [Parameter(Mandatory)] + [string]$FilePath, + + [string[]]$ArgumentList + ) + + Write-Host ">> $FilePath $($ArgumentList -join ' ')" + & $FilePath @ArgumentList + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code $LASTEXITCODE`: $FilePath $($ArgumentList -join ' ')" + } +} + +function Get-Win32VersionParts { + param([Parameter(Mandatory)][string]$Version) + + $parts = @($Version.Split('+')[0].Split('-')[0].Split('.') | ForEach-Object { + if ($_ -notmatch '^\d+$') { + throw "Version segment is not numeric: $_" + } + + $value = [int]$_ + if (($value -lt 0) -or ($value -gt 65535)) { + throw "Version segment is outside the Win32 VERSIONINFO range: $_" + } + + $value + }) + + while ($parts.Count -lt 4) { + $parts += 0 + } + + if ($parts.Count -gt 4) { + throw "Win32 VERSIONINFO supports at most four version segments: $Version" + } + + $parts +} + +function Resolve-WindowsResourceCompiler { + $rcCommand = Get-Command rc.exe -ErrorAction SilentlyContinue + if ($null -ne $rcCommand) { + return $rcCommand.Source + } + + $windowsKitsRoot = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' + if (Test-Path -Path $windowsKitsRoot -PathType Container) { + $kitVersions = @(Get-ChildItem -Path $windowsKitsRoot -Directory | Sort-Object -Property Name -Descending) + foreach ($kitVersion in $kitVersions) { + foreach ($arch in @('x64', 'x86', 'arm64')) { + $candidate = Join-Path $kitVersion.FullName "$arch\rc.exe" + if (Test-Path -Path $candidate -PathType Leaf) { + return $candidate + } + } + } + } + + throw 'Unable to locate rc.exe from the Windows SDK.' +} + +function New-PingetWindowsVersionResource { + param( + [Parameter(Mandatory)][string]$OutputDirectory, + [Parameter(Mandatory)][string]$Version + ) + + if (-not $IsWindows) { + throw 'Windows version resources can only be generated on Windows.' + } + + New-Item -Path $OutputDirectory -ItemType Directory -Force | Out-Null + + $versionParts = @(Get-Win32VersionParts -Version $Version) + $versionCsv = $versionParts -join ',' + $versionString = $versionParts -join '.' + $resourceScript = Join-Path $OutputDirectory 'pinget-version.rc' + $resourceFile = Join-Path $OutputDirectory 'pinget-version.res' + + @" +1 VERSIONINFO + FILEVERSION $versionCsv + PRODUCTVERSION $versionCsv + FILEFLAGSMASK 0x3fL + FILEFLAGS 0x0L + FILEOS 0x40004L + FILETYPE 0x1L + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "Devolutions Inc." + VALUE "FileDescription", "Pinget CLI" + VALUE "FileVersion", "$versionString" + VALUE "InternalName", "pinget" + VALUE "LegalCopyright", "Copyright 2021-2026 Devolutions Inc." + VALUE "OriginalFilename", "pinget.exe" + VALUE "ProductName", "Pinget" + VALUE "ProductVersion", "$versionString" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x0409, 1200 + END +END +"@ | Set-Content -Path $resourceScript -Encoding ascii + + $rc = Resolve-WindowsResourceCompiler + Invoke-NativeCommand -FilePath $rc -ArgumentList @('/nologo', "/fo$resourceFile", $resourceScript) + + $resourceFile +} + +function Add-RustFlag { + param( + [string]$RustFlags, + [Parameter(Mandatory)][string]$Flag + ) + + if ([string]::IsNullOrWhiteSpace($RustFlags)) { + return $Flag + } + + if ($RustFlags -match [regex]::Escape($Flag)) { + return $RustFlags + } + + "$RustFlags $Flag" +} + +function Convert-PeRvaToOffset { + param( + [Parameter(Mandatory)][object[]]$Sections, + [Parameter(Mandatory)][uint32]$Rva + ) + + foreach ($section in $Sections) { + $size = [Math]::Max($section.VirtualSize, $section.RawDataSize) + if (($Rva -ge $section.VirtualAddress) -and ($Rva -lt ($section.VirtualAddress + $size))) { + return [int]($section.RawDataPointer + ($Rva - $section.VirtualAddress)) + } + } + + $null +} + +function Get-NullTerminatedAsciiString { + param( + [Parameter(Mandatory)][byte[]]$Bytes, + [Parameter(Mandatory)][int]$Offset + ) + + $end = $Offset + while (($end -lt $Bytes.Length) -and ($Bytes[$end] -ne 0)) { + $end++ + } + + [System.Text.Encoding]::ASCII.GetString($Bytes, $Offset, $end - $Offset) +} + +function Get-PeImportedDllNames { + param([Parameter(Mandatory)][string]$Path) + + $bytes = [System.IO.File]::ReadAllBytes($Path) + $peOffset = [BitConverter]::ToUInt32($bytes, 0x3c) + $coffOffset = [int]$peOffset + 4 + $sectionCount = [BitConverter]::ToUInt16($bytes, $coffOffset + 2) + $optionalHeaderSize = [BitConverter]::ToUInt16($bytes, $coffOffset + 16) + $optionalHeaderOffset = $coffOffset + 20 + $magic = [BitConverter]::ToUInt16($bytes, $optionalHeaderOffset) + $dataDirectoryOffset = if ($magic -eq 0x20b) { $optionalHeaderOffset + 112 } elseif ($magic -eq 0x10b) { $optionalHeaderOffset + 96 } else { throw "Unsupported PE optional header magic: 0x$($magic.ToString('x'))" } + $importDirectoryRva = [BitConverter]::ToUInt32($bytes, $dataDirectoryOffset + 8) + + if ($importDirectoryRva -eq 0) { + return @() + } + + $sectionTableOffset = $optionalHeaderOffset + $optionalHeaderSize + $sections = @() + for ($index = 0; $index -lt $sectionCount; $index++) { + $sectionOffset = $sectionTableOffset + ($index * 40) + $sections += [pscustomobject]@{ + VirtualSize = [BitConverter]::ToUInt32($bytes, $sectionOffset + 8) + VirtualAddress = [BitConverter]::ToUInt32($bytes, $sectionOffset + 12) + RawDataSize = [BitConverter]::ToUInt32($bytes, $sectionOffset + 16) + RawDataPointer = [BitConverter]::ToUInt32($bytes, $sectionOffset + 20) + } + } + + $importDirectoryOffset = Convert-PeRvaToOffset -Sections $sections -Rva $importDirectoryRva + if ($null -eq $importDirectoryOffset) { + throw "Unable to resolve import directory RVA for $Path." + } + + $importedDlls = @() + for ($descriptorOffset = $importDirectoryOffset; ; $descriptorOffset += 20) { + $originalFirstThunk = [BitConverter]::ToUInt32($bytes, $descriptorOffset) + $timeDateStamp = [BitConverter]::ToUInt32($bytes, $descriptorOffset + 4) + $forwarderChain = [BitConverter]::ToUInt32($bytes, $descriptorOffset + 8) + $nameRva = [BitConverter]::ToUInt32($bytes, $descriptorOffset + 12) + $firstThunk = [BitConverter]::ToUInt32($bytes, $descriptorOffset + 16) + + if (($originalFirstThunk -eq 0) -and ($timeDateStamp -eq 0) -and ($forwarderChain -eq 0) -and ($nameRva -eq 0) -and ($firstThunk -eq 0)) { + break + } + + $nameOffset = Convert-PeRvaToOffset -Sections $sections -Rva $nameRva + if ($null -eq $nameOffset) { + throw "Unable to resolve import name RVA for $Path." + } + + $importedDlls += Get-NullTerminatedAsciiString -Bytes $bytes -Offset $nameOffset + } + + $importedDlls +} + +function Assert-WindowsExecutableMetadata { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$ExpectedOriginalFilename + ) + + if (-not $IsWindows) { + return + } + + $versionInfo = (Get-Item -Path $Path).VersionInfo + $requiredFields = @{ + CompanyName = 'Devolutions Inc.' + FileDescription = 'Pinget CLI' + OriginalFilename = $ExpectedOriginalFilename + ProductName = 'Pinget' + } + + foreach ($field in $requiredFields.GetEnumerator()) { + $actualValue = $versionInfo.PSObject.Properties[$field.Key].Value + if ($actualValue -ne $field.Value) { + throw "Unexpected $($field.Key) in $Path. Expected '$($field.Value)', got '$actualValue'." + } + } + + if ([string]::IsNullOrWhiteSpace($versionInfo.FileVersion) -or [string]::IsNullOrWhiteSpace($versionInfo.ProductVersion)) { + throw "Missing Windows file/product version information in $Path." + } + + $dynamicMsvcRuntimeImports = @(Get-PeImportedDllNames -Path $Path | Where-Object { + $_ -match '^(vcruntime|msvcp|msvcr|ucrtbase)' -or $_ -match '^api-ms-win-crt-' + }) + + if ($dynamicMsvcRuntimeImports.Count -gt 0) { + throw "Expected static MSVC/UCRT runtime linkage for $Path, but found dynamic imports: $($dynamicMsvcRuntimeImports -join ', ')" + } +} + +function Get-SourceVersion { + $rustCliManifest = Join-Path $repoRoot 'rust\crates\pinget-cli\Cargo.toml' + $dotNetCliProgram = Join-Path $repoRoot 'dotnet\src\Devolutions.Pinget.Cli\Program.cs' + + $rustMatch = Select-String -Path $rustCliManifest -Pattern '^version = "([^"]+)"$' | Select-Object -First 1 + $dotNetMatch = Select-String -Path $dotNetCliProgram -Pattern '^const string Version = "([^"]+)";$' | Select-Object -First 1 + + if (($null -eq $rustMatch) -or ($null -eq $dotNetMatch)) { + throw 'Unable to detect CLI package version from source files.' + } + + $rustVersion = $rustMatch.Matches[0].Groups[1].Value + $dotNetVersion = $dotNetMatch.Matches[0].Groups[1].Value + if ($rustVersion -ne $dotNetVersion) { + throw "CLI version mismatch detected: rust=$rustVersion dotnet=$dotNetVersion" + } + + $rustVersion +} + +function Resolve-RustTarget { + param([Parameter(Mandatory)][string]$RuntimeIdentifier) + + switch ($RuntimeIdentifier) { + 'win-x64' { @{ CargoTarget = 'x86_64-pc-windows-msvc'; SourceBinaryName = 'pinget.exe'; PackageBinaryName = 'pinget.exe' } } + 'win-arm64' { @{ CargoTarget = 'aarch64-pc-windows-msvc'; SourceBinaryName = 'pinget.exe'; PackageBinaryName = 'pinget.exe' } } + 'linux-x64' { @{ CargoTarget = 'x86_64-unknown-linux-gnu'; SourceBinaryName = 'pinget'; PackageBinaryName = 'pinget' } } + 'linux-arm64' { @{ CargoTarget = 'aarch64-unknown-linux-gnu'; SourceBinaryName = 'pinget'; PackageBinaryName = 'pinget' } } + 'osx-x64' { @{ CargoTarget = 'x86_64-apple-darwin'; SourceBinaryName = 'pinget'; PackageBinaryName = 'pinget' } } + 'osx-arm64' { @{ CargoTarget = 'aarch64-apple-darwin'; SourceBinaryName = 'pinget'; PackageBinaryName = 'pinget' } } + default { throw "Unsupported Rust runtime identifier: $RuntimeIdentifier" } + } +} + +function Get-DotNetBinaryNames { + param([Parameter(Mandatory)][string]$RuntimeIdentifier) + + switch ($RuntimeIdentifier) { + 'win-x64' { @{ SourceBinaryName = 'pinget.exe'; PackageBinaryName = 'pinget.exe' } } + 'win-arm64' { @{ SourceBinaryName = 'pinget.exe'; PackageBinaryName = 'pinget.exe' } } + default { throw "Unsupported .NET NativeAOT runtime identifier: $RuntimeIdentifier" } + } +} + +function Assert-FileExists { + param([Parameter(Mandatory)][string]$Path) + + if (-not (Test-Path -Path $Path -PathType Leaf)) { + throw "Expected file was not found: $Path" + } +} + +function Set-NupkgUnixExecutablePermissions { + param([Parameter(Mandatory)][string]$PackagePath) + + Add-Type -AssemblyName System.IO.Compression.FileSystem + + $archive = [System.IO.Compression.ZipFile]::Open($PackagePath, [System.IO.Compression.ZipArchiveMode]::Update) + try { + foreach ($entry in $archive.Entries) { + if ($entry.FullName -match '^runtimes/(linux|osx)-[^/]+/native/pinget$') { + $entry.ExternalAttributes = -2115174400 # 0o100755 << 16 as a signed Int32. + } + } + } + finally { + $archive.Dispose() + } +} + +function Assert-DotNetToolPackage { + param([Parameter(Mandatory)][string]$PackagePath) + + Add-Type -AssemblyName System.IO.Compression.FileSystem + + $archive = [System.IO.Compression.ZipFile]::OpenRead($PackagePath) + try { + $entryNames = @($archive.Entries | ForEach-Object { $_.FullName }) + $nuspecEntry = $archive.Entries | Where-Object { $_.FullName -match '\.nuspec$' } | Select-Object -First 1 + if ($null -eq $nuspecEntry) { + throw "Unable to find nuspec entry in $PackagePath." + } + + $reader = New-Object System.IO.StreamReader($nuspecEntry.Open()) + try { + $nuspec = $reader.ReadToEnd() + } + finally { + $reader.Dispose() + } + + if ($nuspec -notmatch ')[^<]+()' ` + -Replacement { param($match) "$($match.Groups[1].Value)$Version$($match.Groups[2].Value)" } + +Set-VersionInFile ` + -RelativePath 'nuget\Devolutions.Pinget.Cli.DotNet\Devolutions.Pinget.Cli.DotNet.csproj' ` + -Pattern '^( )[^<]+()' ` + -Replacement { param($match) "$($match.Groups[1].Value)$Version$($match.Groups[2].Value)" }