Release Build eCan #395
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 & CHANNEL MAPPING (Standard Industry Practice) | |
| # ============================================================================ | |
| # | |
| # Git Ref → Environment → Channel → S3 Path | |
| # ───────────────────────────────────────────────────────────────────────── | |
| # v1.0.0 → production → stable → production/channels/stable/ | |
| # v1.0.0-rc.1 → production → beta → production/channels/beta/ | |
| # v1.0.0-beta.1 → staging → beta → staging/channels/beta/ | |
| # v1.0.0-alpha.1 → test → dev → test/channels/dev/ | |
| # main/master branch → production → nightly → production/channels/nightly/ | |
| # staging branch → staging → stable → staging/channels/stable/ | |
| # develop/dev branch → development → dev → development/channels/dev/ | |
| # feature/* branches → development → dev → development/channels/dev/ | |
| # | |
| # ============================================================================ | |
| # KEY CONCEPTS | |
| # ============================================================================ | |
| # | |
| # ENVIRONMENT (部署环境级别): | |
| # - production: 生产环境 - 最高质量标准,面向所有用户 | |
| # - staging: 预发布环境 - 生产前最后验证 | |
| # - test: 测试环境 - 内部测试 | |
| # - development: 开发环境 - 开发和调试 | |
| # | |
| # CHANNEL (更新渠道): | |
| # - stable: 稳定版本 - 经过充分测试,推荐所有用户使用 | |
| # - beta: 测试版本 - 功能完整,可能有 bug,适合尝鲜用户 | |
| # - nightly: 每日构建 - 最新功能,来自 main 分支,适合开发者 | |
| # - dev: 开发版本 - 不稳定,仅供开发测试 | |
| # | |
| # ============================================================================ | |
| # PRODUCTION ENVIRONMENT RULES | |
| # ============================================================================ | |
| # | |
| # Production 环境包含两个渠道: | |
| # 1. stable channel (production/channels/stable/) | |
| # - 来源:正式版本 tag (v1.0.0) | |
| # - 用户:所有用户(默认更新渠道) | |
| # - 特点:经过完整测试,最稳定 | |
| # | |
| # 2. nightly channel (production/channels/nightly/) | |
| # - 来源:main/master 分支的每日构建 | |
| # - 用户:尝鲜用户、开发者 | |
| # - 特点:最新功能,质量达到生产级别但未经长期验证 | |
| # | |
| # 3. beta channel (production/channels/beta/) | |
| # - 来源:RC tag (v1.0.0-rc.1) | |
| # - 用户:测试用户 | |
| # - 特点:候选版本,即将成为 stable | |
| # | |
| # 为什么 main/master 是 production environment? | |
| # ✅ main 分支应该始终保持生产级别的代码质量 | |
| # ✅ 符合 CI/CD 最佳实践:main = production-ready | |
| # ✅ 与主流软件一致(Chrome/VS Code/Firefox 等) | |
| # ✅ 通过 channel 区分稳定性,而不是通过 environment | |
| # | |
| # ============================================================================ | |
| # PROTECTION RULES | |
| # ============================================================================ | |
| # | |
| # 1. production/stable channel 保护: | |
| # ⚠️ ONLY 正式版本 tag (v1.0.0) 可以部署 | |
| # ⚠️ 任何分支(包括 main/master)都不能部署到 stable channel | |
| # ⚠️ 防止意外覆盖稳定版本 | |
| # | |
| # 2. production/nightly channel: | |
| # ✅ 只接受 main/master 分支 | |
| # ✅ 每次提交自动构建 | |
| # ✅ 不会影响 stable channel | |
| # | |
| # 3. 其他环境: | |
| # - staging: 接受 staging 分支和 beta/rc tags | |
| # - test: 接受 alpha tags 和测试分支 | |
| # - development: 接受所有开发分支 | |
| # | |
| # 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 | |
| # | |
| # ============================================================================ | |
| # USAGE EXAMPLES (使用示例) | |
| # ============================================================================ | |
| # | |
| # 场景 1: 发布正式版本 (Stable Release) | |
| # 操作:git tag v1.0.0 && git push origin v1.0.0 | |
| # 结果:production/stable/appcast-*.xml | |
| # 用户:所有用户(默认更新渠道) | |
| # | |
| # 场景 2: 发布候选版本 (Release Candidate) | |
| # 操作:git tag v1.0.0-rc.1 && git push origin v1.0.0-rc.1 | |
| # 结果:production/beta/appcast-*.xml | |
| # 用户:测试用户 | |
| # | |
| # 场景 3: main/master 分支每日构建 (Nightly Build) | |
| # 操作:git push origin main | |
| # 结果:production/nightly/appcast-*.xml | |
| # 用户:开发者、尝鲜用户 | |
| # 说明:自动触发,无需手动操作 | |
| # | |
| # 场景 4: 测试新功能 (Feature Branch) | |
| # 操作:Manual trigger with ref=feature/new-ui | |
| # 结果:development/dev/appcast-*.xml | |
| # 用户:内部开发测试 | |
| # | |
| # 场景 5: 预发布验证 (Staging) | |
| # 操作:git push origin staging | |
| # 结果:staging/stable/appcast-*.xml | |
| # 用户:预发布测试团队 | |
| # | |
| # ============================================================================ | |
| # MANUAL TRIGGER GUIDE (手动触发指南) | |
| # ============================================================================ | |
| # | |
| # 在 GitHub Actions 界面选择参数时: | |
| # | |
| # 1. 构建 main 分支的 nightly 版本(推荐 - 自动检测): | |
| # - ref: (留空或填 main) | |
| # - environment: (留空 - 自动检测为 production) | |
| # - channel: (留空 - 自动检测为 nightly) | |
| # ✅ 结果:production/nightly/ | |
| # | |
| # 2. 构建正式版本(推荐 - 自动检测): | |
| # - ref: v1.0.0 | |
| # - environment: (留空 - 自动检测为 production) | |
| # - channel: (留空 - 自动检测为 stable) | |
| # ✅ 结果:production/stable/ | |
| # | |
| # 3. 测试功能分支(推荐 - 自动检测): | |
| # - ref: feature/my-feature | |
| # - environment: (留空 - 自动检测为 development) | |
| # - channel: (留空 - 自动检测为 dev) | |
| # ✅ 结果:development/dev/ | |
| # | |
| # 4. 强制指定环境和渠道(高级用户 - 手动覆盖): | |
| # - ref: main | |
| # - environment: production | |
| # - channel: nightly | |
| # ✅ 结果:production/nightly/ | |
| # | |
| # ⚠️ 注意: | |
| # - 如果手动选择 production + stable,必须使用 tag (v1.0.0) | |
| # - main/master 分支不能使用 stable channel | |
| # - 推荐留空让系统自动检测(更安全、更准确) | |
| # | |
| # 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: 'Platform to build (all: build all platforms, windows: Windows only, macos: macOS only, linux: Linux only)' | |
| required: true | |
| default: 'all' | |
| type: choice | |
| options: | |
| - all | |
| - windows | |
| - macos | |
| - linux | |
| 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 (目标环境): | |
| 📋 自动检测规则: | |
| • v1.0.0 → production/stable | |
| • v1.0.0-rc.1 → production/beta | |
| • main/master → production/nightly | |
| • staging branch → staging/stable | |
| • develop/dev → development/dev | |
| ⚠️ 环境说明: | |
| - production: 生产环境 (仅限 tags + main/master) | |
| - staging: 预发布环境 | |
| - test: 测试环境 | |
| - development: 开发环境 | |
| - (留空): 自动检测 | |
| required: false | |
| default: 'production' | |
| type: choice | |
| options: | |
| - production | |
| - '' | |
| - staging | |
| - test | |
| - development | |
| channel: | |
| description: | | |
| Release channel (更新渠道): | |
| 📋 自动检测规则: | |
| • v1.0.0 → stable | |
| • v1.0.0-rc.1 → beta | |
| • main/master → nightly | |
| • develop/dev → dev | |
| ⚠️ 渠道说明: | |
| - stable: 稳定版本 (仅限正式 tag) | |
| - beta: 测试版本 | |
| - nightly: 每日构建 | |
| - dev: 开发版本 | |
| - (留空): 自动检测 | |
| required: false | |
| default: 'stable' | |
| type: choice | |
| options: | |
| - stable | |
| - '' | |
| - beta | |
| - nightly | |
| - dev | |
| 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@v6 | |
| 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]+)+(-[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 tag (version tag) | |
| IS_TAG=false | |
| if [[ "$REF_NAME" =~ ^v[0-9]+(\.[0-9]+)+.*$ ]]; then | |
| IS_TAG=true | |
| fi | |
| # Determine if this is a production-eligible ref (for staging checks) | |
| IS_STAGING_ELIGIBLE=false | |
| if [[ "$IS_TAG" == "true" ]] || \ | |
| [[ "$REF_NAME" == "main" ]] || [[ "$REF_NAME" == "master" ]]; then | |
| IS_STAGING_ELIGIBLE=true | |
| fi | |
| # Use manual selection if provided (skip if empty) | |
| if [[ -n "$INPUT_ENV" ]]; then | |
| ENV="$INPUT_ENV" | |
| # CRITICAL SAFETY CHECK: Production environment with specific channel restrictions | |
| if [[ "$ENV" == "production" ]]; then | |
| # Production environment is allowed for both tags and main/master branches | |
| # But they use DIFFERENT channels to prevent conflicts | |
| if [[ "$IS_TAG" == "false" ]]; then | |
| # Branches in production: only main/master allowed | |
| if [[ "$REF_NAME" != "main" && "$REF_NAME" != "master" ]]; then | |
| echo "======================================================" | |
| echo "❌ ERROR: Only main/master branches can use production environment" | |
| echo "======================================================" | |
| echo "Current ref: $REF_NAME" | |
| echo "Requested environment: production" | |
| echo "" | |
| echo "Production environment accepts:" | |
| echo " ✅ Version tags (v1.0.0, v1.0.0-rc.1) → stable/beta channels" | |
| echo " ✅ main/master branches → nightly channel" | |
| echo " ❌ Other branches → use development/test/staging" | |
| echo "" | |
| echo "Allowed environments for branch '$REF_NAME':" | |
| echo " - development" | |
| echo " - test" | |
| echo " - staging (if eligible)" | |
| echo "======================================================" | |
| exit 1 | |
| fi | |
| fi | |
| fi | |
| # Safety check: Prevent feature branches from using staging | |
| if [[ "$IS_STAGING_ELIGIBLE" == "false" ]] && [[ "$ENV" == "staging" ]]; then | |
| echo "======================================================" | |
| echo "❌ ERROR: Branch '$REF_NAME' cannot use staging environment" | |
| echo "======================================================" | |
| echo "Only the following refs can use staging:" | |
| echo " - Version tags: v1.0.0, v1.0.0-rc.1, v1.0.0-beta.1" | |
| echo " - Main branches: main, master" | |
| echo " - Staging branch: staging" | |
| 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 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]+)+$ ]]; then | |
| # Production: Clean version tag (e.g., v1.0.0, v0.8.9.2) | |
| ENV="production" | |
| elif [[ "$REF_NAME" =~ ^v[0-9]+(\.[0-9]+)+-rc\. ]]; then | |
| # Production: Release candidate (e.g., v1.0.0-rc.1) | |
| ENV="production" | |
| elif [[ "$REF_NAME" =~ ^v[0-9]+(\.[0-9]+)+-beta ]]; then | |
| # Staging: Beta version (e.g., v1.0.0-beta.1) | |
| ENV="staging" | |
| elif [[ "$REF_NAME" =~ ^v[0-9]+(\.[0-9]+)+-alpha ]]; then | |
| # Test: Alpha version (e.g., v1.0.0-alpha.1) | |
| ENV="test" | |
| elif [[ "$REF_NAME" == "main" || "$REF_NAME" == "master" ]]; then | |
| # Production: main/master branch (nightly builds) | |
| ENV="production" | |
| elif [[ "$REF_NAME" == "staging" ]]; then | |
| # Staging: staging branch | |
| ENV="staging" | |
| elif [[ "$REF_NAME" == "develop" || "$REF_NAME" == "dev" ]]; then | |
| # Development: develop branch | |
| ENV="development" | |
| else | |
| # Other branches: default to development | |
| ENV="development" | |
| fi | |
| echo "Auto-detected environment: $ENV" | |
| fi | |
| # Use manual channel if provided (skip if empty) | |
| if [[ -n "$INPUT_CHANNEL" ]]; then | |
| CHANNEL="$INPUT_CHANNEL" | |
| echo "Using manual channel: $CHANNEL" | |
| else | |
| # Auto-detect channel based on ref type and environment | |
| if [[ "$REF_NAME" =~ ^v[0-9]+(\.[0-9]+)+$ ]]; then | |
| # Clean version tag → stable channel | |
| CHANNEL="stable" | |
| elif [[ "$REF_NAME" =~ ^v[0-9]+(\.[0-9]+)+-rc\. ]]; then | |
| # RC tag → beta channel | |
| CHANNEL="beta" | |
| elif [[ "$REF_NAME" =~ ^v[0-9]+(\.[0-9]+)+-beta ]]; then | |
| # Beta tag → beta channel | |
| CHANNEL="beta" | |
| elif [[ "$REF_NAME" =~ ^v[0-9]+(\.[0-9]+)+-alpha ]]; then | |
| # Alpha tag → dev channel | |
| CHANNEL="dev" | |
| elif [[ "$REF_NAME" == "main" || "$REF_NAME" == "master" ]]; then | |
| # main/master branch → nightly channel | |
| CHANNEL="nightly" | |
| elif [[ "$REF_NAME" == "staging" ]]; then | |
| # staging branch → stable channel | |
| CHANNEL="stable" | |
| elif [[ "$REF_NAME" == "develop" || "$REF_NAME" == "dev" ]]; then | |
| # develop branch → dev channel | |
| CHANNEL="dev" | |
| else | |
| # Other branches → dev channel | |
| CHANNEL="dev" | |
| fi | |
| echo "Auto-detected channel: $CHANNEL" | |
| fi | |
| # FINAL SAFETY CHECK: Enforce channel restrictions for production/stable | |
| IS_BRANCH="${{ steps.validate.outputs.is_branch }}" | |
| # Production/stable channel protection: ONLY version tags allowed | |
| if [[ "$ENV" == "production" ]] && [[ "$CHANNEL" == "stable" ]] && [[ "$IS_TAG" == "false" ]]; then | |
| echo "======================================================" | |
| echo "❌ CRITICAL ERROR: production/stable requires version tags" | |
| echo "======================================================" | |
| echo "Current ref: $REF_NAME (branch)" | |
| echo "Target: production/stable" | |
| echo "" | |
| echo "⚠️ PROTECTION: To prevent overwriting stable releases," | |
| echo " ONLY version tags (v1.0.0) can use production/stable." | |
| echo "" | |
| echo "Options for branch '$REF_NAME':" | |
| if [[ "$REF_NAME" == "main" || "$REF_NAME" == "master" ]]; then | |
| echo " ✅ production/nightly (auto-detected for main/master)" | |
| echo " ✅ Change channel to 'nightly' or 'auto'" | |
| else | |
| echo " ✅ development/dev" | |
| echo " ✅ test/dev" | |
| fi | |
| echo "" | |
| echo "To deploy to production/stable:" | |
| echo " 1. Create a version tag: git tag v1.0.1" | |
| echo " 2. Push the tag: git push origin v1.0.1" | |
| echo " 3. Trigger build with tag v1.0.1" | |
| echo "======================================================" | |
| exit 1 | |
| fi | |
| # Staging environment requires tags or main/master/staging branches | |
| if [[ "$ENV" == "staging" ]] && [[ "$IS_STAGING_ELIGIBLE" == "false" ]]; then | |
| echo "======================================================" | |
| echo "❌ ERROR: Staging requires tags or main/master/staging branches" | |
| echo "======================================================" | |
| echo "Current ref: $REF_NAME (feature branch)" | |
| echo "Target environment: staging" | |
| echo "" | |
| echo "Allowed refs for staging:" | |
| echo " - Version tags: v1.0.0, v1.0.0-rc.1, v1.0.0-beta.1" | |
| echo " - Main branches: main, master" | |
| echo " - Staging branch: staging" | |
| echo "" | |
| echo "To deploy to staging:" | |
| echo " 1. Create a version tag, OR" | |
| echo " 2. Merge to main/master/staging branch" | |
| echo "" | |
| echo "Or change environment to 'development' or 'test' for feature branches." | |
| 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: | |
| # ── Azure Trusted Signing (cloud HSM, preferred) ────────────────────── | |
| AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID || 'NOT_SET' }} | |
| AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID || 'NOT_SET' }} | |
| AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET || 'NOT_SET' }} | |
| AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT || 'NOT_SET' }} | |
| AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT || 'NOT_SET' }} | |
| AZURE_SIGNING_PROFILE: ${{ secrets.AZURE_SIGNING_PROFILE || 'NOT_SET' }} | |
| # ── PFX fallback (traditional certificate file) ─────────────────────── | |
| 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@v6 | |
| 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@v5 | |
| 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 2.5: WhatsApp Baileys Bridge Environment | |
| # ------------------------------------------------------------------------ | |
| - name: Setup WhatsApp Baileys Bridge Environment | |
| uses: ./.github/actions/setup-wabaileys-bridge | |
| with: | |
| node-version: '20' | |
| bridge-dir: wabaileys-bridge | |
| # ------------------------------------------------------------------------ | |
| # 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://github.com/jrsoftware/issrc/releases/download/is-6_7_1/innosetup-6.7.1.exe" | |
| $DownloadPath = "$env:TEMP\innosetup-6.7.1.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 | |
| # Priority: Azure Trusted Signing (cloud HSM) → PFX fallback → unsigned | |
| # ------------------------------------------------------------------------ | |
| - name: Check Windows code signing configuration | |
| id: check_signing | |
| shell: pwsh | |
| run: | | |
| Write-Host "=== Checking Windows Code Signing Configuration ===" -ForegroundColor Cyan | |
| $azureConfigured = ( | |
| $env:AZURE_TENANT_ID -ne 'NOT_SET' -and | |
| $env:AZURE_CLIENT_ID -ne 'NOT_SET' -and | |
| $env:AZURE_CLIENT_SECRET -ne 'NOT_SET' -and | |
| $env:AZURE_SIGNING_ENDPOINT -ne 'NOT_SET' -and | |
| $env:AZURE_SIGNING_ACCOUNT -ne 'NOT_SET' -and | |
| $env:AZURE_SIGNING_PROFILE -ne 'NOT_SET' | |
| ) | |
| $pfxConfigured = ( | |
| $env:WIN_CERT_PFX -ne 'NOT_SET' -and | |
| $env:WIN_CERT_PASSWORD -ne 'NOT_SET' | |
| ) | |
| Write-Host "Azure Trusted Signing: $(if ($azureConfigured) { '✅ Configured' } else { '❌ Not configured' })" -ForegroundColor $(if ($azureConfigured) { 'Green' } else { 'Yellow' }) | |
| Write-Host "PFX Fallback: $(if ($pfxConfigured) { '✅ Configured' } else { '❌ Not configured' })" -ForegroundColor $(if ($pfxConfigured) { 'Green' } else { 'Yellow' }) | |
| if ($azureConfigured) { | |
| Write-Host "[INFO] Will use Azure Trusted Signing (cloud HSM - preferred)" -ForegroundColor Cyan | |
| "method=azure" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append | |
| } elseif ($pfxConfigured) { | |
| Write-Host "[INFO] Will use PFX certificate (fallback)" -ForegroundColor Yellow | |
| "method=pfx" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append | |
| } else { | |
| Write-Host "[WARN] No signing credentials configured – build will be unsigned" -ForegroundColor Yellow | |
| Write-Host "" | |
| Write-Host "To enable Azure Trusted Signing (recommended), add GitHub Secrets:" -ForegroundColor Gray | |
| Write-Host " AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET" -ForegroundColor Gray | |
| Write-Host " AZURE_SIGNING_ENDPOINT, AZURE_SIGNING_ACCOUNT, AZURE_SIGNING_PROFILE" -ForegroundColor Gray | |
| Write-Host "" | |
| Write-Host "To enable PFX fallback, add GitHub Secrets:" -ForegroundColor Gray | |
| Write-Host " WIN_CERT_PFX (base64-encoded .pfx), WIN_CERT_PASSWORD" -ForegroundColor Gray | |
| "method=none" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append | |
| } | |
| # ── Method 1: Azure Trusted Signing (cloud HSM, no private key on disk) ── | |
| - name: Code sign Windows artifacts (Azure Trusted Signing) | |
| if: steps.check_signing.outputs.method == 'azure' | |
| uses: ./.github/actions/azure-code-signing | |
| with: | |
| azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} | |
| azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} | |
| azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} | |
| endpoint: ${{ secrets.AZURE_SIGNING_ENDPOINT }} | |
| account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }} | |
| certificate-profile-name: ${{ secrets.AZURE_SIGNING_PROFILE }} | |
| files-folder: dist/eCan | |
| files-folder-filter: exe,dll | |
| files-folder-recurse: 'true' | |
| - name: Sign installers (Azure Trusted Signing) | |
| if: steps.check_signing.outputs.method == 'azure' | |
| uses: ./.github/actions/azure-code-signing | |
| with: | |
| azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} | |
| azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} | |
| azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} | |
| endpoint: ${{ secrets.AZURE_SIGNING_ENDPOINT }} | |
| account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }} | |
| certificate-profile-name: ${{ secrets.AZURE_SIGNING_PROFILE }} | |
| files-folder: dist | |
| files-folder-filter: exe,msi | |
| files-folder-recurse: 'false' | |
| # ── Method 2: PFX certificate fallback ──────────────────────────────────── | |
| - name: Code sign Windows artifacts (PFX fallback) | |
| if: steps.check_signing.outputs.method == 'pfx' | |
| env: | |
| WIN_CERT_PFX: ${{ env.WIN_CERT_PFX }} | |
| WIN_CERT_PASSWORD: ${{ env.WIN_CERT_PASSWORD }} | |
| shell: pwsh | |
| run: | | |
| Write-Host "=== Code signing Windows artifacts (PFX) ===" -ForegroundColor Cyan | |
| $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 | |
| Write-Host "=== Step 1: Signing internal application files ===" -ForegroundColor Yellow | |
| if (Test-Path "dist/eCan") { | |
| $internalFiles = Get-ChildItem -Path "dist/eCan" -Recurse -Include "*.exe","*.dll" -File -ErrorAction SilentlyContinue | |
| foreach ($f in $internalFiles) { | |
| & $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)" -ForegroundColor Green | |
| } | |
| # Step 2: Sign distribution installers | |
| Write-Host "=== Step 2: Signing distribution installers ===" -ForegroundColor Yellow | |
| $installers = @() | |
| Get-ChildItem -Path "dist" -Filter "*-Setup.exe" -File -ErrorAction SilentlyContinue | ForEach-Object { $installers += $_.FullName } | |
| Get-ChildItem -Path "dist" -Filter "*.msi" -File -ErrorAction SilentlyContinue | ForEach-Object { $installers += $_.FullName } | |
| foreach ($installer in $installers) { | |
| & $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 "✅ PFX signing completed: $($installers.Count) installers" -ForegroundColor Green | |
| # Clean up PFX from disk immediately | |
| Remove-Item $pfxPath -Force -ErrorAction SilentlyContinue | |
| - 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@v6 | |
| 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@v6 | |
| 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@v6 | |
| 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.12' | |
| 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@v5 | |
| 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 2.5: WhatsApp Baileys Bridge Environment | |
| # ------------------------------------------------------------------------ | |
| - name: Setup WhatsApp Baileys Bridge Environment | |
| if: steps.arch_check.outputs.should_build == 'true' | |
| uses: ./.github/actions/setup-wabaileys-bridge | |
| with: | |
| node-version: '20' | |
| bridge-dir: wabaileys-bridge | |
| # ------------------------------------------------------------------------ | |
| # 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@v6 | |
| 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@v6 | |
| 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 | |
| # ============================================================================ | |
| # BUILD LAYER: Linux amd64 | |
| # ============================================================================ | |
| build-linux: | |
| name: Build Linux amd64 | |
| needs: validate-tag | |
| if: | | |
| needs.validate-tag.outputs.tag-valid == 'true' && | |
| (github.event.inputs.platform == 'linux' || github.event.inputs.platform == 'all' || github.event.inputs.platform == '') && | |
| (github.event.inputs.arch == 'amd64' || github.event.inputs.arch == 'all' || github.event.inputs.arch == '') | |
| runs-on: ubuntu-22.04 | |
| env: | |
| BUILD_ARCH: amd64 | |
| DEBIAN_FRONTEND: noninteractive | |
| timeout-minutes: 60 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ github.event.inputs.ref || github.ref }} | |
| fetch-depth: 1 | |
| - name: Setup Python Environment | |
| uses: ./.github/actions/setup-python-env | |
| with: | |
| platform: linux | |
| requirements-file: requirements-linux.txt | |
| architecture: x64 | |
| - name: Setup Playwright Browsers | |
| uses: ./.github/actions/setup-playwright | |
| with: | |
| platform: linux | |
| browsers: chromium | |
| - name: Cache Node.js dependencies | |
| uses: actions/cache@v5 | |
| with: | |
| path: gui_v2/node_modules | |
| 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 2.5: WhatsApp Baileys Bridge Environment | |
| # ------------------------------------------------------------------------ | |
| - name: Setup WhatsApp Baileys Bridge Environment | |
| uses: ./.github/actions/setup-wabaileys-bridge | |
| with: | |
| node-version: '20' | |
| bridge-dir: wabaileys-bridge | |
| - name: Install Linux system dependencies | |
| run: | | |
| sudo apt-get update -qq | |
| sudo apt-get install -y -qq gcc g++ make pkg-config libffi-dev libssl-dev | |
| sudo apt-get install -y -qq libgl1 libglib2.0-0 libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxcb-xfixes0 libxcb-xinerama0 libxcb-cursor0 libdbus-1-3 libxcb-xkb1 libxkbcommon0 | |
| sudo apt-get install -y -qq scrot wmctrl xdotool xdg-utils cups cups-client tesseract-ocr tesseract-ocr-eng dpkg-dev fakeroot | |
| - name: Install AppImage tools | |
| run: | | |
| wget -q https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage | |
| chmod +x appimagetool-x86_64.AppImage | |
| sudo mv appimagetool-x86_64.AppImage /usr/local/bin/appimagetool | |
| - name: Setup virtual display (Xvfb) | |
| run: | | |
| sudo apt-get install -y -qq xvfb | |
| Xvfb :99 -screen 0 1920x1080x24 & | |
| echo "DISPLAY=:99" >> $GITHUB_ENV | |
| - 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 }} | |
| run: | | |
| mkdir -p build_system/certificates | |
| echo "$OTA_PRIVATE_KEY" | base64 -d > build_system/certificates/ed25519_private_key.pem | |
| chmod 600 build_system/certificates/ed25519_private_key.pem | |
| - name: Build Linux packages | |
| env: | |
| ECAN_SKIP_FRONTEND_BUILD: '0' | |
| ECAN_ENVIRONMENT: ${{ needs.validate-tag.outputs.environment }} | |
| run: | | |
| python3 build.py prod --version ${{ needs.validate-tag.outputs.version }} | |
| - name: Generate Ed25519 signatures | |
| if: | | |
| needs.validate-tag.outputs.environment == 'production' || | |
| needs.validate-tag.outputs.environment == 'staging' || | |
| needs.validate-tag.outputs.environment == 'test' | |
| run: | | |
| pip install cryptography --quiet | |
| VERSION="${{ needs.validate-tag.outputs.version }}" | |
| KEY_PATH="build_system/certificates/ed25519_private_key.pem" | |
| if [ -f "dist/ecan-${VERSION}_amd64.deb" ]; then | |
| python3 build_system/signing_manager.py "dist/ecan-${VERSION}_amd64.deb" "$KEY_PATH" "dist/ecan-${VERSION}_amd64.deb.sig" | |
| fi | |
| - name: Prepare artifacts directory | |
| run: | | |
| VERSION="${{ needs.validate-tag.outputs.version }}" | |
| mkdir -p artifacts | |
| if [ -f "dist/ecan-${VERSION}_amd64.deb" ]; then | |
| cp "dist/ecan-${VERSION}_amd64.deb" artifacts/ | |
| [ -f "dist/ecan-${VERSION}_amd64.deb.sig" ] && cp "dist/ecan-${VERSION}_amd64.deb.sig" artifacts/ | |
| fi | |
| - name: Upload Linux artifacts for S3 transfer (always, short retention) | |
| uses: actions/upload-artifact@v6 | |
| with: | |
| name: eCan-linux-${{ env.BUILD_ARCH }}-${{ needs.validate-tag.outputs.version }}-s3-transfer | |
| path: artifacts/ | |
| retention-days: 1 | |
| compression-level: 6 | |
| if-no-files-found: warn | |
| - name: Upload Linux artifacts for users (optional, long retention) | |
| if: github.event.inputs.upload_artifacts == 'true' | |
| uses: actions/upload-artifact@v6 | |
| with: | |
| name: eCan-Linux-${{ needs.validate-tag.outputs.version }} | |
| path: artifacts/ | |
| retention-days: 7 | |
| compression-level: 6 | |
| # ============================================================================ | |
| # 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, build-linux] | |
| if: | | |
| always() && | |
| needs.validate-tag.outputs.tag-valid == 'true' && | |
| (needs.build-windows.result == 'success' || needs.build-macos.result == 'success' || needs.build-linux.result == 'success') | |
| 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 }} | |
| linux-build-result: ${{ needs.build-linux.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 | |
| # Generate Appcast for Linux amd64 (if built) | |
| generate-appcast-linux-amd64: | |
| name: Generate Appcast (Linux) | |
| needs: [validate-tag, upload-to-s3, build-linux] | |
| if: | | |
| always() && | |
| needs.validate-tag.outputs.tag-valid == 'true' && | |
| needs.upload-to-s3.result == 'success' && | |
| needs.build-linux.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: 'linux' | |
| arch: 'amd64' | |
| 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, | |
| generate-appcast-linux-amd64 | |
| ] | |
| 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' || | |
| needs.generate-appcast-linux-amd64.result == 'success') | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v6 | |
| - name: Setup Python | |
| uses: actions/setup-python@v6 | |
| with: | |
| python-version: '3.12' | |
| - 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, upload-to-s3, build-windows, build-macos, build-linux] | |
| 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 }} | |
| linux-result: ${{ needs.build-linux.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, | |
| build-linux, | |
| upload-to-s3, | |
| generate-appcast-windows, | |
| generate-appcast-macos-amd64, | |
| generate-appcast-macos-aarch64, | |
| generate-appcast-linux-amd64, | |
| 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 }} | |
| linux-result: ${{ needs.build-linux.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' || needs.generate-appcast-linux-amd64.result == 'success') && 'success' || 'failure' }} | |
| links-result: ${{ needs.generate-download-links.result }} | |
| is-simulation: false | |
| secrets: inherit |