Skip to content

Commit f7e61a2

Browse files
authored
fix(release): retry transient Apple ticket connection failures (#3409)
1 parent 5951cdf commit f7e61a2

3 files changed

Lines changed: 156 additions & 4 deletions

File tree

‎.github/workflows/desktop-package.yml‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,17 @@ jobs:
407407
- name: Verify Apple signature and notarization
408408
if: runner.os == 'macOS'
409409
shell: bash
410-
run: bash scripts/ci/verify-macos-signing.sh "${{ matrix.platform.target }}"
410+
run: |
411+
set -euo pipefail
412+
# Verification policy follows this workflow revision, including
413+
# rebuilds of immutable tags predating a verification fix.
414+
verification_dir="$(mktemp -d)"
415+
trap 'rm -rf "$verification_dir"' EXIT
416+
git fetch --no-tags --depth=1 origin "${{ github.workflow_sha }}"
417+
git archive "${{ github.workflow_sha }}" \
418+
scripts/ci/verify-macos-signing.sh scripts/ci/verify-macos-microphone.sh \
419+
| tar -x -C "$verification_dir"
420+
bash "$verification_dir/scripts/ci/verify-macos-signing.sh" "${{ matrix.platform.target }}"
411421
412422
- name: Verify AppImage fcitx5 GTK module
413423
if: runner.os == 'Linux'

‎scripts/check-github-config.test.mjs‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1718,3 +1718,124 @@ test('release publication omits target overrides and uploads before publishing',
17181718
const prepare = workflow.jobs.prepare.steps.find((entry) => entry.id === 'meta').run;
17191719
assert.match(prepare, /UPLOAD.*true.*-z.*TAG_SHA/);
17201720
});
1721+
1722+
test('macOS release verification uses the pinned workflow revision without changing old-tag artifacts', (t) => {
1723+
const workflow = yaml.parse(readFileSync(path.join(repoRoot, '.github/workflows/desktop-package.yml'), 'utf8'));
1724+
const step = workflow.jobs.package.steps.find(entry => entry.name === 'Verify Apple signature and notarization');
1725+
const root = mkdtempSync(path.join(tmpdir(), 'openbitfun-apple-policy-'));
1726+
t.after(() => rmSync(root, { recursive: true, force: true }));
1727+
const source = path.join(root, 'source');
1728+
const checkout = path.join(root, 'checkout');
1729+
mkdirSync(path.join(source, 'scripts/ci'), { recursive: true });
1730+
const git = (cwd, ...args) => {
1731+
const result = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true });
1732+
assert.equal(result.status, 0, result.stderr);
1733+
return result.stdout.trim();
1734+
};
1735+
const commit = () => git(source, '-c', 'user.name=Release Fixture', '-c', 'user.email=release@example.invalid',
1736+
'-c', 'commit.gpgsign=false', 'commit', '-qm', 'Fixture');
1737+
const verifier = path.join(source, 'scripts/ci/verify-macos-signing.sh');
1738+
const microphone = path.join(source, 'scripts/ci/verify-macos-microphone.sh');
1739+
git(source, 'init', '-q');
1740+
git(source, 'config', 'core.autocrlf', 'false');
1741+
writeFileSync(verifier, 'exit 99\n');
1742+
writeFileSync(microphone, 'exit 99\n');
1743+
git(source, 'add', '.');
1744+
commit();
1745+
const oldSha = git(source, 'rev-parse', 'HEAD');
1746+
git(source, 'tag', 'fixture-old');
1747+
writeFileSync(verifier, 'printf "workflow-revision:%s\\n" "$1" > policy-result\nbash "$(dirname "$0")/verify-macos-microphone.sh"\n');
1748+
writeFileSync(microphone, 'printf "workflow-helper\\n" > helper-result\n');
1749+
git(source, 'add', '.');
1750+
commit();
1751+
const workflowSha = git(source, 'rev-parse', 'HEAD');
1752+
writeFileSync(verifier, 'exit 98\n');
1753+
git(source, 'add', '.');
1754+
commit();
1755+
git(root, 'clone', '--no-local', '--depth=1', '--branch', 'fixture-old', source, checkout);
1756+
const artifact = path.join(checkout, 'signed-asset.bin');
1757+
writeFileSync(artifact, 'signed immutable fixture');
1758+
const result = spawnSync('bash', ['-c', step.run
1759+
.replaceAll('${{ github.workflow_sha }}', workflowSha)
1760+
.replaceAll('${{ matrix.platform.target }}', 'fixture-target')], {
1761+
cwd: checkout, encoding: 'utf8', windowsHide: true,
1762+
});
1763+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
1764+
assert.equal(readFileSync(path.join(checkout, 'policy-result'), 'utf8'), 'workflow-revision:fixture-target\n');
1765+
assert.equal(readFileSync(path.join(checkout, 'helper-result'), 'utf8'), 'workflow-helper\n');
1766+
assert.equal(git(checkout, 'rev-parse', 'HEAD'), oldSha);
1767+
assert.equal(readFileSync(artifact, 'utf8'), 'signed immutable fixture');
1768+
});
1769+
1770+
for (const scenario of [
1771+
{ name: 'accepts a transient app ticket connection loss', failures: 2, network: true, attempts: 3, status: 0, submissions: 1 },
1772+
{ name: 'retries DMG stapling without resubmitting notarization', action: 'staple', kind: 'dmg', failures: 1, network: true, attempts: 2, status: 0, submissions: 1 },
1773+
{ name: 'retries DMG validation without resubmitting notarization', kind: 'dmg', failures: 2, network: true, attempts: 3, status: 0, submissions: 1 },
1774+
{ name: 'rejects exhausted ticket connection retries', failures: 99, network: true, attempts: 3, status: 68, submissions: 0 },
1775+
{ name: 'rejects invalid tickets without retrying', failures: 99, network: false, attempts: 1, status: 68, submissions: 0 },
1776+
{ name: 'preserves other stapler exit codes', failures: 99, network: true, ticketStatus: 65, attempts: 1, status: 65, submissions: 0 },
1777+
{ name: 'rejects signature errors before contacting Apple', signatureStatus: 1, attempts: 0, status: 1, submissions: 0 },
1778+
{ name: 'preserves Gatekeeper rejection', gatekeeperStatus: 3, attempts: 1, status: 3, submissions: 0 },
1779+
{ name: 'preserves notarization submission rejection', notaryStatus: 4, attempts: 1, status: 4, submissions: 1 },
1780+
]) {
1781+
test(`macOS verification ${scenario.name}`, (t) => {
1782+
const cwd = mkdtempSync(path.join(tmpdir(), 'openbitfun-apple-ticket-'));
1783+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
1784+
mkdirSync(path.join(cwd, 'scripts/ci'), { recursive: true });
1785+
copyFileSync(path.join(repoRoot, 'scripts/ci/verify-macos-signing.sh'), path.join(cwd, 'scripts/ci/verify-macos-signing.sh'));
1786+
writeFileSync(path.join(cwd, 'scripts/ci/verify-macos-microphone.sh'), 'printf "microphone\\n" >> "$FIXTURE_CALLS"\n');
1787+
const app = path.join(cwd, 'target/fixture/release/bundle/macos/Test App.app');
1788+
mkdirSync(app, { recursive: true });
1789+
writeFileSync(path.join(app, 'fixture'), 'fixture');
1790+
mkdirSync(path.join(cwd, 'target/fixture/release/bundle/dmg'), { recursive: true });
1791+
writeFileSync(path.join(cwd, 'target/fixture/release/bundle/dmg/Test App.dmg'), 'fixture');
1792+
const result = spawnSync('bash', ['-c', `
1793+
codesign() {
1794+
printf 'codesign %s\\n' "$*" >> "$FIXTURE_CALLS"
1795+
if [[ "$*" == *--verify* ]]; then return "$SIGNATURE_STATUS"; fi
1796+
printf 'Authority=Developer ID Application: Fixture\\nflags=0x10000(runtime)\\nTimestamp=fixture\\n'
1797+
}
1798+
file() { printf 'Mach-O fixture\\n'; }
1799+
spctl() { printf 'spctl %s\\n' "$*" >> "$FIXTURE_CALLS"; return "$GATEKEEPER_STATUS"; }
1800+
sleep() { printf 'sleep %s\\n' "$*" >> "$FIXTURE_CALLS"; }
1801+
xcrun() {
1802+
printf 'xcrun %s\\n' "$*" >> "$FIXTURE_CALLS"
1803+
if [[ "$1" == notarytool ]]; then return "$NOTARY_STATUS"; fi
1804+
local kind=app count=0
1805+
[[ "$3" == *.dmg ]] && kind=dmg
1806+
if [[ "$2" == "$TICKET_ACTION" && "$kind" == "$TICKET_KIND" ]]; then
1807+
[[ ! -f attempts ]] || read -r count < attempts
1808+
count=$((count + 1))
1809+
printf '%s\\n' "$count" > attempts
1810+
if [[ "$count" -le "$TICKET_FAILURES" ]]; then
1811+
if [[ "$TICKET_NETWORK" == true ]]; then
1812+
printf 'Error Domain=NSURLErrorDomain Code=-1005 "The network connection was lost."\\n' >&2
1813+
else
1814+
printf 'The ticket is invalid. The validate action failed! Error 68.\\n' >&2
1815+
fi
1816+
return "$TICKET_EXIT_CODE"
1817+
fi
1818+
fi
1819+
return 0
1820+
}
1821+
export -f codesign file spctl sleep xcrun
1822+
bash scripts/ci/verify-macos-signing.sh fixture
1823+
`], { cwd, encoding: 'utf8', windowsHide: true, env: { ...process.env,
1824+
OPENBITFUN_APPLE_SIGNING_CONFIGURED: 'true', FIXTURE_CALLS: path.join(cwd, 'calls'),
1825+
SIGNATURE_STATUS: String(scenario.signatureStatus ?? 0), GATEKEEPER_STATUS: String(scenario.gatekeeperStatus ?? 0),
1826+
NOTARY_STATUS: String(scenario.notaryStatus ?? 0), APPLE_API_ISSUER: 'fixture', APPLE_API_KEY: 'fixture', APPLE_API_KEY_PATH: 'fixture',
1827+
TICKET_ACTION: scenario.action ?? 'validate', TICKET_KIND: scenario.kind ?? 'app',
1828+
TICKET_FAILURES: String(scenario.failures ?? 0), TICKET_NETWORK: String(scenario.network ?? false),
1829+
TICKET_EXIT_CODE: String(scenario.ticketStatus ?? 68),
1830+
} });
1831+
const calls = readFileSync(path.join(cwd, 'calls'), 'utf8');
1832+
assert.equal(result.status, scenario.status, `${result.stdout}\n${result.stderr}`);
1833+
assert.equal((calls.match(/^xcrun notarytool submit /gm) ?? []).length, scenario.submissions, calls);
1834+
assert.equal((calls.match(/^microphone$/gm) ?? []).length, scenario.signatureStatus ? 0 : 1, calls);
1835+
const attempts = scenario.attempts === 0 ? 0 : Number(readFileSync(path.join(cwd, 'attempts'), 'utf8'));
1836+
assert.equal(attempts, scenario.attempts, calls);
1837+
assert.equal((calls.match(/^sleep /gm) ?? []).length, Math.max(0, scenario.attempts - 1), calls);
1838+
if (scenario.status === 0) assert.match(result.stdout, /Verified Developer ID signatures/);
1839+
else assert.doesNotMatch(result.stdout, /Verified Developer ID signatures/);
1840+
});
1841+
}

