From 42d74c254652c43c03cf9bcdf7c0bb253e4706c0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 26 Oct 2026 19:20:17 +0700 Subject: [PATCH] fix(ci): retry transient Apple staple connection failures (#7933) --- .github/workflows/desktop-release.yml | 28 +++++++ scripts/ci-workflow.test.mjs | 113 +++++++++++++++++++++++++- 2 files changed, 140 insertions(+), 1 deletion(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index f6b07c8b37..d81c036e62 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -169,6 +169,34 @@ jobs: NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }} run: | set -euo pipefail + export PASEO_REAL_XCRUN="$(command -v xcrun)" + stapler_bin="$(mktemp -d)" + trap 'rm -rf "$stapler_bin"' EXIT + cat > "$stapler_bin/xcrun" <<'STAPLER' + #!/bin/bash + if [[ "${1:-}" != stapler || "${2:-}" != staple ]]; then + exec "$PASEO_REAL_XCRUN" "$@" + fi + output="$(mktemp)" + trap 'rm -f "$output"' EXIT + for attempt in 1 2 3 4; do + if "$PASEO_REAL_XCRUN" "$@" >"$output" 2>&1; then + cat "$output" + exit 0 + else + status=$? + fi + cat "$output" + if [[ "$status" != 68 || "$attempt" == 4 ]] || + ! grep -Fq 'NSURLErrorDomain Code=-1005' "$output"; then + exit "$status" + fi + echo "::warning::Apple staple connection lost; retrying the same signed app ($attempt/4)" >&2 + sleep 30 + done + STAPLER + chmod +x "$stapler_bin/xcrun" + export PATH="$stapler_bin:$PATH" build_args=(-- --publish never --mac --${{ matrix.electron_arch }}) build_args+=("-c.publish.releaseType=$RELEASE_TYPE") build_args+=("-c.publish.channel=$RELEASE_CHANNEL") diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index c16e772172..4842870cc3 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -1,5 +1,8 @@ import assert from "node:assert/strict"; -import { readFileSync, readdirSync } from "node:fs"; +import { readFileSync, readdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { relative as relativePath } from "node:path"; import test from "node:test"; import { runInNewContext } from "node:vm"; @@ -530,3 +533,111 @@ test("Windows units and integration run independently with complete routed cover assert.equal(selected(integrationCondition, outputs, true), false); } }); + +test("macOS release retries only transient stapling failures without rebuilding the signed app", async (t) => { + const workflow = readFileSync(new URL(".github/workflows/desktop-release.yml", repoRoot), "utf8"); + const wrapper = workflow + .match(/<<'STAPLER'\n([\s\S]*?)\n STAPLER/)[1] + .replace(/^ /gm, ""); + const networkError = + 'Error Domain=NSURLErrorDomain Code=-1005 "The network connection was lost."'; + const cases = [ + { + name: "success", + outcomes: [0], + output: "stapled", + command: "stapler", + expected: 0, + attempts: 1, + }, + { + name: "transient then success", + outcomes: [68, 0], + output: networkError, + command: "stapler", + expected: 0, + attempts: 2, + }, + { + name: "exhausted network retries", + outcomes: [68, 68, 68, 68, 0], + output: networkError, + command: "stapler", + expected: 68, + attempts: 4, + }, + { + name: "non-network staple error", + outcomes: [68, 0], + output: "ticket not found", + command: "stapler", + expected: 68, + attempts: 1, + }, + { + name: "different exit code", + outcomes: [1, 0], + output: networkError, + command: "stapler", + expected: 1, + attempts: 1, + }, + { + name: "notary submission is not retried", + outcomes: [68, 0], + output: networkError, + command: "notarytool", + expected: 68, + attempts: 1, + }, + ]; + for (const scenario of cases) { + await t.test(scenario.name, () => { + const directory = mkdtempSync(join(tmpdir(), "stapler-retry-")); + try { + const statePath = join(directory, "state.json"); + const realXcrun = join(directory, "real-xcrun"); + const wrapperPath = join(directory, "xcrun"); + writeFileSync(wrapperPath, wrapper, { mode: 0o755 }); + writeFileSync(join(directory, "sleep"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + writeFileSync( + statePath, + JSON.stringify({ outcomes: scenario.outcomes, output: scenario.output, calls: [] }), + ); + writeFileSync( + realXcrun, + `#!/usr/bin/env node +const fs = require("node:fs"); +const state = JSON.parse(fs.readFileSync(process.env.STAPLER_STATE, "utf8")); +state.calls.push(process.argv.slice(2)); +const code = state.outcomes.shift(); +fs.writeFileSync(process.env.STAPLER_STATE, JSON.stringify(state)); +console.log(state.output); +process.exit(code); +`, + { mode: 0o755 }, + ); + const args = [scenario.command, "staple", "-v", "Paseo signed.app"]; + const result = spawnSync("bash", [wrapperPath, ...args], { + encoding: "utf8", + env: { + ...process.env, + PASEO_REAL_XCRUN: realXcrun, + STAPLER_STATE: statePath, + PATH: `${directory}:${process.env.PATH}`, + }, + timeout: 5000, + }); + assert.equal(result.status, scenario.expected, result.stdout + result.stderr); + const state = JSON.parse(readFileSync(statePath, "utf8")); + assert.deepEqual( + state.calls, + Array.from({ length: scenario.attempts }, () => args), + ); + assert.match(result.stdout, /stapled|network connection was lost|ticket not found/); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + } +});