name: Build macOS Installers permissions: contents: write on: workflow_call: inputs: app_version: description: 'Application version' type: string required: false default: '' jobs: build-macos-installer: name: Build macOS ${{ matrix.arch }} Installer runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: include: - arch: Intel runner: macos-15-intel runtime: osx-x64 min_os_version: "10.15" artifact_suffix: intel - arch: ARM runner: macos-15 runtime: osx-arm64 min_os_version: "11.0" artifact_suffix: arm64 steps: - name: Set variables run: | repoFullName=${{ github.repository }} ref=${{ github.ref }} # Use input version if provided, otherwise determine from ref if [[ -n "${{ inputs.app_version }}" ]]; then appVersion="${{ inputs.app_version }}" releaseVersion="v$appVersion" elif [[ "$ref" =~ ^refs/tags/ ]]; then releaseVersion=${ref##refs/tags/} appVersion=${releaseVersion#v} else # For manual dispatch, use a default version releaseVersion="dev-$(date +%Y%m%d-%H%M%S)" appVersion="0.0.1-dev" fi repositoryName=${repoFullName#*/} echo "githubRepository=${{ github.repository }}" >> $GITHUB_ENV echo "githubRepositoryName=$repositoryName" >> $GITHUB_ENV echo "releaseVersion=$releaseVersion" >> $GITHUB_ENV echo "appVersion=$appVersion" >> $GITHUB_ENV echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV - name: Get vault secrets uses: hashicorp/vault-action@v2 with: url: ${{ secrets.VAULT_HOST }} method: approle roleId: ${{ secrets.VAULT_ROLE_ID }} secretId: ${{ secrets.VAULT_SECRET_ID }} secrets: secrets/data/github repo_readonly_pat | REPO_READONLY_PAT; secrets/data/github packages_pat | PACKAGES_PAT - name: Checkout repository uses: actions/checkout@v4 with: repository: ${{ env.githubRepository }} ref: ${{ github.ref_name }} token: ${{ env.REPO_READONLY_PAT }} fetch-depth: 0 - name: Download frontend artifact uses: actions/download-artifact@v4 with: name: frontend-dist path: code/frontend/dist/ui/browser - name: Setup .NET uses: actions/setup-dotnet@v5 with: dotnet-version: 10.0.200 - name: Restore .NET dependencies run: | dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj - name: Build macOS ${{ matrix.arch }} executable run: | # Clean any existing output directory rm -rf dist mkdir -p dist/temp # Build to a temporary location dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj \ -c Release \ --runtime ${{ matrix.runtime }} \ --self-contained true \ -o dist/temp \ /p:PublishSingleFile=true \ /p:Version=${{ env.appVersion }} \ /p:DebugType=None \ /p:DebugSymbols=false \ /p:UseAppHost=true \ /p:EnableMacOSCodeSign=false \ /p:CodeSignOnCopy=false \ /p:_CodeSignDuringBuild=false \ /p:PublishTrimmed=false \ /p:TrimMode=link # Create proper app bundle structure mkdir -p dist/Cleanuparr.app/Contents/MacOS # Copy the built executable (note: AssemblyName is "Cleanuparr" not "Cleanuparr.Api") cp dist/temp/Cleanuparr dist/Cleanuparr.app/Contents/MacOS/Cleanuparr # Copy frontend directly to where it belongs in the app bundle mkdir -p dist/Cleanuparr.app/Contents/MacOS/wwwroot cp -r code/frontend/dist/ui/browser/* dist/Cleanuparr.app/Contents/MacOS/wwwroot/ # Copy any additional runtime files if they exist if [ -d "dist/temp" ]; then find dist/temp -name "*.dylib" -exec cp {} dist/Cleanuparr.app/Contents/MacOS/ \; 2>/dev/null || true find dist/temp -name "createdump" -exec cp {} dist/Cleanuparr.app/Contents/MacOS/ \; 2>/dev/null || true fi - name: Post-build setup run: | # Make sure the executable is actually executable chmod +x dist/Cleanuparr.app/Contents/MacOS/Cleanuparr # Remove any .pdb files that might have been created find dist/Cleanuparr.app/Contents/MacOS -name "*.pdb" -delete 2>/dev/null || true echo "Checking architecture of built binary:" file dist/Cleanuparr.app/Contents/MacOS/Cleanuparr if command -v lipo >/dev/null 2>&1; then lipo -info dist/Cleanuparr.app/Contents/MacOS/Cleanuparr fi echo "Files in MacOS directory:" ls -la dist/Cleanuparr.app/Contents/MacOS/ - name: Create macOS app bundle structure run: | # Create proper app bundle structure mkdir -p dist/Cleanuparr.app/Contents/{MacOS,Resources,Frameworks} # Convert ICO to ICNS for macOS app bundle if command -v iconutil >/dev/null 2>&1; then # Create iconset directory structure mkdir -p Cleanuparr.iconset # Use existing PNG files from Logo directory for different sizes cp Logo/16.png Cleanuparr.iconset/icon_16x16.png cp Logo/32.png Cleanuparr.iconset/icon_16x16@2x.png cp Logo/32.png Cleanuparr.iconset/icon_32x32.png cp Logo/64.png Cleanuparr.iconset/icon_32x32@2x.png cp Logo/128.png Cleanuparr.iconset/icon_128x128.png cp Logo/256.png Cleanuparr.iconset/icon_128x128@2x.png cp Logo/256.png Cleanuparr.iconset/icon_256x256.png cp Logo/512.png Cleanuparr.iconset/icon_256x256@2x.png cp Logo/512.png Cleanuparr.iconset/icon_512x512.png cp Logo/1024.png Cleanuparr.iconset/icon_512x512@2x.png # Create ICNS file iconutil -c icns Cleanuparr.iconset -o dist/Cleanuparr.app/Contents/Resources/Cleanuparr.icns # Clean up iconset directory rm -rf Cleanuparr.iconset fi # Create Launch Daemon plist cat > dist/Cleanuparr.app/Contents/Resources/com.cleanuparr.daemon.plist << EOF Label com.cleanuparr.daemon ProgramArguments /Applications/Cleanuparr.app/Contents/MacOS/Cleanuparr RunAtLoad KeepAlive StandardOutPath /var/log/cleanuparr.log StandardErrorPath /var/log/cleanuparr.error.log WorkingDirectory /Applications/Cleanuparr.app/Contents/MacOS EnvironmentVariables HTTP_PORTS 11011 EOF # Create Info.plist with proper configuration cat > dist/Cleanuparr.app/Contents/Info.plist << EOF CFBundleExecutable Cleanuparr CFBundleIdentifier com.Cleanuparr CFBundleName Cleanuparr CFBundleDisplayName Cleanuparr CFBundleVersion ${{ env.appVersion }} CFBundleShortVersionString ${{ env.appVersion }} CFBundleInfoDictionaryVersion 6.0 CFBundlePackageType APPL CFBundleSignature CLNR CFBundleIconFile Cleanuparr NSHighResolutionCapable NSRequiresAquaSystemAppearance LSMinimumSystemVersion ${{ matrix.min_os_version }} LSApplicationCategoryType public.app-category.productivity NSSupportsAutomaticTermination NSSupportsSuddenTermination LSBackgroundOnly NSAppTransportSecurity NSAllowsArbitraryLoads EOF # Clean up temp directory rm -rf dist/temp - name: Create PKG installer run: | # Create preinstall script to handle existing installations mkdir -p scripts cat > scripts/preinstall << 'EOF' #!/bin/bash # Stop and unload existing launch daemon if it exists if launchctl list | grep -q "com.cleanuparr.daemon"; then launchctl stop com.cleanuparr.daemon 2>/dev/null || true launchctl unload /Library/LaunchDaemons/com.cleanuparr.daemon.plist 2>/dev/null || true fi # Stop any running instances of Cleanuparr pkill -f "Cleanuparr" || true sleep 2 # Remove old installation if it exists if [[ -d "/Applications/Cleanuparr.app" ]]; then rm -rf "/Applications/Cleanuparr.app" fi # Remove old launch daemon plist if it exists if [[ -f "/Library/LaunchDaemons/com.cleanuparr.daemon.plist" ]]; then rm -f "/Library/LaunchDaemons/com.cleanuparr.daemon.plist" fi exit 0 EOF chmod +x scripts/preinstall # Create postinstall script cat > scripts/postinstall << 'EOF' #!/bin/bash # Set proper permissions for the app bundle chmod -R 755 /Applications/Cleanuparr.app chmod +x /Applications/Cleanuparr.app/Contents/MacOS/Cleanuparr # Install the launch daemon cp /Applications/Cleanuparr.app/Contents/Resources/com.cleanuparr.daemon.plist /Library/LaunchDaemons/ chown root:wheel /Library/LaunchDaemons/com.cleanuparr.daemon.plist chmod 644 /Library/LaunchDaemons/com.cleanuparr.daemon.plist # Load and start the service launchctl load /Library/LaunchDaemons/com.cleanuparr.daemon.plist launchctl start com.cleanuparr.daemon # Wait a moment for service to start sleep 3 # Display as system notification osascript -e 'display notification "Cleanuparr service started! Visit http://localhost:11011 in your browser." with title "Installation Complete"' 2>/dev/null || true exit 0 EOF chmod +x scripts/postinstall # Create uninstall script (optional, for user reference) cat > scripts/uninstall_cleanuparr.sh << 'EOF' #!/bin/bash # Cleanuparr Uninstall Script # Run this script with sudo to completely remove Cleanuparr echo "Stopping Cleanuparr service..." launchctl stop com.cleanuparr.daemon 2>/dev/null || true launchctl unload /Library/LaunchDaemons/com.cleanuparr.daemon.plist 2>/dev/null || true echo "Removing service files..." rm -f /Library/LaunchDaemons/com.cleanuparr.daemon.plist echo "Removing application..." rm -rf /Applications/Cleanuparr.app echo "Removing logs..." rm -f /var/log/cleanuparr.log rm -f /var/log/cleanuparr.error.log echo "Cleanuparr has been completely removed." echo "Note: Configuration files in /Applications/Cleanuparr.app/Contents/MacOS/config/ have been removed with the app." EOF chmod +x scripts/uninstall_cleanuparr.sh # Copy uninstall script to app bundle for user access cp scripts/uninstall_cleanuparr.sh dist/Cleanuparr.app/Contents/Resources/ # Determine package name - if app_version input was provided, it's a release build if [[ -n "${{ inputs.app_version }}" ]] || [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then pkg_name="Cleanuparr-${{ env.appVersion }}-macos-${{ matrix.artifact_suffix }}.pkg" else pkg_name="Cleanuparr-${{ env.appVersion }}-macos-${{ matrix.artifact_suffix }}-dev.pkg" fi # Create PKG installer with better metadata pkgbuild --root dist/ \ --scripts scripts/ \ --identifier com.Cleanuparr \ --version ${{ env.appVersion }} \ --install-location /Applications \ --ownership preserve \ ${pkg_name} echo "pkgName=${pkg_name}" >> $GITHUB_ENV - name: Upload installer as artifact uses: actions/upload-artifact@v4 with: name: Cleanuparr-macos-${{ matrix.artifact_suffix }}-installer path: '${{ env.pkgName }}' retention-days: 30