refactor(onboarding): route startup through web service manager lifec… #274
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build Hagicode Desktop | |
| on: | |
| push: | |
| branches: | |
| - main | |
| tags: | |
| - "v*.*.*" | |
| workflow_dispatch: | |
| inputs: | |
| channel: | |
| description: 'Release channel (stable, beta, dev)' | |
| required: false | |
| type: choice | |
| options: | |
| - stable | |
| - beta | |
| - dev | |
| sign_windows_release: | |
| description: 'Sign Windows release artifacts in this manual run' | |
| required: false | |
| type: boolean | |
| default: false | |
| permissions: | |
| contents: write | |
| env: | |
| NODE_VERSION: "22" | |
| HAGISCRIPT_VERSION: "0.1.10" | |
| # Release channel (auto-detected from tag/branch, can be overridden manually) | |
| RELEASE_CHANNEL: "${{ github.event.inputs.channel || '' }}" | |
| jobs: | |
| ################################################################################ | |
| # Windows Build Job | |
| ################################################################################ | |
| build-windows: | |
| name: Build for Windows | |
| if: ${{ !startsWith(github.ref, 'refs/tags/v') }} | |
| runs-on: windows-latest | |
| permissions: | |
| contents: write | |
| id-token: write | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Determine release channel | |
| id: channel | |
| shell: bash | |
| run: | | |
| # If channel is manually specified, use it | |
| if [ -n "${{ github.event.inputs.channel }}" ]; then | |
| CHANNEL="${{ github.event.inputs.channel }}" | |
| echo "Using manually specified channel: ${CHANNEL}" | |
| else | |
| # Auto-detect channel based on tag or branch | |
| if [ "${{ github.ref_type }}" == "tag" ]; then | |
| TAG="${GITHUB_REF_NAME#v}" | |
| # Check if version contains pre-release identifiers | |
| if [[ "$TAG" =~ -(beta|alpha|rc|dev) ]]; then | |
| if [[ "$TAG" =~ -beta ]]; then | |
| CHANNEL="beta" | |
| elif [[ "$TAG" =~ -alpha ]]; then | |
| CHANNEL="dev" | |
| elif [[ "$TAG" =~ -rc ]]; then | |
| CHANNEL="beta" | |
| elif [[ "$TAG" =~ -dev ]]; then | |
| CHANNEL="dev" | |
| fi | |
| else | |
| # Stable version (no pre-release identifier) | |
| CHANNEL="stable" | |
| fi | |
| elif [ "${{ github.ref }}" == "refs/heads/main" ]; then | |
| # Main branch without tag - use beta for testing | |
| CHANNEL="beta" | |
| else | |
| # Other branches | |
| CHANNEL="dev" | |
| fi | |
| echo "Auto-detected channel: ${CHANNEL}" | |
| fi | |
| echo "channel=${CHANNEL}" >> $GITHUB_OUTPUT | |
| echo "RELEASE_CHANNEL=${CHANNEL}" >> $GITHUB_ENV | |
| - name: Determine Windows signing policy | |
| id: signing_policy | |
| shell: bash | |
| run: | | |
| if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then | |
| required=true | |
| reason="tag release" | |
| elif [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ github.event.inputs.sign_windows_release }}" == "true" ]]; then | |
| required=true | |
| reason="manual workflow_dispatch override" | |
| else | |
| required=false | |
| reason="non-release build" | |
| fi | |
| echo "required=${required}" >> "$GITHUB_OUTPUT" | |
| echo "reason=${reason}" >> "$GITHUB_OUTPUT" | |
| echo "Windows signing required: ${required} (${reason})" | |
| - name: Validate Artifact Signing configuration | |
| if: success() && steps.signing_policy.outputs.required == 'true' | |
| shell: bash | |
| env: | |
| AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} | |
| AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} | |
| AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} | |
| AZURE_CODESIGN_ENDPOINT: ${{ secrets.AZURE_CODESIGN_ENDPOINT }} | |
| AZURE_CODESIGN_ACCOUNT_NAME: ${{ secrets.AZURE_CODESIGN_ACCOUNT_NAME }} | |
| AZURE_CODESIGN_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_CODESIGN_CERTIFICATE_PROFILE_NAME }} | |
| AZURE_CODESIGN_APPX_PUBLISHER: ${{ secrets.AZURE_CODESIGN_APPX_PUBLISHER }} | |
| run: | | |
| missing=() | |
| for key in AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID AZURE_CODESIGN_ENDPOINT AZURE_CODESIGN_ACCOUNT_NAME AZURE_CODESIGN_CERTIFICATE_PROFILE_NAME AZURE_CODESIGN_APPX_PUBLISHER; do | |
| if [ -z "${!key}" ]; then | |
| missing+=("${key}") | |
| fi | |
| done | |
| if [ ${#missing[@]} -gt 0 ]; then | |
| printf '::error::Missing Artifact Signing configuration: %s\n' "$(IFS=', '; echo "${missing[*]}")" | |
| { | |
| echo '## Windows code signing configuration error' | |
| echo | |
| echo 'The release build requires Artifact Signing, but the following values are missing:' | |
| echo | |
| for key in "${missing[@]}"; do | |
| echo "- ${key}" | |
| done | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| exit 1 | |
| fi | |
| - name: Configure AppX publisher for Artifact Signing | |
| if: success() && steps.signing_policy.outputs.required == 'true' | |
| shell: pwsh | |
| env: | |
| AZURE_CODESIGN_APPX_PUBLISHER: ${{ secrets.AZURE_CODESIGN_APPX_PUBLISHER }} | |
| run: | | |
| $configPath = Join-Path $env:GITHUB_WORKSPACE 'electron-builder.yml' | |
| $content = Get-Content -Path $configPath -Raw | |
| $escapedPublisher = $env:AZURE_CODESIGN_APPX_PUBLISHER.Replace('"', '\"') | |
| $updated = [regex]::Replace( | |
| $content, | |
| '(?m)^ publisher: .*$', | |
| " publisher: `"$escapedPublisher`"" | |
| ) | |
| if ($updated -eq $content) { | |
| throw "Failed to update appx.publisher in $configPath" | |
| } | |
| Set-Content -Path $configPath -Value $updated | |
| Write-Host "Configured AppX publisher for Artifact Signing." | |
| - name: Azure login | |
| if: success() && steps.signing_policy.outputs.required == 'true' | |
| uses: azure/login@v2 | |
| with: | |
| client-id: ${{ secrets.AZURE_CLIENT_ID }} | |
| tenant-id: ${{ secrets.AZURE_TENANT_ID }} | |
| subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: "npm" | |
| cache-dependency-path: package-lock.json | |
| - name: Install global hagiscript | |
| shell: bash | |
| run: | | |
| npm install --global "@hagicode/hagiscript@${HAGISCRIPT_VERSION}" | |
| prefix="$(npm config get prefix)" | |
| echo "$prefix" >> "$GITHUB_PATH" | |
| if [ -d "$prefix/bin" ]; then | |
| echo "$prefix/bin" >> "$GITHUB_PATH" | |
| fi | |
| hash -r | |
| hagiscript --version | |
| - name: Cache pinned runtime downloads | |
| uses: actions/cache@v4 | |
| with: | |
| path: build/embedded-runtime/downloads | |
| key: ${{ runner.os }}-embedded-runtime-${{ hashFiles('resources/embedded-runtime/runtime-manifest.json') }} | |
| - name: Sync version from tag | |
| if: startsWith(github.ref, 'refs/tags/v') | |
| shell: bash | |
| run: | | |
| VERSION="${GITHUB_REF_NAME#v}" | |
| echo "Syncing npm/package.json version to ${VERSION}" | |
| npm version "${VERSION}" --no-git-tag-version | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Build for Windows | |
| run: node scripts/ci-build.js --platform win | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| HAGICODE_EMBEDDED_DOTNET_PLATFORM: win-x64 | |
| - name: Collect Windows artifacts | |
| id: windows_artifacts | |
| shell: pwsh | |
| run: | | |
| $ErrorActionPreference = 'Stop' | |
| $pkgDir = Join-Path $env:GITHUB_WORKSPACE 'pkg' | |
| if (-not (Test-Path $pkgDir)) { | |
| throw "pkg directory not found: $pkgDir" | |
| } | |
| $allFiles = Get-ChildItem -Path $pkgDir -File | |
| $nsis = @($allFiles | Where-Object { $_.Extension -ieq '.exe' -and $_.Name -like '*Setup*' } | Sort-Object FullName -Unique) | |
| $portable = @($allFiles | Where-Object { $_.Extension -ieq '.exe' -and $_.Name -notlike '*Setup*' } | Sort-Object FullName -Unique) | |
| $appx = @($allFiles | Where-Object { $_.Extension -ieq '.appx' } | Sort-Object FullName -Unique) | |
| $signable = @($nsis + $portable + $appx | Sort-Object FullName -Unique) | |
| $unpackedRoots = @(Get-ChildItem -Path $pkgDir -Directory | Where-Object { $_.Name -eq 'win-unpacked' } | Sort-Object FullName -Unique) | |
| if ($signable.Count -eq 0) { | |
| throw 'No Windows artifacts were found in pkg/ for signing or upload.' | |
| } | |
| if ($unpackedRoots.Count -eq 0) { | |
| throw 'No win-unpacked directory was found in pkg/ for ZIP assembly.' | |
| } | |
| $catalogPath = Join-Path $pkgDir 'windows-signable-artifacts.txt' | |
| $zipPayloadRoot = Join-Path $pkgDir 'windows-zip-payload' | |
| $zipBaseName = if ($portable.Count -gt 0) { | |
| [System.IO.Path]::GetFileNameWithoutExtension($portable[0].Name) | |
| } else { | |
| 'hagicode-windows-unpacked' | |
| } | |
| function Write-MultilineOutput([string]$Name, [object[]]$Files) { | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "$Name<<EOF" | |
| foreach ($file in $Files) { | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value $file.FullName | |
| } | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value 'EOF' | |
| } | |
| Write-MultilineOutput 'nsis_files' $nsis | |
| Write-MultilineOutput 'portable_files' $portable | |
| Write-MultilineOutput 'appx_files' $appx | |
| Write-MultilineOutput 'signable_files' $signable | |
| Write-MultilineOutput 'unpacked_roots' $unpackedRoots | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "catalog_path=$catalogPath" | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "zip_payload_root=$zipPayloadRoot" | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "zip_base_name=$zipBaseName" | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "signable_count=$($signable.Count)" | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "portable_count=$($portable.Count)" | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "unpacked_count=$($unpackedRoots.Count)" | |
| "Found $($nsis.Count) NSIS, $($portable.Count) portable, $($appx.Count) AppX, and $($unpackedRoots.Count) unpacked Windows artifact(s)." | |
| - name: Prepare Windows unpacked ZIP payload workspace | |
| id: windows_zip_payload | |
| shell: pwsh | |
| env: | |
| WINDOWS_UNPACKED_ROOTS: ${{ steps.windows_artifacts.outputs.unpacked_roots }} | |
| WINDOWS_SIGNABLE_FILES: ${{ steps.windows_artifacts.outputs.signable_files }} | |
| WINDOWS_ZIP_BASE_NAME: ${{ steps.windows_artifacts.outputs.zip_base_name }} | |
| run: | | |
| $ErrorActionPreference = 'Stop' | |
| $zipPayloadRoot = '${{ steps.windows_artifacts.outputs.zip_payload_root }}' | |
| $catalogPath = '${{ steps.windows_artifacts.outputs.catalog_path }}' | |
| $pkgDir = Split-Path -Path $catalogPath -Parent | |
| if (Test-Path $zipPayloadRoot) { | |
| Remove-Item -Path $zipPayloadRoot -Recurse -Force | |
| } | |
| New-Item -Path $zipPayloadRoot -ItemType Directory -Force | Out-Null | |
| $unpackedRoots = @() | |
| $env:WINDOWS_UNPACKED_ROOTS -split "`r?`n" | ForEach-Object { | |
| $trimmed = $_.Trim() | |
| if ($trimmed) { | |
| $unpackedRoots += $trimmed | |
| } | |
| } | |
| if ($unpackedRoots.Count -eq 0) { | |
| throw 'No unpacked Windows roots were passed to ZIP payload staging.' | |
| } | |
| $payloadDirName = "{0}-unpacked" -f $env:WINDOWS_ZIP_BASE_NAME | |
| $payloadDir = Join-Path $zipPayloadRoot $payloadDirName | |
| New-Item -Path $payloadDir -ItemType Directory -Force | Out-Null | |
| foreach ($unpackedRootPath in $unpackedRoots) { | |
| $unpackedRoot = Get-Item -LiteralPath $unpackedRootPath | |
| Get-ChildItem -LiteralPath $unpackedRoot.FullName -Force | ForEach-Object { | |
| $targetPath = Join-Path $payloadDir $_.Name | |
| if ($_.PSIsContainer) { | |
| Copy-Item -LiteralPath $_.FullName -Destination $targetPath -Recurse -Force | |
| } else { | |
| Copy-Item -LiteralPath $_.FullName -Destination $targetPath -Force | |
| } | |
| } | |
| } | |
| $zipPayloadFiles = @(Get-ChildItem -LiteralPath $payloadDir -Recurse -File | Sort-Object FullName) | |
| if ($zipPayloadFiles.Count -eq 0) { | |
| throw 'Windows ZIP payload staging did not produce any files.' | |
| } | |
| $signableFiles = @() | |
| $env:WINDOWS_SIGNABLE_FILES -split "`r?`n" | ForEach-Object { | |
| $trimmed = $_.Trim() | |
| if ($trimmed) { | |
| $signableFiles += $trimmed | |
| } | |
| } | |
| $zipPayloadSignableFiles = @( | |
| $zipPayloadFiles | Where-Object { | |
| @('.exe', '.appx', '.msix', '.msi') -contains $_.Extension.ToLowerInvariant() | |
| } | Sort-Object FullName -Unique | |
| ) | |
| $catalogEntries = @() | |
| foreach ($filePath in @($signableFiles + ($zipPayloadSignableFiles | ForEach-Object { $_.FullName }))) { | |
| $resolvedPath = Resolve-Path -LiteralPath $filePath | Select-Object -ExpandProperty Path | |
| $catalogEntries += [System.IO.Path]::GetRelativePath($pkgDir, $resolvedPath) | |
| } | |
| $catalogEntries = @($catalogEntries | Sort-Object -Unique) | |
| $catalogEntries | Set-Content -Path $catalogPath | |
| function Write-MultilineOutput([string]$Name, [object[]]$Files) { | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "$Name<<EOF" | |
| foreach ($file in $Files) { | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value $file.FullName | |
| } | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value 'EOF' | |
| } | |
| Write-MultilineOutput 'zip_payload_files' $zipPayloadFiles | |
| Write-MultilineOutput 'zip_payload_signable_files' $zipPayloadSignableFiles | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "catalog_path=$catalogPath" | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "payload_dir=$payloadDir" | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "payload_dir_name=$payloadDirName" | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "zip_payload_count=$($zipPayloadFiles.Count)" | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "zip_payload_signable_count=$($zipPayloadSignableFiles.Count)" | |
| - name: Sign Windows artifacts with Artifact Signing | |
| if: success() && steps.signing_policy.outputs.required == 'true' | |
| uses: azure/artifact-signing-action@v1 | |
| with: | |
| endpoint: ${{ secrets.AZURE_CODESIGN_ENDPOINT }} | |
| signing-account-name: ${{ secrets.AZURE_CODESIGN_ACCOUNT_NAME }} | |
| certificate-profile-name: ${{ secrets.AZURE_CODESIGN_CERTIFICATE_PROFILE_NAME }} | |
| files-catalog: ${{ steps.windows_zip_payload.outputs.catalog_path }} | |
| file-digest: SHA256 | |
| timestamp-rfc3161: http://timestamp.acs.microsoft.com | |
| timestamp-digest: SHA256 | |
| description: Hagicode Desktop | |
| description-url: https://github.com/HagiCode-org/desktop | |
| timeout: 600 | |
| - name: Verify Windows signatures | |
| id: verify_windows_signatures | |
| if: success() && steps.signing_policy.outputs.required == 'true' | |
| shell: bash | |
| env: | |
| VERIFY_STRICT: "true" | |
| run: | | |
| node scripts/verify-signature.js --catalog "${{ steps.windows_zip_payload.outputs.catalog_path }}" | |
| - name: Create Windows ZIP artifact | |
| id: windows_zip | |
| if: success() | |
| shell: pwsh | |
| run: | | |
| $ErrorActionPreference = 'Stop' | |
| $pkgDir = Join-Path $env:GITHUB_WORKSPACE 'pkg' | |
| $payloadDir = '${{ steps.windows_zip_payload.outputs.payload_dir }}' | |
| $payloadDirName = '${{ steps.windows_zip_payload.outputs.payload_dir_name }}' | |
| if (-not (Test-Path $payloadDir)) { | |
| throw "Windows ZIP payload directory not found: $payloadDir" | |
| } | |
| $zipPath = Join-Path $pkgDir ("{0}.zip" -f $payloadDirName) | |
| if (Test-Path $zipPath) { | |
| Remove-Item -LiteralPath $zipPath -Force | |
| } | |
| Compress-Archive -Path (Join-Path $payloadDir '*') -DestinationPath $zipPath -CompressionLevel Optimal | |
| $zipFile = Get-Item -LiteralPath $zipPath | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "zip_files<<EOF" | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value $zipFile.FullName | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value 'EOF' | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "zip_path=$($zipFile.FullName)" | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "zip_count=1" | |
| - name: Verify Windows ZIP toolchain payload | |
| if: success() && steps.windows_zip.outputs.zip_path != '' | |
| shell: bash | |
| run: | | |
| node scripts/verify-release-archives.js --archive "${{ steps.windows_zip.outputs.zip_path }}" | |
| - name: Summarize unsigned Windows build | |
| if: success() && steps.signing_policy.outputs.required != 'true' | |
| shell: bash | |
| run: | | |
| { | |
| echo '## Windows signing skipped' | |
| echo | |
| echo "- Policy: skipped (${SIGNING_REASON})" | |
| echo '- Signed artifacts: 0' | |
| echo '- Verification: not required for this build' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| env: | |
| SIGNING_REASON: ${{ steps.signing_policy.outputs.reason }} | |
| - name: Summarize signed Windows build | |
| if: success() && steps.signing_policy.outputs.required == 'true' | |
| shell: bash | |
| run: | | |
| { | |
| echo '## Windows signing completed' | |
| echo | |
| echo "- Policy: required (${SIGNING_REASON})" | |
| echo '- Signed installers: ${{ steps.windows_artifacts.outputs.signable_count }}' | |
| echo '- Signed staged unpacked payload executables/packages: ${{ steps.windows_zip_payload.outputs.zip_payload_signable_count }}' | |
| echo '- Verification: passed' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| env: | |
| SIGNING_REASON: ${{ steps.signing_policy.outputs.reason }} | |
| - name: Summarize Windows ZIP packaging | |
| if: success() | |
| shell: bash | |
| run: | | |
| { | |
| echo '## Windows ZIP packaging' | |
| echo | |
| echo '- Portable artifacts discovered: ${{ steps.windows_artifacts.outputs.portable_count }}' | |
| echo '- Unpacked roots discovered: ${{ steps.windows_artifacts.outputs.unpacked_count }}' | |
| echo '- ZIP payload files staged: ${{ steps.windows_zip_payload.outputs.zip_payload_count }}' | |
| echo '- ZIP payload signable executables/packages: ${{ steps.windows_zip_payload.outputs.zip_payload_signable_count }}' | |
| echo '- ZIP artifacts created: ${{ steps.windows_zip.outputs.zip_count }}' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Upload NSIS installer | |
| uses: actions/upload-artifact@v4 | |
| if: success() && !startsWith(github.ref, 'refs/tags/v') && steps.windows_artifacts.outputs.nsis_files != '' | |
| with: | |
| name: hagicode-windows-nsis-${{ github.sha }} | |
| path: ${{ steps.windows_artifacts.outputs.nsis_files }} | |
| retention-days: 30 | |
| if-no-files-found: warn | |
| - name: Upload NSIS installer to Release | |
| uses: softprops/action-gh-release@v2 | |
| if: success() && startsWith(github.ref, 'refs/tags/v') && steps.windows_artifacts.outputs.nsis_files != '' | |
| with: | |
| files: ${{ steps.windows_artifacts.outputs.nsis_files }} | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Upload AppX package | |
| uses: actions/upload-artifact@v4 | |
| if: success() && !startsWith(github.ref, 'refs/tags/v') && steps.windows_artifacts.outputs.appx_files != '' | |
| with: | |
| name: hagicode-windows-appx-${{ github.sha }} | |
| path: ${{ steps.windows_artifacts.outputs.appx_files }} | |
| retention-days: 30 | |
| if-no-files-found: warn | |
| - name: Upload AppX package to Release | |
| uses: softprops/action-gh-release@v2 | |
| if: success() && startsWith(github.ref, 'refs/tags/v') && steps.windows_artifacts.outputs.appx_files != '' | |
| with: | |
| files: ${{ steps.windows_artifacts.outputs.appx_files }} | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Upload portable | |
| uses: actions/upload-artifact@v4 | |
| if: success() && !startsWith(github.ref, 'refs/tags/v') && steps.windows_artifacts.outputs.portable_files != '' | |
| with: | |
| name: hagicode-windows-portable-${{ github.sha }} | |
| path: ${{ steps.windows_artifacts.outputs.portable_files }} | |
| retention-days: 30 | |
| if-no-files-found: warn | |
| - name: Upload portable to Release | |
| uses: softprops/action-gh-release@v2 | |
| if: success() && startsWith(github.ref, 'refs/tags/v') && steps.windows_artifacts.outputs.portable_files != '' | |
| with: | |
| files: ${{ steps.windows_artifacts.outputs.portable_files }} | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Upload Windows ZIP | |
| uses: actions/upload-artifact@v4 | |
| if: success() && steps.windows_zip.outputs.zip_files != '' | |
| with: | |
| name: hagicode-windows-zip-${{ github.sha }} | |
| path: ${{ steps.windows_zip.outputs.zip_files }} | |
| retention-days: 30 | |
| if-no-files-found: error | |
| - name: Upload Windows ZIP to Release | |
| uses: softprops/action-gh-release@v2 | |
| if: success() && startsWith(github.ref, 'refs/tags/v') && steps.windows_zip.outputs.zip_files != '' | |
| with: | |
| files: ${{ steps.windows_zip.outputs.zip_files }} | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Code signing failure notification | |
| if: failure() && steps.signing_policy.outputs.required == 'true' | |
| shell: pwsh | |
| run: | | |
| if ([string]::IsNullOrWhiteSpace($env:FEISHU_WEBHOOK_URL)) { | |
| Write-Host 'FEISHU_WEBHOOK_URL is empty, skipping Windows code signing failure notification.' | |
| exit 0 | |
| } | |
| $message = @( | |
| '代码签名失败 (Windows) ❌', | |
| '', | |
| '平台: Windows', | |
| '版本: ${{ github.ref_name }}', | |
| '提交: ${{ github.sha }}', | |
| '', | |
| '请检查签名配置和证书状态。', | |
| '可能的原因:', | |
| '- GitHub OIDC 到 Azure 的联邦身份或角色配置不完整', | |
| '- Artifact Signing 端点、签名账户或证书配置文件配置错误', | |
| '- 产物签名成功但验签失败', | |
| '', | |
| '查看详情: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' | |
| ) -join "`n" | |
| $payload = @{ | |
| msg_type = 'text' | |
| content = @{ | |
| text = $message | |
| } | |
| } | ConvertTo-Json -Depth 4 | |
| Invoke-RestMethod -Method Post -Uri $env:FEISHU_WEBHOOK_URL -ContentType 'application/json' -Body $payload | Out-Null | |
| env: | |
| FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }} | |
| build-windows-release: | |
| name: Build for Windows Release | |
| if: ${{ startsWith(github.ref, 'refs/tags/v') }} | |
| runs-on: windows-latest | |
| environment: production | |
| permissions: | |
| contents: write | |
| id-token: write | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Determine release channel | |
| id: channel | |
| shell: bash | |
| run: | | |
| # If channel is manually specified, use it | |
| if [ -n "${{ github.event.inputs.channel }}" ]; then | |
| CHANNEL="${{ github.event.inputs.channel }}" | |
| echo "Using manually specified channel: ${CHANNEL}" | |
| else | |
| # Auto-detect channel based on tag or branch | |
| if [ "${{ github.ref_type }}" == "tag" ]; then | |
| TAG="${GITHUB_REF_NAME#v}" | |
| # Check if version contains pre-release identifiers | |
| if [[ "$TAG" =~ -(beta|alpha|rc|dev) ]]; then | |
| if [[ "$TAG" =~ -beta ]]; then | |
| CHANNEL="beta" | |
| elif [[ "$TAG" =~ -alpha ]]; then | |
| CHANNEL="dev" | |
| elif [[ "$TAG" =~ -rc ]]; then | |
| CHANNEL="beta" | |
| elif [[ "$TAG" =~ -dev ]]; then | |
| CHANNEL="dev" | |
| fi | |
| else | |
| # Stable version (no pre-release identifier) | |
| CHANNEL="stable" | |
| fi | |
| elif [ "${{ github.ref }}" == "refs/heads/main" ]; then | |
| # Main branch without tag - use beta for testing | |
| CHANNEL="beta" | |
| else | |
| # Other branches | |
| CHANNEL="dev" | |
| fi | |
| echo "Auto-detected channel: ${CHANNEL}" | |
| fi | |
| echo "channel=${CHANNEL}" >> $GITHUB_OUTPUT | |
| echo "RELEASE_CHANNEL=${CHANNEL}" >> $GITHUB_ENV | |
| - name: Determine Windows signing policy | |
| id: signing_policy | |
| shell: bash | |
| run: | | |
| if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then | |
| required=true | |
| reason="tag release" | |
| elif [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ github.event.inputs.sign_windows_release }}" == "true" ]]; then | |
| required=true | |
| reason="manual workflow_dispatch override" | |
| else | |
| required=false | |
| reason="non-release build" | |
| fi | |
| echo "required=${required}" >> "$GITHUB_OUTPUT" | |
| echo "reason=${reason}" >> "$GITHUB_OUTPUT" | |
| echo "Windows signing required: ${required} (${reason})" | |
| - name: Validate Artifact Signing configuration | |
| if: success() && steps.signing_policy.outputs.required == 'true' | |
| shell: bash | |
| env: | |
| AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} | |
| AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} | |
| AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} | |
| AZURE_CODESIGN_ENDPOINT: ${{ secrets.AZURE_CODESIGN_ENDPOINT }} | |
| AZURE_CODESIGN_ACCOUNT_NAME: ${{ secrets.AZURE_CODESIGN_ACCOUNT_NAME }} | |
| AZURE_CODESIGN_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_CODESIGN_CERTIFICATE_PROFILE_NAME }} | |
| AZURE_CODESIGN_APPX_PUBLISHER: ${{ secrets.AZURE_CODESIGN_APPX_PUBLISHER }} | |
| run: | | |
| missing=() | |
| for key in AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID AZURE_CODESIGN_ENDPOINT AZURE_CODESIGN_ACCOUNT_NAME AZURE_CODESIGN_CERTIFICATE_PROFILE_NAME AZURE_CODESIGN_APPX_PUBLISHER; do | |
| if [ -z "${!key}" ]; then | |
| missing+=("${key}") | |
| fi | |
| done | |
| if [ ${#missing[@]} -gt 0 ]; then | |
| printf '::error::Missing Artifact Signing configuration: %s\n' "$(IFS=', '; echo "${missing[*]}")" | |
| { | |
| echo '## Windows code signing configuration error' | |
| echo | |
| echo 'The release build requires Artifact Signing, but the following values are missing:' | |
| echo | |
| for key in "${missing[@]}"; do | |
| echo "- ${key}" | |
| done | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| exit 1 | |
| fi | |
| - name: Configure AppX publisher for Artifact Signing | |
| if: success() && steps.signing_policy.outputs.required == 'true' | |
| shell: pwsh | |
| env: | |
| AZURE_CODESIGN_APPX_PUBLISHER: ${{ secrets.AZURE_CODESIGN_APPX_PUBLISHER }} | |
| run: | | |
| $configPath = Join-Path $env:GITHUB_WORKSPACE 'electron-builder.yml' | |
| $content = Get-Content -Path $configPath -Raw | |
| $escapedPublisher = $env:AZURE_CODESIGN_APPX_PUBLISHER.Replace('"', '\"') | |
| $updated = [regex]::Replace( | |
| $content, | |
| '(?m)^ publisher: .*$', | |
| " publisher: `"$escapedPublisher`"" | |
| ) | |
| if ($updated -eq $content) { | |
| throw "Failed to update appx.publisher in $configPath" | |
| } | |
| Set-Content -Path $configPath -Value $updated | |
| Write-Host "Configured AppX publisher for Artifact Signing." | |
| - name: Azure login | |
| if: success() && steps.signing_policy.outputs.required == 'true' | |
| uses: azure/login@v2 | |
| with: | |
| client-id: ${{ secrets.AZURE_CLIENT_ID }} | |
| tenant-id: ${{ secrets.AZURE_TENANT_ID }} | |
| subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: "npm" | |
| cache-dependency-path: package-lock.json | |
| - name: Install global hagiscript | |
| shell: bash | |
| run: | | |
| npm install --global "@hagicode/hagiscript@${HAGISCRIPT_VERSION}" | |
| prefix="$(npm config get prefix)" | |
| echo "$prefix" >> "$GITHUB_PATH" | |
| if [ -d "$prefix/bin" ]; then | |
| echo "$prefix/bin" >> "$GITHUB_PATH" | |
| fi | |
| hash -r | |
| hagiscript --version | |
| - name: Cache pinned runtime downloads | |
| uses: actions/cache@v4 | |
| with: | |
| path: build/embedded-runtime/downloads | |
| key: ${{ runner.os }}-embedded-runtime-${{ hashFiles('resources/embedded-runtime/runtime-manifest.json') }} | |
| - name: Sync version from tag | |
| if: startsWith(github.ref, 'refs/tags/v') | |
| shell: bash | |
| run: | | |
| VERSION="${GITHUB_REF_NAME#v}" | |
| echo "Syncing npm/package.json version to ${VERSION}" | |
| npm version "${VERSION}" --no-git-tag-version | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Build for Windows | |
| run: node scripts/ci-build.js --platform win | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| HAGICODE_EMBEDDED_DOTNET_PLATFORM: win-x64 | |
| - name: Collect Windows artifacts | |
| id: windows_artifacts | |
| shell: pwsh | |
| run: | | |
| $ErrorActionPreference = 'Stop' | |
| $pkgDir = Join-Path $env:GITHUB_WORKSPACE 'pkg' | |
| if (-not (Test-Path $pkgDir)) { | |
| throw "pkg directory not found: $pkgDir" | |
| } | |
| $allFiles = Get-ChildItem -Path $pkgDir -File | |
| $nsis = @($allFiles | Where-Object { $_.Extension -ieq '.exe' -and $_.Name -like '*Setup*' } | Sort-Object FullName -Unique) | |
| $portable = @($allFiles | Where-Object { $_.Extension -ieq '.exe' -and $_.Name -notlike '*Setup*' } | Sort-Object FullName -Unique) | |
| $appx = @($allFiles | Where-Object { $_.Extension -ieq '.appx' } | Sort-Object FullName -Unique) | |
| $signable = @($nsis + $portable + $appx | Sort-Object FullName -Unique) | |
| $unpackedRoots = @(Get-ChildItem -Path $pkgDir -Directory | Where-Object { $_.Name -eq 'win-unpacked' } | Sort-Object FullName -Unique) | |
| if ($signable.Count -eq 0) { | |
| throw 'No Windows artifacts were found in pkg/ for signing or upload.' | |
| } | |
| if ($unpackedRoots.Count -eq 0) { | |
| throw 'No win-unpacked directory was found in pkg/ for ZIP assembly.' | |
| } | |
| $catalogPath = Join-Path $pkgDir 'windows-signable-artifacts.txt' | |
| $zipPayloadRoot = Join-Path $pkgDir 'windows-zip-payload' | |
| $zipBaseName = if ($portable.Count -gt 0) { | |
| [System.IO.Path]::GetFileNameWithoutExtension($portable[0].Name) | |
| } else { | |
| 'hagicode-windows-unpacked' | |
| } | |
| function Write-MultilineOutput([string]$Name, [object[]]$Files) { | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "$Name<<EOF" | |
| foreach ($file in $Files) { | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value $file.FullName | |
| } | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value 'EOF' | |
| } | |
| Write-MultilineOutput 'nsis_files' $nsis | |
| Write-MultilineOutput 'portable_files' $portable | |
| Write-MultilineOutput 'appx_files' $appx | |
| Write-MultilineOutput 'signable_files' $signable | |
| Write-MultilineOutput 'unpacked_roots' $unpackedRoots | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "catalog_path=$catalogPath" | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "zip_payload_root=$zipPayloadRoot" | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "zip_base_name=$zipBaseName" | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "signable_count=$($signable.Count)" | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "portable_count=$($portable.Count)" | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "unpacked_count=$($unpackedRoots.Count)" | |
| "Found $($nsis.Count) NSIS, $($portable.Count) portable, $($appx.Count) AppX, and $($unpackedRoots.Count) unpacked Windows artifact(s)." | |
| - name: Prepare Windows unpacked ZIP payload workspace | |
| id: windows_zip_payload | |
| shell: pwsh | |
| env: | |
| WINDOWS_UNPACKED_ROOTS: ${{ steps.windows_artifacts.outputs.unpacked_roots }} | |
| WINDOWS_SIGNABLE_FILES: ${{ steps.windows_artifacts.outputs.signable_files }} | |
| WINDOWS_ZIP_BASE_NAME: ${{ steps.windows_artifacts.outputs.zip_base_name }} | |
| run: | | |
| $ErrorActionPreference = 'Stop' | |
| $zipPayloadRoot = '${{ steps.windows_artifacts.outputs.zip_payload_root }}' | |
| $catalogPath = '${{ steps.windows_artifacts.outputs.catalog_path }}' | |
| $pkgDir = Split-Path -Path $catalogPath -Parent | |
| if (Test-Path $zipPayloadRoot) { | |
| Remove-Item -Path $zipPayloadRoot -Recurse -Force | |
| } | |
| New-Item -Path $zipPayloadRoot -ItemType Directory -Force | Out-Null | |
| $unpackedRoots = @() | |
| $env:WINDOWS_UNPACKED_ROOTS -split "`r?`n" | ForEach-Object { | |
| $trimmed = $_.Trim() | |
| if ($trimmed) { | |
| $unpackedRoots += $trimmed | |
| } | |
| } | |
| if ($unpackedRoots.Count -eq 0) { | |
| throw 'No unpacked Windows roots were passed to ZIP payload staging.' | |
| } | |
| $payloadDirName = "{0}-unpacked" -f $env:WINDOWS_ZIP_BASE_NAME | |
| $payloadDir = Join-Path $zipPayloadRoot $payloadDirName | |
| New-Item -Path $payloadDir -ItemType Directory -Force | Out-Null | |
| foreach ($unpackedRootPath in $unpackedRoots) { | |
| $unpackedRoot = Get-Item -LiteralPath $unpackedRootPath | |
| Get-ChildItem -LiteralPath $unpackedRoot.FullName -Force | ForEach-Object { | |
| $targetPath = Join-Path $payloadDir $_.Name | |
| if ($_.PSIsContainer) { | |
| Copy-Item -LiteralPath $_.FullName -Destination $targetPath -Recurse -Force | |
| } else { | |
| Copy-Item -LiteralPath $_.FullName -Destination $targetPath -Force | |
| } | |
| } | |
| } | |
| $zipPayloadFiles = @(Get-ChildItem -LiteralPath $payloadDir -Recurse -File | Sort-Object FullName) | |
| if ($zipPayloadFiles.Count -eq 0) { | |
| throw 'Windows ZIP payload staging did not produce any files.' | |
| } | |
| $signableFiles = @() | |
| $env:WINDOWS_SIGNABLE_FILES -split "`r?`n" | ForEach-Object { | |
| $trimmed = $_.Trim() | |
| if ($trimmed) { | |
| $signableFiles += $trimmed | |
| } | |
| } | |
| $zipPayloadSignableFiles = @( | |
| $zipPayloadFiles | Where-Object { | |
| @('.exe', '.appx', '.msix', '.msi') -contains $_.Extension.ToLowerInvariant() | |
| } | Sort-Object FullName -Unique | |
| ) | |
| $catalogEntries = @() | |
| foreach ($filePath in @($signableFiles + ($zipPayloadSignableFiles | ForEach-Object { $_.FullName }))) { | |
| $resolvedPath = Resolve-Path -LiteralPath $filePath | Select-Object -ExpandProperty Path | |
| $catalogEntries += [System.IO.Path]::GetRelativePath($pkgDir, $resolvedPath) | |
| } | |
| $catalogEntries = @($catalogEntries | Sort-Object -Unique) | |
| $catalogEntries | Set-Content -Path $catalogPath | |
| function Write-MultilineOutput([string]$Name, [object[]]$Files) { | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "$Name<<EOF" | |
| foreach ($file in $Files) { | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value $file.FullName | |
| } | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value 'EOF' | |
| } | |
| Write-MultilineOutput 'zip_payload_files' $zipPayloadFiles | |
| Write-MultilineOutput 'zip_payload_signable_files' $zipPayloadSignableFiles | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "catalog_path=$catalogPath" | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "payload_dir=$payloadDir" | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "payload_dir_name=$payloadDirName" | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "zip_payload_count=$($zipPayloadFiles.Count)" | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "zip_payload_signable_count=$($zipPayloadSignableFiles.Count)" | |
| - name: Sign Windows artifacts with Artifact Signing | |
| if: success() && steps.signing_policy.outputs.required == 'true' | |
| uses: azure/artifact-signing-action@v1 | |
| with: | |
| endpoint: ${{ secrets.AZURE_CODESIGN_ENDPOINT }} | |
| signing-account-name: ${{ secrets.AZURE_CODESIGN_ACCOUNT_NAME }} | |
| certificate-profile-name: ${{ secrets.AZURE_CODESIGN_CERTIFICATE_PROFILE_NAME }} | |
| files-catalog: ${{ steps.windows_zip_payload.outputs.catalog_path }} | |
| file-digest: SHA256 | |
| timestamp-rfc3161: http://timestamp.acs.microsoft.com | |
| timestamp-digest: SHA256 | |
| description: Hagicode Desktop | |
| description-url: https://github.com/HagiCode-org/desktop | |
| timeout: 600 | |
| - name: Verify Windows signatures | |
| id: verify_windows_signatures | |
| if: success() && steps.signing_policy.outputs.required == 'true' | |
| shell: bash | |
| env: | |
| VERIFY_STRICT: "true" | |
| run: | | |
| node scripts/verify-signature.js --catalog "${{ steps.windows_zip_payload.outputs.catalog_path }}" | |
| - name: Create Windows ZIP artifact | |
| id: windows_zip | |
| if: success() | |
| shell: pwsh | |
| run: | | |
| $ErrorActionPreference = 'Stop' | |
| $pkgDir = Join-Path $env:GITHUB_WORKSPACE 'pkg' | |
| $payloadDir = '${{ steps.windows_zip_payload.outputs.payload_dir }}' | |
| $payloadDirName = '${{ steps.windows_zip_payload.outputs.payload_dir_name }}' | |
| if (-not (Test-Path $payloadDir)) { | |
| throw "Windows ZIP payload directory not found: $payloadDir" | |
| } | |
| $zipPath = Join-Path $pkgDir ("{0}.zip" -f $payloadDirName) | |
| if (Test-Path $zipPath) { | |
| Remove-Item -LiteralPath $zipPath -Force | |
| } | |
| Compress-Archive -Path (Join-Path $payloadDir '*') -DestinationPath $zipPath -CompressionLevel Optimal | |
| $zipFile = Get-Item -LiteralPath $zipPath | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "zip_files<<EOF" | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value $zipFile.FullName | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value 'EOF' | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "zip_count=1" | |
| - name: Summarize unsigned Windows build | |
| if: success() && steps.signing_policy.outputs.required != 'true' | |
| shell: bash | |
| run: | | |
| { | |
| echo '## Windows signing skipped' | |
| echo | |
| echo "- Policy: skipped (${SIGNING_REASON})" | |
| echo '- Signed artifacts: 0' | |
| echo '- Verification: not required for this build' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| env: | |
| SIGNING_REASON: ${{ steps.signing_policy.outputs.reason }} | |
| - name: Summarize signed Windows build | |
| if: success() && steps.signing_policy.outputs.required == 'true' | |
| shell: bash | |
| run: | | |
| { | |
| echo '## Windows signing completed' | |
| echo | |
| echo "- Policy: required (${SIGNING_REASON})" | |
| echo '- Signed installers: ${{ steps.windows_artifacts.outputs.signable_count }}' | |
| echo '- Signed staged unpacked payload executables/packages: ${{ steps.windows_zip_payload.outputs.zip_payload_signable_count }}' | |
| echo '- Verification: passed' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| env: | |
| SIGNING_REASON: ${{ steps.signing_policy.outputs.reason }} | |
| - name: Summarize Windows ZIP packaging | |
| if: success() | |
| shell: bash | |
| run: | | |
| { | |
| echo '## Windows ZIP packaging' | |
| echo | |
| echo '- Portable artifacts discovered: ${{ steps.windows_artifacts.outputs.portable_count }}' | |
| echo '- Unpacked roots discovered: ${{ steps.windows_artifacts.outputs.unpacked_count }}' | |
| echo '- ZIP payload files staged: ${{ steps.windows_zip_payload.outputs.zip_payload_count }}' | |
| echo '- ZIP payload signable executables/packages: ${{ steps.windows_zip_payload.outputs.zip_payload_signable_count }}' | |
| echo '- ZIP artifacts created: ${{ steps.windows_zip.outputs.zip_count }}' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Upload NSIS installer | |
| uses: actions/upload-artifact@v4 | |
| if: success() && !startsWith(github.ref, 'refs/tags/v') && steps.windows_artifacts.outputs.nsis_files != '' | |
| with: | |
| name: hagicode-windows-nsis-${{ github.sha }} | |
| path: ${{ steps.windows_artifacts.outputs.nsis_files }} | |
| retention-days: 30 | |
| if-no-files-found: warn | |
| - name: Upload NSIS installer to Release | |
| uses: softprops/action-gh-release@v2 | |
| if: success() && startsWith(github.ref, 'refs/tags/v') && steps.windows_artifacts.outputs.nsis_files != '' | |
| with: | |
| files: ${{ steps.windows_artifacts.outputs.nsis_files }} | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Upload AppX package | |
| uses: actions/upload-artifact@v4 | |
| if: success() && !startsWith(github.ref, 'refs/tags/v') && steps.windows_artifacts.outputs.appx_files != '' | |
| with: | |
| name: hagicode-windows-appx-${{ github.sha }} | |
| path: ${{ steps.windows_artifacts.outputs.appx_files }} | |
| retention-days: 30 | |
| if-no-files-found: warn | |
| - name: Upload AppX package to Release | |
| uses: softprops/action-gh-release@v2 | |
| if: success() && startsWith(github.ref, 'refs/tags/v') && steps.windows_artifacts.outputs.appx_files != '' | |
| with: | |
| files: ${{ steps.windows_artifacts.outputs.appx_files }} | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Upload portable | |
| uses: actions/upload-artifact@v4 | |
| if: success() && !startsWith(github.ref, 'refs/tags/v') && steps.windows_artifacts.outputs.portable_files != '' | |
| with: | |
| name: hagicode-windows-portable-${{ github.sha }} | |
| path: ${{ steps.windows_artifacts.outputs.portable_files }} | |
| retention-days: 30 | |
| if-no-files-found: warn | |
| - name: Upload portable to Release | |
| uses: softprops/action-gh-release@v2 | |
| if: success() && startsWith(github.ref, 'refs/tags/v') && steps.windows_artifacts.outputs.portable_files != '' | |
| with: | |
| files: ${{ steps.windows_artifacts.outputs.portable_files }} | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Upload Windows ZIP | |
| uses: actions/upload-artifact@v4 | |
| if: success() && steps.windows_zip.outputs.zip_files != '' | |
| with: | |
| name: hagicode-windows-zip-${{ github.sha }} | |
| path: ${{ steps.windows_zip.outputs.zip_files }} | |
| retention-days: 30 | |
| if-no-files-found: error | |
| - name: Upload Windows ZIP to Release | |
| uses: softprops/action-gh-release@v2 | |
| if: success() && startsWith(github.ref, 'refs/tags/v') && steps.windows_zip.outputs.zip_files != '' | |
| with: | |
| files: ${{ steps.windows_zip.outputs.zip_files }} | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Code signing failure notification | |
| if: failure() && steps.signing_policy.outputs.required == 'true' | |
| shell: pwsh | |
| run: | | |
| if ([string]::IsNullOrWhiteSpace($env:FEISHU_WEBHOOK_URL)) { | |
| Write-Host 'FEISHU_WEBHOOK_URL is empty, skipping Windows code signing failure notification.' | |
| exit 0 | |
| } | |
| $message = @( | |
| '代码签名失败 (Windows) ❌', | |
| '', | |
| '平台: Windows', | |
| '版本: ${{ github.ref_name }}', | |
| '提交: ${{ github.sha }}', | |
| '', | |
| '请检查签名配置和证书状态。', | |
| '可能的原因:', | |
| '- GitHub OIDC 到 Azure 的联邦身份或角色配置不完整', | |
| '- Artifact Signing 端点、签名账户或证书配置文件配置错误', | |
| '- 产物签名成功但验签失败', | |
| '', | |
| '查看详情: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' | |
| ) -join "`n" | |
| $payload = @{ | |
| msg_type = 'text' | |
| content = @{ | |
| text = $message | |
| } | |
| } | ConvertTo-Json -Depth 4 | |
| Invoke-RestMethod -Method Post -Uri $env:FEISHU_WEBHOOK_URL -ContentType 'application/json' -Body $payload | Out-Null | |
| env: | |
| FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }} | |
| ################################################################################ | |
| # Linux Build Job | |
| ################################################################################ | |
| build-linux: | |
| name: Build for Linux | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Determine release channel | |
| id: channel | |
| run: | | |
| # If channel is manually specified, use it | |
| if [ -n "${{ github.event.inputs.channel }}" ]; then | |
| CHANNEL="${{ github.event.inputs.channel }}" | |
| echo "Using manually specified channel: ${CHANNEL}" | |
| else | |
| # Auto-detect channel based on tag or branch | |
| if [ "${{ github.ref_type }}" == "tag" ]; then | |
| TAG="${GITHUB_REF_NAME#v}" | |
| # Check if version contains pre-release identifiers | |
| if [[ "$TAG" =~ -(beta|alpha|rc|dev) ]]; then | |
| if [[ "$TAG" =~ -beta ]]; then | |
| CHANNEL="beta" | |
| elif [[ "$TAG" =~ -alpha ]]; then | |
| CHANNEL="dev" | |
| elif [[ "$TAG" =~ -rc ]]; then | |
| CHANNEL="beta" | |
| elif [[ "$TAG" =~ -dev ]]; then | |
| CHANNEL="dev" | |
| fi | |
| else | |
| # Stable version (no pre-release identifier) | |
| CHANNEL="stable" | |
| fi | |
| elif [ "${{ github.ref }}" == "refs/heads/main" ]; then | |
| # Main branch without tag - use beta for testing | |
| CHANNEL="beta" | |
| else | |
| # Other branches | |
| CHANNEL="dev" | |
| fi | |
| echo "Auto-detected channel: ${CHANNEL}" | |
| fi | |
| echo "channel=${CHANNEL}" >> $GITHUB_OUTPUT | |
| echo "RELEASE_CHANNEL=${CHANNEL}" >> $GITHUB_ENV | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: "npm" | |
| cache-dependency-path: package-lock.json | |
| - name: Install global hagiscript | |
| shell: bash | |
| run: | | |
| npm install --global "@hagicode/hagiscript@${HAGISCRIPT_VERSION}" | |
| prefix="$(npm config get prefix)" | |
| echo "$prefix" >> "$GITHUB_PATH" | |
| if [ -d "$prefix/bin" ]; then | |
| echo "$prefix/bin" >> "$GITHUB_PATH" | |
| fi | |
| hash -r | |
| hagiscript --version | |
| - name: Cache pinned runtime downloads | |
| uses: actions/cache@v4 | |
| with: | |
| path: build/embedded-runtime/downloads | |
| key: ${{ runner.os }}-embedded-runtime-${{ hashFiles('resources/embedded-runtime/runtime-manifest.json') }} | |
| - name: Sync version from tag | |
| if: startsWith(github.ref, 'refs/tags/v') | |
| shell: bash | |
| run: | | |
| VERSION="${GITHUB_REF_NAME#v}" | |
| echo "Syncing npm/package.json version to ${VERSION}" | |
| npm version "${VERSION}" --no-git-tag-version | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Build for Linux | |
| run: node scripts/ci-build.js --platform linux | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| HAGICODE_EMBEDDED_DOTNET_PLATFORM: linux-x64 | |
| - name: Summarize Linux ZIP artifacts | |
| if: success() | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| shopt -s nullglob | |
| zip_files=(pkg/*.zip) | |
| zip_count=${#zip_files[@]} | |
| { | |
| echo '## Linux packaging artifacts' | |
| echo | |
| echo "- AppImage upload pattern: pkg/*.AppImage" | |
| echo "- deb upload pattern: pkg/*.deb" | |
| echo "- tar.gz upload pattern: pkg/*.tar.gz" | |
| if [ "$zip_count" -eq 0 ]; then | |
| echo '- ZIP upload pattern: pkg/*.zip (missing)' | |
| else | |
| echo "- ZIP upload pattern: pkg/*.zip (${zip_count} file(s))" | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| if [ "$zip_count" -eq 0 ]; then | |
| echo '::error::Linux ZIP artifact was not produced in pkg/.' | |
| exit 1 | |
| fi | |
| # Upload each artifact separately | |
| - name: Upload AppImage | |
| uses: actions/upload-artifact@v4 | |
| if: success() && !startsWith(github.ref, 'refs/tags/v') | |
| with: | |
| name: hagicode-linux-appimage-${{ github.sha }} | |
| path: pkg/*.AppImage | |
| retention-days: 30 | |
| if-no-files-found: warn | |
| - name: Upload AppImage to Release | |
| uses: softprops/action-gh-release@v2 | |
| if: startsWith(github.ref, 'refs/tags/v') | |
| with: | |
| files: pkg/*.AppImage | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Upload deb | |
| uses: actions/upload-artifact@v4 | |
| if: success() && !startsWith(github.ref, 'refs/tags/v') | |
| with: | |
| name: hagicode-linux-deb-${{ github.sha }} | |
| path: pkg/*.deb | |
| retention-days: 30 | |
| if-no-files-found: warn | |
| - name: Upload deb to Release | |
| uses: softprops/action-gh-release@v2 | |
| if: startsWith(github.ref, 'refs/tags/v') | |
| with: | |
| files: pkg/*.deb | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Upload tar.gz | |
| uses: actions/upload-artifact@v4 | |
| if: success() && !startsWith(github.ref, 'refs/tags/v') | |
| with: | |
| name: hagicode-linux-targz-${{ github.sha }} | |
| path: pkg/*.tar.gz | |
| retention-days: 30 | |
| if-no-files-found: warn | |
| - name: Upload tar.gz to Release | |
| uses: softprops/action-gh-release@v2 | |
| if: startsWith(github.ref, 'refs/tags/v') | |
| with: | |
| files: pkg/*.tar.gz | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Upload Linux ZIP | |
| uses: actions/upload-artifact@v4 | |
| if: success() | |
| with: | |
| name: hagicode-linux-zip-${{ github.sha }} | |
| path: pkg/*.zip | |
| retention-days: 30 | |
| if-no-files-found: error | |
| - name: Upload Linux ZIP to Release | |
| uses: softprops/action-gh-release@v2 | |
| if: startsWith(github.ref, 'refs/tags/v') | |
| with: | |
| files: pkg/*.zip | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| ################################################################################ | |
| # macOS Build Job | |
| ################################################################################ | |
| build-macos: | |
| name: Build for macOS | |
| runs-on: macos-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Determine release channel | |
| id: channel | |
| run: | | |
| # If channel is manually specified, use it | |
| if [ -n "${{ github.event.inputs.channel }}" ]; then | |
| CHANNEL="${{ github.event.inputs.channel }}" | |
| echo "Using manually specified channel: ${CHANNEL}" | |
| else | |
| # Auto-detect channel based on tag or branch | |
| if [ "${{ github.ref_type }}" == "tag" ]; then | |
| TAG="${GITHUB_REF_NAME#v}" | |
| # Check if version contains pre-release identifiers | |
| if [[ "$TAG" =~ -(beta|alpha|rc|dev) ]]; then | |
| if [[ "$TAG" =~ -beta ]]; then | |
| CHANNEL="beta" | |
| elif [[ "$TAG" =~ -alpha ]]; then | |
| CHANNEL="dev" | |
| elif [[ "$TAG" =~ -rc ]]; then | |
| CHANNEL="beta" | |
| elif [[ "$TAG" =~ -dev ]]; then | |
| CHANNEL="dev" | |
| fi | |
| else | |
| # Stable version (no pre-release identifier) | |
| CHANNEL="stable" | |
| fi | |
| elif [ "${{ github.ref }}" == "refs/heads/main" ]; then | |
| # Main branch without tag - use beta for testing | |
| CHANNEL="beta" | |
| else | |
| # Other branches | |
| CHANNEL="dev" | |
| fi | |
| echo "Auto-detected channel: ${CHANNEL}" | |
| fi | |
| echo "channel=${CHANNEL}" >> $GITHUB_OUTPUT | |
| echo "RELEASE_CHANNEL=${CHANNEL}" >> $GITHUB_ENV | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: "npm" | |
| cache-dependency-path: package-lock.json | |
| - name: Install global hagiscript | |
| shell: bash | |
| run: | | |
| npm install --global "@hagicode/hagiscript@${HAGISCRIPT_VERSION}" | |
| prefix="$(npm config get prefix)" | |
| echo "$prefix" >> "$GITHUB_PATH" | |
| if [ -d "$prefix/bin" ]; then | |
| echo "$prefix/bin" >> "$GITHUB_PATH" | |
| fi | |
| hash -r | |
| hagiscript --version | |
| - name: Sync version from tag | |
| if: startsWith(github.ref, 'refs/tags/v') | |
| shell: bash | |
| run: | | |
| VERSION="${GITHUB_REF_NAME#v}" | |
| echo "Syncing npm/package.json version to ${VERSION}" | |
| npm version "${VERSION}" --no-git-tag-version | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Build for macOS | |
| run: node scripts/ci-build.js --platform mac | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| # Upload each artifact separately | |
| - name: Upload DMG | |
| uses: actions/upload-artifact@v4 | |
| if: success() && !startsWith(github.ref, 'refs/tags/v') | |
| with: | |
| name: hagicode-macos-dmg-${{ github.sha }} | |
| path: pkg/*.dmg | |
| retention-days: 30 | |
| if-no-files-found: warn | |
| - name: Upload DMG to Release | |
| uses: softprops/action-gh-release@v2 | |
| if: startsWith(github.ref, 'refs/tags/v') | |
| with: | |
| files: pkg/*.dmg | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Upload ZIP | |
| uses: actions/upload-artifact@v4 | |
| if: success() | |
| with: | |
| name: hagicode-macos-zip-${{ github.sha }} | |
| path: pkg/*.zip | |
| retention-days: 30 | |
| if-no-files-found: error | |
| - name: Upload ZIP to Release | |
| uses: softprops/action-gh-release@v2 | |
| if: startsWith(github.ref, 'refs/tags/v') | |
| with: | |
| files: pkg/*.zip | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| ################################################################################ | |
| # Build Summary Job | |
| ################################################################################ | |
| build-summary: | |
| name: Build Summary | |
| outputs: | |
| channel: ${{ steps.channel.outputs.channel }} | |
| release_status: ${{ steps.status.outputs.overall }} | |
| effective_windows_status: ${{ steps.platform_status.outputs.windows }} | |
| effective_windows_job: ${{ steps.platform_status.outputs.windows_job }} | |
| sync_eligible: ${{ steps.status.outputs.sync_eligible }} | |
| needs: | |
| - build-windows | |
| - build-windows-release | |
| - build-linux | |
| - build-macos | |
| if: always() | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Check build status | |
| run: | | |
| echo "Windows Build: ${{ needs.build-windows.result }}" | |
| echo "Windows Release Build: ${{ needs.build-windows-release.result }}" | |
| echo "Linux Build: ${{ needs.build-linux.result }}" | |
| echo "macOS Build: ${{ needs.build-macos.result }}" | |
| - name: Normalize platform status | |
| id: platform_status | |
| if: always() | |
| run: | | |
| windows_build_status="${{ needs.build-windows.result }}" | |
| windows_release_status="${{ needs.build-windows-release.result }}" | |
| if [ "${windows_release_status}" != "skipped" ]; then | |
| windows_status="${windows_release_status}" | |
| windows_job="build-windows-release" | |
| else | |
| windows_status="${windows_build_status}" | |
| windows_job="build-windows" | |
| fi | |
| echo "windows=${windows_status}" >> "$GITHUB_OUTPUT" | |
| echo "windows_job=${windows_job}" >> "$GITHUB_OUTPUT" | |
| echo "linux=${{ needs.build-linux.result }}" >> "$GITHUB_OUTPUT" | |
| echo "macos=${{ needs.build-macos.result }}" >> "$GITHUB_OUTPUT" | |
| - name: Determine release channel | |
| id: channel | |
| if: always() | |
| run: | | |
| # If channel is manually specified, use it | |
| if [ -n "${{ github.event.inputs.channel }}" ]; then | |
| CHANNEL="${{ github.event.inputs.channel }}" | |
| echo "Using manually specified channel: ${CHANNEL}" | |
| else | |
| # Auto-detect channel based on tag or branch | |
| if [ "${{ github.ref_type }}" == "tag" ]; then | |
| TAG="${GITHUB_REF_NAME#v}" | |
| # Check if version contains pre-release identifiers | |
| if [[ "$TAG" =~ -(beta|alpha|rc|dev) ]]; then | |
| if [[ "$TAG" =~ -beta ]]; then | |
| CHANNEL="beta" | |
| elif [[ "$TAG" =~ -alpha ]]; then | |
| CHANNEL="dev" | |
| elif [[ "$TAG" =~ -rc ]]; then | |
| CHANNEL="beta" | |
| elif [[ "$TAG" =~ -dev ]]; then | |
| CHANNEL="dev" | |
| fi | |
| else | |
| # Stable version (no pre-release identifier) | |
| CHANNEL="stable" | |
| fi | |
| elif [ "${{ github.ref }}" == "refs/heads/main" ]; then | |
| # Main branch without tag - use beta for testing | |
| CHANNEL="beta" | |
| else | |
| # Other branches | |
| CHANNEL="dev" | |
| fi | |
| echo "Auto-detected channel: ${CHANNEL}" | |
| fi | |
| echo "channel=${CHANNEL}" >> $GITHUB_OUTPUT | |
| - name: Determine workflow status | |
| id: status | |
| if: always() | |
| run: | | |
| # Check all dependent jobs | |
| windows_status="${{ steps.platform_status.outputs.windows }}" | |
| windows_job="${{ steps.platform_status.outputs.windows_job }}" | |
| linux_status="${{ steps.platform_status.outputs.linux }}" | |
| macos_status="${{ steps.platform_status.outputs.macos }}" | |
| is_tag_release="${{ startsWith(github.ref, 'refs/tags/v') }}" | |
| echo "Windows (${windows_job}): ${windows_status}" | |
| echo "Linux: ${linux_status}" | |
| echo "macOS: ${macos_status}" | |
| # Downstream publish is only allowed after every required platform succeeds. | |
| if [ "${windows_status}" != "success" ] || [ "${linux_status}" != "success" ] || [ "${macos_status}" != "success" ]; then | |
| echo "overall=failed" >> $GITHUB_OUTPUT | |
| echo "status_icon=❌" >> $GITHUB_OUTPUT | |
| echo "status_text=失败" >> $GITHUB_OUTPUT | |
| echo "sync_eligible=false" >> $GITHUB_OUTPUT | |
| else | |
| echo "overall=success" >> $GITHUB_OUTPUT | |
| echo "status_icon=✅" >> $GITHUB_OUTPUT | |
| echo "status_text=成功" >> $GITHUB_OUTPUT | |
| if [ "${is_tag_release}" == "true" ]; then | |
| echo "sync_eligible=true" >> $GITHUB_OUTPUT | |
| else | |
| echo "sync_eligible=false" >> $GITHUB_OUTPUT | |
| fi | |
| fi | |
| - name: Build platforms summary | |
| id: platforms | |
| if: always() | |
| run: | | |
| windows_status="${{ steps.platform_status.outputs.windows }}" | |
| linux_status="${{ steps.platform_status.outputs.linux }}" | |
| macos_status="${{ steps.platform_status.outputs.macos }}" | |
| platforms=() | |
| [ "${windows_status}" == "success" ] && platforms+=("Windows") | |
| [ "${linux_status}" == "success" ] && platforms+=("Linux") | |
| [ "${macos_status}" == "success" ] && platforms+=("macOS") | |
| if [ ${#platforms[@]} -eq 0 ]; then | |
| platforms_str="无" | |
| else | |
| platforms_str=$(IFS=", "; echo "${platforms[*]}") | |
| fi | |
| echo "platforms=${platforms_str}" >> $GITHUB_OUTPUT | |
| - name: Publish workflow summary | |
| if: always() | |
| run: | | |
| { | |
| echo '## Release orchestration summary' | |
| echo | |
| echo "- Effective Windows job: ${{ steps.platform_status.outputs.windows_job }}" | |
| echo "- Effective Windows status: ${{ steps.platform_status.outputs.windows }}" | |
| echo "- Linux status: ${{ steps.platform_status.outputs.linux }}" | |
| echo "- macOS status: ${{ steps.platform_status.outputs.macos }}" | |
| echo "- Normalized overall status: ${{ steps.status.outputs.overall }}" | |
| echo "- Release channel: ${{ steps.channel.outputs.channel }}" | |
| echo "- Azure sync eligible: ${{ steps.status.outputs.sync_eligible }}" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Notify Feishu | |
| if: always() | |
| uses: HagiCode-org/haginotifier@v1.0.0 | |
| with: | |
| message: | | |
| **Flow 执行完成** | |
| 状态: ${{ steps.status.outputs.status_icon }} ${{ steps.status.outputs.status_text }} | |
| 分支: ${{ github.ref_name }} | |
| 提交: `${{ github.sha }}` | |
| 触发者: ${{ github.actor }} | |
| 事件: ${{ github.event_name }} | |
| 构建平台: ${{ steps.platforms.outputs.platforms }} | |
| [查看详情](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) | |
| msg_type: 'post' | |
| title: 'Hagicode Desktop 构建通知 ${{ steps.status.outputs.status_icon }}' | |
| env: | |
| FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }} | |
| - name: Fail workflow on normalized platform failure | |
| if: steps.status.outputs.overall != 'success' | |
| run: | | |
| echo "::error::One or more release platform builds failed. Downstream publish is blocked." | |
| exit 1 | |
| ################################################################################ | |
| # Sync to Azure Storage Job | |
| ################################################################################ | |
| sync-azure-upload: | |
| name: Upload Release Shards to Azure Storage | |
| needs: build-summary | |
| if: ${{ always() && startsWith(github.ref, 'refs/tags/v') && needs.build-summary.outputs.release_status == 'success' }} | |
| uses: ./.github/workflows/sync-azure-storage.yml | |
| with: | |
| release_tag: ${{ github.ref_name }} | |
| release_channel: ${{ needs.build-summary.outputs.channel }} | |
| secrets: inherit | |
| sync-azure-finalize: | |
| name: Finalize Azure Storage Sync | |
| needs: sync-azure-upload | |
| if: ${{ always() && needs.sync-azure-upload.result == 'success' }} | |
| uses: ./.github/workflows/finalize-azure-storage.yml | |
| secrets: inherit |