Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 67 additions & 4 deletions .github/workflows/_build-reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ on:
description: 'Append short commit hash to artifact names'
type: boolean
default: false
mac_release_checkpoints:
description: 'Preserve verified Mac ZIP/app before retryable DMG construction'
type: boolean
default: false
upload_installers_only:
description: 'Only upload primary installers (exe/msi/dmg/deb), skip zip/yml/blockmap'
type: boolean
Expand Down Expand Up @@ -760,8 +764,35 @@ jobs:

# macOS: Build with notarization - DMG failure = CI failure, notarization failure = warning only
# macOS: 构建并公证 - DMG 失败 = CI 失败,公证失败 = 仅警告
- name: Restore verified Mac release checkpoint from this producer
id: mac-checkpoint
if: startsWith(matrix.platform, 'macos') && inputs.mac_release_checkpoints
shell: bash
env:
GH_TOKEN: ${{ github.token }}
CHECKPOINT_NAME: ${{ matrix.artifact-name }}-checkpoint-${{ github.sha }}
WAYLAND_MAC_TEAM_ID: ${{ secrets.TEAM_ID }}
run: |
set -euo pipefail
matches="$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts" | jq -s --arg name "$CHECKPOINT_NAME" '[.[].artifacts[] | select(.name == $name)]')"
count="$(printf '%s' "$matches" | jq length)"
if [[ "$count" == 0 ]]; then
echo "restored=false" >> "$GITHUB_OUTPUT"
exit 0
fi
[[ "$count" == 1 ]]
[[ "$(printf '%s' "$matches" | jq -r '.[0].expired')" == false ]]
artifact_id="$(printf '%s' "$matches" | jq -r '.[0].id')"
artifact_digest="$(printf '%s' "$matches" | jq -r '.[0].digest')"
checkpoint_dir="$RUNNER_TEMP/mac-release-checkpoint"
mkdir -p "$checkpoint_dir"
gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$artifact_id/zip" > "$checkpoint_dir/download.zip"
python3 scripts/lib/macReleaseCheckpointArchive.py unwrap "$checkpoint_dir/download.zip" "$checkpoint_dir/checkpoint.tar" "$artifact_digest"
node scripts/lib/macReleaseCheckpoint.cjs restore --arch "${{ matrix.arch }}" --out out --checkpoint "$checkpoint_dir/checkpoint.tar"
echo "restored=true" >> "$GITHUB_OUTPUT"

