Skip to content

Release Build eCan #157

Release Build eCan

Release Build eCan #157

Workflow file for this run

name: Release Build eCan
# ============================================================================
# Workflow Overview
# ============================================================================
# This workflow builds and releases eCan application for multiple platforms.
#
# Architecture:
# - validate-tag: Validates git ref and computes version
# - build-windows: Builds Windows amd64 installer
# - build-macos: Matrix build for macOS (amd64 + aarch64)
# - upload-to-s3: Uploads artifacts to S3 and generates OTA feeds
#
# ============================================================================
# Configuration Parameters
# ============================================================================
# Build platform, CPU architecture and reference can be selected via workflow_dispatch:
# - platform: windows, macos, all
# - arch: amd64, aarch64, all
# - ref: Branch name or tag (e.g., gui-v2, master, v0.0.4)
# - upload_artifacts: Upload to GitHub Artifacts (default: false to save costs)
#
# Platform Support:
# - Windows: amd64 only (GitHub Actions has no ARM64 runner)
# - macOS: amd64 (Intel) + aarch64 (Apple Silicon)
#
# ============================================================================
# Artifact Storage Strategy (Cost Optimization)
# ============================================================================
# - S3 Upload: ALWAYS happens when build succeeds (primary storage)
# - GitHub Artifacts: OPTIONAL (default: disabled) - only for debugging
# - Cost Saving: GitHub Artifacts ~$0.008/GB/day, S3 is much cheaper
#
# ============================================================================
# Trigger Examples
# ============================================================================
# - Manual: Enter branch/tag name in workflow_dispatch
# - Auto: Push tag or publish release (default: all platforms + amd64)
on:
# Trigger conditions: create tag or release
# push:
# tags:
# - 'v*' # Match formats like v1.0.0, v2.1.3
# release:
# types: [published, edited, created]
# Manual trigger
workflow_dispatch:
inputs:
platform:
description: 'Build platform (windows, macos, all)'
required: true
default: 'all'
type: choice
options:
- all
- windows
- macos
arch:
description: 'CPU architecture (all: build all supported, amd64: x86_64 only, aarch64: ARM64 only)'
required: true
default: 'all'
type: choice
options:
- all
- amd64
- aarch64
ref:
description: 'Git ref to build (branch name or tag, e.g., master, gui-v2, v0.0.4)'
required: true
default: 'master'
type: string
environment:
description: 'Target environment'
required: false
default: 'production'
type: choice
options:
- production
- staging
- test
- development
channel:
description: 'Release channel'
required: false
default: 'stable'
type: choice
options:
- stable
- beta
upload_artifacts:
description: 'Upload build artifacts to GitHub Artifacts (for debugging only, costs extra storage). S3 upload always happens regardless of this setting.'
required: false
default: 'false'
type: choice
options:
- 'true'
- 'false'
concurrency:
group: ecan-release-${{ github.ref }}-${{ github.event.inputs.platform }}-${{ github.event.inputs.arch }}
cancel-in-progress: true
jobs:
# ============================================================================
# Validation Layer: Validate ref and compute version
# Tags produce semantic version; branches get fallback version
# ============================================================================
validate-tag:
runs-on: ubuntu-latest
outputs:
tag-valid: ${{ steps.validate.outputs.valid }}
version: ${{ steps.validate.outputs.version }}
is-branch: ${{ steps.validate.outputs.is_branch }}
ref-name: ${{ steps.validate.outputs.ref_name }}
environment: ${{ steps.detect-env.outputs.environment }}
channel: ${{ steps.detect-env.outputs.channel }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.ref || github.ref }}
fetch-depth: 0 # Fetch all history for tags and branches
- name: Validate ref and compute version
id: validate
shell: bash
run: |
set -euo pipefail
INPUT_REF="${{ github.event.inputs.ref }}"
REF_FULL="${GITHUB_REF}"
# Determine the actual ref to use
if [ -n "$INPUT_REF" ]; then
REF_NAME="$INPUT_REF"
else
REF_NAME="${REF_FULL#refs/*/}"
fi
echo "ref_name=$REF_NAME" >> $GITHUB_OUTPUT
echo "Selected ref: $REF_NAME"
# Simple validation with helpful error message
if ! (git show-ref --verify --quiet "refs/heads/$REF_NAME" 2>/dev/null || \
git show-ref --verify --quiet "refs/tags/$REF_NAME" 2>/dev/null || \
git show-ref --verify --quiet "refs/remotes/origin/$REF_NAME" 2>/dev/null); then
echo "[ERROR] Ref '$REF_NAME' does not exist!"
echo "Available branches: $(git branch -r --format='%(refname:short)' | sed 's/origin\///' | grep -v '^HEAD' | sort | tr '\n' ' ')"
echo "Available tags: $(git tag --sort=-version:refname | head -5 | tr '\n' ' ')"
exit 1
fi
if [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?(\+[A-Za-z0-9.-]+)?$ ]]; then
echo "valid=true" >> $GITHUB_OUTPUT
echo "is_branch=false" >> $GITHUB_OUTPUT
echo "version=${REF_NAME#v}" >> $GITHUB_OUTPUT
echo "Tag detected: $REF_NAME"
else
# Treat as branch; read VERSION file as base version
if [ -f "VERSION" ]; then
BASE_VERSION=$(cat VERSION | tr -d '[:space:]')
echo "Read base version from VERSION file: $BASE_VERSION"
else
BASE_VERSION="0.0.0"
echo "VERSION file not found, using default: $BASE_VERSION"
fi
SHORT_SHA=$(git rev-parse --short HEAD)
SAFE_BRANCH=$(echo "$REF_NAME" | tr '/' '-')
FALLBACK="${BASE_VERSION}-${SAFE_BRANCH}-${SHORT_SHA}"
echo "valid=true" >> $GITHUB_OUTPUT
echo "is_branch=true" >> $GITHUB_OUTPUT
echo "version=$FALLBACK" >> $GITHUB_OUTPUT
echo "Branch detected: $REF_NAME -> version=$FALLBACK"
fi
- name: Detect environment and channel
id: detect-env
shell: bash
run: |
REF_NAME="${{ steps.validate.outputs.ref_name }}"
VERSION="${{ steps.validate.outputs.version }}"
INPUT_ENV="${{ github.event.inputs.environment }}"
INPUT_CHANNEL="${{ github.event.inputs.channel }}"
echo "=== Detecting Environment ==="
echo "Ref: $REF_NAME"
echo "Version: $VERSION"
echo "Input Environment: $INPUT_ENV"
echo "Input Channel: $INPUT_CHANNEL"
# Use manual selection if provided
if [[ -n "$INPUT_ENV" ]]; then
ENV="$INPUT_ENV"
echo "Using manual environment: $ENV"
else
# Auto-detect environment based on ref and version
if [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
# Production: Clean version tag (e.g., v1.0.0)
ENV="production"
elif [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\. ]]; then
# Staging: Release candidate (e.g., v1.0.0-rc.1)
ENV="staging"
elif [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-beta ]]; then
# Test: Beta version (e.g., v1.0.0-beta.1)
ENV="test"
elif [[ "$REF_NAME" == "develop" || "$REF_NAME" == "dev" ]]; then
# Development: develop branch
ENV="development"
elif [[ "$REF_NAME" == "staging" ]]; then
# Staging: staging branch
ENV="staging"
elif [[ "$REF_NAME" == "main" || "$REF_NAME" == "master" ]]; then
# Production: main/master branch
ENV="production"
else
# Default: development for other branches
ENV="development"
fi
echo "Auto-detected environment: $ENV"
fi
# Use manual channel if provided
if [[ -n "$INPUT_CHANNEL" ]]; then
CHANNEL="$INPUT_CHANNEL"
echo "Using manual channel: $CHANNEL"
else
# Auto-detect channel based on environment
case "$ENV" in
production)
CHANNEL="stable"
;;
staging)
CHANNEL="stable"
;;
test)
CHANNEL="beta"
;;
development)
CHANNEL="beta"
;;
*)
CHANNEL="stable"
;;
esac
echo "Auto-detected channel: $CHANNEL"
fi
echo "environment=$ENV" >> $GITHUB_OUTPUT
echo "channel=$CHANNEL" >> $GITHUB_OUTPUT
echo "Final environment: $ENV"
echo "Final channel: $CHANNEL"
# ============================================================================
# BUILD LAYER: Windows amd64
# ============================================================================
# Builds Windows installer with the following steps:
# 1. Environment Setup: Python, Node.js, Playwright, Inno Setup
# 2. Build Process: Frontend + Backend compilation
# 3. Code Signing: Optional Windows Authenticode signing
# 4. Artifact Upload: Short-term (S3 transfer) + Optional long-term
# ============================================================================
build-windows:
name: Build Windows amd64
needs: validate-tag
if: |
needs.validate-tag.outputs.tag-valid == 'true' &&
(github.event.inputs.platform == 'windows' || github.event.inputs.platform == 'all') &&
(github.event.inputs.arch == 'amd64' || github.event.inputs.arch == 'all')
runs-on: windows-latest
env:
# Expose secrets to env for conditional checks without referencing `secrets` in `if:`
WIN_CERT_PFX: ${{ secrets.WIN_CERT_PFX || 'NOT_SET' }}
WIN_CERT_PASSWORD: ${{ secrets.WIN_CERT_PASSWORD || 'NOT_SET' }}
# Set build architecture for the build process (Windows only supports amd64)
BUILD_ARCH: amd64
timeout-minutes: 60
steps:
# ------------------------------------------------------------------------
# Step Group 1: Repository Setup
# ------------------------------------------------------------------------
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.ref || github.ref }}
fetch-depth: 1 # Shallow clone: build only needs current code
# ------------------------------------------------------------------------
# Step Group 2: Environment Setup
# ------------------------------------------------------------------------
# Note: pip caching is handled by setup-python-env action
# Frontend is always built (ECAN_SKIP_FRONTEND_BUILD=0)
- name: Setup Python Environment
uses: ./.github/actions/setup-python-env
with:
platform: windows
requirements-file: requirements-windows.txt
extra-packages: pywin32-ctypes
- name: Setup Playwright Browsers
uses: ./.github/actions/setup-playwright
with:
platform: windows
browsers: chromium
- name: Cache Node.js dependencies
uses: actions/cache@v4
with:
path: gui_v2/node_modules
# Include architecture in cache key to avoid cross-arch contamination (rollup has native binaries)
key: ${{ runner.os }}-${{ env.BUILD_ARCH }}-node-${{ hashFiles('gui_v2/package-lock.json') }}
restore-keys: |
${{ runner.os }}-${{ env.BUILD_ARCH }}-node-
- name: Setup Node.js Environment
uses: ./.github/actions/setup-node-env
# ------------------------------------------------------------------------
# Step Group 3: Windows-Specific Dependencies
# ------------------------------------------------------------------------
- name: Install Windows-specific packages
run: |
# Ensure pywin32 is present in the same interpreter
python -m pip install -U pywin32
# Rarely needed but safe; registers COM bits
python -m pywin32_postinstall -install
echo "=== Verifying pywin32 installation ==="
python -c "import win32api; print('pywin32 installed successfully')"
# ------------------------------------------------------------------------
# Step Group 4: Inno Setup Installation (Windows Installer Builder)
# ------------------------------------------------------------------------
- name: Install Inno Setup
run: |
echo "=== Installing Inno Setup ==="
$DownloadUrl = "https://files.jrsoftware.org/is/6/innosetup-6.6.0.exe"
$DownloadPath = "$env:TEMP\innosetup-6.6.0.exe"
Write-Host "Downloading Inno Setup..."
Invoke-WebRequest -Uri $DownloadUrl -OutFile $DownloadPath -UseBasicParsing
Write-Host "Installing Inno Setup..."
Start-Process -FilePath $DownloadPath -ArgumentList "/SILENT", "/SUPPRESSMSGBOXES", "/NORESTART" -Wait
Write-Host "Inno Setup installation completed"
# Verify installation
$innoPath = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
if (Test-Path $innoPath) {
Write-Host "Inno Setup verified at: $innoPath"
} else {
Write-Host "ERROR: Inno Setup not found at expected location"
exit 1
}
- name: Verify Inno Setup (Unicode) version
shell: pwsh
run: |
$ErrorActionPreference = 'Continue'
$innoPath = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
if (-not (Test-Path $innoPath)) { $innoPath = "${env:ProgramFiles}\Inno Setup 6\ISCC.exe" }
if (-not (Test-Path $innoPath)) {
Write-Host "ERROR: ISCC.exe not found" -ForegroundColor Red
exit 1
}
Write-Host "=== ISCC Version Output ===" -ForegroundColor Cyan
$help = & $innoPath '/?'
# Note: ISCC /? may return non-zero exit code even on success, so we don't check $LASTEXITCODE
$help | ForEach-Object { " $_" }
# Join lines for reliable regex matching; avoids array -notmatch pitfalls
$helpText = $help -join "`n"
if (-not ($helpText -match 'Inno Setup\s+\d')) {
Write-Host "ERROR: Failed to get ISCC version" -ForegroundColor Red
exit 1
}
# Inno Setup 6 compiler is Unicode-only; log as info if not explicitly present
$unicodeHint = ($helpText -match 'Unicode') -or ((Get-Item $innoPath).VersionInfo.FileDescription -match 'Unicode')
if (-not $unicodeHint) {
Write-Host "INFO: Proceeding - Inno Setup 6 is Unicode-only; explicit 'Unicode' string not found in help." -ForegroundColor Yellow
} else {
Write-Host "OK: ISCC Unicode build detected" -ForegroundColor Green
}
Write-Host "OK: ISCC check passed" -ForegroundColor Green
exit 0
- name: Install Inno Setup Language Packs
run: |
Write-Host "=== Installing Inno Setup Language Packs ===" -ForegroundColor Cyan
# Detect Inno Setup installation path
$innoPath = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
if (-not (Test-Path $innoPath)) { $innoPath = "${env:ProgramFiles}\Inno Setup 6\ISCC.exe" }
if (-not (Test-Path $innoPath)) {
Write-Host "ERROR: ISCC.exe not found; cannot determine Languages directory" -ForegroundColor Red
exit 1
}
# Target Languages directory
$installDir = Split-Path -Parent $innoPath
$targetLangDir = Join-Path $installDir 'Languages'
$sourceLangDir = "${{ github.workspace }}\build_system\inno_setup_languages"
Write-Host "Source: $sourceLangDir" -ForegroundColor Gray
Write-Host "Target: $targetLangDir" -ForegroundColor Gray
Write-Host ""
# Ensure target directory exists
if (-not (Test-Path $targetLangDir)) {
New-Item -ItemType Directory -Path $targetLangDir -Force | Out-Null
}
# AUTO-DOWNLOAD: Download required language packs from official repository if not present
Write-Host "=== Checking and downloading required language packs ===" -ForegroundColor Cyan
# Define required language packs (can be extended)
$requiredLanguages = @{
"ChineseSimplified" = "https://raw.githubusercontent.com/jrsoftware/issrc/main/Files/Languages/Unofficial/ChineseSimplified.isl"
}
$downloadedCount = 0
$skippedCount = 0
foreach ($langName in $requiredLanguages.Keys) {
$langUrl = $requiredLanguages[$langName]
$targetFile = Join-Path $targetLangDir "$langName.isl"
# Check if language pack already exists
if (Test-Path $targetFile) {
Write-Host " ✓ $langName.isl already exists (skipped)" -ForegroundColor Gray
$skippedCount++
continue
}
# Download from official repository
Write-Host " Downloading $langName.isl from official repository..." -ForegroundColor Yellow
try {
Invoke-WebRequest -Uri $langUrl -OutFile $targetFile -UseBasicParsing -ErrorAction Stop
# Verify download
if (Test-Path $targetFile) {
$size = [math]::Round((Get-Item $targetFile).Length / 1KB, 2)
Write-Host " ✓ Downloaded: $langName.isl ($size KB)" -ForegroundColor Green
$downloadedCount++
} else {
Write-Host " ✗ Failed to download: $langName.isl" -ForegroundColor Red
}
} catch {
Write-Host " ✗ Error downloading $langName.isl: $_" -ForegroundColor Red
Write-Host " Installer will use English for this language" -ForegroundColor Yellow
}
}
Write-Host ""
Write-Host "=== Download Summary ===" -ForegroundColor Cyan
Write-Host " Downloaded: $downloadedCount" -ForegroundColor Green
Write-Host " Already present: $skippedCount" -ForegroundColor Gray
Write-Host "✓ Language pack preparation completed" -ForegroundColor Green
Write-Host ""
# Verify final language files
Write-Host "=== Final Language Files Verification ===" -ForegroundColor Cyan
$finalLangFiles = Get-ChildItem -Path $targetLangDir -Include "*.isl","*.islu" -ErrorAction SilentlyContinue
$langCount = 0
foreach ($file in $finalLangFiles) {
$size = [math]::Round($file.Length / 1KB, 2)
$status = if ($file.Name -eq "Default.isl") { "[System]" } else { "[Available]" }
Write-Host " $status $($file.Name) ($size KB)" -ForegroundColor $(if ($file.Name -eq "Default.isl") { "Cyan" } else { "Green" })
$langCount++
}
Write-Host ""
Write-Host "✓ Total $langCount language packs available" -ForegroundColor Green
# ------------------------------------------------------------------------
# Step Group 5: Build Environment Preparation
# ------------------------------------------------------------------------
- name: Setup Build Directories
uses: ./.github/actions/setup-build-dirs
with:
platform: windows
- name: Check Build Environment
uses: ./.github/actions/check-build-env
with:
platform: windows
- name: Setup Windows signtool Environment
uses: ./.github/actions/setup-signtool-env
with:
skip-if-available: true
sdk-version: '2004'
timeout: '600'
- name: Check Windows Architecture Selection
run: |
echo "=== Checking Windows Architecture Selection ==="
echo "Selected architecture: ${{ github.event.inputs.arch || 'all (default)' }}"
echo "Platform: ${{ github.event.inputs.platform || 'all (default)' }}"
echo "Note: Windows only supports amd64 architecture"
# Architecture selection logic for Windows
$INPUT_ARCH = "${{ github.event.inputs.arch }}"
if ($INPUT_ARCH -eq "aarch64") {
Write-Host "ERROR: Windows does not support aarch64 architecture" -ForegroundColor Red
Write-Host "INFO: Windows only supports amd64 (x86_64) architecture" -ForegroundColor Yellow
Write-Host "INFO: Please select 'amd64' or 'all' for Windows builds" -ForegroundColor Yellow
exit 1
} elseif ($INPUT_ARCH -eq "all" -or $INPUT_ARCH -eq "amd64" -or [string]::IsNullOrEmpty($INPUT_ARCH)) {
Write-Host "INFO: Building Windows amd64 version" -ForegroundColor Green
} else {
Write-Host "ERROR: Unsupported architecture '$INPUT_ARCH' for Windows" -ForegroundColor Red
Write-Host "INFO: Windows only supports amd64 architecture" -ForegroundColor Yellow
exit 1
}
# ------------------------------------------------------------------------
# Step Group 6: Build Process
# ------------------------------------------------------------------------
- name: Clean build artifacts
uses: ./.github/actions/clean-build-artifacts
with:
platform: windows
- name: Inject Build Info
run: |
echo "=== Injecting Build Info ==="
python build_system/scripts/inject_build_info.py --version ${{ needs.validate-tag.outputs.version }} --environment ${{ needs.validate-tag.outputs.environment }}
- name: Build Windows EXE
shell: pwsh
env:
ECAN_SKIP_FRONTEND_BUILD: '0' # Always build frontend
run: |
Write-Host "=== Starting Windows Build ===" -ForegroundColor Cyan
Write-Host "Command: python build.py prod" -ForegroundColor Gray
# Set environment variables
$env:PYTHONPATH = "$PWD"
$env:PYTHONUNBUFFERED = "1"
$env:VIRTUAL_ENV = "$PWD\.venv"
$env:PATH = "$PWD\.venv\Scripts;$env:PATH"
# Verify build architecture environment variable
Write-Host "BUILD_ARCH environment variable: $env:BUILD_ARCH"
# Verify virtual environment and dependencies
Write-Host "=== Verifying Build Environment ==="
Write-Host "Python executable: $((Get-Command python).Source)"
Write-Host "Virtual environment: $env:VIRTUAL_ENV"
# Test key dependencies
python -c "
import sys
print(f'Python version: {sys.version}')
print(f'Python executable: {sys.executable}')
packages = ['PyInstaller', 'PySide6', 'requests', 'playwright', 'colorlog']
for pkg in packages:
try:
__import__(pkg)
print(f'[OK] {pkg} available')
except ImportError as e:
print(f'[ERROR] {pkg} not found: {e}')
"
# Set environment variable for OTA signing requirement check
$env:ECAN_ENVIRONMENT = "${{ needs.validate-tag.outputs.environment }}"
Write-Host "Build environment: $env:ECAN_ENVIRONMENT" -ForegroundColor Cyan
# Run build with detailed output (self-contained OTA system)
python build.py prod --version ${{ needs.validate-tag.outputs.version }} 2>&1 | Tee-Object -FilePath "build.log"
# Check if build succeeded by verifying key files exist
$buildSuccess = $true
$srcExe = "dist/eCan/eCan.exe"
if (!(Test-Path $srcExe)) {
Write-Host "[ERROR] Build failed - main executable not found: $srcExe" -ForegroundColor Red
$buildSuccess = $false
}
# Check for standardized artifacts (created by build.py)
$stdExe = "dist/eCan-${{ needs.validate-tag.outputs.version }}-windows-$env:BUILD_ARCH.exe"
if ($buildSuccess) {
if (Test-Path $stdExe) {
Write-Host "[OK] Standardized executable found: $stdExe"
} else {
Write-Host "[WARN] Standardized executable not found, creating manually..."
if (Test-Path $srcExe) {
Copy-Item -LiteralPath $srcExe -Destination $stdExe -Force
Write-Host "Created: $stdExe"
}
}
}
if (-not $buildSuccess) {
Write-Host "=== Build Log ===" -ForegroundColor Yellow
Get-Content "build.log"
exit 1
}
Write-Host "[OK] Build completed successfully" -ForegroundColor Green
Write-Host "=== Validating Build Artifacts ===" -ForegroundColor Cyan
python build_system/build_validator.py --artifacts --version ${{ needs.validate-tag.outputs.version }} --arch $env:BUILD_ARCH
if ($LASTEXITCODE -ne 0) {
Write-Host "[ERROR] Build artifact validation failed" -ForegroundColor Red
exit 1
}
Write-Host "=== Build artifacts verification completed ===" -ForegroundColor Cyan
# ------------------------------------------------------------------------
# Step Group 7: Code Signing (REQUIRED for production/staging)
# ------------------------------------------------------------------------
- name: Code sign Windows artifacts
if: |
(needs.validate-tag.outputs.environment == 'production' ||
needs.validate-tag.outputs.environment == 'staging') &&
(env.WIN_CERT_PFX == 'NOT_SET' || env.WIN_CERT_PASSWORD == 'NOT_SET')
run: |
echo "======================================================"
echo "ERROR: Code signing REQUIRED for production/staging"
echo "======================================================"
echo "Environment: ${{ needs.validate-tag.outputs.environment }}"
echo "WIN_CERT_PFX: ${{ env.WIN_CERT_PFX != 'NOT_SET' && 'Configured' || 'MISSING' }}"
echo "WIN_CERT_PASSWORD: ${{ env.WIN_CERT_PASSWORD != 'NOT_SET' && 'Configured' || 'MISSING' }}"
echo ""
echo "Production and staging builds MUST be code signed."
echo "Please configure the following GitHub Secrets:"
echo " - WIN_CERT_PFX (base64-encoded certificate)"
echo " - WIN_CERT_PASSWORD (certificate password)"
echo "======================================================"
exit 1
- name: Code sign Windows artifacts (execute)
if: ${{ env.WIN_CERT_PFX != 'NOT_SET' && env.WIN_CERT_PASSWORD != 'NOT_SET' }}
env:
WIN_CERT_PFX: ${{ env.WIN_CERT_PFX }}
WIN_CERT_PASSWORD: ${{ env.WIN_CERT_PASSWORD }}
run: |
echo "=== Code signing Windows artifacts ==="
echo "Certificate status: Available"
echo "Password status: Available"
$pfxPath = "$env:RUNNER_TEMP\codesign.pfx"
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:WIN_CERT_PFX))
$signtool = "C:\Program Files (x86)\Windows Kits\10\bin\x64\signtool.exe"
if (-not (Test-Path $signtool)) { $signtool = "signtool.exe" }
# Step 1: Sign internal application files first (before they are packaged into installer)
Write-Host "=== Step 1: Signing internal application files ==="
if (Test-Path "dist/eCan") {
$internalFiles = Get-ChildItem -Path "dist/eCan" -Recurse -Include "*.exe","*.dll" -File -ErrorAction SilentlyContinue
foreach ($f in $internalFiles) {
Write-Host "Signing internal file: $($f.FullName)"
& $signtool sign /fd SHA256 /f $pfxPath /p $env:WIN_CERT_PASSWORD /tr http://timestamp.digicert.com /td SHA256 $f.FullName
if ($LASTEXITCODE -ne 0) {
Write-Host "Sign failed: $($f.FullName)" -ForegroundColor Red
exit 1
}
}
Write-Host "Internal files signed: $($internalFiles.Count) files" -ForegroundColor Green
} else {
Write-Host "Warning: dist/eCan directory not found, skipping internal file signing" -ForegroundColor Yellow
}
# Step 2: Sign distribution installers (Setup.exe, MSI)
Write-Host ""
Write-Host "=== Step 2: Signing distribution installers ==="
$installers = @()
# Sign Setup.exe installers
$setupFiles = Get-ChildItem -Path "dist" -Filter "*-Setup.exe" -File -ErrorAction SilentlyContinue
foreach ($sf in $setupFiles) { $installers += $sf.FullName }
# Sign MSI installers if present
$msiFiles = Get-ChildItem -Path "dist" -Filter "*.msi" -File -ErrorAction SilentlyContinue
foreach ($mf in $msiFiles) { $installers += $mf.FullName }
foreach ($installer in $installers) {
Write-Host "Signing installer: $installer"
& $signtool sign /fd SHA256 /f $pfxPath /p $env:WIN_CERT_PASSWORD /tr http://timestamp.digicert.com /td SHA256 $installer
if ($LASTEXITCODE -ne 0) {
Write-Host "Sign failed: $installer" -ForegroundColor Red
exit 1
}
}
Write-Host ""
Write-Host "✅ Windows code signing completed successfully" -ForegroundColor Green
Write-Host " Internal files signed: $(if (Test-Path 'dist/eCan') { (Get-ChildItem -Path 'dist/eCan' -Recurse -Include '*.exe','*.dll' -File).Count } else { 0 })" -ForegroundColor Gray
Write-Host " Installers signed: $($installers.Count)" -ForegroundColor Gray
- name: Check build result
shell: pwsh
run: |
Write-Host "=== Build Result Check ===" -ForegroundColor Cyan
Write-Host "=== Environment Variables Summary ===" -ForegroundColor Cyan
Write-Host "Windows Code Signing:" -ForegroundColor Yellow
Write-Host " WIN_CERT_PFX: ${{ env.WIN_CERT_PFX != 'NOT_SET' && 'Available' || 'Not Available' }}" -ForegroundColor Gray
Write-Host " WIN_CERT_PASSWORD: ${{ env.WIN_CERT_PASSWORD != 'NOT_SET' && 'Available' || 'Not Available' }}" -ForegroundColor Gray
Write-Host " Status: ${{ env.WIN_CERT_PFX != 'NOT_SET' && env.WIN_CERT_PASSWORD != 'NOT_SET' && 'Ready for signing' || 'Skipping signing' }}" -ForegroundColor Gray
Write-Host "Dist directory contents (top-level only):" -ForegroundColor Yellow
Get-ChildItem -Path "dist" | Format-Table -AutoSize
$exeFound = $false
$setupFound = $false
if (Test-Path "dist/eCan/eCan.exe") {
$exe = Get-Item "dist/eCan/eCan.exe"
Write-Host "Windows EXE found: dist/eCan/eCan.exe" -ForegroundColor Green
Write-Host " Size: $([math]::Round($exe.Length / 1MB, 2)) MB" -ForegroundColor Gray
Write-Host " Created: $($exe.CreationTime)" -ForegroundColor Gray
$exeFound = $true
} else {
Write-Host "ERROR: Windows EXE not found" -ForegroundColor Red
Write-Host "Searching for .exe files..." -ForegroundColor Yellow
Get-ChildItem -Path "dist" -Recurse -Filter "*.exe" | ForEach-Object {
Write-Host " Found: $($_.FullName)" -ForegroundColor Gray
}
}
# Check for standardized installer
$stdSetup = "dist/eCan-${{ needs.validate-tag.outputs.version }}-windows-$env:BUILD_ARCH-Setup.exe"
if (Test-Path $stdSetup) {
$setup = Get-Item $stdSetup
Write-Host "Installer found: $stdSetup" -ForegroundColor Green
Write-Host " Size: $([math]::Round($setup.Length / 1MB, 2)) MB" -ForegroundColor Gray
Write-Host " Created: $($setup.CreationTime)" -ForegroundColor Gray
$setupFound = $true
} else {
Write-Host "WARN: No installer found: $stdSetup" -ForegroundColor Yellow
}
if (-not $exeFound) {
Write-Host "ERROR: Build validation failed - no executable found" -ForegroundColor Red
exit 1
}
Write-Host "Build validation passed" -ForegroundColor Green
- name: Dump dist tree on failure (Windows)
if: failure()
shell: pwsh
run: |
Write-Host "=== dist/ tree (failure only) ===" -ForegroundColor Cyan
if (Test-Path "dist") {
Get-ChildItem -Path "dist" -Recurse | Format-Table -AutoSize
} else {
Write-Host "dist directory not found" -ForegroundColor Yellow
}
# ------------------------------------------------------------------------
# Step Group 8: Artifact Preparation and Upload
# ------------------------------------------------------------------------
- name: Compress Windows artifacts for upload
shell: pwsh
run: |
Write-Host "=== Compressing Windows artifacts ===" -ForegroundColor Cyan
$version = "${{ needs.validate-tag.outputs.version }}"
$arch = "$env:BUILD_ARCH"
# Create artifacts directory
New-Item -ItemType Directory -Force -Path "artifacts" | Out-Null
$artifactCount = 0
# Copy main installer
$setupFile = "dist/eCan-${version}-windows-${arch}-Setup.exe"
if (Test-Path $setupFile) {
Copy-Item $setupFile "artifacts/" -Force
$size = [math]::Round((Get-Item $setupFile).Length / 1MB, 2)
Write-Host "Copied installer: ${size} MB"
$artifactCount++
# Copy signature file if exists
$sigFile = "${setupFile}.sig"
if (Test-Path $sigFile) {
Copy-Item $sigFile "artifacts/" -Force
Write-Host "Copied signature: $(Split-Path -Leaf $sigFile)"
}
} else {
Write-Host "WARNING: Setup file not found: $setupFile" -ForegroundColor Yellow
}
# Copy MSI files if exist
Get-ChildItem "dist/*.msi" -ErrorAction SilentlyContinue | ForEach-Object {
Copy-Item $_.FullName "artifacts/" -Force
Write-Host "Copied MSI: $($_.Name)"
$artifactCount++
# Copy signature file if exists
$sigFile = "$($_.FullName).sig"
if (Test-Path $sigFile) {
Copy-Item $sigFile "artifacts/" -Force
Write-Host "Copied signature: $(Split-Path -Leaf $sigFile)"
}
}
# Verify artifacts were copied
if ($artifactCount -eq 0) {
Write-Host "ERROR: No artifacts were copied to artifacts/ directory" -ForegroundColor Red
Write-Host "Contents of dist/ directory:" -ForegroundColor Yellow
Get-ChildItem "dist" -Recurse | Select-Object FullName
exit 1
}
Write-Host "Artifacts prepared for upload ($artifactCount files)"
Get-ChildItem "artifacts" | ForEach-Object {
$sizeMB = [math]::Round($_.Length / 1MB, 2)
Write-Host " $($_.Name): ${sizeMB} MB"
}
- name: Upload Windows artifacts for S3 transfer (always, short retention)
uses: actions/upload-artifact@v4
with:
name: eCan-windows-amd64-${{ needs.validate-tag.outputs.version }}-s3-transfer
path: artifacts/
retention-days: 1
compression-level: 6
if-no-files-found: error
- name: Upload Windows artifacts for users (optional, long retention)
if: github.event.inputs.upload_artifacts == 'true'
uses: actions/upload-artifact@v4
with:
name: eCan-Windows-${{ needs.validate-tag.outputs.version }}
path: artifacts/
retention-days: 30
compression-level: 6
# ============================================================================
# BUILD LAYER: macOS (Matrix: amd64 + aarch64)
# ============================================================================
# Builds macOS PKG installers for both Intel and Apple Silicon:
# - Matrix Strategy: Parallel builds for amd64 (Intel) and aarch64 (ARM)
# - Native Runners: macos-14-large (Intel), macos-latest (Apple Silicon)
# - Architecture-specific: Each build uses native runner for target arch
#
# Build Steps:
# 1. Environment Setup: Python, Node.js, Playwright, Xcode
# 2. Build Process: Frontend + Backend + PKG creation
# 3. Code Signing: Optional Apple Developer ID signing
# 4. Notarization: Optional Apple notarization service
# 5. Artifact Upload: Short-term (S3 transfer) + Optional long-term
# ============================================================================
build-macos:
name: Build macOS
needs: validate-tag
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: macos-15-intel # Intel x86_64 runner (free standard runner, newer than macos-13)
target_arch: x86_64
pyinstaller_arch: x86_64
- arch: aarch64
runner: macos-latest # Apple Silicon ARM64 runner
target_arch: arm64
pyinstaller_arch: arm64
if: |
needs.validate-tag.outputs.tag-valid == 'true' &&
(github.event.inputs.platform == 'macos' || github.event.inputs.platform == 'all')
runs-on: ${{ matrix.runner }}
env:
# Expose macOS signing/notarization secrets to env for conditional checks without referencing `secrets` in `if:`
MAC_CERT_P12: ${{ secrets.MAC_CERT_P12 || 'NOT_SET' }}
MAC_CERT_PASSWORD: ${{ secrets.MAC_CERT_PASSWORD || 'NOT_SET' }}
MAC_CODESIGN_IDENTITY: ${{ secrets.MAC_CODESIGN_IDENTITY || 'NOT_SET' }}
APPLE_ID: ${{ secrets.APPLE_ID || 'NOT_SET' }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD || 'NOT_SET' }}
TEAM_ID: ${{ secrets.TEAM_ID || 'NOT_SET' }}
# Set build architecture for the build process (per matrix)
BUILD_ARCH: ${{ matrix.arch }}
TARGET_ARCH: ${{ matrix.target_arch }}
PYINSTALLER_TARGET_ARCH: ${{ matrix.pyinstaller_arch }}
timeout-minutes: 60
steps:
# ------------------------------------------------------------------------
# Step 0: Architecture Filtering (MUST BE FIRST - No dependencies)
# ------------------------------------------------------------------------
# This step runs immediately without any setup to decide if this matrix
# combination should proceed. If not needed, the job exits early without
# wasting resources on environment setup.
- name: Check if this architecture should be built
id: arch_check
run: |
ARCH="${{ matrix.arch }}"
INPUT="${{ github.event.inputs.arch }}"
echo "Matrix architecture: $ARCH"
echo "Requested architecture: $INPUT"
if [ "$INPUT" = "all" ] || [ -z "$INPUT" ] || [ "$INPUT" = "$ARCH" ]; then
echo "✅ This architecture will be built"
echo "should_build=true" >> $GITHUB_OUTPUT
else
echo "⏭️ Skipping this architecture (not requested)"
echo "should_build=false" >> $GITHUB_OUTPUT
exit 0
fi
# Early exit if architecture not needed - saves all setup time
- name: Exit early if architecture not needed
if: steps.arch_check.outputs.should_build != 'true'
run: |
echo "Architecture ${{ matrix.arch }} not needed for this build"
echo "Exiting early to save resources"
exit 0
# ------------------------------------------------------------------------
# Step Group 1: Repository Setup
# ------------------------------------------------------------------------
- name: Checkout code
if: steps.arch_check.outputs.should_build == 'true'
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.ref || github.ref }}
fetch-depth: 1 # Shallow clone: build only needs current code
# ------------------------------------------------------------------------
# Step Group 2: Environment Setup
# ------------------------------------------------------------------------
# Note: pip caching is handled by setup-python-env action
# Frontend is always built (ECAN_SKIP_FRONTEND_BUILD=0)
- name: Setup Python Environment
if: steps.arch_check.outputs.should_build == 'true'
uses: ./.github/actions/setup-python-env
with:
platform: macos
requirements-file: requirements-macos.txt
python-version: '3.11'
- name: Setup Playwright Browsers
if: steps.arch_check.outputs.should_build == 'true'
uses: ./.github/actions/setup-playwright
with:
platform: macos
arch: ${{ matrix.arch }}
browsers: chromium
- name: Cache Node.js dependencies
if: steps.arch_check.outputs.should_build == 'true'
uses: actions/cache@v4
with:
path: gui_v2/node_modules
# Include architecture in cache key to avoid cross-arch contamination (rollup has native binaries)
key: ${{ runner.os }}-${{ matrix.arch }}-node-${{ hashFiles('gui_v2/package-lock.json') }}
restore-keys: |
${{ runner.os }}-${{ matrix.arch }}-node-
- name: Setup Node.js Environment
if: steps.arch_check.outputs.should_build == 'true'
uses: ./.github/actions/setup-node-env
- name: Check macOS package architectures
if: steps.arch_check.outputs.should_build == 'true'
run: |
echo "=== Checking installed package architectures ==="
python -c "
import platform
import sys
print(f'Python executable: {sys.executable}')
print(f'Python platform: {platform.platform()}')
print(f'Python machine: {platform.machine()}')
try:
import numpy
print(f'NumPy version: {numpy.__version__}')
except ImportError:
print('NumPy not installed')
try:
import PySide6
print(f'PySide6 available')
except ImportError:
print('PySide6 not available')
"
# ------------------------------------------------------------------------
# Step Group 3: Build Environment Preparation
# ------------------------------------------------------------------------
- name: Setup Build Directories
if: steps.arch_check.outputs.should_build == 'true'
uses: ./.github/actions/setup-build-dirs
with:
platform: macos
- name: Setup Xcode License
if: steps.arch_check.outputs.should_build == 'true'
uses: ./.github/actions/setup-xcode-license
with:
platform: macos
- name: Check Build Environment
if: steps.arch_check.outputs.should_build == 'true'
uses: ./.github/actions/check-build-env
with:
platform: macos
matrix-arch: ${{ matrix.arch }}
matrix-target-arch: ${{ matrix.target_arch }}
matrix-pyinstaller-arch: ${{ matrix.pyinstaller_arch }}
# ------------------------------------------------------------------------
# Step Group 4: Build Process
# ------------------------------------------------------------------------
- name: Clean build artifacts
if: steps.arch_check.outputs.should_build == 'true'
uses: ./.github/actions/clean-build-artifacts
with:
platform: macos
- name: Inject Build Info
if: steps.arch_check.outputs.should_build == 'true'
run: |
echo "=== Injecting Build Info ==="
python build_system/scripts/inject_build_info.py --version ${{ needs.validate-tag.outputs.version }} --environment ${{ needs.validate-tag.outputs.environment }}
- name: Build macOS App
if: steps.arch_check.outputs.should_build == 'true'
env:
ECAN_SKIP_FRONTEND_BUILD: '0' # Always build frontend
run: |
echo "=== Starting macOS Build ==="
echo "Command: python build.py prod"
# Set environment variables
export PYTHONPATH="$PWD"
export PYTHONUNBUFFERED=1
# Use matrix architecture instead of input arch
export BUILD_ARCH="${{ matrix.arch }}"
export TARGET_ARCH="${{ matrix.target_arch }}"
echo "Building for architecture: $BUILD_ARCH (target: $TARGET_ARCH)"
# Configure architecture-specific build settings
if [ "${{ matrix.arch }}" = "aarch64" ]; then
echo "[INFO] Configuring ARM64 (Apple Silicon) build on native ARM64 runner"
export ARCHFLAGS="-arch arm64"
export _PYTHON_HOST_PLATFORM="macosx-11.0-arm64"
export MACOSX_DEPLOYMENT_TARGET="11.0"
export CMAKE_OSX_ARCHITECTURES="arm64"
export CMAKE_APPLE_SILICON_PROCESSOR="arm64"
# Use ARM64 wheels on native ARM64 runner
export PIP_PLATFORM="macosx_11_0_arm64"
export PYINSTALLER_TARGET_ARCH="arm64"
export TARGET_ARCH="arm64"
echo "[INFO] ARM64 native environment configured"
else
echo "[INFO] Configuring Intel (x86_64) build on native Intel runner"
export ARCHFLAGS="-arch x86_64"
export _PYTHON_HOST_PLATFORM="macosx-10.15-x86_64"
export MACOSX_DEPLOYMENT_TARGET="10.15"
export CMAKE_OSX_ARCHITECTURES="x86_64"
# Use x86_64 wheels on native Intel runner
export PIP_PLATFORM="macosx_10_15_x86_64"
export PYINSTALLER_TARGET_ARCH="x86_64"
export TARGET_ARCH="x86_64"
echo "[INFO] Intel native environment configured"
fi
# Verify Python architecture
echo "[INFO] Python architecture check:"
python -c "import platform; print(f'Python platform: {platform.platform()}'); print(f'Python machine: {platform.machine()}'); print(f'Python architecture: {platform.architecture()}')"
# Set environment variable for OTA signing requirement check
export ECAN_ENVIRONMENT="${{ needs.validate-tag.outputs.environment }}"
echo "Build environment: $ECAN_ENVIRONMENT"
# Run build with detailed output (self-contained OTA system)
python build.py prod --version ${{ needs.validate-tag.outputs.version }} 2>&1 | tee build.log
# Check if build succeeded by verifying key files exist
BUILD_SUCCESS=true
APP_PATH="dist/eCan.app"
if [ ! -d "$APP_PATH" ]; then
echo "[ERROR] Build failed - app bundle not found: $APP_PATH"
BUILD_SUCCESS=false
fi
# Check for standardized PKG (created by build.py)
ARCH="$BUILD_ARCH"
VERSION="${{ needs.validate-tag.outputs.version }}"
STD_PKG="dist/eCan-${VERSION}-macos-${ARCH}.pkg"
if [ "$BUILD_SUCCESS" = "true" ]; then
if [ -f "$STD_PKG" ]; then
echo "[OK] Standardized PKG found: $STD_PKG"
else
echo "[ERROR] Standardized PKG not found: $STD_PKG"
echo "[ERROR] PKG installer creation failed during build"
echo "[INFO] Contents of dist/ directory:"
ls -la dist/ || true
BUILD_SUCCESS=false
fi
fi
if [ "$BUILD_SUCCESS" != "true" ]; then
echo "=== Build Log ==="
cat build.log
exit 1
fi
echo "[OK] Build completed successfully"
- name: Validate build artifacts
if: steps.arch_check.outputs.should_build == 'true'
run: |
echo "=== Validating Build Artifacts ==="
python build_system/build_validator.py --artifacts --version ${{ needs.validate-tag.outputs.version }} --arch $BUILD_ARCH
if [ $? -ne 0 ]; then
echo "[ERROR] Build artifact validation failed"
exit 1
fi
- name: Verify architecture
if: steps.arch_check.outputs.should_build == 'true'
env:
BUILD_ARCH: ${{ matrix.arch }}
TARGET_ARCH: ${{ matrix.target_arch }}
PYINSTALLER_TARGET_ARCH: ${{ matrix.pyinstaller_arch }}
VERSION: ${{ needs.validate-tag.outputs.version }}
run: |
echo "=== Verifying Build Architecture ==="
python build_system/verify_architecture.py
if [ $? -ne 0 ]; then
echo "[ERROR] Architecture verification failed"
exit 1
fi
# ------------------------------------------------------------------------
# Step Group 6: Code Signing and Notarization (REQUIRED for production/staging)
# ------------------------------------------------------------------------
- name: Check macOS code signing requirements
if: |
steps.arch_check.outputs.should_build == 'true' &&
(needs.validate-tag.outputs.environment == 'production' ||
needs.validate-tag.outputs.environment == 'staging') &&
(env.MAC_CERT_P12 == 'NOT_SET' ||
env.MAC_CERT_PASSWORD == 'NOT_SET' ||
env.MAC_CODESIGN_IDENTITY == 'NOT_SET')
run: |
echo "======================================================"
echo "ERROR: Code signing REQUIRED for production/staging"
echo "======================================================"
echo "Environment: ${{ needs.validate-tag.outputs.environment }}"
echo "MAC_CERT_P12: ${{ env.MAC_CERT_P12 != 'NOT_SET' && 'Configured' || 'MISSING' }}"
echo "MAC_CERT_PASSWORD: ${{ env.MAC_CERT_PASSWORD != 'NOT_SET' && 'Configured' || 'MISSING' }}"
echo "MAC_CODESIGN_IDENTITY: ${{ env.MAC_CODESIGN_IDENTITY != 'NOT_SET' && 'Configured' || 'MISSING' }}"
echo ""
echo "Production and staging builds MUST be code signed."
echo "Please configure the following GitHub Secrets:"
echo " - MAC_CERT_P12 (base64-encoded certificate)"
echo " - MAC_CERT_PASSWORD (certificate password)"
echo " - MAC_CODESIGN_IDENTITY (Developer ID)"
echo "======================================================"
exit 1
- name: Code sign macOS app (execute)
if: |
steps.arch_check.outputs.should_build == 'true' &&
env.MAC_CERT_P12 != 'NOT_SET' &&
env.MAC_CERT_PASSWORD != 'NOT_SET' &&
env.MAC_CODESIGN_IDENTITY != 'NOT_SET'
env:
MAC_CERT_P12: ${{ env.MAC_CERT_P12 }}
MAC_CERT_PASSWORD: ${{ env.MAC_CERT_PASSWORD }}
MAC_CODESIGN_IDENTITY: ${{ env.MAC_CODESIGN_IDENTITY }}
run: |
echo "=== Code signing macOS app (if secrets provided) ==="
echo "Certificate status: Available"
echo "Password status: Available"
echo "Identity status: Available"
if [ -z "${MAC_CERT_P12}" ] || [ -z "${MAC_CERT_PASSWORD}" ] || [ -z "${MAC_CODESIGN_IDENTITY}" ]; then
echo "No macOS signing secrets; skip codesign"
exit 0
fi
CERT_PATH="$RUNNER_TEMP/cert.p12"
echo "$MAC_CERT_P12" | base64 --decode > "$CERT_PATH"
KEYCHAIN=build.keychain
KEYCHAIN_PW="actions"
security create-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN"
security import "$CERT_PATH" -k "$KEYCHAIN" -P "$MAC_CERT_PASSWORD" -T /usr/bin/codesign
security list-keychain -d user -s "$KEYCHAIN" login.keychain
if [ -d "dist/eCan.app" ]; then
echo "Signing dist/eCan.app"
codesign --deep --force --options runtime --sign "$MAC_CODESIGN_IDENTITY" "dist/eCan.app"
codesign --verify --deep --strict --verbose=2 "dist/eCan.app"
else
echo "dist/eCan.app not found; skip codesign"
fi
- name: Check macOS notarization requirements
if: |
steps.arch_check.outputs.should_build == 'true' &&
(needs.validate-tag.outputs.environment == 'production' ||
needs.validate-tag.outputs.environment == 'staging') &&
(env.APPLE_ID == 'NOT_SET' ||
env.APPLE_APP_SPECIFIC_PASSWORD == 'NOT_SET' ||
env.TEAM_ID == 'NOT_SET')
run: |
echo "======================================================"
echo "ERROR: Notarization REQUIRED for production/staging"
echo "======================================================"
echo "Environment: ${{ needs.validate-tag.outputs.environment }}"
echo "APPLE_ID: ${{ env.APPLE_ID != 'NOT_SET' && 'Configured' || 'MISSING' }}"
echo "APPLE_APP_SPECIFIC_PASSWORD: ${{ env.APPLE_APP_SPECIFIC_PASSWORD != 'NOT_SET' && 'Configured' || 'MISSING' }}"
echo "TEAM_ID: ${{ env.TEAM_ID != 'NOT_SET' && 'Configured' || 'MISSING' }}"
echo ""
echo "Production and staging builds MUST be notarized by Apple."
echo "Please configure the following GitHub Secrets:"
echo " - APPLE_ID (Apple Developer account email)"
echo " - APPLE_APP_SPECIFIC_PASSWORD (app-specific password)"
echo " - TEAM_ID (Apple Developer Team ID)"
echo "======================================================"
exit 1
- name: Notarize macOS PKG (execute)
if: |
steps.arch_check.outputs.should_build == 'true' &&
env.APPLE_ID != 'NOT_SET' &&
env.APPLE_APP_SPECIFIC_PASSWORD != 'NOT_SET' &&
env.TEAM_ID != 'NOT_SET'
env:
APPLE_ID: ${{ env.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ env.APPLE_APP_SPECIFIC_PASSWORD }}
TEAM_ID: ${{ env.TEAM_ID }}
run: |
echo "=== Notarize macOS PKG (if Apple credentials provided) ==="
if [ -z "${APPLE_ID}" ] || [ -z "${APPLE_APP_SPECIFIC_PASSWORD}" ] || [ -z "${TEAM_ID}" ]; then
echo "No Apple notarization credentials; skip notarization"
exit 0
fi
ARCH="$BUILD_ARCH"
VERSION="${{ needs.validate-tag.outputs.version }}"
PKG_PATH="dist/eCan-${VERSION}-macos-${ARCH}.pkg"
if [ -f "$PKG_PATH" ]; then
echo "Submitting $PKG_PATH for notarization..."
xcrun notarytool submit "$PKG_PATH" --apple-id "$APPLE_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --team-id "$TEAM_ID" --wait
NOTARIZE_EXIT_CODE=$?
if [ $NOTARIZE_EXIT_CODE -eq 0 ]; then
echo "Stapling notarization to PKG..."
xcrun stapler staple "$PKG_PATH"
STAPLE_EXIT_CODE=$?
if [ $STAPLE_EXIT_CODE -eq 0 ]; then
echo "[OK] Notarization and stapling completed successfully"
else
echo "[ERROR] Stapling failed with exit code: $STAPLE_EXIT_CODE"
# In production/staging, stapling failure should block release
if [ "${{ needs.validate-tag.outputs.environment }}" = "production" ] || [ "${{ needs.validate-tag.outputs.environment }}" = "staging" ]; then
exit 1
fi
fi
else
echo "[ERROR] Notarization failed with exit code: $NOTARIZE_EXIT_CODE"
# In production/staging, notarization failure should block release
if [ "${{ needs.validate-tag.outputs.environment }}" = "production" ] || [ "${{ needs.validate-tag.outputs.environment }}" = "staging" ]; then
echo "======================================================"
echo "Notarization FAILED - blocking release"
echo "Environment: ${{ needs.validate-tag.outputs.environment }}"
echo "======================================================"
exit 1
fi
fi
else
echo "[ERROR] PKG not found at: $PKG_PATH"
if [ "${{ needs.validate-tag.outputs.environment }}" = "production" ] || [ "${{ needs.validate-tag.outputs.environment }}" = "staging" ]; then
exit 1
fi
fi
- name: Check build result
if: steps.arch_check.outputs.should_build == 'true'
run: |
echo "=== Build Result Check ==="
echo "=== Environment Variables Summary ==="
echo "macOS Code Signing:"
echo " MAC_CERT_P12: ${{ env.MAC_CERT_P12 != 'NOT_SET' && 'Available' || 'Not Available' }}"
echo " MAC_CERT_PASSWORD: ${{ env.MAC_CERT_PASSWORD != 'NOT_SET' && 'Available' || 'Not Available' }}"
echo " MAC_CODESIGN_IDENTITY: ${{ env.MAC_CODESIGN_IDENTITY != 'NOT_SET' && 'Available' || 'Not Available' }}"
echo " Status: ${{ env.MAC_CERT_P12 != 'NOT_SET' && env.MAC_CERT_PASSWORD != 'NOT_SET' && env.MAC_CODESIGN_IDENTITY != 'NOT_SET' && 'Ready for signing' || 'Skipping signing' }}"
echo "macOS Notarization:"
echo " APPLE_ID: ${{ env.APPLE_ID != 'NOT_SET' && 'Available' || 'Not Available' }}"
echo " APPLE_APP_SPECIFIC_PASSWORD: ${{ env.APPLE_APP_SPECIFIC_PASSWORD != 'NOT_SET' && 'Available' || 'Not Available' }}"
echo " TEAM_ID: ${{ env.TEAM_ID != 'NOT_SET' && 'Available' || 'Not Available' }}"
echo " Status: ${{ env.APPLE_ID != 'NOT_SET' && env.APPLE_APP_SPECIFIC_PASSWORD != 'NOT_SET' && env.TEAM_ID != 'NOT_SET' && 'Ready for notarization' || 'Skipping notarization' }}"
echo "Dist directory contents:"
ls -la dist/
ARCH="$BUILD_ARCH"
VERSION="${{ needs.validate-tag.outputs.version }}"
PKG_PATH="dist/eCan-${VERSION}-macos-${ARCH}.pkg"
if [ -f "$PKG_PATH" ]; then
echo "[OK] macOS PKG found: $PKG_PATH"
pkg_size=$(du -sh "$PKG_PATH" | cut -f1)
echo " Size: $pkg_size"
# Verify PKG architecture
echo "=== Verifying PKG Architecture ==="
if command -v pkgutil >/dev/null 2>&1; then
echo "PKG contents:"
pkgutil --payload-files "$PKG_PATH" | head -10
fi
elif [ -d "dist/eCan.app" ]; then
echo "[OK] macOS App found: dist/eCan.app"
app_size=$(du -sh dist/eCan.app | cut -f1)
echo " Size: $app_size"
echo " App structure:"
find dist/eCan.app -type f -name "eCan" | head -5
# Verify app bundle architecture
echo "=== Verifying App Bundle Architecture ==="
if [ -f "dist/eCan.app/Contents/MacOS/eCan" ]; then
echo "Main executable architecture:"
file "dist/eCan.app/Contents/MacOS/eCan" || echo "Could not determine architecture"
if command -v lipo >/dev/null 2>&1; then
echo "Detailed architecture info:"
lipo -info "dist/eCan.app/Contents/MacOS/eCan" || echo "lipo failed"
fi
fi
else
echo "[ERROR] macOS build not found"
echo "Searching for .pkg and .app files..."
find dist -name "*.pkg" 2>/dev/null || echo "No .pkg files found"
find dist -name "*.app" 2>/dev/null || echo "No .app files found"
exit 1
fi
echo "[OK] Build validation passed"
# ------------------------------------------------------------------------
# Step Group 7: Artifact Preparation and Upload
# ------------------------------------------------------------------------
- name: Compress macOS artifacts for upload
if: steps.arch_check.outputs.should_build == 'true'
id: compress_artifacts
run: |
echo "=== Compressing macOS artifacts ==="
VERSION="${{ needs.validate-tag.outputs.version }}"
ARCH="${{ matrix.arch }}"
# Create artifacts directory
mkdir -p artifacts
# Copy main PKG file
PKG_FILE="dist/eCan-${VERSION}-macos-${ARCH}.pkg"
if [ -f "$PKG_FILE" ]; then
cp "$PKG_FILE" artifacts/
SIZE=$(du -sh "$PKG_FILE" | cut -f1)
echo "Copied PKG: ${SIZE}"
echo "has_artifacts=true" >> $GITHUB_OUTPUT
# Copy signature file if exists
SIG_FILE="${PKG_FILE}.sig"
if [ -f "$SIG_FILE" ]; then
cp "$SIG_FILE" artifacts/
echo "Copied signature: $(basename $SIG_FILE)"
fi
else
echo "[ERROR] PKG file not found: $PKG_FILE"
echo "[ERROR] Build may have failed or PKG was not created"
echo "Contents of dist/ directory:"
ls -la dist/ || true
echo "has_artifacts=false" >> $GITHUB_OUTPUT
exit 1
fi
echo "Artifacts prepared for upload:"
ls -lh artifacts/
- name: Upload macOS artifacts for S3 transfer (always, short retention)
if: steps.arch_check.outputs.should_build == 'true'
uses: actions/upload-artifact@v4
with:
name: eCan-macos-${{ matrix.arch }}-${{ needs.validate-tag.outputs.version }}-s3-transfer
path: artifacts/
retention-days: 1
compression-level: 6
if-no-files-found: error
- name: Upload macOS artifacts for users (optional, long retention)
if: steps.arch_check.outputs.should_build == 'true' && github.event.inputs.upload_artifacts == 'true'
uses: actions/upload-artifact@v4
with:
name: eCan-macos-${{ matrix.arch }}-${{ needs.validate-tag.outputs.version }}
path: artifacts/
retention-days: 30
compression-level: 6
if-no-files-found: error
# ============================================================================
# DISTRIBUTION LAYER: S3 Upload and OTA Feed Generation
# ============================================================================
# This layer uses reusable workflows to handle distribution tasks:
# 1. upload-to-s3: Upload installers to AWS S3 (shared-s3-upload.yml)
# 2. generate-appcast: Create OTA update feeds (shared-appcast-generation.yml)
# 3. generate-download-links: Generate download URLs (shared-download-links.yml)
#
# Benefits of reusable workflows:
# - Reduces code duplication between release.yml and release-simulate.yml
# - Easier to maintain and update
# - Consistent behavior across workflows
# ============================================================================
# ----------------------------------------------------------------------------
# Step 1: Upload artifacts to S3
# ----------------------------------------------------------------------------
upload-to-s3:
name: Upload to S3
needs: [validate-tag, build-windows, build-macos]
if: |
always() &&
needs.validate-tag.outputs.tag-valid == 'true' &&
(needs.build-windows.result == 'success' || needs.build-windows.result == 'skipped') &&
(needs.build-macos.result == 'success' || needs.build-macos.result == 'skipped')
uses: ./.github/workflows/shared-s3-upload.yml
with:
version: ${{ needs.validate-tag.outputs.version }}
environment: ${{ needs.validate-tag.outputs.environment }}
windows-build-result: ${{ needs.build-windows.result }}
macos-build-result: ${{ needs.build-macos.result }}
secrets: inherit
# ----------------------------------------------------------------------------
# Step 2: Generate OTA update feeds (Appcast)
# ----------------------------------------------------------------------------
generate-appcast:
name: Generate Appcast
needs: [validate-tag, upload-to-s3]
if: |
always() &&
needs.validate-tag.outputs.tag-valid == 'true' &&
needs.upload-to-s3.result == 'success'
uses: ./.github/workflows/shared-appcast-generation.yml
with:
environment: ${{ needs.validate-tag.outputs.environment }}
channel: ${{ needs.validate-tag.outputs.channel }}
secrets: inherit
# ----------------------------------------------------------------------------
# Step 3: Generate download links
# ----------------------------------------------------------------------------
generate-download-links:
name: Generate Download Links
needs: [validate-tag, build-windows, build-macos, upload-to-s3]
if: |
always() &&
needs.validate-tag.outputs.tag-valid == 'true' &&
needs.upload-to-s3.result == 'success'
uses: ./.github/workflows/shared-download-links.yml
with:
version: ${{ needs.validate-tag.outputs.version }}
windows-build-result: ${{ needs.build-windows.result }}
macos-build-result: ${{ needs.build-macos.result }}
macos-built-amd64: ${{ (github.event.inputs.platform == 'macos' || github.event.inputs.platform == 'all') && (github.event.inputs.arch == 'amd64' || github.event.inputs.arch == 'all') }}
macos-built-aarch64: ${{ (github.event.inputs.platform == 'macos' || github.event.inputs.platform == 'all') && (github.event.inputs.arch == 'aarch64' || github.event.inputs.arch == 'all') }}
secrets: inherit
# ----------------------------------------------------------------------------
# Step 4: Final status summary
# ----------------------------------------------------------------------------
final-status:
name: Final Status Summary
needs: [validate-tag, build-windows, build-macos, upload-to-s3, generate-appcast, generate-download-links]
if: always()
uses: ./.github/workflows/shared-final-status.yml
with:
version: ${{ needs.validate-tag.outputs.version }}
windows-result: ${{ needs.build-windows.result }}
macos-result: ${{ needs.build-macos.result }}
upload-result: ${{ needs.upload-to-s3.result }}
appcast-result: ${{ needs.generate-appcast.result }}
links-result: ${{ needs.generate-download-links.result }}
is-simulation: false
secrets: inherit