From ce725ce20796ec5ebd5fac9149bc3d7ee1ea898d Mon Sep 17 00:00:00 2001 From: Travis Plunk Date: Thu, 20 Nov 2025 13:50:38 -0800 Subject: [PATCH] [release/v7.6] Replace fpm with native macOS packaging tools (pkgbuild/productbuild) (#26501) Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: TravisEz13 <10873629+TravisEz13@users.noreply.github.com> --- .../instructions/build-configuration-guide.md | 48 ++- .../start-native-execution.instructions.md | 149 +++++++ .github/workflows/macos-ci.yml | 55 ++- docs/maintainers/releasing.md | 17 +- tools/packaging/packaging.psm1 | 362 ++++++++++++++---- .../releaseTests/macOSPackage.tests.ps1 | 162 ++++++++ 6 files changed, 706 insertions(+), 87 deletions(-) create mode 100644 .github/instructions/start-native-execution.instructions.md create mode 100644 tools/packaging/releaseTests/macOSPackage.tests.ps1 diff --git a/.github/instructions/build-configuration-guide.md b/.github/instructions/build-configuration-guide.md index d082bcbe77..c31f8139c6 100644 --- a/.github/instructions/build-configuration-guide.md +++ b/.github/instructions/build-configuration-guide.md @@ -27,13 +27,15 @@ ### For Release/Packaging -**Use: Release with version tag** +**Use: Release with version tag and public NuGet feeds** ```yaml - name: Build for Release shell: pwsh run: | + Import-Module ./build.psm1 Import-Module ./tools/ci.psm1 + Switch-PSNugetConfig -Source Public $releaseTag = Get-ReleaseTag Start-PSBuild -Configuration 'Release' -ReleaseTag $releaseTag ``` @@ -43,6 +45,11 @@ - No debug symbols (smaller size) - Production-ready +**Why Switch-PSNugetConfig -Source Public:** +- Switches NuGet package sources to public feeds (nuget.org and public Azure DevOps feeds) +- Required for CI/CD environments that don't have access to private feeds +- Uses publicly available packages instead of Microsoft internal feeds + ### For Code Coverage **Use: CodeCoverage configuration** @@ -93,3 +100,42 @@ src/powershell-win-core/bin/Debug///publish/ 3. Match configuration to purpose 4. Use `-CI` only when needed 5. Always specify `-ReleaseTag` for release or packaging builds +6. Use `Switch-PSNugetConfig -Source Public` in CI/CD for release builds + +## NuGet Feed Configuration + +### Switch-PSNugetConfig + +The `Switch-PSNugetConfig` function in `build.psm1` manages NuGet package source configuration. + +**Available Sources:** + +- **Public**: Uses public feeds (nuget.org and public Azure DevOps feeds) + - Required for: CI/CD environments, public builds, packaging + - Does not require authentication + +- **Private**: Uses internal PowerShell team feeds + - Required for: Internal development with preview packages + - Requires authentication credentials + +- **NuGetOnly**: Uses only nuget.org + - Required for: Minimal dependency scenarios + +**Usage:** + +```powershell +# Switch to public feeds (most common for CI/CD) +Switch-PSNugetConfig -Source Public + +# Switch to private feeds with authentication +Switch-PSNugetConfig -Source Private -UserName $userName -ClearTextPAT $pat + +# Switch to nuget.org only +Switch-PSNugetConfig -Source NuGetOnly +``` + +**When to Use:** + +- **Always use `-Source Public`** before building in CI/CD workflows +- Use before any build that will create packages for distribution +- Use in forks or environments without access to Microsoft internal feeds diff --git a/.github/instructions/start-native-execution.instructions.md b/.github/instructions/start-native-execution.instructions.md new file mode 100644 index 0000000000..347e496b3b --- /dev/null +++ b/.github/instructions/start-native-execution.instructions.md @@ -0,0 +1,149 @@ +--- +applyTo: + - "**/*.ps1" + - "**/*.psm1" +--- + +# Using Start-NativeExecution for Native Command Execution + +## Purpose + +`Start-NativeExecution` is the standard function for executing native commands (external executables) in PowerShell scripts within this repository. It provides consistent error handling and better diagnostics when native commands fail. + +## When to Use + +Use `Start-NativeExecution` whenever you need to: +- Execute external commands (e.g., `git`, `dotnet`, `pkgbuild`, `productbuild`, `fpm`, `rpmbuild`) +- Ensure proper exit code checking +- Get better error messages with caller information +- Handle verbose output on error + +## Basic Usage + +```powershell +Start-NativeExecution { + git clone https://github.com/PowerShell/PowerShell.git +} +``` + +## With Parameters + +Use backticks for line continuation within the script block: + +```powershell +Start-NativeExecution { + pkgbuild --root $pkgRoot ` + --identifier $pkgIdentifier ` + --version $Version ` + --scripts $scriptsDir ` + $outputPath +} +``` + +## Common Parameters + +### -VerboseOutputOnError + +Captures command output and displays it only if the command fails: + +```powershell +Start-NativeExecution -VerboseOutputOnError { + dotnet build --configuration Release +} +``` + +### -IgnoreExitcode + +Allows the command to fail without throwing an exception: + +```powershell +Start-NativeExecution -IgnoreExitcode { + git diff --exit-code # Returns 1 if differences exist +} +``` + +## Availability + +The function is defined in `tools/buildCommon/startNativeExecution.ps1` and is available in: +- `build.psm1` (dot-sourced automatically) +- `tools/packaging/packaging.psm1` (dot-sourced automatically) +- Test modules that include `HelpersCommon.psm1` + +To use in other scripts, dot-source the function: + +```powershell +. "$PSScriptRoot/../buildCommon/startNativeExecution.ps1" +``` + +## Error Handling + +When a native command fails (non-zero exit code), `Start-NativeExecution`: +1. Captures the exit code +2. Identifies the calling location (file and line number) +3. Throws a descriptive error with full context + +Example error message: +``` +Execution of {git clone ...} by /path/to/script.ps1: line 42 failed with exit code 1 +``` + +## Examples from the Codebase + +### Git Operations +```powershell +Start-NativeExecution { + git fetch --tags --quiet upstream +} +``` + +### Build Operations +```powershell +Start-NativeExecution -VerboseOutputOnError { + dotnet publish --configuration Release +} +``` + +### Packaging Operations +```powershell +Start-NativeExecution -VerboseOutputOnError { + pkgbuild --root $pkgRoot --identifier $pkgId --version $version $outputPath +} +``` + +### Permission Changes +```powershell +Start-NativeExecution { + find $staging -type d | xargs chmod 755 + find $staging -type f | xargs chmod 644 +} +``` + +## Anti-Patterns + +**Don't do this:** +```powershell +& somecommand $args +if ($LASTEXITCODE -ne 0) { + throw "Command failed" +} +``` + +**Do this instead:** +```powershell +Start-NativeExecution { + somecommand $args +} +``` + +## Best Practices + +1. **Always use Start-NativeExecution** for native commands to ensure consistent error handling +2. **Use -VerboseOutputOnError** for commands with useful diagnostic output +3. **Use backticks for readability** when commands have multiple arguments +4. **Don't capture output unnecessarily** - let the function handle it +5. **Use -IgnoreExitcode sparingly** - only when non-zero exit codes are expected and acceptable + +## Related Documentation + +- Source: `tools/buildCommon/startNativeExecution.ps1` +- Blog post: https://mnaoumov.wordpress.com/2015/01/11/execution-of-external-commands-in-powershell-done-right/ diff --git a/.github/workflows/macos-ci.yml b/.github/workflows/macos-ci.yml index d6b333b6d5..847d1e01cd 100644 --- a/.github/workflows/macos-ci.yml +++ b/.github/workflows/macos-ci.yml @@ -157,7 +157,7 @@ jobs: runner_os: macos-15-large test_results_artifact_name: testResults-xunit PackageMac-macos_packaging: - name: macOS packaging (bootstrap only) + name: macOS packaging and testing needs: - changes if: ${{ needs.changes.outputs.source == 'true' }} @@ -166,12 +166,63 @@ jobs: steps: - name: checkout uses: actions/checkout@v5 + with: + fetch-depth: 1000 + - uses: actions/setup-dotnet@v4 + with: + global-json-file: ./global.json - name: Bootstrap packaging - if: success() || failure() + if: success() run: |- import-module ./build.psm1 start-psbootstrap -Scenario package shell: pwsh + - name: Build PowerShell and Create macOS package + if: success() + run: |- + import-module ./build.psm1 + import-module ./tools/ci.psm1 + import-module ./tools/packaging/packaging.psm1 + Switch-PSNugetConfig -Source Public + Sync-PSTags -AddRemoteIfMissing + $releaseTag = Get-ReleaseTag + Start-PSBuild -Configuration Release -PSModuleRestore -ReleaseTag $releaseTag + $macOSRuntime = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq 'Arm64') { 'osx-arm64' } else { 'osx-x64' } + Start-PSPackage -Type osxpkg -ReleaseTag $releaseTag -MacOSRuntime $macOSRuntime -SkipReleaseChecks + shell: pwsh + - name: Test package contents + if: success() + run: |- + $env:PACKAGE_FOLDER = Get-Location + $testResultsPath = Join-Path $env:RUNNER_WORKSPACE "testResults" + if (-not (Test-Path $testResultsPath)) { + New-Item -ItemType Directory -Path $testResultsPath -Force | Out-Null + } + Import-Module Pester + $pesterConfig = New-PesterConfiguration + $pesterConfig.Run.Path = './tools/packaging/releaseTests/macOSPackage.tests.ps1' + $pesterConfig.Run.PassThru = $true + $pesterConfig.Output.Verbosity = 'Detailed' + $pesterConfig.TestResult.Enabled = $true + $pesterConfig.TestResult.OutputFormat = 'NUnitXml' + $pesterConfig.TestResult.OutputPath = Join-Path $testResultsPath "macOSPackage.xml" + $result = Invoke-Pester -Configuration $pesterConfig + if ($result.FailedCount -gt 0) { + throw "Package validation failed with $($result.FailedCount) failed test(s)" + } + shell: pwsh + - name: Publish and Upload Pester Test Results + if: always() + uses: "./.github/actions/test/process-pester-results" + with: + name: "macOSPackage" + testResultsFolder: "${{ runner.workspace }}/testResults" + - name: Upload package artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: macos-package + path: "*.pkg" ready_to_merge: name: macos ready to merge needs: diff --git a/docs/maintainers/releasing.md b/docs/maintainers/releasing.md index 5aae87582c..3562962e68 100644 --- a/docs/maintainers/releasing.md +++ b/docs/maintainers/releasing.md @@ -72,11 +72,18 @@ The output of `Start-PSBuild` includes a `powershell.exe` executable which can s #### Linux / macOS The `Start-PSPackage` function delegates to `New-UnixPackage`. -It relies on the [Effing Package Management][fpm] project, -which makes building packages for any (non-Windows) platform a breeze. -Similarly, the PowerShell man-page is generated from the Markdown-like file + +For **Linux** (Debian-based distributions), it relies on the [Effing Package Management][fpm] project, +which makes building packages a breeze. + +For **macOS**, it uses native packaging tools (`pkgbuild` and `productbuild`) from Xcode Command Line Tools, +eliminating the need for Ruby or fpm. + +For **Linux** (Red Hat-based distributions), it uses `rpmbuild` directly. + +The PowerShell man-page is generated from the Markdown-like file [`assets/pwsh.1.ronn`][man] using [Ronn][]. -The function `Start-PSBootstrap -Package` will install both these tools. +The function `Start-PSBootstrap -Package` will install these tools. To modify any property of the packages, edit the `New-UnixPackage` function. Please also refer to the function for details on the package properties @@ -131,7 +138,7 @@ Without `-Name` specified, the primary `powershell` package will instead be created. [fpm]: https://github.com/jordansissel/fpm -[man]: ../../assets/pwsh.1.ronn +[man]: ../../assets/manpage/pwsh.1.ronn [ronn]: https://github.com/rtomayko/ronn ### Build and Packaging Examples diff --git a/tools/packaging/packaging.psm1 b/tools/packaging/packaging.psm1 index 894323a4b9..35ba148fac 100644 --- a/tools/packaging/packaging.psm1 +++ b/tools/packaging/packaging.psm1 @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +. "$PSScriptRoot\..\buildCommon\startNativeExecution.ps1" + $Environment = Get-EnvironmentInformation $RepoRoot = (Resolve-Path -Path "$PSScriptRoot/../..").Path @@ -1197,19 +1199,7 @@ function New-UnixPackage { # Generate After Install and After Remove scripts $AfterScriptInfo = New-AfterScripts -Link $Link -Distribution $DebDistro -Destination $Destination - # there is a weird bug in fpm - # if the target of the powershell symlink exists, `fpm` aborts - # with a `utime` error on macOS. - # so we move it to make symlink broken - # refers to executable, does not vary by channel - $symlink_dest = "$Destination/pwsh" - $hack_dest = "./_fpm_symlink_hack_powershell" - if ($Environment.IsMacOS) { - if (Test-Path $symlink_dest) { - Write-Warning "Move $symlink_dest to $hack_dest (fpm utime bug)" - Start-NativeExecution ([ScriptBlock]::Create("$sudo mv $symlink_dest $hack_dest")) - } - } + # Note: The fpm symlink workaround is no longer needed with native macOS packaging tools # Generate gzip of man file $ManGzipInfo = New-ManGzip -IsPreview:$IsPreview -IsLTS:$LTS @@ -1329,8 +1319,38 @@ function New-UnixPackage { throw } } + } elseif ($Type -eq 'osxpkg') { + # Use native macOS packaging tools + if ($PSCmdlet.ShouldProcess("Create macOS package with pkgbuild/productbuild")) { + Write-Log "Creating macOS package with native tools..." + + $macPkgArgs = @{ + Name = $Name + Version = $packageVersion + Iteration = $Iteration + Staging = $Staging + Destination = $Destination + ManGzipFile = $ManGzipInfo.GzipFile + ManDestination = $ManGzipInfo.ManFile + LinkInfo = $Links + AfterInstallScript = $AfterScriptInfo.AfterInstallScript + AppsFolder = $AppsFolder + HostArchitecture = $HostArchitecture + CurrentLocation = $CurrentLocation + } + + try { + $packageFile = New-MacOSPackage @macPkgArgs + $Output = @("Created package {:path=>""$($packageFile.Name)""}") + } + catch { + Write-Verbose -Message "!!!Handling error in macOS packaging!!!" -Verbose -ErrorAction SilentlyContinue + Get-Error -InputObject $_ + throw + } + } } else { - # Use fpm for DEB and macOS packages + # Use fpm for DEB packages $Arguments = @() $Arguments += Get-FpmArguments ` @@ -1372,12 +1392,6 @@ function New-UnixPackage { { Clear-MacOSLauncher } - - # this is continuation of a fpm hack for a weird bug - if (Test-Path $hack_dest) { - Write-Warning "Move $hack_dest to $symlink_dest (fpm utime bug)" - Start-NativeExecution -sb ([ScriptBlock]::Create("$sudo mv $hack_dest $symlink_dest")) -VerboseOutputOnError - } } # Clean up rpmbuild directory if it was created @@ -1401,12 +1415,9 @@ function New-UnixPackage { # Magic to get path output $createdPackage = Get-Item (Join-Path $CurrentLocation (($Output[-1] -split ":path=>")[-1] -replace '["{}]')) - if ($Environment.IsMacOS) { - if ($PSCmdlet.ShouldProcess("Add distribution information and Fix PackageName")) - { - $createdPackage = New-MacOsDistributionPackage -FpmPackage $createdPackage -HostArchitecture $HostArchitecture -IsPreview:$IsPreview - } - } + # For macOS with native tools, the package is already in the correct format + # For macOS with fpm (no longer used), we would need New-MacOsDistributionPackage + # For other platforms, the package name from fpm/rpmbuild is sufficient if (Test-Path $createdPackage) { @@ -1451,14 +1462,27 @@ Function New-LinkInfo function New-MacOsDistributionPackage { + [CmdletBinding(SupportsShouldProcess=$true)] param( - [Parameter(Mandatory,HelpMessage='The FileInfo of the file created by FPM')] - [System.IO.FileInfo]$FpmPackage, + [Parameter(Mandatory,HelpMessage='The FileInfo of the component package')] + [System.IO.FileInfo]$ComponentPackage, + + [Parameter(Mandatory,HelpMessage='Package name for the output file')] + [string]$PackageName, + + [Parameter(Mandatory,HelpMessage='Package version')] + [string]$Version, + + [Parameter(Mandatory,HelpMessage='Output directory for the final package')] + [string]$OutputDirectory, [Parameter(HelpMessage='x86_64 for Intel or arm64 for Apple Silicon')] [ValidateSet("x86_64", "arm64")] [string] $HostArchitecture = "x86_64", + [Parameter(HelpMessage='Package identifier')] + [string]$PackageIdentifier, + [Switch] $IsPreview ) @@ -1467,64 +1491,81 @@ function New-MacOsDistributionPackage throw 'New-MacOsDistributionPackage is only supported on macOS!' } - $packageName = Split-Path -Leaf -Path $FpmPackage - # Create a temp directory to store the needed files $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName()) New-Item -ItemType Directory -Path $tempDir -Force > $null $resourcesDir = Join-Path -Path $tempDir -ChildPath 'resources' New-Item -ItemType Directory -Path $resourcesDir -Force > $null - #Copy background file to temp directory + + # Copy background file to temp directory $backgroundFile = "$RepoRoot/assets/macDialog.png" - Copy-Item -Path $backgroundFile -Destination $resourcesDir - # Move the current package to the temp directory - $tempPackagePath = Join-Path -Path $tempDir -ChildPath $packageName - Move-Item -Path $FpmPackage -Destination $tempPackagePath -Force - - # Add the OS information to the macOS package file name. - $packageExt = [System.IO.Path]::GetExtension($FpmPackage.Name) - - # get the package name from fpm without the extension, but replace powershell-preview at the beginning of the name with powershell. - $packageNameWithoutExt = [System.IO.Path]::GetFileNameWithoutExtension($FpmPackage.Name) -replace '^powershell\-preview' , 'powershell' - - $newPackageName = "{0}-{1}{2}" -f $packageNameWithoutExt, $script:Options.Runtime, $packageExt - $newPackagePath = Join-Path $FpmPackage.DirectoryName $newPackageName - - # -Force is not deleting the NewName if it exists, so delete it if it does - if ($Force -and (Test-Path -Path $newPackagePath)) - { - Remove-Item -Force $newPackagePath + if (Test-Path $backgroundFile) { + Copy-Item -Path $backgroundFile -Destination $resourcesDir -Force } + + # Copy the component package to temp directory + $componentFileName = Split-Path -Leaf -Path $ComponentPackage + $tempComponentPath = Join-Path -Path $tempDir -ChildPath $componentFileName + Copy-Item -Path $ComponentPackage -Destination $tempComponentPath -Force # Create the distribution xml $distributionXmlPath = Join-Path -Path $tempDir -ChildPath 'powershellDistribution.xml' - $packageId = Get-MacOSPackageId -IsPreview:$IsPreview.IsPresent + # Get package ID if not provided + if (-not $PackageIdentifier) { + $PackageIdentifier = Get-MacOSPackageId -IsPreview:$IsPreview.IsPresent + } + # Minimum OS version + $minOSVersion = "11.0" # macOS Big Sur minimum + # format distribution template with: # 0 - title # 1 - version - # 2 - package path + # 2 - package path (component package filename) # 3 - minimum os version # 4 - Package Identifier # 5 - host architecture (x86_64 for Intel or arm64 for Apple Silicon) - $PackagingStrings.OsxDistributionTemplate -f "PowerShell - $packageVersion", $packageVersion, $packageName, '10.14', $packageId, $HostArchitecture | Out-File -Encoding ascii -FilePath $distributionXmlPath -Force + $PackagingStrings.OsxDistributionTemplate -f $PackageName, $Version, $componentFileName, $minOSVersion, $PackageIdentifier, $HostArchitecture | Out-File -Encoding utf8 -FilePath $distributionXmlPath -Force - Write-Log "Applying distribution.xml to package..." - Push-Location $tempDir - try - { - # productbuild is an xcode command line tool, and those tools are installed when you install brew - Start-NativeExecution -sb {productbuild --distribution $distributionXmlPath --resources $resourcesDir $newPackagePath} -VerboseOutputOnError - } - finally - { - Pop-Location - Remove-Item -Path $tempDir -Recurse -Force + # Build final package path + $finalPackagePath = Join-Path $OutputDirectory "$PackageName-$Version-osx-$HostArchitecture.pkg" + + # Remove existing package if it exists + if (Test-Path $finalPackagePath) { + Write-Warning "Removing existing package: $finalPackagePath" + Remove-Item $finalPackagePath -Force } - return (Get-Item $newPackagePath) + if ($PSCmdlet.ShouldProcess("Build product package with productbuild")) { + Write-Log "Applying distribution.xml to package..." + Push-Location $tempDir + try + { + # productbuild is an xcode command line tool + Start-NativeExecution -VerboseOutputOnError { + productbuild --distribution $distributionXmlPath ` + --package-path $tempDir ` + --resources $resourcesDir ` + $finalPackagePath + } + + if (Test-Path $finalPackagePath) { + Write-Log "Successfully created macOS package: $finalPackagePath" + } + else { + throw "Package was not created at expected location: $finalPackagePath" + } + } + finally + { + Pop-Location + Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + + return (Get-Item $finalPackagePath) } Class LinkInfo @@ -1692,6 +1733,157 @@ cp $ManGzipFile `$RPM_BUILD_ROOT$ManDestination return $specContent } +function New-MacOSPackage +{ + [CmdletBinding(SupportsShouldProcess=$true)] + param( + [Parameter(Mandatory)] + [string]$Name, + + [Parameter(Mandatory)] + [string]$Version, + + [Parameter(Mandatory)] + [string]$Iteration, + + [Parameter(Mandatory)] + [string]$Staging, + + [Parameter(Mandatory)] + [string]$Destination, + + [Parameter(Mandatory)] + [string]$ManGzipFile, + + [Parameter(Mandatory)] + [string]$ManDestination, + + [Parameter(Mandatory)] + [LinkInfo[]]$LinkInfo, + + [Parameter(Mandatory)] + [string]$AfterInstallScript, + + [Parameter(Mandatory)] + [string]$AppsFolder, + + [Parameter(Mandatory)] + [string]$HostArchitecture, + + [string]$CurrentLocation = (Get-Location) + ) + + Write-Log "Creating macOS package using pkgbuild and productbuild..." + + # Create a temporary directory for package building + $tempRoot = New-TempFolder + $componentPkgPath = Join-Path $tempRoot "component.pkg" + $scriptsDir = Join-Path $tempRoot "scripts" + $resourcesDir = Join-Path $tempRoot "resources" + $distributionFile = Join-Path $tempRoot "distribution.xml" + + try { + # Create scripts directory + New-Item -ItemType Directory -Path $scriptsDir -Force | Out-Null + + # Copy and prepare the postinstall script + $postInstallPath = Join-Path $scriptsDir "postinstall" + Copy-Item -Path $AfterInstallScript -Destination $postInstallPath -Force + Start-NativeExecution { + chmod 755 $postInstallPath + } + + # Create a temporary directory for the package root + $pkgRoot = Join-Path $tempRoot "pkgroot" + New-Item -ItemType Directory -Path $pkgRoot -Force | Out-Null + + # Copy staging files to destination path in package root + $destInPkg = Join-Path $pkgRoot $Destination + New-Item -ItemType Directory -Path $destInPkg -Force | Out-Null + Write-Verbose "Copying staging files from $Staging to $destInPkg" -Verbose + Copy-Item -Path "$Staging/*" -Destination $destInPkg -Recurse -Force + + # Create man page directory structure + $manDir = Join-Path $pkgRoot (Split-Path $ManDestination -Parent) + New-Item -ItemType Directory -Path $manDir -Force | Out-Null + Copy-Item -Path $ManGzipFile -Destination (Join-Path $pkgRoot $ManDestination) -Force + + # Create symlinks in package root + # The LinkInfo contains Source (a temp file that IS a symlink) and Destination (where to install it) + foreach ($link in $LinkInfo) { + $linkDestDir = Join-Path $pkgRoot (Split-Path $link.Destination -Parent) + New-Item -ItemType Directory -Path $linkDestDir -Force | Out-Null + $finalLinkPath = Join-Path $pkgRoot $link.Destination + + Write-Verbose "Creating symlink at $finalLinkPath" -Verbose + + # Remove if exists + if (Test-Path $finalLinkPath) { + Remove-Item $finalLinkPath -Force + } + + # Get the target of the original symlink and recreate it in the package root + if (Test-Path $link.Source) { + $linkTarget = (Get-Item $link.Source).Target + if ($linkTarget) { + Write-Verbose "Creating symlink to target: $linkTarget" -Verbose + New-Item -ItemType SymbolicLink -Path $finalLinkPath -Target $linkTarget -Force | Out-Null + } else { + Write-Warning "Could not determine target for symlink at $($link.Source), copying file instead" + Copy-Item -Path $link.Source -Destination $finalLinkPath -Force + } + } else { + Write-Warning "Source symlink $($link.Source) does not exist" + } + } + + # Copy launcher app folder if provided + if ($AppsFolder) { + $appsInPkg = Join-Path $pkgRoot "Applications" + New-Item -ItemType Directory -Path $appsInPkg -Force | Out-Null + Write-Verbose "Copying launcher app from $AppsFolder to $appsInPkg" -Verbose + Copy-Item -Path "$AppsFolder/*" -Destination $appsInPkg -Recurse -Force + } + + # Build the component package using pkgbuild + $pkgIdentifier = Get-MacOSPackageId -IsPreview:($Name -like '*-preview') + + if ($PSCmdlet.ShouldProcess("Build component package with pkgbuild")) { + Write-Log "Running pkgbuild to create component package..." + + Start-NativeExecution -VerboseOutputOnError { + pkgbuild --root $pkgRoot ` + --identifier $pkgIdentifier ` + --version $Version ` + --scripts $scriptsDir ` + --install-location "/" ` + $componentPkgPath + } + + Write-Verbose "Component package created: $componentPkgPath" -Verbose + } + + # Create the final distribution package using the refactored function + $distributionPackage = New-MacOsDistributionPackage ` + -ComponentPackage (Get-Item $componentPkgPath) ` + -PackageName $Name ` + -Version $Version ` + -OutputDirectory $CurrentLocation ` + -HostArchitecture $HostArchitecture ` + -PackageIdentifier $pkgIdentifier ` + -IsPreview:($Name -like '*-preview') + + return $distributionPackage + } + finally { + # Clean up temporary directory + if (Test-Path $tempRoot) { + Write-Verbose "Cleaning up temporary directory: $tempRoot" -Verbose + Remove-Item -Path $tempRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + function Get-FpmArguments { param( @@ -1905,6 +2097,7 @@ function Get-PackageDependencies function Test-Dependencies { # Note: RPM packages no longer require fpm; they use rpmbuild directly + # macOS packages use pkgbuild and productbuild from Xcode Command Line Tools # DEB packages still use fpm $Dependencies = @() @@ -1913,22 +2106,31 @@ function Test-Dependencies $Dependencies += "fpm" } + # Check for macOS packaging tools + if ($Environment.IsMacOS) { + $Dependencies += "pkgbuild" + $Dependencies += "productbuild" + } + foreach ($Dependency in $Dependencies) { if (!(precheck $Dependency "Package dependency '$Dependency' not found. Run Start-PSBootstrap -Scenario Package")) { - # These tools are not added to the path automatically on OpenSUSE 13.2 - # try adding them to the path and re-tesing first - [string] $gemsPath = $null - [string] $depenencyPath = $null - $gemsPath = Get-ChildItem -Path /usr/lib64/ruby/gems | Sort-Object -Property LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName - if ($gemsPath) { - $depenencyPath = Get-ChildItem -Path (Join-Path -Path $gemsPath -ChildPath "gems" -AdditionalChildPath $Dependency) -Recurse | Sort-Object -Property LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty DirectoryName - $originalPath = $env:PATH - $env:PATH = $ENV:PATH +":" + $depenencyPath - if ((precheck $Dependency "Package dependency '$Dependency' not found. Run Start-PSBootstrap -Scenario Package")) { - continue - } - else { - $env:PATH = $originalPath + # For Debian systems, try adding ruby gems to the path + if ($Environment.IsDebianFamily) { + # These tools are not added to the path automatically on OpenSUSE 13.2 + # try adding them to the path and re-tesing first + [string] $gemsPath = $null + [string] $depenencyPath = $null + $gemsPath = Get-ChildItem -Path /usr/lib64/ruby/gems | Sort-Object -Property LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName + if ($gemsPath) { + $depenencyPath = Get-ChildItem -Path (Join-Path -Path $gemsPath -ChildPath "gems" -AdditionalChildPath $Dependency) -Recurse | Sort-Object -Property LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty DirectoryName + $originalPath = $env:PATH + $env:PATH = $ENV:PATH +":" + $depenencyPath + if ((precheck $Dependency "Package dependency '$Dependency' not found. Run Start-PSBootstrap -Scenario Package")) { + continue + } + else { + $env:PATH = $originalPath + } } } @@ -2086,10 +2288,12 @@ function New-MacOSLauncher # Set permissions for plist and shell script. Start-NativeExecution { chmod 644 $plist + } + Start-NativeExecution { chmod 755 $shellscript } - # Add app folder to fpm paths. + # Return the app folder path for packaging $appsfolder = (Resolve-Path -Path "$macosapp/..").Path return $appsfolder diff --git a/tools/packaging/releaseTests/macOSPackage.tests.ps1 b/tools/packaging/releaseTests/macOSPackage.tests.ps1 new file mode 100644 index 0000000000..c1de109156 --- /dev/null +++ b/tools/packaging/releaseTests/macOSPackage.tests.ps1 @@ -0,0 +1,162 @@ +Describe "Verify macOS Package" { + BeforeAll { + Write-Verbose "In Describe BeforeAll" -Verbose + Import-Module $PSScriptRoot/../../../build.psm1 + + # Find the macOS package + $packagePath = $env:PACKAGE_FOLDER + if (-not $packagePath) { + $packagePath = Get-Location + } + + Write-Verbose "Looking for package in: $packagePath" -Verbose + $package = Get-ChildItem -Path $packagePath -Filter "*.pkg" -ErrorAction SilentlyContinue | Select-Object -First 1 + + if (-not $package) { + Write-Warning "No .pkg file found in $packagePath" + } else { + Write-Verbose "Found package: $($package.FullName)" -Verbose + } + + # Set up test directories + $script:package = $package + $script:expandDir = $null + $script:payloadDir = $null + $script:extractedFiles = @() + + if ($package) { + # Use TestDrive for temporary directories - pkgutil will create the expand directory + $script:expandDir = Join-Path "TestDrive:" -ChildPath "package-contents-test" + $expandDirResolved = (Resolve-Path "TestDrive:").ProviderPath + $script:expandDir = Join-Path $expandDirResolved -ChildPath "package-contents-test" + + Write-Verbose "Expanding package to: $($script:expandDir)" -Verbose + # pkgutil will create the directory itself, so don't pre-create it + Start-NativeExecution { + pkgutil --expand $package.FullName $script:expandDir + } + + # Extract the payload to verify files + $script:payloadDir = Join-Path "TestDrive:" -ChildPath "package-payload-test" + $payloadDirResolved = (Resolve-Path "TestDrive:").ProviderPath + $script:payloadDir = Join-Path $payloadDirResolved -ChildPath "package-payload-test" + + # Create payload directory since cpio needs it + if (-not (Test-Path $script:payloadDir)) { + $null = New-Item -ItemType Directory -Path $script:payloadDir -Force + } + + $componentPkg = Get-ChildItem -Path $script:expandDir -Filter "*.pkg" -Recurse | Select-Object -First 1 + if ($componentPkg) { + Write-Verbose "Extracting payload from: $($componentPkg.FullName)" -Verbose + Push-Location $script:payloadDir + try { + $payloadFile = Join-Path $componentPkg.FullName "Payload" + Get-Content -Path $payloadFile -Raw -AsByteStream | & cpio -i 2>&1 | Out-Null + } finally { + Pop-Location + } + } + + # Get all extracted files for verification + $script:extractedFiles = Get-ChildItem -Path $script:payloadDir -Recurse -ErrorAction SilentlyContinue + Write-Verbose "Extracted $($script:extractedFiles.Count) files" -Verbose + } + } + + AfterAll { + # TestDrive automatically cleans up, but we can ensure cleanup happens + # No manual cleanup needed as TestDrive handles it + } + + Context "Package existence and structure" { + It "Package file should exist" { + $script:package | Should -Not -BeNullOrEmpty -Because "A .pkg file should be created" + $script:package.Extension | Should -Be ".pkg" + } + + It "Package should expand successfully" { + $script:expandDir | Should -Exist + Get-ChildItem -Path $script:expandDir | Should -Not -BeNullOrEmpty + } + + It "Package should have a component package" { + $componentPkg = Get-ChildItem -Path $script:expandDir -Filter "*.pkg" -Recurse -ErrorAction SilentlyContinue + $componentPkg | Should -Not -BeNullOrEmpty -Because "Package should contain a component.pkg" + } + + It "Payload should extract successfully" { + $script:payloadDir | Should -Exist + $script:extractedFiles | Should -Not -BeNullOrEmpty -Because "Package payload should contain files" + } + } + + Context "Required files in package" { + BeforeAll { + $expectedFilePatterns = @{ + "PowerShell executable" = "usr/local/microsoft/powershell/*/pwsh" + "PowerShell symlink in /usr/local/bin" = "usr/local/bin/pwsh*" + "Man page" = "usr/local/share/man/man1/pwsh*.gz" + "Launcher application plist" = "Applications/PowerShell*.app/Contents/Info.plist" + } + + $testCases = @() + foreach ($key in $expectedFilePatterns.Keys) { + $testCases += @{ + Description = $key + Pattern = $expectedFilePatterns[$key] + } + } + + $script:testCases = $testCases + } + + It "Should contain " -TestCases $script:testCases { + param($Description, $Pattern) + + $found = $script:extractedFiles | Where-Object { $_.FullName -like "*$Pattern*" } + $found | Should -Not -BeNullOrEmpty -Because "$Description should exist in the package at path matching '$Pattern'" + } + } + + Context "PowerShell binary verification" { + It "PowerShell executable should be executable" { + $pwshBinary = $script:extractedFiles | Where-Object { $_.FullName -like "*/pwsh" -and $_.FullName -like "*/microsoft/powershell/*" } + $pwshBinary | Should -Not -BeNullOrEmpty + + # Check if file has executable permissions (on Unix-like systems) + if ($IsLinux -or $IsMacOS) { + $permissions = (Get-Item $pwshBinary[0].FullName).UnixFileMode + # Executable bit should be set + $permissions.ToString() | Should -Match 'x' -Because "pwsh binary should have execute permissions" + } + } + } + + Context "Launcher application" { + It "Launcher app should have proper bundle structure" { + $plistFile = $script:extractedFiles | Where-Object { $_.FullName -like "*PowerShell*.app/Contents/Info.plist" } + $plistFile | Should -Not -BeNullOrEmpty + + # Verify the bundle has required components + $appPath = Split-Path (Split-Path $plistFile[0].FullName -Parent) -Parent + $macOSDir = Join-Path $appPath "Contents/MacOS" + $resourcesDir = Join-Path $appPath "Contents/Resources" + + Test-Path $macOSDir | Should -Be $true -Because "App bundle should have Contents/MacOS directory" + Test-Path $resourcesDir | Should -Be $true -Because "App bundle should have Contents/Resources directory" + } + + It "Launcher script should exist and be executable" { + $launcherScript = $script:extractedFiles | Where-Object { + $_.FullName -like "*PowerShell*.app/Contents/MacOS/PowerShell.sh" + } + $launcherScript | Should -Not -BeNullOrEmpty -Because "Launcher script should exist" + + if ($IsLinux -or $IsMacOS) { + $permissions = (Get-Item $launcherScript[0].FullName).UnixFileMode + $permissions.ToString() | Should -Match 'x' -Because "Launcher script should have execute permissions" + } + } + } +}