Release Build eCan #184
Workflow file for this run
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: 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 | |
| # | |
| # AUTOMATIC CLEANUP (Cost Optimization): | |
| # - S3 transfer artifacts: retention-days: 1 (auto-delete after 1 day) | |
| # - User download artifacts: retention-days: 7 (only if enabled) | |
| # - Compression: level 6 (balance between size and speed) | |
| # - Estimated cost: ~$0.14/month (10 builds, S3 transfer only) | |
| # | |
| # See: docs/GITHUB_ARTIFACTS_COST_OPTIMIZATION.md for details | |
| # | |
| # ============================================================================ | |
| # Tag and Release Format Guide | |
| # ============================================================================ | |
| # | |
| # VERSION TAG FORMAT: | |
| # Production Release: v1.0.0, v1.2.3, v2.0.0 | |
| # Release Candidate: v1.0.0-rc.1, v1.0.0-rc.2 | |
| # Beta Release: v1.0.0-beta.1, v1.0.0-beta.2 | |
| # Alpha Release: v1.0.0-alpha.1 | |
| # | |
| # ENVIRONMENT AUTO-DETECTION: | |
| # v1.0.0 → production environment, stable channel | |
| # v1.0.0-rc.1 → staging environment, stable channel | |
| # v1.0.0-beta.1 → test environment, beta channel | |
| # v1.0.0-alpha.1 → development environment, dev channel | |
| # staging branch → staging environment, stable channel | |
| # main/master → production environment, stable channel | |
| # develop/dev → development environment, dev channel | |
| # other branches → development environment, dev channel | |
| # | |
| # HOW TO CREATE A RELEASE: | |
| # | |
| # Method 1: Create Git Tag (Recommended for production) | |
| # git tag -a v1.0.0 -m "Release version 1.0.0" | |
| # git push origin v1.0.0 | |
| # | |
| # Method 2: Create GitHub Release | |
| # 1. Go to: https://github.com/your-org/eCan.ai/releases/new | |
| # 2. Tag version: v1.0.0 | |
| # 3. Release title: eCan.ai v1.0.0 | |
| # 4. Description: Copy from CHANGELOG.md | |
| # 5. Click "Publish release" | |
| # | |
| # Method 3: Manual Trigger (for testing or branch builds) | |
| # 1. Go to: Actions → Release Build eCan → Run workflow | |
| # 2. Use workflow from: Select branch | |
| # 3. Enter ref: branch/tag (leave EMPTY to auto-sync with workflow branch) | |
| # 4. Select platform and architecture | |
| # 5. Click "Run workflow" | |
| # | |
| # IMPORTANT: "Use workflow from" vs "ref" | |
| # - Use workflow from: Which branch's workflow file to use | |
| # - ref: Which branch/tag's code to build (EMPTY = auto-sync) | |
| # | |
| # AUTO-SYNC FEATURE: | |
| # - If ref is empty, it automatically uses the workflow branch | |
| # - Example: Use workflow from=main, ref=(empty) → builds main | |
| # - Example: Use workflow from=gui-v2, ref=(empty) → builds gui-v2 | |
| # | |
| # MANUAL OVERRIDE: | |
| # - Example: Use workflow from=main, ref=v1.0.0 → builds v1.0.0 tag | |
| # - Example: Use workflow from=feature/fix, ref=main → test new workflow | |
| # | |
| # EXAMPLES: | |
| # Production: git tag v1.0.0 && git push origin v1.0.0 | |
| # RC: git tag v1.0.0-rc.1 && git push origin v1.0.0-rc.1 | |
| # Beta: git tag v1.0.0-beta.1 && git push origin v1.0.0-beta.1 | |
| # Branch: Manual trigger with ref=gui-v2 | |
| # | |
| # VERSION CALCULATION: | |
| # - Tag (v1.0.0): → 1.0.0 | |
| # - Branch (gui-v2): → 1.0.1-gui-v2-abc1234 (from VERSION file + branch + commit) | |
| # - Branch (main): → 1.0.1-main-abc1234 | |
| # | |
| # ============================================================================ | |
| # 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 (leave empty to use same as workflow branch)' | |
| required: false | |
| default: '' | |
| 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 and extract 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 ref is empty, use the workflow branch (auto-sync) | |
| if [ -n "$INPUT_REF" ]; then | |
| REF_NAME="$INPUT_REF" | |
| echo "✅ Using manual ref: $REF_NAME" | |
| else | |
| REF_NAME="${REF_FULL#refs/*/}" | |
| echo "✅ Auto-sync: Using workflow branch as ref: $REF_NAME" | |
| 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" | |
| # Determine if this is a production-eligible ref (for safety checks) | |
| IS_PRODUCTION_ELIGIBLE=false | |
| if [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+.*$ ]] || \ | |
| [[ "$REF_NAME" == "main" ]] || [[ "$REF_NAME" == "master" ]]; then | |
| IS_PRODUCTION_ELIGIBLE=true | |
| fi | |
| # Use manual selection if provided | |
| if [[ -n "$INPUT_ENV" ]]; then | |
| ENV="$INPUT_ENV" | |
| # Safety check: Prevent feature branches from using production/staging | |
| if [[ "$IS_PRODUCTION_ELIGIBLE" == "false" ]] && [[ "$ENV" == "production" || "$ENV" == "staging" ]]; then | |
| echo "======================================================" | |
| echo "❌ ERROR: Branch '$REF_NAME' cannot use '$ENV' environment" | |
| echo "======================================================" | |
| echo "Only the following refs can use production/staging:" | |
| echo " - Version tags: v1.0.0, v1.0.0-rc.1, v1.0.0-beta.1" | |
| echo " - Main branches: main, master" | |
| echo "" | |
| echo "Current ref: $REF_NAME (feature branch)" | |
| echo "Requested environment: $ENV" | |
| echo "" | |
| echo "Allowed environments for this branch:" | |
| echo " - development" | |
| echo " - test" | |
| echo "" | |
| echo "💡 Tip: Create a version tag to enable production/staging deployment" | |
| echo "======================================================" | |
| exit 1 | |
| fi | |
| 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 | |
| # Other branches: default to development | |
| 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 | |
| # Safety check: Prevent non-production-eligible branch builds in production/staging | |
| # Note: main/master branches are production-eligible and allowed | |
| IS_BRANCH="${{ steps.validate.outputs.is_branch }}" | |
| if [[ "$IS_BRANCH" == "true" ]] && [[ "$IS_PRODUCTION_ELIGIBLE" == "false" ]] && [[ "$ENV" == "production" || "$ENV" == "staging" ]]; then | |
| echo "======================================================" | |
| echo "❌ ERROR: Branch builds cannot be deployed to $ENV" | |
| echo "======================================================" | |
| echo "Current ref: $REF_NAME (branch)" | |
| echo "Target environment: $ENV" | |
| echo "" | |
| echo "Production and staging environments require version tags." | |
| echo "" | |
| echo "To fix:" | |
| echo " 1. Create a version tag: git tag v1.0.1" | |
| echo " 2. Push the tag: git push origin v1.0.1" | |
| echo " 3. Use the tag as ref: v1.0.1" | |
| echo "" | |
| echo "Or change environment to 'development' or 'test' for branch builds." | |
| echo "======================================================" | |
| exit 1 | |
| 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 | |
| architecture: x64 | |
| - 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 }} | |
| # ------------------------------------------------------------------------ | |
| # Step: Setup OTA Signing Key from GitHub Secrets | |
| # ------------------------------------------------------------------------ | |
| - name: Setup OTA signing key | |
| if: | | |
| needs.validate-tag.outputs.environment == 'production' || | |
| needs.validate-tag.outputs.environment == 'staging' || | |
| needs.validate-tag.outputs.environment == 'test' | |
| env: | |
| OTA_PRIVATE_KEY: ${{ secrets.OTA_ED25519_PRIVATE_KEY }} | |
| shell: pwsh | |
| run: | | |
| Write-Host "=== Setting up OTA signing key ===" -ForegroundColor Cyan | |
| Write-Host "Environment: ${{ needs.validate-tag.outputs.environment }}" -ForegroundColor Gray | |
| Write-Host "" | |
| # Create certificates directory | |
| $certDir = "build_system\certificates" | |
| if (-not (Test-Path $certDir)) { | |
| New-Item -ItemType Directory -Force -Path $certDir | Out-Null | |
| Write-Host "[OK] Created directory: $certDir" -ForegroundColor Green | |
| } | |
| # Check if secret is available | |
| if ([string]::IsNullOrEmpty($env:OTA_PRIVATE_KEY)) { | |
| Write-Host "[ERROR] ========================================" -ForegroundColor Red | |
| Write-Host "[ERROR] OTA_ED25519_PRIVATE_KEY secret is NOT configured" -ForegroundColor Red | |
| Write-Host "[ERROR] ========================================" -ForegroundColor Red | |
| Write-Host "" | |
| Write-Host "To fix this issue:" -ForegroundColor Yellow | |
| Write-Host "1. Run: .\prepare_github_secret.ps1" -ForegroundColor Gray | |
| Write-Host "2. Go to: GitHub repository -> Settings -> Secrets -> Actions" -ForegroundColor Gray | |
| Write-Host "3. Add secret:" -ForegroundColor Gray | |
| Write-Host " Name: OTA_ED25519_PRIVATE_KEY" -ForegroundColor Gray | |
| Write-Host " Value: [paste from clipboard]" -ForegroundColor Gray | |
| Write-Host "" | |
| Write-Host "See: OTA_SIGNING_COMPLETE_GUIDE.md for detailed instructions" -ForegroundColor Gray | |
| Write-Host "" | |
| exit 1 | |
| } | |
| # Decode base64 and write private key | |
| $keyPath = "$certDir\ed25519_private_key.pem" | |
| Write-Host "[1/3] Decoding base64 private key..." -ForegroundColor Yellow | |
| try { | |
| $keyBytes = [Convert]::FromBase64String($env:OTA_PRIVATE_KEY) | |
| $keyContent = [System.Text.Encoding]::UTF8.GetString($keyBytes) | |
| Set-Content -Path $keyPath -Value $keyContent -NoNewline | |
| Write-Host " ✓ Base64 decoded successfully" -ForegroundColor Green | |
| } catch { | |
| Write-Host " ✗ Failed to decode base64: $_" -ForegroundColor Red | |
| exit 1 | |
| } | |
| # Verify key format | |
| Write-Host "[2/3] Verifying key format..." -ForegroundColor Yellow | |
| $keyCheck = Get-Content $keyPath -Raw | |
| if ($keyCheck -match "BEGIN PRIVATE KEY") { | |
| Write-Host " ✓ Key format verified (PEM)" -ForegroundColor Green | |
| } else { | |
| Write-Host " ✗ Invalid key format (expected PEM)" -ForegroundColor Red | |
| Write-Host " Key should start with: -----BEGIN PRIVATE KEY-----" -ForegroundColor Gray | |
| exit 1 | |
| } | |
| # Set file permissions | |
| Write-Host "[3/3] Setting file permissions..." -ForegroundColor Yellow | |
| try { | |
| $acl = Get-Acl $keyPath | |
| $acl.SetAccessRuleProtection($true, $false) | |
| $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( | |
| [System.Security.Principal.WindowsIdentity]::GetCurrent().Name, | |
| "Read", | |
| "Allow" | |
| ) | |
| $acl.SetAccessRule($rule) | |
| Set-Acl $keyPath $acl | |
| Write-Host " ✓ File permissions set (read-only)" -ForegroundColor Green | |
| } catch { | |
| Write-Host " ⚠ Warning: Could not set file permissions: $_" -ForegroundColor Yellow | |
| } | |
| Write-Host "" | |
| Write-Host "============================================" -ForegroundColor Green | |
| Write-Host " ✓ OTA signing key configured successfully" -ForegroundColor Green | |
| Write-Host "============================================" -ForegroundColor Green | |
| Write-Host "Key path: $keyPath" -ForegroundColor Gray | |
| Write-Host "" | |
| # ------------------------------------------------------------------------ | |
| # Step Group 6: Build Application | |
| # ------------------------------------------------------------------------ | |
| - 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 (Optional) | |
| # ------------------------------------------------------------------------ | |
| - name: Check Windows code signing requirements | |
| 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 "WARNING: Code signing certificates not configured" | |
| 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 "Building unsigned Windows package (not recommended for production)." | |
| echo "To enable code signing, configure the following GitHub Secrets:" | |
| echo " - WIN_CERT_PFX (base64-encoded certificate)" | |
| echo " - WIN_CERT_PASSWORD (certificate password)" | |
| echo "======================================================" | |
| - 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: Generate Ed25519 Signatures for OTA Updates | |
| # ------------------------------------------------------------------------ | |
| - name: Generate Ed25519 signatures | |
| if: | | |
| needs.validate-tag.outputs.environment == 'production' || | |
| needs.validate-tag.outputs.environment == 'staging' || | |
| needs.validate-tag.outputs.environment == 'test' | |
| shell: pwsh | |
| run: | | |
| # Install cryptography library for Ed25519 signing | |
| python -m pip install cryptography --quiet | |
| Write-Host "=== Generating Ed25519 Signatures ===" -ForegroundColor Cyan | |
| $version = "${{ needs.validate-tag.outputs.version }}" | |
| $arch = "$env:BUILD_ARCH" | |
| # Check if private key exists | |
| $keyPath = "build_system\certificates\ed25519_private_key.pem" | |
| if (-not (Test-Path $keyPath)) { | |
| Write-Host "[ERROR] Private key not found: $keyPath" -ForegroundColor Red | |
| Write-Host "[ERROR] OTA signatures cannot be generated" -ForegroundColor Red | |
| exit 1 | |
| } | |
| Write-Host "[OK] Private key found: $keyPath" -ForegroundColor Green | |
| # Generate signature for main installer | |
| $setupFile = "dist\eCan-${version}-windows-${arch}-Setup.exe" | |
| if (Test-Path $setupFile) { | |
| Write-Host "Generating signature for: $(Split-Path -Leaf $setupFile)" -ForegroundColor Yellow | |
| # Use signing_manager.py for Ed25519 signing (OpenSSL pkeyutl doesn't support Ed25519 in all versions) | |
| $sigFile = "${setupFile}.sig" | |
| python build_system/signing_manager.py $setupFile $keyPath $sigFile | |
| if (Test-Path $sigFile) { | |
| $sigSize = (Get-Item $sigFile).Length | |
| Write-Host " ✓ Signature generated: $sigSize bytes" -ForegroundColor Green | |
| # Verify signature size (Ed25519 signatures are always 64 bytes) | |
| if ($sigSize -ne 64) { | |
| Write-Host " ✗ Invalid signature size: expected 64 bytes, got $sigSize bytes" -ForegroundColor Red | |
| exit 1 | |
| } | |
| } else { | |
| Write-Host " ✗ Failed to generate signature" -ForegroundColor Red | |
| exit 1 | |
| } | |
| } else { | |
| Write-Host "[WARN] Setup file not found: $setupFile" -ForegroundColor Yellow | |
| } | |
| # Generate signatures for MSI files if they exist | |
| Get-ChildItem "dist\*.msi" -ErrorAction SilentlyContinue | ForEach-Object { | |
| Write-Host "Generating signature for: $($_.Name)" -ForegroundColor Yellow | |
| $sigFile = "$($_.FullName).sig" | |
| python build_system/signing_manager.py $_.FullName $keyPath $sigFile | |
| if (Test-Path $sigFile) { | |
| $sigSize = (Get-Item $sigFile).Length | |
| Write-Host " ✓ Signature generated: $sigSize bytes" -ForegroundColor Green | |
| # Verify signature size (Ed25519 signatures are always 64 bytes) | |
| if ($sigSize -ne 64) { | |
| Write-Host " ✗ Invalid signature size: expected 64 bytes, got $sigSize bytes" -ForegroundColor Red | |
| } | |
| } else { | |
| Write-Host " ✗ Failed to generate signature" -ForegroundColor Red | |
| } | |
| } | |
| Write-Host "" | |
| Write-Host "============================================" -ForegroundColor Green | |
| Write-Host " ✓ All signatures generated successfully" -ForegroundColor Green | |
| Write-Host "============================================" -ForegroundColor Green | |
| # ------------------------------------------------------------------------ | |
| # 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: Signature file not found: $sigFile" -ForegroundColor Yellow | |
| } | |
| } 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: 7 | |
| 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' | |
| architecture: ${{ matrix.target_arch == 'arm64' && 'arm64' || 'x64' }} | |
| - 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 }} | |
| # ------------------------------------------------------------------------ | |
| # Step: Setup OTA Signing Key from GitHub Secrets | |
| # ------------------------------------------------------------------------ | |
| - name: Setup OTA signing key | |
| if: | | |
| steps.arch_check.outputs.should_build == 'true' && ( | |
| needs.validate-tag.outputs.environment == 'production' || | |
| needs.validate-tag.outputs.environment == 'staging' || | |
| needs.validate-tag.outputs.environment == 'test' | |
| ) | |
| env: | |
| OTA_PRIVATE_KEY: ${{ secrets.OTA_ED25519_PRIVATE_KEY }} | |
| run: | | |
| echo "=== Setting up OTA signing key ===" | |
| echo "Environment: ${{ needs.validate-tag.outputs.environment }}" | |
| echo "" | |
| # Create certificates directory | |
| mkdir -p build_system/certificates | |
| echo "[OK] Created directory: build_system/certificates" | |
| # Check if secret is available | |
| if [ -z "$OTA_PRIVATE_KEY" ]; then | |
| echo "[ERROR] ========================================" | |
| echo "[ERROR] OTA_ED25519_PRIVATE_KEY secret is NOT configured" | |
| echo "[ERROR] ========================================" | |
| echo "" | |
| echo "To fix this issue:" | |
| echo "1. Run: ./prepare_github_secret.ps1 (on Windows)" | |
| echo "2. Go to: GitHub repository → Settings → Secrets → Actions" | |
| echo "3. Add secret:" | |
| echo " Name: OTA_ED25519_PRIVATE_KEY" | |
| echo " Value: [paste from clipboard]" | |
| echo "" | |
| echo "See: OTA_SIGNING_COMPLETE_GUIDE.md for detailed instructions" | |
| echo "" | |
| exit 1 | |
| fi | |
| # Decode base64 and write private key | |
| KEY_PATH="build_system/certificates/ed25519_private_key.pem" | |
| echo "[1/3] Decoding base64 private key..." | |
| echo "$OTA_PRIVATE_KEY" | base64 -d > "$KEY_PATH" | |
| echo " ✓ Base64 decoded successfully" | |
| # Verify key format | |
| echo "[2/3] Verifying key format..." | |
| if grep -q "BEGIN PRIVATE KEY" "$KEY_PATH"; then | |
| echo " ✓ Key format verified (PEM)" | |
| else | |
| echo " ✗ Invalid key format (expected PEM)" | |
| echo " Key should start with: -----BEGIN PRIVATE KEY-----" | |
| exit 1 | |
| fi | |
| # Set file permissions | |
| echo "[3/3] Setting file permissions..." | |
| chmod 600 "$KEY_PATH" | |
| echo " ✓ File permissions set (600)" | |
| echo "" | |
| echo "============================================" | |
| echo " ✓ OTA signing key configured successfully" | |
| echo "============================================" | |
| echo "Key path: $KEY_PATH" | |
| echo "" | |
| # ------------------------------------------------------------------------ | |
| # Step: Build Application | |
| # ------------------------------------------------------------------------ | |
| - 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 (Optional) | |
| # ------------------------------------------------------------------------ | |
| - 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 "WARNING: Code signing certificates not configured" | |
| 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 "Building unsigned macOS package (not recommended for production)." | |
| echo "To enable code signing, 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 "======================================================" | |
| - 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 "WARNING: Notarization credentials not configured" | |
| 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 "Building without notarization (not recommended for production)." | |
| echo "To enable notarization, 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 "======================================================" | |
| - 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: Generate Ed25519 Signatures for OTA Updates | |
| # ------------------------------------------------------------------------ | |
| - name: Generate Ed25519 signatures | |
| if: | | |
| steps.arch_check.outputs.should_build == 'true' && ( | |
| needs.validate-tag.outputs.environment == 'production' || | |
| needs.validate-tag.outputs.environment == 'staging' || | |
| needs.validate-tag.outputs.environment == 'test' | |
| ) | |
| run: | | |
| # Install cryptography library for Ed25519 signing | |
| python -m pip install cryptography --quiet | |
| echo "=== Generating Ed25519 Signatures ===" | |
| VERSION="${{ needs.validate-tag.outputs.version }}" | |
| ARCH="${{ matrix.arch }}" | |
| # Check if private key exists | |
| KEY_PATH="build_system/certificates/ed25519_private_key.pem" | |
| if [ ! -f "$KEY_PATH" ]; then | |
| echo "[ERROR] Private key not found: $KEY_PATH" | |
| echo "[ERROR] OTA signatures cannot be generated" | |
| exit 1 | |
| fi | |
| echo "[OK] Private key found: $KEY_PATH" | |
| # Install OpenSSL if needed | |
| if ! command -v openssl &> /dev/null; then | |
| echo "Installing OpenSSL..." | |
| brew install openssl | |
| fi | |
| # Generate signature for main PKG file | |
| PKG_FILE="dist/eCan-${VERSION}-macos-${ARCH}.pkg" | |
| if [ -f "$PKG_FILE" ]; then | |
| echo "Generating signature for: $(basename $PKG_FILE)" | |
| # Use signing_manager.py for Ed25519 signing (OpenSSL pkeyutl doesn't support Ed25519 in all versions) | |
| SIG_FILE="${PKG_FILE}.sig" | |
| python build_system/signing_manager.py "$PKG_FILE" "$KEY_PATH" "$SIG_FILE" | |
| if [ -f "$SIG_FILE" ]; then | |
| SIG_SIZE=$(wc -c < "$SIG_FILE" | tr -d ' ') | |
| echo " ✓ Signature generated: $SIG_SIZE bytes" | |
| # Verify signature size (Ed25519 signatures are always 64 bytes) | |
| if [ "$SIG_SIZE" -ne 64 ]; then | |
| echo " ✗ Invalid signature size: expected 64 bytes, got $SIG_SIZE bytes" | |
| exit 1 | |
| fi | |
| else | |
| echo " ✗ Failed to generate signature" | |
| exit 1 | |
| fi | |
| else | |
| echo "[WARN] PKG file not found: $PKG_FILE" | |
| fi | |
| echo "" | |
| echo "============================================" | |
| echo " ✓ All signatures generated successfully" | |
| echo "============================================" | |
| # ------------------------------------------------------------------------ | |
| # 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)" | |
| else | |
| echo "WARNING: Signature file not found: $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: 7 | |
| compression-level: 6 | |
| if-no-files-found: error | |
| # Note: Matrix job outputs removed - using input parameters directly | |
| # for architecture detection in downstream jobs | |
| # ============================================================================ | |
| # 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 for Windows (if built) | |
| generate-appcast-windows: | |
| name: Generate Appcast (Windows) | |
| needs: [validate-tag, upload-to-s3, build-windows] | |
| if: | | |
| always() && | |
| needs.validate-tag.outputs.tag-valid == 'true' && | |
| needs.upload-to-s3.result == 'success' && | |
| needs.build-windows.result == 'success' | |
| uses: ./.github/workflows/shared-appcast-generation.yml | |
| with: | |
| environment: ${{ needs.validate-tag.outputs.environment }} | |
| channel: ${{ needs.validate-tag.outputs.channel }} | |
| version: ${{ needs.validate-tag.outputs.version }} | |
| platform: 'windows' | |
| arch: 'amd64' | |
| secrets: inherit | |
| # Generate Appcast for macOS amd64 (if built) | |
| generate-appcast-macos-amd64: | |
| name: Generate Appcast (macOS Intel) | |
| needs: [validate-tag, upload-to-s3, build-macos] | |
| if: | | |
| always() && | |
| needs.validate-tag.outputs.tag-valid == 'true' && | |
| needs.upload-to-s3.result == 'success' && | |
| needs.build-macos.result == 'success' && | |
| (github.event.inputs.arch == 'all' || github.event.inputs.arch == '' || github.event.inputs.arch == 'amd64') | |
| uses: ./.github/workflows/shared-appcast-generation.yml | |
| with: | |
| environment: ${{ needs.validate-tag.outputs.environment }} | |
| channel: ${{ needs.validate-tag.outputs.channel }} | |
| version: ${{ needs.validate-tag.outputs.version }} | |
| platform: 'macos' | |
| arch: 'amd64' | |
| secrets: inherit | |
| # Generate Appcast for macOS aarch64 (if built) | |
| generate-appcast-macos-aarch64: | |
| name: Generate Appcast (macOS ARM) | |
| needs: [validate-tag, upload-to-s3, build-macos] | |
| if: | | |
| always() && | |
| needs.validate-tag.outputs.tag-valid == 'true' && | |
| needs.upload-to-s3.result == 'success' && | |
| needs.build-macos.result == 'success' && | |
| (github.event.inputs.arch == 'all' || github.event.inputs.arch == '' || github.event.inputs.arch == 'aarch64') | |
| uses: ./.github/workflows/shared-appcast-generation.yml | |
| with: | |
| environment: ${{ needs.validate-tag.outputs.environment }} | |
| channel: ${{ needs.validate-tag.outputs.channel }} | |
| version: ${{ needs.validate-tag.outputs.version }} | |
| platform: 'macos' | |
| arch: 'aarch64' | |
| secrets: inherit | |
| # ---------------------------------------------------------------------------- | |
| # Step 2.5: Generate latest.json (after all Appcasts) | |
| # ---------------------------------------------------------------------------- | |
| generate-latest-json: | |
| name: Generate latest.json | |
| needs: [ | |
| validate-tag, | |
| upload-to-s3, | |
| generate-appcast-windows, | |
| generate-appcast-macos-amd64, | |
| generate-appcast-macos-aarch64 | |
| ] | |
| if: | | |
| always() && | |
| needs.validate-tag.outputs.tag-valid == 'true' && | |
| needs.upload-to-s3.result == 'success' && | |
| (needs.generate-appcast-windows.result == 'success' || | |
| needs.generate-appcast-macos-amd64.result == 'success' || | |
| needs.generate-appcast-macos-aarch64.result == 'success') | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Setup Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.11' | |
| - name: Install dependencies | |
| run: | | |
| pip install boto3 pyyaml packaging | |
| - name: Generate latest.json | |
| env: | |
| AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} | |
| AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} | |
| AWS_REGION: ${{ secrets.AWS_REGION || 'us-east-1' }} | |
| run: | | |
| python3 << 'PYTHON_SCRIPT' | |
| import sys | |
| sys.path.insert(0, 'build_system/scripts') | |
| from generate_appcast import AppcastGenerator | |
| generator = AppcastGenerator( | |
| environment='${{ needs.validate-tag.outputs.environment }}', | |
| channel='${{ needs.validate-tag.outputs.channel }}' | |
| ) | |
| print('[INFO] Generating latest.json only...') | |
| success = generator.generate_latest_json() | |
| if success: | |
| print('[OK] latest.json generated successfully') | |
| sys.exit(0) | |
| else: | |
| print('[ERROR] Failed to generate latest.json') | |
| sys.exit(1) | |
| PYTHON_SCRIPT | |
| # ---------------------------------------------------------------------------- | |
| # 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 }} | |
| environment: ${{ needs.validate-tag.outputs.environment }} | |
| channel: ${{ needs.validate-tag.outputs.channel }} | |
| 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-windows, | |
| generate-appcast-macos-amd64, | |
| generate-appcast-macos-aarch64, | |
| generate-latest-json, | |
| 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-windows.result == 'success' || needs.generate-appcast-macos-amd64.result == 'success' || needs.generate-appcast-macos-aarch64.result == 'success') && 'success' || 'failure' }} | |
| links-result: ${{ needs.generate-download-links.result }} | |
| is-simulation: false | |
| secrets: inherit |