- name: Build with electron-builder (macOS)
if: startsWith(matrix.platform, 'macos')
if: startsWith(matrix.platform, 'macos') && (!inputs.mac_release_checkpoints || steps.mac-checkpoint.outputs.restored != 'true')
id: macos-build
shell: bash
run: |
Expand All @@ -774,8 +805,13 @@ jobs:
rm -f out/*.dmg

# Run build command
${{ matrix.command }} 2>&1 | tee "${RUNNER_TEMP}/build.log"
BUILD_EXIT_CODE=${PIPESTATUS[0]}
if [[ "${{ inputs.mac_release_checkpoints }}" == true ]]; then
node scripts/build-with-builder.js "${{ matrix.arch }}" --mac zip --${{ matrix.arch }} 2>&1 | tee "${RUNNER_TEMP}/build.log"
BUILD_EXIT_CODE=${PIPESTATUS[0]}
else
${{ matrix.command }} 2>&1 | tee "${RUNNER_TEMP}/build.log"
BUILD_EXIT_CODE=${PIPESTATUS[0]}
fi

# Check if DMG was created (most important artifact)
DMG_EXISTS=false
Expand All @@ -797,7 +833,7 @@ jobs:
echo "❌ Build or post-package verification failed (exit ${BUILD_EXIT_CODE})"
echo "notarization_status=build_failed" >> $GITHUB_OUTPUT
exit $BUILD_EXIT_CODE
env:
env: &mac_build_env
NODE_OPTIONS: '--max-old-space-size=8192'
npm_config_arch: ${{ matrix.arch }}
APP_ID: ${{ secrets.APP_ID }}
Expand All @@ -823,6 +859,33 @@ jobs:
CI: true
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}

- name: Save verified Mac ZIP before DMG construction
id: mac-checkpoint-save
if: startsWith(matrix.platform, 'macos') && inputs.mac_release_checkpoints && steps.mac-checkpoint.outputs.restored != 'true'
env:
WAYLAND_MAC_TEAM_ID: ${{ secrets.TEAM_ID }}
run: node scripts/lib/macReleaseCheckpoint.cjs save --arch "${{ matrix.arch }}" --out out --checkpoint "$RUNNER_TEMP/mac-release-checkpoint/checkpoint.tar"

- name: Preserve immutable Mac release checkpoint
if: startsWith(matrix.platform, 'macos') && inputs.mac_release_checkpoints && steps.mac-checkpoint.outputs.restored != 'true'
uses: actions/upload-artifact@v6
with:
name: ${{ matrix.artifact-name }}-checkpoint-${{ github.sha }}
path: ${{ runner.temp }}/mac-release-checkpoint/checkpoint.tar
if-no-files-found: error
retention-days: 7

- name: Construct DMG from the verified app only
if: startsWith(matrix.platform, 'macos') && inputs.mac_release_checkpoints
env: *mac_build_env
run: node scripts/lib/packageMacDmg.cjs --app "${{ steps.mac-checkpoint.outputs.app_path || steps.mac-checkpoint-save.outputs.app_path }}" --arch "${{ matrix.arch }}" --out out

- name: Preserve ZIP and merge post-staple DMG update metadata
if: startsWith(matrix.platform, 'macos') && inputs.mac_release_checkpoints
env:
WAYLAND_MAC_TEAM_ID: ${{ secrets.TEAM_ID }}
run: node scripts/lib/macReleaseCheckpoint.cjs finalize --arch "${{ matrix.arch }}" --out out --checkpoint "$RUNNER_TEMP/mac-release-checkpoint/checkpoint.tar" --app "${{ steps.mac-checkpoint.outputs.app_path || steps.mac-checkpoint-save.outputs.app_path }}"

# Post-build: repair the macOS update feed. electron-builder computes the
# dmg's sha512/size at artifact-created time — BEFORE notarizeDmg staples
# it — and flushes latest-mac.yml only in its final publish-task phase
Expand Down
101 changes: 29 additions & 72 deletions .github/workflows/build-and-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ jobs:
(needs.release-preflight.result == 'success' || needs.release-preflight.result == 'skipped') &&
(github.ref == 'refs/heads/dev' || (startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-dev-')))
with:
mac_release_checkpoints: ${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-dev-') }}
matrix: >-
{"include":[
{"platform":"macos-arm64","target_platform":"darwin","os":"macos-15","command":"node scripts/build-with-builder.js arm64 --mac --arm64","artifact-name":"macos-build-arm64","arch":"arm64"},
Expand All @@ -131,66 +132,26 @@ jobs:
]}
secrets: inherit

# 自动重试 workflow(当构建失败时)
auto-retry-workflow:
name: Auto Retry on Build Failure
# A completed producer may retry failed jobs while retaining successful artifacts.
build-failure-summary:
name: Preserve successful artifacts and report failed-only retry
runs-on: ubuntu-latest
needs: build-pipeline
permissions:
actions: write
contents: read
# 关键:只在首次失败时触发,避免无限循环
if: |
failure() &&
github.run_attempt == 1 &&
(github.event_name == 'push' || github.event_name == 'schedule')

if: always() && needs.build-pipeline.result == 'failure'
steps:
- name: Log retry information
run: |
echo "=========================================="
echo "🔄 Auto retry triggered (first failure)"
echo "=========================================="
echo "Build failed on first attempt, preparing auto retry..."
echo "Current attempt: ${{ github.run_attempt }}"
echo "Wait strategy: 5 minutes cooldown before retry"
echo "=========================================="

- name: Wait before retry (5 min cooldown)
run: |
echo "⏳ Waiting 5 minutes before retry..."
echo "Start: $(date)"
sleep 300
echo "End: $(date)"
echo "Triggering retry..."

- name: Trigger workflow rerun
- name: Report exact post-completion retry
shell: bash
run: |
echo "🔄 Triggering full workflow rerun (attempt 2)..."

# Use re-run API (not rerun-failed-jobs, to avoid loops)
response=$(curl -X POST \
-H "Accept: application/vnd.github.v3+json" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-w "\n%{http_code}" \
https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/rerun)

http_code=$(echo "$response" | tail -n1)

if [ "$http_code" = "201" ]; then
echo ""
echo "✅ Retry triggered successfully"
echo "This will be attempt 2"
echo "Details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
else
echo ""
echo "❌ Retry trigger failed, HTTP status: $http_code"
echo "Response:"
echo "$response" | head -n-1
exit 1
fi

# 自动创建tag(仅 dev 分支推送时)
{
echo "Build failed. Successful artifacts and verified Mac checkpoints remain in producer $GITHUB_RUN_ID."
echo "Source: $GITHUB_SHA. Wait for this producer to complete before retrying."
echo 'Retry only failed jobs after inspecting the failure; do not start a new producer or rerun successful builds:'
echo "gh run rerun $GITHUB_RUN_ID --repo $GITHUB_REPOSITORY --failed"
} >> "$GITHUB_STEP_SUMMARY"

# Create a tag only for a dev branch build.
create-tag:
name: Create Tag from Branch
runs-on: ubuntu-latest
Expand Down Expand Up @@ -336,6 +297,7 @@ jobs:
- name: Download all build artifacts
uses: actions/download-artifact@v7
with:
pattern: '{macos,windows,linux}-build-{arm64,x64}'
path: build-artifacts

- name: Prepare release assets (normalize updater metadata)
Expand All @@ -353,26 +315,20 @@ jobs:
shell: bash
run: bash scripts/verify-release-assets.sh release-assets

- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag_name }}
name: ${{ steps.version.outputs.is_dev == 'true' && format('Development Build {0}', steps.version.outputs.tag_name) || steps.version.outputs.tag_name }}
files: |
release-assets/**/*.exe
release-assets/**/*.msi
release-assets/**/*.dmg
release-assets/**/*.deb
release-assets/**/*.AppImage
release-assets/**/*.rpm
release-assets/**/*.zip
release-assets/**/*.yml
release-assets/**/*.blockmap
generate_release_notes: true
draft: true
prerelease: ${{ steps.version.outputs.is_dev == 'true' || contains(steps.version.outputs.tag_name, 'beta') || contains(steps.version.outputs.tag_name, 'alpha') || contains(steps.version.outputs.tag_name, 'rc') }}
- name: Create draft metadata without replacing an existing release
id: draft-assets
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
RELEASE_TAG: ${{ steps.version.outputs.tag_name }}
RELEASE_NAME: ${{ steps.version.outputs.is_dev == 'true' && format('Development Build {0}', steps.version.outputs.tag_name) || steps.version.outputs.tag_name }}
RELEASE_PRERELEASE: ${{ steps.version.outputs.is_dev == 'true' || contains(steps.version.outputs.tag_name, 'beta') || contains(steps.version.outputs.tag_name, 'alpha') || contains(steps.version.outputs.tag_name, 'rc') }}
run: node scripts/lib/publishDraftAssets.cjs prepare --repository "$GITHUB_REPOSITORY" --tag "$RELEASE_TAG" --commit "$GITHUB_SHA" --name "$RELEASE_NAME" --prerelease "$RELEASE_PRERELEASE"

