diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 52d544cd8d..c1d50d8022 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -309,8 +309,8 @@ jobs: Remove-Item "unigetui_bin" -Recurse -Force -ErrorAction SilentlyContinue build-avalonia: - name: Build Avalonia (${{ matrix.platform }}) - runs-on: macos-latest + name: Build (${{ matrix.name }}) + runs-on: ${{ matrix.os }} needs: [preflight] environment: ${{ needs.preflight.outputs.package-env }} permissions: @@ -322,7 +322,19 @@ jobs: strategy: fail-fast: false matrix: - platform: [arm64, x64] + include: + - os: macos-latest + name: macos-arm64 + runtime: osx-arm64 + - os: macos-latest + name: macos-x64 + runtime: osx-x64 + - os: ubuntu-latest + name: linux-x64 + runtime: linux-x64 + - os: ubuntu-latest + name: linux-arm64 + runtime: linux-arm64 steps: - name: Checkout @@ -341,6 +353,12 @@ jobs: restore-keys: | ${{ runner.os }}-nuget- + - name: Set version + shell: pwsh + run: | + $PackageVersion = '${{ needs.preflight.outputs.package-version }}' + ./scripts/set-version.ps1 -Version $PackageVersion + - name: Restore dependencies working-directory: src run: dotnet restore UniGetUI.Avalonia/UniGetUI.Avalonia.csproj @@ -350,15 +368,85 @@ jobs: run: | dotnet publish UniGetUI.Avalonia/UniGetUI.Avalonia.csproj \ --configuration Release \ - --runtime osx-${{ matrix.platform }} \ + --runtime ${{ matrix.runtime }} \ --self-contained true \ - --output ../avalonia_bin/${{ matrix.platform }} + --output ../bin/${{ matrix.name }} + + - name: Package (macOS) + if: runner.os == 'macOS' + run: | + mkdir -p output + + # .tar.gz + tar -czf output/UniGetUI.${{ matrix.name }}.tar.gz -C bin/${{ matrix.name }} . + + # .dmg — create a staging folder, then convert to a compressed read-only image + DMG_STAGING=$(mktemp -d) + trap "rm -rf '$DMG_STAGING'" EXIT + mkdir -p "$DMG_STAGING/UniGetUI" + cp -R bin/${{ matrix.name }}/. "$DMG_STAGING/UniGetUI/" + hdiutil create \ + -volname "UniGetUI" \ + -srcfolder "$DMG_STAGING" \ + -ov \ + -format UDZO \ + output/UniGetUI.${{ matrix.name }}.dmg + + - name: Install Linux packaging tools + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y rpm + + - name: Package (Linux) + if: runner.os == 'Linux' + shell: pwsh + run: | + $Version = '${{ needs.preflight.outputs.package-version }}' + $Parts = $Version -split '-', 2 + $BaseVersion = $Parts[0] + $Prerelease = if ($Parts.Count -gt 1) { $Parts[1] } else { $null } + + # deb: use tilde notation for pre-releases (Ubuntu 18.04+ compatible) + $DebVersion = if ($Prerelease) { "${BaseVersion}~${Prerelease}" } else { $Version } + # rpm: pre-release encoded in Release/Iteration field (RHEL 8+ / RPM 4.14+) + $RpmVersion = $BaseVersion + $RpmIteration = if ($Prerelease) { "0.${Prerelease}" } else { '1' } + + # Map .NET RID -> package arch names + $DebArch, $RpmArch = switch ('${{ matrix.runtime }}') { + 'linux-arm64' { 'arm64', 'aarch64' } + default { 'amd64', 'x86_64' } + } + + New-Item -ItemType Directory -Force -Path output | Out-Null + + # .tar.gz + & tar -czf "output/UniGetUI.${{ matrix.name }}.tar.gz" -C "bin/${{ matrix.name }}" . + if ($LASTEXITCODE -ne 0) { exit 1 } + + # .deb — Ubuntu 18.04+ (glibc 2.27, Debian policy 3.9.6) + & ./scripts/package-linux.ps1 ` + -PackageType deb ` + -SourceDir "bin/${{ matrix.name }}" ` + -OutputPath "output/UniGetUI.${{ matrix.name }}.deb" ` + -Version $DebVersion ` + -Architecture $DebArch + + # .rpm — RHEL 8+ (RPM 4.14, glibc 2.28) + & ./scripts/package-linux.ps1 ` + -PackageType rpm ` + -SourceDir "bin/${{ matrix.name }}" ` + -OutputPath "output/UniGetUI.${{ matrix.name }}.rpm" ` + -Version $RpmVersion ` + -Iteration $RpmIteration ` + -Architecture $RpmArch - name: Upload artifacts uses: actions/upload-artifact@v7 with: - name: UniGetUI-Avalonia-${{ matrix.platform }} - path: avalonia_bin/${{ matrix.platform }}/* + name: UniGetUI-${{ matrix.name }} + path: output/* publish: name: Publish GitHub Release diff --git a/scripts/package-linux.ps1 b/scripts/package-linux.ps1 new file mode 100644 index 0000000000..c413abeebc --- /dev/null +++ b/scripts/package-linux.ps1 @@ -0,0 +1,276 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Creates a .deb or .rpm package from a pre-built binary directory. + +.DESCRIPTION + Builds a native Linux package without requiring Ruby/fpm. + .deb is assembled with tar and ar (binutils). + .rpm is built with rpmbuild (rpm-build). + +.PARAMETER PackageType + Package format to produce: 'deb' or 'rpm'. + +.PARAMETER SourceDir + Directory containing the pre-built binaries to package. + +.PARAMETER OutputPath + Full path to the output package file (e.g. output/unigetui.deb). + +.PARAMETER Version + Package version string, already formatted for the target format: + deb: "2026.1.0" or "2026.1.0~beta1" + rpm: "2026.1.0" + +.PARAMETER Architecture + Target CPU architecture in the format expected by the package type: + deb: amd64 | arm64 + rpm: x86_64 | aarch64 + +.PARAMETER Iteration + RPM Release/iteration field (default: 1). Ignored for .deb. + +.PARAMETER PackageName + Package name (default: unigetui). + +.PARAMETER InstallPrefix + Absolute install directory on the target system (default: /opt/unigetui). + +.PARAMETER Description + One-line package description. + +.PARAMETER Maintainer + Maintainer field value, e.g. "Name ". + +.PARAMETER Url + Homepage / upstream URL. + +.PARAMETER AppExecutableName + Executable filename inside InstallPrefix. + +.PARAMETER LauncherName + Public command name exposed in PATH. + +.PARAMETER IconSourcePath + Source path for the installed desktop icon. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateSet('deb', 'rpm')] + [string] $PackageType, + + [Parameter(Mandatory)] + [string] $SourceDir, + + [Parameter(Mandatory)] + [string] $OutputPath, + + [Parameter(Mandatory)] + [string] $Version, + + [Parameter(Mandatory)] + [string] $Architecture, + + [string] $Iteration = '1', + [string] $PackageName = 'unigetui', + [string] $InstallPrefix = '/opt/unigetui', + [string] $Description = 'UniGetUI - GUI for package managers', + [string] $Maintainer = 'Devolutions Inc. ', + [string] $Url = 'https://github.com/Devolutions/UniGetUI', + [string] $AppExecutableName = 'UniGetUI.Avalonia', + [string] $LauncherName = 'unigetui', + [string] $IconSourcePath = (Join-Path $PSScriptRoot '..\src\UniGetUI\Assets\Images\icon.png') +) + +$ErrorActionPreference = 'Stop' + +$SourceDir = (Resolve-Path $SourceDir).Path +$OutputPath = [System.IO.Path]::GetFullPath($OutputPath) +$IconSourcePath = (Resolve-Path $IconSourcePath).Path +$OutDir = Split-Path $OutputPath +if ($OutDir) { New-Item -ItemType Directory -Path $OutDir -Force | Out-Null } + +$TmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "pkg-$(New-Guid)" +New-Item -ItemType Directory -Path $TmpDir | Out-Null + +$IconInstallDir = '/usr/share/icons/hicolor/512x512/apps' +$IconTargetName = "$LauncherName.png" +$IconInstallPath = "$IconInstallDir/$IconTargetName" +$DesktopFilePath = "/usr/share/applications/$LauncherName.desktop" +$LauncherPath = "/usr/bin/$LauncherName" + +function New-LinuxIntegrationAssets { + param( + [Parameter(Mandatory)] + [string] $StageRoot + ) + + $payloadDir = Join-Path $StageRoot ($InstallPrefix.TrimStart('/')) + New-Item -ItemType Directory -Path $payloadDir -Force | Out-Null + & /bin/cp -a "$SourceDir/." $payloadDir + if ($LASTEXITCODE -ne 0) { throw "cp (payload staging) exited $LASTEXITCODE" } + + $launcherFullPath = Join-Path $StageRoot $LauncherPath.TrimStart('/') + New-Item -ItemType Directory -Path (Split-Path $launcherFullPath -Parent) -Force | Out-Null + $launcherCommand = 'exec {0}/{1} "$@"' -f $InstallPrefix, $AppExecutableName + $launcherScript = @( + '#!/bin/sh', + $launcherCommand, + '' + ) -join "`n" + [System.IO.File]::WriteAllText($launcherFullPath, $launcherScript) + & chmod 755 $launcherFullPath + if ($LASTEXITCODE -ne 0) { throw "chmod (launcher) exited $LASTEXITCODE" } + + $desktopEntryFullPath = Join-Path $StageRoot $DesktopFilePath.TrimStart('/') + New-Item -ItemType Directory -Path (Split-Path $desktopEntryFullPath -Parent) -Force | Out-Null + $desktopEntry = @( + '[Desktop Entry]', + 'Version=1.0', + 'Type=Application', + 'Name=UniGetUI', + "Comment=$Description", + "Exec=$LauncherPath", + "Icon=$LauncherName", + 'Terminal=false', + 'Categories=System;Utility;', + 'StartupNotify=true', + '' + ) -join "`n" + [System.IO.File]::WriteAllText($desktopEntryFullPath, $desktopEntry) + & chmod 644 $desktopEntryFullPath + if ($LASTEXITCODE -ne 0) { throw "chmod (desktop entry) exited $LASTEXITCODE" } + + $iconFullPath = Join-Path $StageRoot $IconInstallPath.TrimStart('/') + New-Item -ItemType Directory -Path (Split-Path $iconFullPath -Parent) -Force | Out-Null + Copy-Item -Path $IconSourcePath -Destination $iconFullPath -Force + & chmod 644 $iconFullPath + if ($LASTEXITCODE -ne 0) { throw "chmod (icon) exited $LASTEXITCODE" } +} + +try { + # ------------------------------------------------------------------ + # .deb + # Structure: ar archive containing debian-binary, control.tar.gz, data.tar.gz + # Tools required: tar, ar (binutils – pre-installed on Ubuntu runners) + # ------------------------------------------------------------------ + if ($PackageType -eq 'deb') { + # 1. debian-binary (must be the first ar member, content is "2.0\n") + $DebianBinaryPath = Join-Path $TmpDir 'debian-binary' + [System.IO.File]::WriteAllText($DebianBinaryPath, "2.0`n") + + # 2. data.tar.gz – payload and Linux integration assets staged under target paths + $DataStage = Join-Path $TmpDir 'data' + New-LinuxIntegrationAssets -StageRoot $DataStage + + $InstalledSizeKb = [long][System.Math]::Ceiling( + (Get-ChildItem -Recurse -File $DataStage | + Measure-Object -Property Length -Sum).Sum / 1024 + ) + + # 3. control.tar.gz + $ControlDir = Join-Path $TmpDir 'control' + New-Item -ItemType Directory -Path $ControlDir | Out-Null + + # dpkg requires LF line endings and a trailing newline in control files + $ControlLines = @( + "Package: $PackageName", + "Version: $Version", + "Architecture: $Architecture", + "Maintainer: $Maintainer", + "Installed-Size: $InstalledSizeKb", + "Homepage: $Url", + "Description: $Description", + "Priority: optional", + "" + ) + [System.IO.File]::WriteAllText( + (Join-Path $ControlDir 'control'), + ($ControlLines -join "`n") + ) + + $ControlTarPath = Join-Path $TmpDir 'control.tar.gz' + & tar -czf $ControlTarPath -C $ControlDir . + if ($LASTEXITCODE -ne 0) { throw "tar (control) exited $LASTEXITCODE" } + + $DataTarPath = Join-Path $TmpDir 'data.tar.gz' + & tar -czf $DataTarPath -C $DataStage . + if ($LASTEXITCODE -ne 0) { throw "tar (data) exited $LASTEXITCODE" } + + # 4. Assemble .deb with ar + # Member names in the archive must be bare filenames (not paths), + # so Push-Location into TmpDir before invoking ar. + Push-Location $TmpDir + try { + & ar rc $OutputPath 'debian-binary' 'control.tar.gz' 'data.tar.gz' + if ($LASTEXITCODE -ne 0) { throw "ar exited $LASTEXITCODE" } + } + finally { Pop-Location } + } + + # ------------------------------------------------------------------ + # .rpm + # Built with rpmbuild. %install copies from the absolute SourceDir + # into the buildroot so no pre-staged directory tricks are needed. + # Tools required: rpmbuild (rpm-build apt package) + # ------------------------------------------------------------------ + elseif ($PackageType -eq 'rpm') { + $DataStage = Join-Path $TmpDir 'data' + New-LinuxIntegrationAssets -StageRoot $DataStage + + $RpmTop = Join-Path $TmpDir 'rpmbuild' + foreach ($d in 'BUILD', 'BUILDROOT', 'RPMS', 'SOURCES', 'SPECS', 'SRPMS') { + New-Item -ItemType Directory -Path (Join-Path $RpmTop $d) | Out-Null + } + + $SpecPath = Join-Path $RpmTop 'SPECS' "$PackageName.spec" + + # Spec uses LF line endings; here-string keeps them on Linux/macOS + $SpecText = @" +Name: $PackageName +Version: $Version +Release: $Iteration +Summary: $Description +License: GPL-3.0-or-later +URL: $Url + +%description +$Description + +%install +cp -rp $DataStage/. %{buildroot}/ + +%files +%defattr(-,root,root,-) +$InstallPrefix +$LauncherPath +$DesktopFilePath +$IconInstallPath + +%changelog +"@ + [System.IO.File]::WriteAllText($SpecPath, $SpecText) + + & rpmbuild -bb ` + --define "_topdir $RpmTop" ` + --define "_build_name_fmt %%{NAME}-%%{VERSION}-%%{RELEASE}.%%{ARCH}.rpm" ` + --define "__strip /bin/true" ` + --define "__debug_install_post %{nil}" ` + --define "debug_package %{nil}" ` + --target "$Architecture-unknown-linux" ` + $SpecPath + if ($LASTEXITCODE -ne 0) { throw "rpmbuild exited $LASTEXITCODE" } + + $BuiltRpm = Get-ChildItem -Path (Join-Path $RpmTop 'RPMS') -Recurse -Filter '*.rpm' | + Select-Object -First 1 + if (-not $BuiltRpm) { throw "rpmbuild produced no .rpm file" } + Move-Item -Path $BuiltRpm.FullName -Destination $OutputPath -Force + } +} +finally { + Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue +} + +Write-Host "Created: $OutputPath"