Skip to content

refactor(onboarding): route startup through web service manager lifec… #274

refactor(onboarding): route startup through web service manager lifec…

refactor(onboarding): route startup through web service manager lifec… #274

Workflow file for this run

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