‎scripts/ci/verify-macos-signing.sh‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,27 @@ if [[ "${#apps[@]}" -eq 0 || "${#dmgs[@]}" -eq 0 ]]; then
1818
exit 1
1919
fi
2020

21+
# CloudKit can drop a connection even after notarization was accepted. Retry
22+
# only that transport failure; an invalid/missing ticket must still fail.
23+
stapler_with_network_retry() {
24+
local attempt output exit_code
25+
for attempt in 1 2 3; do
26+
if output="$(xcrun stapler "$@" 2>&1)"; then
27+
printf '%s\n' "${output}"
28+
return 0
29+
else
30+
exit_code=$?
31+
fi
32+
printf '%s\n' "${output}" >&2
33+
if [[ "${exit_code}" -ne 68 || "${attempt}" -eq 3 ]] || \
34+
! grep -Fq 'Error Domain=NSURLErrorDomain Code=-1005' <<<"${output}"; then
35+
return "${exit_code}"
36+
fi
37+
echo "Apple ticket service connection was lost; retrying stapler ${1} (${attempt}/3)." >&2
38+
sleep "$((attempt * 10))"
39+
done
40+
}
41+
2142
for app in "${apps[@]}"; do
2243
codesign --verify --deep --strict --verbose=2 "${app}"
2344
bash "$(dirname "$0")/verify-macos-microphone.sh" "${app}"
@@ -30,7 +51,7 @@ for app in "${apps[@]}"; do
3051
grep -Eq 'flags=.*\(runtime\)' <<<"${signature_details}"
3152
grep -Fq 'Timestamp=' <<<"${signature_details}"
3253
done < <(find "${app}" -type f -print0)
33-
xcrun stapler validate "${app}"
54+
stapler_with_network_retry validate "${app}"
3455
spctl --assess --type execute --verbose=4 "${app}"
3556
done
3657

@@ -40,8 +61,8 @@ for dmg in "${dmgs[@]}"; do
4061
--key-id "${APPLE_API_KEY}" \
4162
--key "${APPLE_API_KEY_PATH}" \
4263
--wait
43-
xcrun stapler staple "${dmg}"
44-
xcrun stapler validate "${dmg}"
64+
stapler_with_network_retry staple "${dmg}"
65+
stapler_with_network_retry validate "${dmg}"
4566
spctl --assess --type open --context context:primary-signature --verbose=4 "${dmg}"
4667
done
4768

0 commit comments

Comments
 (0)