Build, Stage, and Deploy Release #33
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
name: Build, Stage, and Deploy Release | |
# Workflow supports three deployment modes (selectable via dropdown when manually triggered): | |
# 1. 'production' - Full deployment to all stores and Docker Hub (auto-selected for PR merges) | |
# 2. 'stage-only' - Creates draft/candidate releases in stores, skips Docker push | |
# 3. 'dry-run' - Builds everything but doesn't deploy anywhere (default for manual triggers) | |
on: | |
pull_request: | |
types: [ closed ] | |
branches: [ release ] | |
workflow_dispatch: | |
inputs: | |
deployment_mode: | |
description: 'Deployment Mode' | |
required: true | |
type: choice | |
default: 'dry-run' | |
options: | |
- 'dry-run' # Build only, no deployments | |
- 'stage-only' # Deploy to stores as drafts/candidates, skip Docker | |
- 'production' # Full production deployment | |
jobs: | |
calculate-version: | |
name: Calculate Next Version | |
runs-on: ubuntu-latest | |
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'release') | |
outputs: | |
version: ${{ steps.version.outputs.version }} | |
previous_version: ${{ steps.version.outputs.previous_version }} | |
deployment_mode: ${{ steps.deployment_mode.outputs.mode }} | |
dry_run: ${{ steps.dry_run.outputs.enabled }} | |
stage_only: ${{ steps.stage_only.outputs.enabled }} | |
pr_body: ${{ steps.pr_details.outputs.body }} | |
steps: | |
- name: Checkout | |
uses: actions/checkout@v4 | |
with: | |
fetch-depth: 0 | |
- name: Set deployment mode | |
id: deployment_mode | |
run: | | |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then | |
MODE="${{ inputs.deployment_mode }}" | |
else | |
# PR merges to release branch are always production | |
MODE="production" | |
fi | |
# Set individual flags based on mode | |
if [ "$MODE" = "dry-run" ]; then | |
DRY_RUN="true" | |
STAGE_ONLY="false" | |
elif [ "$MODE" = "stage-only" ]; then | |
DRY_RUN="false" | |
STAGE_ONLY="true" | |
else # production | |
DRY_RUN="false" | |
STAGE_ONLY="false" | |
fi | |
echo "mode=$MODE" >> $GITHUB_OUTPUT | |
echo "Deployment mode: $MODE" | |
- name: Set dry run mode | |
id: dry_run | |
run: | | |
# Extract from deployment mode | |
MODE="${{ steps.deployment_mode.outputs.mode }}" | |
if [ "$MODE" = "dry-run" ]; then | |
DRY_RUN="true" | |
else | |
DRY_RUN="false" | |
fi | |
echo "enabled=$DRY_RUN" >> $GITHUB_OUTPUT | |
echo "Dry run mode: $DRY_RUN" | |
- name: Set stage only mode | |
id: stage_only | |
run: | | |
# Extract from deployment mode | |
MODE="${{ steps.deployment_mode.outputs.mode }}" | |
if [ "$MODE" = "stage-only" ]; then | |
STAGE_ONLY="true" | |
else | |
STAGE_ONLY="false" | |
fi | |
echo "enabled=$STAGE_ONLY" >> $GITHUB_OUTPUT | |
echo "Stage only mode: $STAGE_ONLY" | |
- name: Check all secrets availability | |
run: | | |
echo "🎯 DEPLOYMENT MODE: ${{ steps.deployment_mode.outputs.mode }}" | |
echo "============================" | |
echo "" | |
if [ "${{ steps.deployment_mode.outputs.mode }}" = "dry-run" ]; then | |
echo "🏃 DRY RUN MODE - Building only, no deployments will be made" | |
elif [ "${{ steps.deployment_mode.outputs.mode }}" = "stage-only" ]; then | |
echo "📦 STAGE ONLY MODE - Will create drafts/candidates, no production releases" | |
else | |
echo "🚀 PRODUCTION MODE - Full deployment to all platforms" | |
fi | |
echo "" | |
echo "🔐 SECRET AVAILABILITY CHECK" | |
echo "============================" | |
echo "" | |
echo "📦 Docker Hub:" | |
[ -n "${{ secrets.DOCKERHUB_USERNAME }}" ] && echo " ✅ DOCKERHUB_USERNAME" || echo " ❌ DOCKERHUB_USERNAME" | |
[ -n "${{ secrets.DOCKERHUB_TOKEN }}" ] && echo " ✅ DOCKERHUB_TOKEN" || echo " ❌ DOCKERHUB_TOKEN" | |
echo "" | |
echo "📦 Snap Store:" | |
[ -n "${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}" ] && echo " ✅ SNAPCRAFT_STORE_CREDENTIALS" || echo " ❌ SNAPCRAFT_STORE_CREDENTIALS" | |
echo "" | |
echo "🪟 Microsoft Store:" | |
[ -n "${{ secrets.MS_TENANT_ID }}" ] && echo " ✅ MS_TENANT_ID" || echo " ❌ MS_TENANT_ID" | |
[ -n "${{ secrets.MS_CLIENT_ID }}" ] && echo " ✅ MS_CLIENT_ID" || echo " ❌ MS_CLIENT_ID" | |
[ -n "${{ secrets.MS_CLIENT_SECRET }}" ] && echo " ✅ MS_CLIENT_SECRET" || echo " ❌ MS_CLIENT_SECRET" | |
[ -n "${{ secrets.MS_APP_ID }}" ] && echo " ✅ MS_APP_ID" || echo " ❌ MS_APP_ID" | |
echo "" | |
echo "🍎 Mac App Store:" | |
[ -n "${{ secrets.MAS_CODESIGN_ID }}" ] && echo " ✅ MAS_CODESIGN_ID" || echo " ❌ MAS_CODESIGN_ID" | |
[ -n "${{ secrets.MAS_INSTALLER_ID }}" ] && echo " ✅ MAS_INSTALLER_ID" || echo " ❌ MAS_INSTALLER_ID" | |
echo "" | |
echo "🍎 Apple Store Connect API:" | |
[ -n "${{ secrets.APP_STORE_CONNECT_API_KEY }}" ] && echo " ✅ APP_STORE_CONNECT_API_KEY" || echo " ❌ APP_STORE_CONNECT_API_KEY" | |
[ -n "${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}" ] && echo " ✅ APP_STORE_CONNECT_API_KEY_ID" || echo " ❌ APP_STORE_CONNECT_API_KEY_ID" | |
[ -n "${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}" ] && echo " ✅ APP_STORE_CONNECT_ISSUER_ID" || echo " ❌ APP_STORE_CONNECT_ISSUER_ID" | |
[ -n "${{ secrets.APPLE_ID }}" ] && echo " ✅ APPLE_ID" || echo " ❌ APPLE_ID" | |
echo "" | |
echo "============================" | |
echo "" | |
- name: Calculate version | |
id: version | |
run: | | |
NEXT_VERSION=$(bash scripts/version-bump.sh) | |
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") | |
PREVIOUS_VERSION="${LATEST_TAG#v}" | |
echo "version=$NEXT_VERSION" >> $GITHUB_OUTPUT | |
echo "previous_version=$PREVIOUS_VERSION" >> $GITHUB_OUTPUT | |
echo "Next version: $NEXT_VERSION" | |
echo "Previous version: $PREVIOUS_VERSION" | |
- name: Capture PR details | |
id: pr_details | |
run: | | |
if [ "${{ github.event_name }}" = "pull_request" ]; then | |
# PR event - capture the PR body | |
PR_BODY="${{ github.event.pull_request.body }}" | |
else | |
# Manual trigger - provide default release notes with mode info | |
MODE="${{ steps.deployment_mode.outputs.mode }}" | |
PR_BODY="Manual release deployment (${MODE} mode) for v${{ steps.version.outputs.version }}" | |
fi | |
# Escape special characters and prepare for multi-line output | |
PR_BODY="${PR_BODY//'%'/'%25'}" | |
PR_BODY="${PR_BODY//$'\n'/'%0A'}" | |
PR_BODY="${PR_BODY//$'\r'/'%0D'}" | |
echo "body=$PR_BODY" >> $GITHUB_OUTPUT | |
build-docker-ce: | |
name: Build Docker Images (CE) | |
strategy: | |
matrix: | |
include: | |
- runner: ubuntu-latest | |
platform: linux/amd64 | |
arch: amd64 | |
- runner: ubuntu-24.04-arm # Native ARM64 build | |
platform: linux/arm64 | |
arch: arm64 | |
runs-on: ${{ matrix.runner }} | |
needs: calculate-version | |
steps: | |
- name: Checkout | |
uses: actions/checkout@v4 | |
- name: Set up Docker Buildx | |
uses: docker/setup-buildx-action@v3 | |
- name: Build Docker image for ${{ matrix.platform }} | |
uses: docker/build-push-action@v6 | |
with: | |
context: . | |
file: ./core/Dockerfile | |
platforms: ${{ matrix.platform }} | |
push: false | |
tags: clidey/whodb:${{ needs.calculate-version.outputs.version }}-${{ matrix.arch }} | |
outputs: type=docker,dest=/tmp/whodb-docker-${{ matrix.arch }}.tar | |
- name: Upload Docker artifact | |
uses: actions/upload-artifact@v4 | |
with: | |
name: docker-image-${{ matrix.arch }} | |
path: /tmp/whodb-docker-${{ matrix.arch }}.tar | |
retention-days: 1 | |
# Build desktop executables for store packaging | |
build-desktop-ce: | |
name: Build Desktop Executables (CE) | |
strategy: | |
matrix: | |
include: | |
# Windows builds for Microsoft Store | |
- os: windows-latest | |
platform: windows | |
arch: amd64 | |
make-target: build-prod-windows-amd64 | |
- os: windows-11-arm # Use native ARM64 runner for Windows ARM64 | |
platform: windows | |
arch: arm64 | |
make-target: build-prod-windows-arm64 | |
# macOS build for Mac App Store | |
- os: macos-latest | |
platform: darwin | |
arch: universal | |
make-target: build-prod-mac | |
runs-on: ${{ matrix.os }} | |
needs: calculate-version | |
steps: | |
- name: Checkout | |
uses: actions/checkout@v4 | |
- name: Setup Go | |
uses: actions/setup-go@v5 | |
with: | |
go-version-file: 'desktop-ce/go.mod' | |
- name: Setup Node.js and pnpm | |
uses: actions/setup-node@v4 | |
with: | |
node-version: 'lts/*' | |
- uses: pnpm/action-setup@v4 | |
with: | |
version: 10 | |
- name: Install frontend dependencies | |
working-directory: ./frontend | |
run: pnpm i | |
- name: Install Wails CLI | |
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest | |
- name: Setup ARM64 compiler on Windows ARM | |
if: matrix.platform == 'windows' && matrix.arch == 'arm64' | |
shell: pwsh | |
run: | | |
Write-Host "Checking available GCC compilers..." | |
Get-Command gcc -ErrorAction SilentlyContinue | Format-List | |
Write-Host "Go environment:" | |
go env GOARCH | |
go env CC | |
Write-Host "Setting GOARCH=arm64 explicitly..." | |
$env:GOARCH = "arm64" | |
Write-Host "Updated GOARCH:" | |
go env GOARCH | |
- name: Build Windows | |
if: matrix.platform == 'windows' && matrix.arch == 'amd64' | |
working-directory: ./desktop-ce | |
shell: pwsh | |
env: | |
GOARCH: ${{ matrix.arch }} | |
run: | | |
# Build raw exe for MSIX packaging (not NSIS installer) | |
Write-Host "Building raw Windows executable for MSIX packaging..." | |
$env:GOWORK = "$PWD/../go.work.desktop-ce" | |
# Clean previous build | |
Remove-Item -Path build -Recurse -Force -ErrorAction SilentlyContinue | |
# Prepare frontend | |
Write-Host "Preparing frontend..." | |
New-Item -ItemType Directory -Path frontend/dist -Force | Out-Null | |
Set-Location ../frontend | |
pnpm run build:ce | |
Copy-Item -Path build/* -Destination ../desktop-ce/frontend/dist/ -Recurse | |
Set-Location ../desktop-ce | |
# Build without NSIS flag to get raw exe | |
Write-Host "Building with wails..." | |
wails build -clean -platform windows/amd64 ` | |
-windowsconsole=false -ldflags="-s -w -H windowsgui" ` | |
-o whodb.exe | |
if ($LASTEXITCODE -ne 0) { | |
Write-Error "Wails build failed with exit code: $LASTEXITCODE" | |
exit 1 | |
} | |
# Find where wails actually put the exe | |
Write-Host "Looking for built executable..." | |
Write-Host "Contents of build directory:" | |
Get-ChildItem -Path build -Recurse -ErrorAction SilentlyContinue | Format-Table Name, FullName | |
# Check common locations | |
$possibleLocations = @( | |
"build\bin\whodb.exe", | |
"build\bin\whodb-amd64.exe", | |
"build\whodb.exe" | |
) | |
$foundExe = $null | |
foreach ($location in $possibleLocations) { | |
if (Test-Path $location) { | |
$foundExe = $location | |
Write-Host "Found exe at: $foundExe" | |
break | |
} | |
} | |
if (-not $foundExe) { | |
Write-Error "Could not find whodb.exe after build" | |
exit 1 | |
} | |
# Move to expected location | |
New-Item -ItemType Directory -Path build/windows/amd64 -Force | Out-Null | |
Move-Item $foundExe build/windows/amd64/whodb.exe -Force | |
Write-Host "Raw executable moved to: build/windows/amd64/whodb.exe" | |
Write-Host "Verifying final location..." | |
if (Test-Path "build/windows/amd64/whodb.exe") { | |
$fileInfo = Get-Item "build/windows/amd64/whodb.exe" | |
Write-Host "✅ Executable ready: $($fileInfo.FullName) (Size: $($fileInfo.Length) bytes)" | |
} else { | |
Write-Error "Failed to move executable to expected location" | |
exit 1 | |
} | |
- name: Skip Windows ARM64 Build (Temporarily Disabled) | |
if: matrix.platform == 'windows' && matrix.arch == 'arm64' | |
shell: pwsh | |
run: | | |
Write-Host "⚠️ Windows ARM64 build is temporarily disabled due to toolchain issues" | |
Write-Host "Creating placeholder for build artifacts..." | |
New-Item -ItemType Directory -Force -Path "./desktop-ce/build/windows/arm64" | |
Write-Host "Skipping actual build..." | |
- name: Build macOS | |
if: matrix.platform == 'darwin' | |
working-directory: ./desktop-ce | |
run: make ${{ matrix.make-target }} VERSION=${{ needs.calculate-version.outputs.version }} | |
- name: List build outputs before upload | |
if: matrix.platform == 'windows' && matrix.arch == 'amd64' | |
shell: pwsh | |
working-directory: ./desktop-ce | |
run: | | |
Write-Host "📦 Listing build outputs before upload:" | |
if (Test-Path "build") { | |
Get-ChildItem -Path build -Recurse | Format-Table Name, FullName, Length | |
} else { | |
Write-Host "❌ Build directory does not exist!" | |
} | |
- name: Upload artifacts (AMD64 and macOS) | |
if: matrix.arch != 'arm64' || matrix.platform != 'windows' | |
uses: actions/upload-artifact@v4 | |
with: | |
name: desktop-${{ matrix.platform }}-${{ matrix.arch || 'all' }} | |
path: | | |
desktop-ce/build/ | |
retention-days: 1 | |
- name: Skip ARM64 Artifacts Upload | |
if: matrix.platform == 'windows' && matrix.arch == 'arm64' | |
shell: pwsh | |
run: | | |
Write-Host "⚠️ Skipping ARM64 artifact upload (build was disabled)" | |
# Package for Mac App Store | |
package-mac-app-store: | |
name: Package for Mac App Store | |
runs-on: macos-latest | |
needs: [ calculate-version, build-desktop-ce ] | |
continue-on-error: ${{ needs.calculate-version.outputs.stage_only == 'true' }} | |
steps: | |
- name: Checkout | |
uses: actions/checkout@v4 | |
- name: Download macOS build | |
uses: actions/download-artifact@v4 | |
with: | |
name: desktop-darwin-universal | |
path: desktop-ce/ | |
- name: Check certificate availability | |
id: check_mac_cert | |
run: | | |
echo "🔍 Checking Mac App Store certificates..." | |
if [ -n "${{ secrets.MAS_CODESIGN_ID }}" ]; then | |
echo " ✅ MAS_CODESIGN_ID is configured" | |
else | |
echo " ❌ MAS_CODESIGN_ID is NOT configured" | |
fi | |
if [ -n "${{ secrets.MAS_INSTALLER_ID }}" ]; then | |
echo " ✅ MAS_INSTALLER_ID is configured" | |
else | |
echo " ❌ MAS_INSTALLER_ID is NOT configured" | |
fi | |
if [ -n "${{ secrets.MAS_CODESIGN_ID }}" ] && [ -n "${{ secrets.MAS_INSTALLER_ID }}" ]; then | |
echo "has_cert=true" >> $GITHUB_OUTPUT | |
echo "✅ Mac certificates available - will build pkg package" | |
else | |
echo "has_cert=false" >> $GITHUB_OUTPUT | |
echo "⚠️ Mac certificates not configured - will create dummy package" | |
fi | |
- name: Create dummy pkg for validation (no certs) | |
if: steps.check_mac_cert.outputs.has_cert != 'true' | |
run: | | |
if [ "${{ needs.calculate-version.outputs.dry_run }}" = "true" ]; then | |
echo "🏃 DRY RUN MODE - No certificates available" | |
elif [ "${{ needs.calculate-version.outputs.stage_only }}" = "true" ]; then | |
echo "📦 STAGE ONLY MODE - No certificates available" | |
else | |
echo "⚠️ PRODUCTION MODE - No certificates available" | |
fi | |
echo "Creating dummy pkg for validation..." | |
mkdir -p desktop-ce | |
# Create a non-empty dummy file (1 byte minimum for GitHub release) | |
echo "dummy-package-placeholder" > "desktop-ce/WhoDB-${{ needs.calculate-version.outputs.version }}.pkg" | |
- name: Package for Mac App Store | |
if: steps.check_mac_cert.outputs.has_cert == 'true' | |
working-directory: ./desktop-ce | |
env: | |
MAS_CODESIGN_ID: ${{ secrets.MAS_CODESIGN_ID }} | |
MAS_INSTALLER_ID: ${{ secrets.MAS_INSTALLER_ID }} | |
run: | | |
if [ "${{ needs.calculate-version.outputs.dry_run }}" = "true" ]; then | |
echo "🏃 DRY RUN MODE - Building Mac App Store package (will not publish)" | |
fi | |
# Sign the app for Mac App Store | |
codesign --deep --force --options runtime --sign "$MAS_CODESIGN_ID" \ | |
"build/darwin/universal/WhoDB.app" | |
# Create Mac App Store package | |
productbuild --component "build/darwin/universal/WhoDB.app" /Applications \ | |
--sign "$MAS_INSTALLER_ID" \ | |
"WhoDB-${{ needs.calculate-version.outputs.version }}.pkg" | |
- name: Upload Mac App Store package | |
uses: actions/upload-artifact@v4 | |
with: | |
name: mac-app-store | |
path: desktop-ce/WhoDB-*.pkg | |
retention-days: 1 | |
package-windows-msix: | |
name: Package Windows MSIX | |
runs-on: windows-latest | |
needs: [ calculate-version, build-desktop-ce ] | |
continue-on-error: ${{ needs.calculate-version.outputs.stage_only == 'true' }} | |
steps: | |
- name: Checkout | |
uses: actions/checkout@v4 | |
- name: Check Windows SDK availability | |
shell: pwsh | |
run: | | |
Write-Host "🔍 Checking for Windows SDK..." | |
# Check if makeappx is directly available | |
$makeappxCmd = Get-Command makeappx -ErrorAction SilentlyContinue | |
if ($makeappxCmd) { | |
Write-Host "✅ makeappx is available in PATH: $($makeappxCmd.Source)" | |
} else { | |
# Check if makeappx exists in any SDK version | |
$sdkRoot = "C:\Program Files (x86)\Windows Kits\10\bin" | |
if (Test-Path $sdkRoot) { | |
$versions = Get-ChildItem -Path $sdkRoot -Directory | | |
Where-Object { $_.Name -match "^10\.\d+\.\d+\.\d+$" } | | |
Sort-Object { [version]($_.Name -replace "^10\.", "") } -Descending | |
Write-Host "Found Windows SDK versions:" | |
$versions | ForEach-Object { Write-Host " - $($_.Name)" } | |
$makeappxFound = $false | |
foreach ($version in $versions) { | |
if (Test-Path (Join-Path $version.FullName "x64\makeappx.exe")) { | |
Write-Host "✅ makeappx.exe available in SDK $($version.Name)" | |
$makeappxFound = $true | |
break | |
} | |
} | |
if (-not $makeappxFound) { | |
Write-Host "⚠️ makeappx.exe not found in any SDK version" | |
Write-Host "Note: Will attempt to use makeappx directly or create dummy for dry run" | |
} | |
} else { | |
Write-Host "⚠️ Windows SDK not found at expected location" | |
} | |
} | |
- name: Download Windows amd64 builds | |
uses: actions/download-artifact@v4 | |
with: | |
name: desktop-windows-amd64 | |
path: desktop-ce/ | |
# ARM64 builds temporarily disabled | |
# - name: Download Windows arm64 builds | |
# uses: actions/download-artifact@v4 | |
# with: | |
# name: desktop-windows-arm64 | |
# path: desktop-ce/ | |
- name: List downloaded Windows build artifacts | |
shell: pwsh | |
run: | | |
Write-Host "📁 Current directory: $PWD" | |
Write-Host "" | |
Write-Host "📁 Contents of desktop-ce directory:" | |
if (Test-Path "desktop-ce") { | |
Get-ChildItem -Path "desktop-ce" -Recurse | Format-Table Name, FullName, Length | |
} else { | |
Write-Host "❌ desktop-ce directory does not exist!" | |
Write-Host "Looking for any build directories..." | |
Get-ChildItem -Path . | Format-Table Name, FullName | |
} | |
Write-Host "" | |
Write-Host "🔍 Looking for .exe files anywhere:" | |
Get-ChildItem -Path . -Filter "*.exe" -Recurse -ErrorAction SilentlyContinue | Format-Table Name, FullName, Length | |
- name: Build unsigned MSIX for Microsoft Store | |
shell: pwsh | |
run: | | |
if ("${{ needs.calculate-version.outputs.dry_run }}" -eq "true") { | |
echo "🏃 DRY RUN MODE - Building unsigned MSIX package (will not publish)" | |
} else { | |
echo "📦 Building unsigned MSIX package for Microsoft Store" | |
} | |
echo "ℹ️ Microsoft will sign this package when uploaded to Partner Center" | |
# Add Windows SDK to PATH if available | |
$sdkVersions = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" -Directory -ErrorAction SilentlyContinue | | |
Where-Object { $_.Name -match "^10\.\d+\.\d+\.\d+$" } | | |
Sort-Object { [version]($_.Name -replace "^10\.", "") } -Descending | |
foreach ($version in $sdkVersions) { | |
$binPath = Join-Path $version.FullName "x64" | |
if (Test-Path (Join-Path $binPath "makeappx.exe")) { | |
$env:PATH = "$binPath;$env:PATH" | |
Write-Host "Added Windows SDK to PATH: $binPath" | |
break | |
} | |
} | |
# Try to build MSIX package | |
$ErrorActionPreference = "Stop" | |
try { | |
.\scripts\build-msix.ps1 -Architecture amd64 -Version ${{ needs.calculate-version.outputs.version }} -SkipSigning | |
Write-Host "✅ MSIX build script completed" | |
} catch { | |
Write-Host "Failed to build MSIX: $_" | |
if ("${{ needs.calculate-version.outputs.dry_run }}" -eq "true") { | |
Write-Host "Creating dummy MSIX bundle for dry run validation..." | |
New-Item -ItemType File -Path "WhoDB-${{ needs.calculate-version.outputs.version }}.msixbundle" -Force | Out-Null | |
Write-Host "✅ Created dummy MSIX bundle for dry run" | |
} else { | |
Write-Error "Failed to create MSIX package: $_" | |
exit 1 | |
} | |
} | |
# ARM64 build temporarily disabled | |
# - name: Build MSIX arm64 | |
# if: needs.calculate-version.outputs.dry_run == 'false' | |
# shell: pwsh | |
# env: | |
# WINDOWS_PFX_PASSWORD: ${{ secrets.WINDOWS_PFX_PASSWORD }} | |
# run: | | |
# .\scripts\build-msix.ps1 -Architecture arm64 -Version ${{ needs.calculate-version.outputs.version }} -PublisherCN "${{ secrets.WINDOWS_PUBLISHER_CN }}" -CertPath cert.pfx | |
- name: Create MSIX bundle (AMD64 only for now) | |
shell: pwsh | |
run: | | |
# Since we only have AMD64 for now, rename it as the bundle | |
Move-Item "WhoDB-${{ needs.calculate-version.outputs.version }}-amd64.msix" "WhoDB-${{ needs.calculate-version.outputs.version }}.msixbundle" -Force | |
- name: Upload MSIX artifacts | |
uses: actions/upload-artifact@v4 | |
with: | |
name: windows-msix | |
path: WhoDB-*.msixbundle | |
retention-days: 1 | |
build-snap: | |
name: Build Snap Package | |
strategy: | |
matrix: | |
include: | |
- runner: ubuntu-latest | |
arch: amd64 | |
- runner: ubuntu-24.04-arm | |
arch: arm64 | |
runs-on: ${{ matrix.runner }} | |
needs: [ calculate-version ] | |
continue-on-error: ${{ needs.calculate-version.outputs.stage_only == 'true' }} | |
steps: | |
- name: Checkout | |
uses: actions/checkout@v4 | |
- name: Prepare snap build | |
run: | | |
# Copy the CE snapcraft file to the expected location | |
cp snapcraft-ce.yaml snapcraft.yaml | |
- name: Build snap from source | |
uses: snapcore/action-build@v1 | |
id: build | |
- name: Rename snap with architecture | |
run: | | |
SNAP_FILE="${{ steps.build.outputs.snap }}" | |
NEW_NAME="${SNAP_FILE%.snap}_${{ matrix.arch }}.snap" | |
mv "$SNAP_FILE" "$NEW_NAME" | |
echo "SNAP_FILE=$NEW_NAME" >> $GITHUB_ENV | |
- name: Upload snap artifact | |
uses: actions/upload-artifact@v4 | |
with: | |
name: snap-package-${{ matrix.arch }} | |
path: ${{ env.SNAP_FILE }} | |
retention-days: 1 | |
sign-with-sigstore: | |
name: Sign Artifacts with Sigstore | |
runs-on: ubuntu-latest | |
needs: [ calculate-version, package-windows-msix, package-mac-app-store, build-snap, build-desktop-ce ] | |
continue-on-error: ${{ needs.calculate-version.outputs.stage_only == 'true' }} | |
# Need write permissions for OIDC token | |
permissions: | |
contents: write | |
id-token: write | |
steps: | |
- name: Checkout | |
uses: actions/checkout@v4 | |
- name: Install Cosign | |
uses: sigstore/cosign-installer@v3 | |
- name: Download all artifacts | |
uses: actions/download-artifact@v4 | |
with: | |
path: artifacts/ | |
# Continue even if dockerbuild metadata artifacts fail to download | |
continue-on-error: true | |
- name: Sign Windows MSIX bundle | |
env: | |
COSIGN_EXPERIMENTAL: 1 | |
run: | | |
for file in artifacts/windows-msix/*.msixbundle; do | |
if [ -f "$file" ]; then | |
echo "Signing $file with Sigstore..." | |
cosign sign-blob --yes "$file" \ | |
--output-signature="${file}.sig" \ | |
--output-certificate="${file}.pem" \ | |
--oidc-issuer=https://token.actions.githubusercontent.com \ | |
|| echo "Warning: Failed to sign $file, continuing..." | |
fi | |
done | |
- name: Sign Mac App Store package | |
env: | |
COSIGN_EXPERIMENTAL: 1 | |
run: | | |
for file in artifacts/mac-app-store/*.pkg; do | |
if [ -f "$file" ]; then | |
echo "Signing $file with Sigstore..." | |
cosign sign-blob --yes "$file" \ | |
--output-signature="${file}.sig" \ | |
--output-certificate="${file}.pem" \ | |
--oidc-issuer=https://token.actions.githubusercontent.com \ | |
|| echo "Warning: Failed to sign $file, continuing..." | |
fi | |
done | |
- name: Upload signed artifacts | |
uses: actions/upload-artifact@v4 | |
with: | |
name: signed-artifacts | |
path: artifacts/ | |
retention-days: 1 | |
validate-all-builds: | |
name: Validate All Builds | |
runs-on: ubuntu-latest | |
needs: [ sign-with-sigstore, build-docker-ce, calculate-version ] | |
steps: | |
- name: Checkout for scripts | |
uses: actions/checkout@v4 | |
- name: Download required artifacts for validation | |
uses: actions/download-artifact@v4 | |
with: | |
path: artifacts/ | |
# Continue even if dockerbuild metadata artifacts fail | |
continue-on-error: true | |
- name: Clean up Docker metadata artifacts | |
run: | | |
# Remove any dockerbuild artifacts that may have partially downloaded | |
rm -rf artifacts/*dockerbuild* || true | |
echo "Cleaned up Docker metadata artifacts (not required for validation)" | |
- name: Validate artifacts | |
run: | | |
echo "Checking for required artifacts..." | |
[ -f "artifacts/docker-image-amd64/whodb-docker-amd64.tar" ] || (echo "Docker amd64 image missing" && exit 1) | |
[ -f "artifacts/docker-image-arm64/whodb-docker-arm64.tar" ] || (echo "Docker arm64 image missing" && exit 1) | |
[ -f "artifacts/windows-msix/"*.msixbundle ] || (echo "Windows MSIX bundle missing" && exit 1) | |
[ -f "artifacts/mac-app-store/"*.pkg ] || (echo "Mac App Store package missing" && exit 1) | |
[ -f "artifacts/snap-package-amd64/"*.snap ] || (echo "Snap amd64 package missing" && exit 1) | |
[ -f "artifacts/snap-package-arm64/"*.snap ] || (echo "Snap arm64 package missing" && exit 1) | |
echo "✓ All required artifacts present" | |
echo "Note: Windows ARM64 build is temporarily disabled" | |
- name: Initialize deployment manifest | |
run: | | |
bash scripts/track-deployment.sh docker pending ${{ needs.calculate-version.outputs.version }} | |
- name: Upload deployment manifest | |
uses: actions/upload-artifact@v4 | |
with: | |
name: deployment-manifest | |
path: deployment_manifest.json | |
retention-days: 1 | |
# Deploy to stores first (these can be reviewed/rolled back before going live) | |
deploy-snap: | |
name: Deploy to Snap Store | |
runs-on: ubuntu-latest | |
needs: [ calculate-version, validate-all-builds ] | |
continue-on-error: ${{ needs.calculate-version.outputs.stage_only == 'true' }} | |
steps: | |
- name: Checkout for scripts | |
uses: actions/checkout@v4 | |
- name: Check Snapcraft credentials | |
run: | | |
echo "🔍 Checking Snapcraft credentials..." | |
if [ -n "${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}" ]; then | |
echo " ✅ SNAPCRAFT_STORE_CREDENTIALS is configured" | |
else | |
echo " ❌ SNAPCRAFT_STORE_CREDENTIALS is NOT configured" | |
if [ "${{ needs.calculate-version.outputs.dry_run }}" = "false" ] && [ "${{ needs.calculate-version.outputs.stage_only }}" = "false" ]; then | |
echo "::error::Production deployment requires SNAPCRAFT_STORE_CREDENTIALS" | |
exit 1 | |
fi | |
fi | |
- name: Download snap amd64 artifact | |
uses: actions/download-artifact@v4 | |
with: | |
name: snap-package-amd64 | |
- name: Download snap arm64 artifact | |
uses: actions/download-artifact@v4 | |
with: | |
name: snap-package-arm64 | |
- name: Download deployment manifest | |
uses: actions/download-artifact@v4 | |
with: | |
name: deployment-manifest | |
path: . | |
- name: Check Snapcraft credentials availability | |
id: check_snap_creds | |
run: | | |
if [ -n "${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}" ]; then | |
echo "has_creds=true" >> $GITHUB_OUTPUT | |
echo "✅ Snapcraft credentials are configured" | |
else | |
echo "has_creds=false" >> $GITHUB_OUTPUT | |
echo "⚠️ Snapcraft credentials not configured" | |
if [ "${{ needs.calculate-version.outputs.stage_only }}" = "true" ]; then | |
echo "Will skip Snap Store upload in stage-only mode" | |
elif [ "${{ needs.calculate-version.outputs.dry_run }}" = "false" ]; then | |
echo "::error::Production deployment requires SNAPCRAFT_STORE_CREDENTIALS" | |
exit 1 | |
fi | |
fi | |
- name: Find and list snap files | |
if: needs.calculate-version.outputs.dry_run == 'false' | |
id: find_snaps | |
run: | | |
echo "Looking for snap files..." | |
ls -la *.snap || echo "No snap files found" | |
# Find all snap files and create a list | |
SNAP_FILES=$(ls *.snap 2>/dev/null | head -1 || echo "") | |
if [ -z "$SNAP_FILES" ]; then | |
echo "❌ No snap files found in current directory" | |
if [ "${{ needs.calculate-version.outputs.stage_only }}" = "true" ]; then | |
echo "Continuing in stage-only mode..." | |
echo "snap_found=false" >> $GITHUB_OUTPUT | |
else | |
exit 1 | |
fi | |
else | |
echo "✅ Found snap file: $SNAP_FILES" | |
echo "snap_file=$SNAP_FILES" >> $GITHUB_OUTPUT | |
echo "snap_found=true" >> $GITHUB_OUTPUT | |
fi | |
- name: Publish Snaps to Store (Direct Method) | |
if: needs.calculate-version.outputs.dry_run == 'false' && steps.check_snap_creds.outputs.has_creds == 'true' && steps.find_snaps.outputs.snap_found == 'true' | |
env: | |
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} | |
run: | | |
# Install snapcraft | |
echo "Installing snapcraft..." | |
sudo snap install snapcraft --classic | |
# Determine release channel | |
if [ "${{ needs.calculate-version.outputs.stage_only }}" = "true" ]; then | |
CHANNEL="candidate" | |
echo "📦 Publishing to candidate channel (stage only)" | |
else | |
CHANNEL="stable" | |
echo "📦 Publishing to stable channel" | |
fi | |
# Upload AMD64 snap | |
AMD64_SNAP=$(ls *amd64*.snap 2>/dev/null | head -1 || echo "") | |
if [ -n "$AMD64_SNAP" ]; then | |
echo "Found AMD64 snap: $AMD64_SNAP" | |
echo "Uploading $AMD64_SNAP to $CHANNEL channel..." | |
snapcraft upload "$AMD64_SNAP" --release "$CHANNEL" || { | |
echo "❌ Failed to upload AMD64 snap" | |
if [ "${{ needs.calculate-version.outputs.stage_only }}" = "true" ]; then | |
echo "Continuing in stage-only mode despite failure..." | |
else | |
exit 1 | |
fi | |
} | |
else | |
echo "❌ No AMD64 snap found" | |
exit 1 | |
fi | |
- name: Publish ARM64 Snap if exists | |
if: needs.calculate-version.outputs.dry_run == 'false' && steps.check_snap_creds.outputs.has_creds == 'true' | |
env: | |
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} | |
run: | | |
# Find and upload ARM64 snap if it exists | |
ARM64_SNAP=$(ls *arm64*.snap 2>/dev/null | head -1 || echo "") | |
if [ -n "$ARM64_SNAP" ]; then | |
echo "Found ARM64 snap: $ARM64_SNAP" | |
# Snapcraft should already be installed from previous step | |
if ! command -v snapcraft &> /dev/null; then | |
echo "Installing snapcraft..." | |
sudo snap install snapcraft --classic | |
fi | |
# Determine release channel | |
if [ "${{ needs.calculate-version.outputs.stage_only }}" = "true" ]; then | |
CHANNEL="candidate" | |
echo "📦 Uploading ARM64 to candidate channel" | |
else | |
CHANNEL="stable" | |
echo "📦 Uploading ARM64 to stable channel" | |
fi | |
# Upload ARM64 snap | |
echo "Uploading $ARM64_SNAP to $CHANNEL channel..." | |
snapcraft upload "$ARM64_SNAP" --release "$CHANNEL" || { | |
echo "❌ Failed to upload ARM64 snap" | |
if [ "${{ needs.calculate-version.outputs.stage_only }}" = "true" ]; then | |
echo "Continuing in stage-only mode despite failure..." | |
else | |
exit 1 | |
fi | |
} | |
else | |
echo "No ARM64 snap found, skipping" | |
fi | |
- name: Track Snap deployment | |
if: needs.calculate-version.outputs.dry_run == 'false' | |
run: | | |
bash scripts/track-deployment.sh snap deployed ${{ needs.calculate-version.outputs.version }} | |
- name: Dry run summary | |
if: needs.calculate-version.outputs.dry_run == 'true' | |
run: | | |
echo "🏃 DRY RUN MODE - Would have published:" | |
if [ "${{ needs.calculate-version.outputs.stage_only }}" = "true" ]; then | |
echo " - Snap packages (amd64 and arm64) to candidate channel (stage only)" | |
else | |
echo " - Snap packages (amd64 and arm64) to stable channel" | |
fi | |
ls -lh *.snap | |
- name: Upload updated manifest | |
if: always() | |
uses: actions/upload-artifact@v4 | |
with: | |
name: deployment-manifest-snap | |
path: deployment_manifest.json | |
retention-days: 1 | |
- name: Upload store state | |
if: always() | |
uses: actions/upload-artifact@v4 | |
with: | |
name: store-state | |
path: store_state.json | |
retention-days: 1 | |
deploy-microsoft-store: | |
name: Deploy to Microsoft Store | |
runs-on: windows-latest | |
needs: [ calculate-version, package-windows-msix, deploy-snap ] | |
if: | | |
needs.calculate-version.outputs.dry_run == 'false' && | |
(needs.calculate-version.outputs.stage_only == 'true' || needs.deploy-snap.result == 'success') | |
continue-on-error: ${{ needs.calculate-version.outputs.stage_only == 'true' }} | |
steps: | |
- name: Checkout for scripts | |
uses: actions/checkout@v4 | |
- name: Check Microsoft Store credentials | |
shell: pwsh | |
run: | | |
Write-Host "🔍 Checking Microsoft Store credentials..." | |
if ("${{ secrets.MS_TENANT_ID }}" -ne "") { | |
Write-Host " ✅ MS_TENANT_ID is configured" | |
} else { | |
Write-Host " ❌ MS_TENANT_ID is NOT configured" | |
} | |
if ("${{ secrets.MS_CLIENT_ID }}" -ne "") { | |
Write-Host " ✅ MS_CLIENT_ID is configured" | |
} else { | |
Write-Host " ❌ MS_CLIENT_ID is NOT configured" | |
} | |
if ("${{ secrets.MS_CLIENT_SECRET }}" -ne "") { | |
Write-Host " ✅ MS_CLIENT_SECRET is configured" | |
} else { | |
Write-Host " ❌ MS_CLIENT_SECRET is NOT configured" | |
} | |
if ("${{ secrets.MS_APP_ID }}" -ne "") { | |
Write-Host " ✅ MS_APP_ID is configured" | |
} else { | |
Write-Host " ❌ MS_APP_ID is NOT configured" | |
} | |
$allConfigured = ("${{ secrets.MS_TENANT_ID }}" -ne "") -and | |
("${{ secrets.MS_CLIENT_ID }}" -ne "") -and | |
("${{ secrets.MS_CLIENT_SECRET }}" -ne "") -and | |
("${{ secrets.MS_APP_ID }}" -ne "") | |
if ($allConfigured) { | |
Write-Host "✅ All Microsoft Store credentials are configured" | |
} else { | |
Write-Host "⚠️ Some Microsoft Store credentials are missing" | |
Write-Host "::warning::Microsoft Store deployment may fail due to missing credentials" | |
} | |
- name: Download MSIX package | |
uses: actions/download-artifact@v4 | |
with: | |
name: windows-msix | |
path: . | |
- name: Download deployment manifest | |
uses: actions/download-artifact@v4 | |
with: | |
name: deployment-manifest-snap | |
path: . | |
- name: Setup and Submit to Microsoft Store | |
shell: pwsh | |
env: | |
MS_TENANT_ID: ${{ secrets.MS_TENANT_ID }} | |
MS_CLIENT_ID: ${{ secrets.MS_CLIENT_ID }} | |
MS_CLIENT_SECRET: ${{ secrets.MS_CLIENT_SECRET }} | |
MS_APP_ID: ${{ secrets.MS_APP_ID }} | |
run: | | |
# Install Windows Store CLI tools | |
Write-Host "Installing StoreBroker module..." | |
Install-Module -Name StoreBroker -Force -Scope CurrentUser -AllowClobber | |
Import-Module StoreBroker | |
# Validate credentials are available | |
if ([string]::IsNullOrWhiteSpace($env:MS_TENANT_ID) -or | |
[string]::IsNullOrWhiteSpace($env:MS_CLIENT_ID) -or | |
[string]::IsNullOrWhiteSpace($env:MS_CLIENT_SECRET)) { | |
Write-Error "Missing Microsoft Store credentials. Please check MS_TENANT_ID, MS_CLIENT_ID, and MS_CLIENT_SECRET secrets." | |
exit 1 | |
} | |
# Authenticate to Partner Center | |
Write-Host "Authenticating to Microsoft Partner Center..." | |
Write-Host "Tenant ID: $env:MS_TENANT_ID" | |
Write-Host "Client ID: $env:MS_CLIENT_ID" | |
try { | |
$securePassword = ConvertTo-SecureString $env:MS_CLIENT_SECRET -AsPlainText -Force | |
$credential = New-Object System.Management.Automation.PSCredential($env:MS_CLIENT_ID, $securePassword) | |
Set-StoreBrokerAuthentication -TenantId $env:MS_TENANT_ID -Credential $credential | |
Write-Host "✅ Successfully authenticated to Microsoft Partner Center" | |
} | |
catch { | |
Write-Error "Failed to authenticate to Microsoft Partner Center: $_" | |
exit 1 | |
} | |
# Now create and submit the app submission | |
Write-Host "Creating Microsoft Store submission..." | |
$appId = $env:MS_APP_ID | |
if ([string]::IsNullOrWhiteSpace($appId)) { | |
Write-Error "MS_APP_ID is not set or is empty" | |
exit 1 | |
} | |
$msixPath = Get-ChildItem -Path "." -Filter "*.msixbundle" | Select-Object -First 1 | |
if ($null -eq $msixPath) { | |
Write-Error "No MSIX bundle found in current directory" | |
exit 1 | |
} | |
Write-Host "Found MSIX bundle: $($msixPath.Name)" | |
Write-Host "App ID: $appId" | |
try { | |
# Test API access first | |
Write-Host "Testing Partner Center API access..." | |
try { | |
# This should work if permissions are correct | |
$testApps = Get-Applications | |
Write-Host "✅ Successfully accessed Partner Center API" | |
Write-Host "Found $($testApps.Count) apps in account" | |
} | |
catch { | |
Write-Host "❌ Cannot access Partner Center API" | |
Write-Host "Error details: $_" | |
Write-Host "This usually means:" | |
Write-Host " 1. The Azure AD app doesn't have Partner Center API permissions" | |
Write-Host " 2. Admin consent hasn't been granted for the permissions" | |
Write-Host " 3. The app isn't properly linked in Partner Center" | |
throw | |
} | |
# Create new submission | |
Write-Host "Creating new submission..." | |
$submission = New-ApplicationSubmission -AppId $appId -Force | |
# Update submission with new package | |
Write-Host "Updating submission with package..." | |
Update-ApplicationSubmission -AppId $appId -SubmissionDataPath $submission -PackagePath $msixPath.FullName | |
# Determine publish mode based on stage_only flag | |
if ("${{ needs.calculate-version.outputs.stage_only }}" -eq "true") { | |
Write-Host "📦 STAGE ONLY MODE - Creating draft submission (not publishing)" | |
$publishMode = "Manual" | |
} else { | |
Write-Host "📦 PRODUCTION MODE - Submitting for immediate publication" | |
$publishMode = "Immediate" | |
} | |
# Submit to Store | |
Write-Host "Submitting to store with publish mode: $publishMode" | |
$submissionId = Submit-ApplicationSubmission -AppId $appId -SubmissionDataPath $submission -TargetPublishMode $publishMode | |
Write-Host "✅ Submission created successfully with ID: $submissionId" | |
if ("${{ needs.calculate-version.outputs.stage_only }}" -eq "true") { | |
Write-Host "Draft submission created. Manual review and publish required in Partner Center" | |
} else { | |
Write-Host "Submission will be published after Partner Center review" | |
} | |
} | |
catch { | |
Write-Error "Failed to create Microsoft Store submission: $_" | |
exit 1 | |
} | |
- name: Upload updated manifest | |
if: always() | |
uses: actions/upload-artifact@v4 | |
with: | |
name: deployment-manifest-msstore | |
path: deployment_manifest.json | |
retention-days: 1 | |
deploy-apple-store: | |
name: Deploy to Apple App Store | |
runs-on: macos-latest | |
needs: [ calculate-version, package-mac-app-store, deploy-snap ] | |
if: | | |
needs.calculate-version.outputs.dry_run == 'false' && | |
(needs.calculate-version.outputs.stage_only == 'true' || needs.deploy-snap.result == 'success') | |
continue-on-error: ${{ needs.calculate-version.outputs.stage_only == 'true' }} | |
steps: | |
- name: Checkout for scripts | |
uses: actions/checkout@v4 | |
- name: Check Apple Store credentials | |
run: | | |
echo "🔍 Checking Apple Store credentials..." | |
if [ -n "${{ secrets.APP_STORE_CONNECT_API_KEY }}" ]; then | |
echo " ✅ APP_STORE_CONNECT_API_KEY is configured" | |
else | |
echo " ❌ APP_STORE_CONNECT_API_KEY is NOT configured" | |
fi | |
if [ -n "${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}" ]; then | |
echo " ✅ APP_STORE_CONNECT_API_KEY_ID is configured" | |
else | |
echo " ❌ APP_STORE_CONNECT_API_KEY_ID is NOT configured" | |
fi | |
if [ -n "${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}" ]; then | |
echo " ✅ APP_STORE_CONNECT_ISSUER_ID is configured" | |
else | |
echo " ❌ APP_STORE_CONNECT_ISSUER_ID is NOT configured" | |
fi | |
if [ -n "${{ secrets.APPLE_ID }}" ]; then | |
echo " ✅ APPLE_ID is configured" | |
else | |
echo " ❌ APPLE_ID is NOT configured" | |
fi | |
if [ -n "${{ secrets.APP_STORE_CONNECT_API_KEY }}" ] && [ -n "${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}" ] && [ -n "${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}" ]; then | |
echo "✅ All Apple Store credentials are configured" | |
else | |
echo "⚠️ Some Apple Store credentials are missing" | |
echo "::warning::Apple Store deployment may fail due to missing credentials" | |
fi | |
- name: Download Mac App Store package | |
uses: actions/download-artifact@v4 | |
with: | |
name: mac-app-store | |
path: . | |
- name: Download deployment manifest | |
uses: actions/download-artifact@v4 | |
with: | |
name: deployment-manifest-snap | |
path: . | |
continue-on-error: true | |
- name: Setup App Store Connect API Key | |
env: | |
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }} | |
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} | |
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} | |
run: | | |
# Check if credentials are available | |
if [ -z "$APP_STORE_CONNECT_API_KEY" ] || [ -z "$APP_STORE_CONNECT_API_KEY_ID" ] || [ -z "$APP_STORE_CONNECT_ISSUER_ID" ]; then | |
echo "⚠️ Apple Store Connect API credentials are not configured" | |
echo "Skipping API key setup" | |
exit 0 | |
fi | |
# Create private keys directory | |
mkdir -p ~/.appstoreconnect/private_keys | |
# Save API key | |
echo "$APP_STORE_CONNECT_API_KEY" > ~/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8 | |
# Set proper permissions | |
chmod 600 ~/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8 | |
echo "✅ Apple Store Connect API key configured" | |
- name: Upload to App Store Connect | |
env: | |
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} | |
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} | |
APPLE_ID: ${{ secrets.APPLE_ID }} | |
run: | | |
# Check if credentials are available | |
if [ -z "$APP_STORE_CONNECT_API_KEY_ID" ] || [ -z "$APP_STORE_CONNECT_ISSUER_ID" ]; then | |
echo "⚠️ Apple Store Connect API credentials are not configured" | |
echo "Missing one or more of: APP_STORE_CONNECT_API_KEY_ID, APP_STORE_CONNECT_ISSUER_ID" | |
if [ "${{ needs.calculate-version.outputs.stage_only }}" = "true" ]; then | |
echo "Skipping Apple Store upload in stage-only mode due to missing credentials" | |
echo "To test Apple Store uploads, please configure the following secrets:" | |
echo " - APP_STORE_CONNECT_API_KEY" | |
echo " - APP_STORE_CONNECT_API_KEY_ID" | |
echo " - APP_STORE_CONNECT_ISSUER_ID" | |
exit 0 # Don't fail in stage-only mode | |
else | |
echo "::error::Production deployment requires Apple Store Connect credentials" | |
exit 1 | |
fi | |
fi | |
# Find the .pkg file | |
PKG_FILE=$(find . -name "*.pkg" -type f | head -1) | |
if [ -z "$PKG_FILE" ]; then | |
echo "❌ No .pkg file found in the current directory" | |
exit 1 | |
fi | |
if [ "${{ needs.calculate-version.outputs.stage_only }}" = "true" ]; then | |
echo "📦 STAGE ONLY MODE - Uploading for TestFlight only (not for App Store review)" | |
else | |
echo "📦 PRODUCTION MODE - Uploading for App Store release" | |
fi | |
echo "Uploading $PKG_FILE to App Store Connect..." | |
# Upload using Transporter (altool is deprecated) | |
xcrun iTMSTransporter -m upload \ | |
-assetFile "$PKG_FILE" \ | |
-apiKey "$APP_STORE_CONNECT_API_KEY_ID" \ | |
-apiIssuer "$APP_STORE_CONNECT_ISSUER_ID" \ | |
-v detailed \ | |
-verboseLogging | |
echo "✅ Package uploaded to App Store Connect" | |
if [ "${{ needs.calculate-version.outputs.stage_only }}" = "true" ]; then | |
echo "📝 Package available for TestFlight distribution only" | |
echo "📝 Manual submission required for App Store release" | |
else | |
echo "📝 Review and submit for App Store review in App Store Connect" | |
fi | |
- name: Create TestFlight release (optional) | |
env: | |
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} | |
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} | |
run: | | |
if [ "${{ needs.calculate-version.outputs.stage_only }}" = "true" ]; then | |
echo "📦 Stage only mode: Package uploaded for TestFlight testing only" | |
echo "TestFlight distribution can be configured in App Store Connect" | |
else | |
echo "Package is now available in App Store Connect for TestFlight distribution" | |
echo "Manual review and release required via App Store Connect dashboard" | |
fi | |
- name: Upload updated manifest | |
if: always() | |
uses: actions/upload-artifact@v4 | |
with: | |
name: deployment-manifest-appstore | |
path: deployment_manifest.json | |
retention-days: 1 | |
# Create GitHub release after stores are deployed | |
create-github-release: | |
name: Create GitHub Release | |
runs-on: ubuntu-latest | |
needs: [ calculate-version, deploy-snap, deploy-microsoft-store, deploy-apple-store ] | |
if: | | |
always() && | |
needs.calculate-version.result == 'success' && | |
(needs.calculate-version.outputs.stage_only == 'true' || | |
(needs.deploy-snap.result == 'success' && | |
needs.deploy-microsoft-store.result == 'success' && | |
needs.deploy-apple-store.result == 'success')) | |
continue-on-error: ${{ needs.calculate-version.outputs.stage_only == 'true' }} | |
permissions: | |
contents: write | |
steps: | |
- name: Checkout | |
uses: actions/checkout@v4 | |
- name: Download deployment manifest | |
uses: actions/download-artifact@v4 | |
with: | |
name: deployment-manifest-snap | |
path: . | |
continue-on-error: true | |
- name: Download all signed artifacts | |
uses: actions/download-artifact@v4 | |
with: | |
name: signed-artifacts | |
path: release-assets/ | |
- name: Prepare release assets | |
run: | | |
mkdir -p final-assets | |
echo "📦 Preparing release assets..." | |
# Function to copy only non-empty files | |
copy_if_not_empty() { | |
local src="$1" | |
local dest="$2" | |
if [ -f "$src" ]; then | |
size=$(stat -c%s "$src" 2>/dev/null || stat -f%z "$src" 2>/dev/null || echo "0") | |
if [ "$size" -gt 0 ]; then | |
echo " ✅ Including $(basename "$src") (${size} bytes)" | |
cp "$src" "$dest" | |
else | |
echo " ⚠️ Skipping $(basename "$src") (empty file)" | |
fi | |
fi | |
} | |
# Copy Windows MSIX bundle (for users who prefer direct download) | |
for file in release-assets/windows-msix/*.msixbundle release-assets/windows-msix/*.sig; do | |
[ -f "$file" ] && copy_if_not_empty "$file" final-assets/ | |
done || true | |
# Copy Mac App Store package (for users who prefer direct download) | |
for file in release-assets/mac-app-store/*.pkg release-assets/mac-app-store/*.sig; do | |
[ -f "$file" ] && copy_if_not_empty "$file" final-assets/ | |
done || true | |
- name: Generate checksums | |
working-directory: final-assets | |
run: | | |
if [ "$(ls -A .)" ]; then | |
sha256sum * > SHA256SUMS.txt || true | |
else | |
echo "No files to generate checksums for" | |
echo "No release assets available" > SHA256SUMS.txt | |
fi | |
- name: Validate release assets | |
working-directory: final-assets | |
run: | | |
echo "📋 Final release assets:" | |
echo "" | |
for file in *; do | |
if [ -f "$file" ]; then | |
size=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null || echo "0") | |
if [ "$size" -gt 0 ]; then | |
echo " ✅ $file (${size} bytes)" | |
else | |
echo " ❌ $file is empty - removing" | |
rm "$file" | |
fi | |
fi | |
done | |
echo "" | |
echo "Total files: $(ls -1 | wc -l)" | |
- name: Create Release | |
id: create_release | |
if: needs.calculate-version.outputs.dry_run == 'false' | |
uses: softprops/action-gh-release@v2 | |
with: | |
tag_name: v${{ needs.calculate-version.outputs.version }} | |
name: v${{ needs.calculate-version.outputs.version }} | |
body: | | |
# WhoDB v${{ needs.calculate-version.outputs.version }} | |
## Release Notes | |
${{ needs.calculate-version.outputs.pr_body }} | |
## Docker Images | |
- `docker pull clidey/whodb:${{ needs.calculate-version.outputs.version }}` | |
- `docker pull clidey/whodb:latest` | |
## Installation | |
**Snap:** | |
```bash | |
sudo snap install whodb | |
``` | |
**Flatpak:** | |
Available on [Flathub](https://flathub.org/apps/com.clidey.whodb) | |
**Windows:** | |
Available on Microsoft Store | |
**macOS:** | |
Available on Mac App Store | |
## Verification | |
All binaries are signed with Sigstore. Verify signatures using cosign: | |
```bash | |
cosign verify-blob --signature <file>.sig --certificate <file>.pem <file> | |
``` | |
files: final-assets/* | |
draft: ${{ needs.calculate-version.outputs.stage_only == 'true' }} | |
prerelease: false | |
- name: Track GitHub release | |
if: needs.calculate-version.outputs.dry_run == 'false' | |
run: | | |
bash scripts/track-deployment.sh github_release deployed ${{ needs.calculate-version.outputs.version }} "${{ steps.create_release.outputs.id }}" | |
- name: Dry run summary | |
if: needs.calculate-version.outputs.dry_run == 'true' | |
run: | | |
echo "🏃 DRY RUN MODE - Would have created GitHub release:" | |
echo " - Tag: v${{ needs.calculate-version.outputs.version }}" | |
if [ "${{ needs.calculate-version.outputs.stage_only }}" = "true" ]; then | |
echo " - Status: Draft (stage only)" | |
else | |
echo " - Status: Published" | |
fi | |
echo " - Files:" | |
ls -lh final-assets/ | |
- name: Upload updated manifest | |
if: always() | |
uses: actions/upload-artifact@v4 | |
with: | |
name: deployment-manifest-github | |
path: deployment_manifest.json | |
retention-days: 1 | |
# Deploy Docker images last (hardest to rollback) | |
deploy-docker: | |
name: Deploy Docker Images | |
runs-on: ubuntu-latest | |
needs: [ calculate-version, create-github-release ] | |
if: | | |
always() && | |
needs.calculate-version.result == 'success' && | |
(needs.calculate-version.outputs.stage_only == 'true' || | |
needs.create-github-release.result == 'success') | |
continue-on-error: ${{ needs.calculate-version.outputs.stage_only == 'true' }} | |
steps: | |
- name: Checkout for scripts | |
uses: actions/checkout@v4 | |
- name: Check Docker Hub credentials | |
run: | | |
echo "🔍 Checking Docker Hub credentials..." | |
if [ -n "${{ secrets.DOCKERHUB_USERNAME }}" ]; then | |
echo " ✅ DOCKERHUB_USERNAME is configured" | |
else | |
echo " ❌ DOCKERHUB_USERNAME is NOT configured" | |
fi | |
if [ -n "${{ secrets.DOCKERHUB_TOKEN }}" ]; then | |
echo " ✅ DOCKERHUB_TOKEN is configured" | |
else | |
echo " ❌ DOCKERHUB_TOKEN is NOT configured" | |
fi | |
if [ -n "${{ secrets.DOCKERHUB_USERNAME }}" ] && [ -n "${{ secrets.DOCKERHUB_TOKEN }}" ]; then | |
echo "✅ All Docker Hub credentials are configured" | |
else | |
echo "❌ Docker Hub credentials are missing" | |
if [ "${{ needs.calculate-version.outputs.dry_run }}" = "false" ] && [ "${{ needs.calculate-version.outputs.stage_only }}" = "false" ]; then | |
echo "::error::Production deployment requires Docker Hub credentials" | |
exit 1 | |
elif [ "${{ needs.calculate-version.outputs.stage_only }}" = "true" ]; then | |
echo "ℹ️ Docker Hub credentials not required for stage-only mode (Docker push is skipped)" | |
fi | |
fi | |
- name: Download Docker amd64 artifact | |
uses: actions/download-artifact@v4 | |
with: | |
name: docker-image-amd64 | |
path: /tmp | |
- name: Download Docker arm64 artifact | |
uses: actions/download-artifact@v4 | |
with: | |
name: docker-image-arm64 | |
path: /tmp | |
- name: Download deployment manifest | |
uses: actions/download-artifact@v4 | |
with: | |
name: deployment-manifest-github | |
path: . | |
- name: Load Docker images | |
run: | | |
docker load --input /tmp/whodb-docker-amd64.tar | |
docker load --input /tmp/whodb-docker-arm64.tar | |
- name: Login to Docker Hub | |
uses: docker/login-action@v3 | |
with: | |
username: ${{ secrets.DOCKERHUB_USERNAME }} | |
password: ${{ secrets.DOCKERHUB_TOKEN }} | |
- name: Install Cosign | |
if: needs.calculate-version.outputs.dry_run == 'false' | |
uses: sigstore/cosign-installer@v3 | |
- name: Set up Docker Buildx for manifest | |
uses: docker/setup-buildx-action@v3 | |
- name: Push Docker images and create manifest | |
if: needs.calculate-version.outputs.dry_run == 'false' && needs.calculate-version.outputs.stage_only != 'true' | |
run: | | |
# Push individual platform images | |
docker push clidey/whodb:${{ needs.calculate-version.outputs.version }}-amd64 || exit 1 | |
docker push clidey/whodb:${{ needs.calculate-version.outputs.version }}-arm64 || exit 1 | |
# Create and push multi-platform manifests | |
docker buildx imagetools create -t clidey/whodb:${{ needs.calculate-version.outputs.version }} \ | |
clidey/whodb:${{ needs.calculate-version.outputs.version }}-amd64 \ | |
clidey/whodb:${{ needs.calculate-version.outputs.version }}-arm64 | |
docker buildx imagetools create -t clidey/whodb:latest \ | |
clidey/whodb:${{ needs.calculate-version.outputs.version }}-amd64 \ | |
clidey/whodb:${{ needs.calculate-version.outputs.version }}-arm64 | |
bash scripts/track-deployment.sh docker deployed ${{ needs.calculate-version.outputs.version }} | |
- name: Sign Docker images | |
if: needs.calculate-version.outputs.dry_run == 'false' && needs.calculate-version.outputs.stage_only != 'true' | |
run: | | |
cosign sign --yes clidey/whodb:${{ needs.calculate-version.outputs.version }} | |
cosign sign --yes clidey/whodb:latest | |
- name: Stage only summary | |
if: needs.calculate-version.outputs.stage_only == 'true' | |
run: | | |
echo "📦 STAGE ONLY MODE" | |
echo "✅ Successfully logged in to Docker Hub" | |
echo "✅ Docker images built and loaded locally" | |
echo "⏸️ Docker push skipped (stage-only mode behaves like dry run for Docker)" | |
echo "📝 Images ready but not published:" | |
echo " - clidey/whodb:${{ needs.calculate-version.outputs.version }}" | |
echo " - clidey/whodb:latest" | |
- name: Dry run summary | |
if: needs.calculate-version.outputs.dry_run == 'true' | |
run: | | |
echo "🏃 DRY RUN MODE" | |
echo "✅ Successfully logged in to Docker Hub" | |
echo "📦 Would have pushed:" | |
echo " - clidey/whodb:${{ needs.calculate-version.outputs.version }}" | |
echo " - clidey/whodb:latest" | |
echo "🔏 Would have signed both images with Sigstore" | |
- name: Upload updated manifest | |
if: always() | |
uses: actions/upload-artifact@v4 | |
with: | |
name: deployment-manifest-docker | |
path: deployment_manifest.json | |
retention-days: 1 | |
rollback: | |
name: Rollback on Failure | |
runs-on: ubuntu-latest | |
if: failure() && needs.calculate-version.outputs.dry_run == 'false' && needs.calculate-version.outputs.stage_only != 'true' | |
needs: [ calculate-version, deploy-docker, create-github-release, deploy-snap, deploy-microsoft-store, deploy-apple-store ] | |
steps: | |
- name: Checkout | |
uses: actions/checkout@v4 | |
- name: Download deployment manifest | |
uses: actions/download-artifact@v4 | |
with: | |
name: deployment-manifest-docker | |
path: . | |
continue-on-error: true | |
- name: Download store state | |
uses: actions/download-artifact@v4 | |
with: | |
name: store-state | |
path: . | |
continue-on-error: true | |
- name: Enhanced comprehensive rollback | |
env: | |
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} | |
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} | |
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} | |
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
GITHUB_REPOSITORY: ${{ github.repository }} | |
MS_TENANT_ID: ${{ secrets.MS_TENANT_ID }} | |
MS_CLIENT_ID: ${{ secrets.MS_CLIENT_ID }} | |
MS_CLIENT_SECRET: ${{ secrets.MS_CLIENT_SECRET }} | |
MS_APP_ID: ${{ secrets.MS_APP_ID }} | |
run: | | |
bash scripts/rollback-all.sh \ | |
${{ needs.calculate-version.outputs.version }} \ | |
${{ needs.calculate-version.outputs.previous_version }} \ | |
deployment_manifest.json \ | |
store_state.json | |
- name: Notify rollback | |
run: | | |
echo "::error::Deployment failed and has been rolled back" | |
echo "Failed version: ${{ needs.calculate-version.outputs.version }}" | |
echo "Reverted to: ${{ needs.calculate-version.outputs.previous_version }}" | |
echo "" | |
echo "Rollback actions taken:" | |
echo " - Docker: Reverted latest tag to ${{ needs.calculate-version.outputs.previous_version }}" | |
echo " - Docker: Deleted tag ${{ needs.calculate-version.outputs.version }}" | |
echo " - Snap: Manual verification required in Snapcraft dashboard" | |
echo " - GitHub: Release and tag deleted if they existed" | |
exit 1 | |
verify-deployment: | |
name: Verify Deployment Success | |
runs-on: ubuntu-latest | |
needs: [ calculate-version, deploy-docker ] | |
if: needs.calculate-version.outputs.dry_run == 'false' && needs.calculate-version.outputs.stage_only != 'true' | |
steps: | |
- name: Checkout | |
uses: actions/checkout@v4 | |
- name: Download final deployment manifest | |
uses: actions/download-artifact@v4 | |
with: | |
name: deployment-manifest-docker | |
path: . | |
- name: Verify all deployments successful | |
run: | | |
echo "Verifying deployment status..." | |
DOCKER_STATUS=$(jq -r '.docker.status' deployment_manifest.json) | |
SNAP_STATUS=$(jq -r '.snap.status' deployment_manifest.json) | |
GITHUB_STATUS=$(jq -r '.github_release.status' deployment_manifest.json) | |
echo "Docker deployment: $DOCKER_STATUS" | |
echo "Snap deployment: $SNAP_STATUS" | |
echo "GitHub release: $GITHUB_STATUS" | |
if [ "$DOCKER_STATUS" != "deployed" ] || [ "$SNAP_STATUS" != "deployed" ] || [ "$GITHUB_STATUS" != "deployed" ]; then | |
echo "::error::Not all deployments completed successfully" | |
exit 1 | |
fi | |
echo "✓ All deployments verified successful" | |
- name: Test Docker image availability | |
run: | | |
echo "Testing Docker image pull..." | |
docker pull clidey/whodb:${{ needs.calculate-version.outputs.version }} || exit 1 | |
echo "✓ Docker image is accessible" | |
- name: Verify GitHub release | |
env: | |
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
run: | | |
echo "Verifying GitHub release..." | |
RELEASE_URL="https://api.github.com/repos/${{ github.repository }}/releases/tags/v${{ needs.calculate-version.outputs.version }}" | |
RELEASE_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: token $GITHUB_TOKEN" "$RELEASE_URL") | |
if [ "$RELEASE_STATUS" != "200" ]; then | |
echo "::error::GitHub release not found (HTTP $RELEASE_STATUS)" | |
exit 1 | |
fi | |
echo "✓ GitHub release verified" | |
# Summary job for stage-only mode to report what worked and what didn't | |
stage-only-summary: | |
name: Stage-Only Mode Summary | |
runs-on: ubuntu-latest | |
if: | | |
always() && | |
needs.calculate-version.outputs.stage_only == 'true' && | |
needs.calculate-version.result == 'success' | |
needs: [ calculate-version, deploy-snap, deploy-microsoft-store, deploy-apple-store, create-github-release, deploy-docker, package-mac-app-store, package-windows-msix ] | |
steps: | |
- name: Generate deployment summary | |
run: | | |
echo "📊 STAGE-ONLY MODE DEPLOYMENT SUMMARY" | |
echo "=====================================" | |
echo "" | |
echo "Deployment Mode: ${{ needs.calculate-version.outputs.deployment_mode }}" | |
echo "Version: ${{ needs.calculate-version.outputs.version }}" | |
echo "" | |
echo "📦 Store Deployments (Best Effort):" | |
echo "" | |
# Check each deployment status | |
if [ "${{ needs.deploy-snap.result }}" = "success" ]; then | |
echo "✅ Snap Store: Successfully staged to candidate channel" | |
elif [ "${{ needs.deploy-snap.result }}" = "skipped" ]; then | |
echo "⏭️ Snap Store: Skipped" | |
else | |
echo "❌ Snap Store: Failed (check logs for details)" | |
fi | |
if [ "${{ needs.deploy-microsoft-store.result }}" = "success" ]; then | |
echo "✅ Microsoft Store: Successfully created draft submission" | |
elif [ "${{ needs.deploy-microsoft-store.result }}" = "skipped" ]; then | |
echo "⏭️ Microsoft Store: Skipped" | |
else | |
echo "❌ Microsoft Store: Failed (check logs for details)" | |
fi | |
if [ "${{ needs.deploy-apple-store.result }}" = "success" ]; then | |
echo "✅ Apple App Store: Successfully uploaded for TestFlight" | |
elif [ "${{ needs.deploy-apple-store.result }}" = "skipped" ]; then | |
echo "⏭️ Apple App Store: Skipped" | |
else | |
echo "❌ Apple App Store: Failed (check logs for details)" | |
fi | |
echo "" | |
echo "📄 GitHub Release:" | |
if [ "${{ needs.create-github-release.result }}" = "success" ]; then | |
echo "✅ Draft release created successfully" | |
elif [ "${{ needs.create-github-release.result }}" = "skipped" ]; then | |
echo "⏭️ GitHub Release: Skipped" | |
else | |
echo "❌ Failed to create draft release" | |
fi | |
echo "" | |
echo "🐳 Docker:" | |
if [ "${{ needs.deploy-docker.result }}" = "success" ]; then | |
echo "✅ Images built and loaded (not pushed in stage-only mode)" | |
elif [ "${{ needs.deploy-docker.result }}" = "skipped" ]; then | |
echo "⏭️ Docker: Skipped" | |
else | |
echo "❌ Failed to build Docker images" | |
fi | |
echo "" | |
echo "📦 Packaging Status:" | |
if [ "${{ needs.package-mac-app-store.result }}" = "success" ]; then | |
echo "✅ Mac App Store package created" | |
elif [ "${{ needs.package-mac-app-store.result }}" = "skipped" ]; then | |
echo "⏭️ Mac App Store package: Skipped" | |
else | |
echo "❌ Mac App Store package failed" | |
fi | |
if [ "${{ needs.package-windows-msix.result }}" = "success" ]; then | |
echo "✅ Windows MSIX package created" | |
elif [ "${{ needs.package-windows-msix.result }}" = "skipped" ]; then | |
echo "⏭️ Windows MSIX package: Skipped" | |
else | |
echo "❌ Windows MSIX package failed" | |
fi | |
echo "" | |
echo "=====================================" | |
echo "" | |
echo "ℹ️ Stage-only mode uses a best-effort approach." | |
echo "ℹ️ Failed deployments did not block other deployments." | |
echo "ℹ️ Check individual job logs for failure details." |