- name: Upload only missing immutable draft assets
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
RELEASE_TAG: ${{ steps.version.outputs.tag_name }}
run: node scripts/lib/publishDraftAssets.cjs upload --repository "$GITHUB_REPOSITORY" --tag "$RELEASE_TAG" --commit "$GITHUB_SHA" --dir release-assets

# 发布前体检:在草稿发布上对真实下载件跑用户路径校验,绿了才公开。
# The release above is created as a DRAFT. This gate downloads the draft's real
Expand Down Expand Up @@ -700,6 +656,7 @@ jobs:
- name: Download exact canonical build artifacts
uses: actions/download-artifact@v7
with:
pattern: '{macos-build-arm64,macos-build-x64,windows-build-arm64,windows-build-x64,linux-build-arm64,linux-build-x64,capability-acceptance-${{ github.sha }},protected-platform-observations-${{ github.sha }},protected-updater-observations-${{ github.sha }}}'
path: canonical-artifacts

# The engine tag is READ from the bundle authority, never re-typed.
Expand Down
38 changes: 31 additions & 7 deletions scripts/build-with-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const { execFileSync, execSync, spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { allowsDmgRecovery, configureDmgEnvironment, deterministicDmgFailure } = require('./lib/macDmgPackaging.cjs');
const prepareBundledBun = require('./prepareBundledBun');
const prepareWaylandCore = require('./prepareWaylandCore');
const prepareWaylandNano = require('./prepareWaylandNano');
Expand Down Expand Up @@ -278,7 +279,7 @@ function formatExecError(error) {

// Create DMG using electron-builder --prepackaged with .app path
// This preserves DMG styling from electron-builder.yml (window size, icon positions, background)
function createDmgWithPrepackaged(appDir, targetArch) {
function createDmgWithPrepackaged(appDir, targetArch, env) {
const appName = fs.readdirSync(appDir).find((f) => f.endsWith('.app'));
if (!appName) throw new Error(`No .app found in ${appDir}`);
const appPath = path.join(appDir, appName);
Expand All @@ -288,6 +289,7 @@ function createDmgWithPrepackaged(appDir, targetArch) {
{
stdio: 'inherit',
shell: process.platform === 'win32',
env,
}
);
}
Expand All @@ -301,16 +303,17 @@ function resolveDmgRetryTarget(outDir, targetPlatform, targetArch, previousPacka
function buildWithDmgRetry(cmd, targetPlatform, targetArch, previousPackages, previousDmgs, allowDmgRetry = true) {
const isMac = process.platform === 'darwin';
const outDir = BUILDER_OUTPUT_DIR;
const env = isMac ? configureDmgEnvironment(outDir) : process.env;

try {
execSync(cmd, { stdio: 'inherit', shell: process.platform === 'win32' });
execSync(cmd, { stdio: 'inherit', shell: process.platform === 'win32', env });
return;
} catch (error) {
// A local verification build is directory-only and MUST NOT synthesize a
// distributable. Never recover a failed build into a DMG here — the retried
// DMG would be built from the intentionally-unsealed `.app` (an unsealed
// shippable artifact). Rethrow so the verification build simply fails.
if (!allowDmgRetry) throw error;
if (!allowDmgRetry || deterministicDmgFailure(env)) throw error;
// On non-macOS or if .app doesn't exist, just throw
let packagedTarget = null;
if (isMac) {
Expand All @@ -331,10 +334,11 @@ function buildWithDmgRetry(cmd, targetPlatform, targetArch, previousPackages, pr

try {
console.log(`\n📀 DMG retry attempt ${attempt}/${DMG_RETRY_MAX}...`);
createDmgWithPrepackaged(appDir, targetArch);
createDmgWithPrepackaged(appDir, targetArch, env);
console.log('✅ DMG created successfully on retry');
return;
} catch (retryError) {
if (deterministicDmgFailure(env)) throw retryError;
console.log(` ⚠️ DMG retry ${attempt}/${DMG_RETRY_MAX} failed`);
cleanupDiskImages();
if (attempt === DMG_RETRY_MAX) {
Expand Down Expand Up @@ -463,12 +467,14 @@ function prepareWhatsAppBridgeResources(options = {}) {
const validate = options.validate || (() => verifySourceMirror(bridgeDir, bridgeDir, undefined, platform, arch));
fs.rmSync(nodeModules, { recursive: true, force: true });
try {
run('bun', ['install', '--frozen-lockfile', '--os', platform, '--cpu', arch], {
const installArgs = ['install', '--frozen-lockfile', '--os', platform, '--cpu', arch];
if (options.verificationOnly) installArgs.push('--ignore-scripts');
run('bun', installArgs, {
cwd: bridgeDir,
stdio: 'inherit',
env: process.env,
});
if (platform === 'darwin') signWhatsAppBridgeNatives(nodeModules, options);
if (platform === 'darwin' && !options.verificationOnly) signWhatsAppBridgeNatives(nodeModules, options);
if (!validate()) throw new Error('WhatsApp bridge clean frozen-lock input failed source/dependency validation');
} catch (error) {
fs.rmSync(nodeModules, { recursive: true, force: true });
Expand Down Expand Up @@ -924,6 +930,24 @@ try {
}
}

// Real Core registration, before expensive packaging/signing. Unsupported
// targets remain explicitly unmeasured; installed-platform gates still apply.
if (packagePlatforms.includes('win32')) {
for (const arch of packageArchitectures) {
if (process.platform !== 'win32' || process.arch !== arch || arch !== 'x64') {
console.warn(
`[windows-core-mcp] NOT_CHECKED: early probe requires native win32-x64 (host ${process.platform}-${process.arch}, target win32-${arch})`
);
continue;
}
execFileSync(
process.execPath,
[path.join(__dirname, 'lib/windowsCoreMcpSmoke.cjs'), path.resolve(__dirname, '..', 'resources'), arch],
{ stdio: 'inherit', timeout: 180000 }
);
}
}

// 5b-nano. Prepare wayland-nano for every requested package target under the
// same strict contract as wayland-core: exact pinned tag, independently
// verified archive + extracted-binary digests, no local-prebuilt, no skip,
Expand Down Expand Up @@ -1111,7 +1135,7 @@ try {
targetArch,
previousPackages,
previousDmgs,
!localVerificationBuild
allowsDmgRecovery(builderArgs, localVerificationBuild)
);
} catch (error) {
const winExePath = path.join(BUILDER_OUTPUT_DIR, 'win-unpacked', BUILDER_EXECUTABLE_NAME);
Expand Down
30 changes: 30 additions & 0 deletions scripts/lib/dmgbuildCheckedCopy.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env node
const path = require('path');
const { spawnSync } = require('child_process');
const CHECKSUMS = {
'dmgbuild-bundle-x86_64-75c8a6c.tar.gz': '87b3bb72148b11451ee90ede79cc8d59305c9173b68b0f2b50a3bea51fc4a4e2',
'dmgbuild-bundle-arm64-75c8a6c.tar.gz': 'a785f2a385c8c31996a089ef8e26361904b40c772d5ea65a36001212f1fc25e0',
};
async function main() {
const { downloadBuilderToolset } = require('app-builder-lib/out/util/electronGet');
const nativeArch = process.arch === 'arm64' ? 'arm64' : 'x86_64';
const root = await downloadBuilderToolset({
releaseName: 'dmg-builder@1.2.0',
filenameWithExt: `dmgbuild-bundle-${nativeArch}-75c8a6c.tar.gz`,
checksums: CHECKSUMS,
githubOrgRepo: 'electron-userland/electron-builder-binaries',
});
const result = spawnSync(
path.join(root, 'python/bin/python3'),
[path.join(__dirname, 'dmgbuild_checked_copy.py'), ...process.argv.slice(2)],
{ stdio: 'inherit', env: { ...process.env, PYTHONPATH: path.join(root, 'python/lib') } }
);
if (result.error) throw result.error;
process.exitCode = result.status === null ? 1 : result.status;
}
if (require.main === module)
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
module.exports = { CHECKSUMS };
Loading
Loading