Skip to content

Commit 25b60e3

Browse files
authored
ci(macos): resume notary polling without repeating uploads (#3413)
1 parent 970b1bd commit 25b60e3

4 files changed

Lines changed: 193 additions & 7 deletions

File tree

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,7 @@ jobs:
424424
}
425425
426426
- name: Verify Apple signature and notarization
427+
id: apple_verification
427428
if: runner.os == 'macOS'
428429
shell: bash
429430
run: |
@@ -443,6 +444,22 @@ jobs:
443444
shell: bash
444445
run: bash scripts/ci/verify-appimage-fcitx.sh "${{ matrix.platform.target }}"
445446

447+
- name: Preserve macOS verification recovery bundles
448+
if: failure() && runner.os == 'macOS' && steps.apple_verification.outcome == 'failure'
449+
shell: bash
450+
run: |
451+
tar -czf macos-verification-recovery.tar.gz \
452+
-C "target/${{ matrix.platform.target }}/release" bundle
453+
454+
- name: Upload macOS verification recovery bundles
455+
if: failure() && runner.os == 'macOS' && steps.apple_verification.outcome == 'failure'
456+
uses: actions/upload-artifact@v6
457+
with:
458+
name: openbitfun-${{ needs.prepare.outputs.release_tag }}-${{ matrix.platform.name }}-verification-recovery
459+
path: macos-verification-recovery.tar.gz
460+
if-no-files-found: error
461+
retention-days: 7
462+
446463
- name: Upload bundles
447464
uses: actions/upload-artifact@v6
448465
with:

‎docs/development/releasing.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,19 @@ Missing, duplicate, expired, or unverified producers stop finalization before
4747
publication. Do not combine `artifact_run_ids` with a single-platform target or
4848
`relay_image_only`.
4949

50+
macOS verification uploads a DMG once, records its submission ID and SHA256 in
51+
`<dmg>.notary-submission.json`, then waits for that submission. A transient
52+
`NSURLErrorDomain` connection loss (`-1005`) or offline response (`-1009`)
53+
retries the wait, without uploading the DMG again. Apple must explicitly return
54+
`Accepted`; invalid results, other errors, and changed DMG bytes remain failures.
55+
Stapling updates the receipt's hash to the exact stapled DMG bytes.
56+
57+
If Apple verification fails, the workflow retains a seven-day
58+
`*-verification-recovery` artifact containing a tar archive of the bundles and
59+
receipt. The archive preserves executable modes and symlinks for re-verification.
60+
It is unverified recovery input, never a release installer: it does not match
61+
the normal `*-bundle` publication pattern or satisfy the complete producer gate.
62+
5063
## Mobile Android releases
5164

5265
Mobile Android packages are released independently from Desktop packages. Use a

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

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1906,6 +1906,39 @@ test('macOS release verification uses the pinned workflow revision without chang
19061906
assert.equal(readFileSync(artifact, 'utf8'), 'signed immutable fixture');
19071907
});
19081908

1909+
test('failed Mac verification retains a tar archive without publishing it', (t) => {
1910+
if (process.platform === 'win32') { t.skip('Mac archive mode checks run on Unix'); return; }
1911+
const workflow = yaml.parse(readFileSync(path.join(repoRoot, '.github/workflows/desktop-package.yml'), 'utf8'));
1912+
const steps = workflow.jobs.package.steps;
1913+
const verify = steps.find((step) => step.name === 'Verify Apple signature and notarization');
1914+
const preserve = steps.find((step) => step.name === 'Preserve macOS verification recovery bundles');
1915+
const upload = steps.find((step) => step.name === 'Upload macOS verification recovery bundles');
1916+
assert.equal(verify.id, 'apple_verification');
1917+
assert.equal(verify['continue-on-error'], undefined);
1918+
for (const step of [preserve, upload]) {
1919+
for (const failed of [true, false]) for (const os of ['macOS', 'Linux', 'Windows']) for (const outcome of ['success', 'failure']) {
1920+
const enabled = Function('failure', 'runner', 'steps', `return (${step.if});`)(() => failed, { os }, { apple_verification: { outcome } });
1921+
assert.equal(enabled, failed && os === 'macOS' && outcome === 'failure');
1922+
}
1923+
}
1924+
assert.match(upload.with.name, /-verification-recovery$/);
1925+
assert.doesNotMatch(upload.with.name, /-bundle$/);
1926+
const cwd = mkdtempSync(path.join(tmpdir(), 'openbitfun-notary-archive-'));
1927+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
1928+
const app = path.join(cwd, 'target/fixture/release/bundle/macos/Test.app');
1929+
mkdirSync(app, { recursive: true });
1930+
writeFileSync(path.join(app, 'tool'), 'signed executable fixture', { mode: 0o755 });
1931+
assert.equal(spawnSync('ln', ['-s', 'tool', path.join(app, 'tool-link')], { windowsHide: true }).status, 0);
1932+
const archived = spawnSync('bash', ['-c', preserve.run.replaceAll('${{ matrix.platform.target }}', 'fixture')], { cwd, encoding: 'utf8', windowsHide: true });
1933+
assert.equal(archived.status, 0, archived.stderr);
1934+
mkdirSync(path.join(cwd, 'restored'));
1935+
assert.equal(spawnSync('tar', ['-xzf', 'macos-verification-recovery.tar.gz', '-C', 'restored'], { cwd, windowsHide: true }).status, 0);
1936+
const restored = path.join(cwd, 'restored/bundle/macos/Test.app');
1937+
assert.equal(readFileSync(path.join(restored, 'tool-link'), 'utf8'), 'signed executable fixture');
1938+
assert.equal(spawnSync('bash', ['-c', 'test -x "$1"', 'fixture', path.join(restored, 'tool')], { windowsHide: true }).status, 0);
1939+
assert.equal(spawnSync('readlink', [path.join(restored, 'tool-link')], { encoding: 'utf8', windowsHide: true }).stdout.trim(), 'tool');
1940+
});
1941+
19091942
for (const scenario of [
19101943
{ name: 'accepts a transient app ticket connection loss', failures: 2, network: true, attempts: 3, status: 0, submissions: 1 },
19111944
{ name: 'retries DMG stapling without resubmitting notarization', action: 'staple', kind: 'dmg', failures: 1, network: true, attempts: 2, status: 0, submissions: 1 },
@@ -1916,6 +1949,14 @@ for (const scenario of [
19161949
{ name: 'rejects signature errors before contacting Apple', signatureStatus: 1, attempts: 0, status: 1, submissions: 0 },
19171950
{ name: 'preserves Gatekeeper rejection', gatekeeperStatus: 3, attempts: 1, status: 3, submissions: 0 },
19181951
{ name: 'preserves notarization submission rejection', notaryStatus: 4, attempts: 1, status: 4, submissions: 1 },
1952+
{ name: 'resumes an uploaded DMG after transient offline polling', waitFailures: 2, waitCode: -1009, waitAttempts: 3, attempts: 1, status: 0, submissions: 1 },
1953+
{ name: 'resumes polling after a lost connection', waitFailures: 1, waitCode: -1005, waitAttempts: 2, attempts: 1, status: 0, submissions: 1 },
1954+
{ name: 'retains a receipt after exhausted polling retries', waitFailures: 99, waitCode: -1009, waitAttempts: 3, attempts: 1, status: 1, submissions: 1, resume: true },
1955+
{ name: 'does not retry unrelated polling failures', waitFailures: 99, waitCode: -1001, waitAttempts: 1, attempts: 1, status: 1, submissions: 1 },
1956+
{ name: 'rejects a missing submission ID without retrying upload', submissionId: 'invalid', waitAttempts: 0, attempts: 1, status: 1, submissions: 1 },
1957+
{ name: 'rejects a response for another submission', responseId: '22222222-2222-4222-8222-222222222222', waitAttempts: 1, attempts: 1, status: 1, submissions: 1 },
1958+
{ name: 'rejects an Invalid notarization response', waitStatus: 'Invalid', waitAttempts: 1, attempts: 1, status: 1, submissions: 1 },
1959+
19191960
]) {
19201961
test(`macOS verification ${scenario.name}`, (t) => {
19211962
const cwd = mkdtempSync(path.join(tmpdir(), 'openbitfun-apple-ticket-'));
@@ -1939,7 +1980,23 @@ for (const scenario of [
19391980
sleep() { printf 'sleep %s\\n' "$*" >> "$FIXTURE_CALLS"; }
19401981
xcrun() {
19411982
printf 'xcrun %s\\n' "$*" >> "$FIXTURE_CALLS"
1942-
if [[ "$1" == notarytool ]]; then return "$NOTARY_STATUS"; fi
1983+
if [[ "$1" == notarytool ]]; then
1984+
if [[ "$2" == submit ]]; then
1985+
[[ "$NOTARY_STATUS" == 0 ]] || return "$NOTARY_STATUS"
1986+
printf '{"id":"%s"}\\n' "$SUBMISSION_ID"
1987+
else
1988+
local waits=0
1989+
[[ ! -f waits ]] || read -r waits < waits
1990+
waits=$((waits + 1))
1991+
printf '%s\\n' "$waits" > waits
1992+
if [[ "$waits" -le "$WAIT_FAILURES" ]]; then
1993+
printf 'Error Domain=NSURLErrorDomain Code=%s "Network error"\\n' "$WAIT_CODE" >&2
1994+
return 1
1995+
fi
1996+
printf '{"id":"%s","status":"%s"}\\n' "$RESPONSE_ID" "$WAIT_STATUS"
1997+
fi
1998+
return 0
1999+
fi
19432000
local kind=app count=0
19442001
[[ "$3" == *.dmg ]] && kind=dmg
19452002
if [[ "$2" == "$TICKET_ACTION" && "$kind" == "$TICKET_KIND" ]]; then
@@ -1962,6 +2019,8 @@ for (const scenario of [
19622019
`], { cwd, encoding: 'utf8', windowsHide: true, env: { ...process.env,
19632020
OPENBITFUN_APPLE_SIGNING_CONFIGURED: 'true', FIXTURE_CALLS: path.join(cwd, 'calls'),
19642021
SIGNATURE_STATUS: String(scenario.signatureStatus ?? 0), GATEKEEPER_STATUS: String(scenario.gatekeeperStatus ?? 0),
2022+
SUBMISSION_ID: scenario.submissionId ?? '11111111-1111-4111-8111-111111111111', RESPONSE_ID: scenario.responseId ?? '11111111-1111-4111-8111-111111111111',
2023+
WAIT_FAILURES: String(scenario.waitFailures ?? 0), WAIT_CODE: String(scenario.waitCode ?? -1009), WAIT_STATUS: scenario.waitStatus ?? 'Accepted',
19652024
NOTARY_STATUS: String(scenario.notaryStatus ?? 0), APPLE_API_ISSUER: 'fixture', APPLE_API_KEY: 'fixture', APPLE_API_KEY_PATH: 'fixture',
19662025
TICKET_ACTION: scenario.action ?? 'validate', TICKET_KIND: scenario.kind ?? 'app',
19672026
TICKET_FAILURES: String(scenario.failures ?? 0), TICKET_NETWORK: String(scenario.network ?? false),
@@ -1973,7 +2032,38 @@ for (const scenario of [
19732032
assert.equal((calls.match(/^microphone$/gm) ?? []).length, scenario.signatureStatus ? 0 : 1, calls);
19742033
const attempts = scenario.attempts === 0 ? 0 : Number(readFileSync(path.join(cwd, 'attempts'), 'utf8'));
19752034
assert.equal(attempts, scenario.attempts, calls);
1976-
assert.equal((calls.match(/^sleep /gm) ?? []).length, Math.max(0, scenario.attempts - 1), calls);
2035+
assert.equal((calls.match(/^sleep /gm) ?? []).length, Math.max(0, scenario.attempts - 1) + Math.max(0, (scenario.waitAttempts ?? 1) - 1), calls);
2036+
assert.equal((calls.match(/^xcrun notarytool wait /gm) ?? []).length,
2037+
scenario.waitAttempts ?? (scenario.notaryStatus ? 0 : scenario.submissions), calls);
2038+
if (scenario.resume) {
2039+
const receipt = path.join(cwd, 'target/fixture/release/bundle/dmg/Test App.dmg.notary-submission.json');
2040+
assert.equal(JSON.parse(readFileSync(receipt, 'utf8')).id, '11111111-1111-4111-8111-111111111111');
2041+
const retryScript = `
2042+
codesign() { printf 'Authority=Developer ID Application: Fixture\\nflags=0x10000(runtime)\\nTimestamp=fixture\\n'; }
2043+
file() { printf 'Mach-O fixture\\n'; }
2044+
spctl() { return 0; }
2045+
xcrun() {
2046+
printf '%s\\n' "$*" >> resumed
2047+
if [[ "$1 $2" == 'notarytool submit' ]]; then return 99; fi
2048+
if [[ "$1 $2" == 'notarytool wait' ]]; then
2049+
printf '{"id":"11111111-1111-4111-8111-111111111111","status":"Accepted"}\\n'
2050+
fi
2051+
}
2052+
export -f codesign file spctl xcrun
2053+
bash scripts/ci/verify-macos-signing.sh fixture
2054+
`;
2055+
const retryOptions = { cwd, encoding: 'utf8', windowsHide: true, env: { ...process.env,
2056+
OPENBITFUN_APPLE_SIGNING_CONFIGURED: 'true', FIXTURE_CALLS: path.join(cwd, 'calls'),
2057+
APPLE_API_ISSUER: 'fixture', APPLE_API_KEY: 'fixture', APPLE_API_KEY_PATH: 'fixture' } };
2058+
const retry = spawnSync('bash', ['-c', retryScript], retryOptions);
2059+
assert.equal(retry.status, 0, retry.stderr);
2060+
assert.doesNotMatch(readFileSync(path.join(cwd, 'resumed'), 'utf8'), /notarytool submit/);
2061+
writeFileSync(path.join(cwd, 'target/fixture/release/bundle/dmg/Test App.dmg'), 'changed after receipt');
2062+
const changed = spawnSync('bash', ['-c', retryScript], retryOptions);
2063+
assert.notEqual(changed.status, 0);
2064+
assert.match(changed.stderr, /receipt does not match/);
2065+
2066+
}
19772067
if (scenario.status === 0) assert.match(result.stdout, /Verified Developer ID signatures/);
19782068
else assert.doesNotMatch(result.stdout, /Verified Developer ID signatures/);
19792069
});

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

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,66 @@ stapler_with_network_retry() {
3939
done
4040
}
4141

42+
# Separate upload from polling so a lost connection never re-submits a DMG.
43+
# Keep the receipt with the bundle for a verification-only recovery run.
44+
notarize_dmg() {
45+
local dmg="$1" receipt="${1}.notary-submission.json" submission_id output exit_code attempt
46+
local auth=(--issuer "${APPLE_API_ISSUER}" --key-id "${APPLE_API_KEY}" --key "${APPLE_API_KEY_PATH}")
47+
if [[ -f "${receipt}" ]]; then
48+
submission_id="$(node -e '
49+
const fs = require("node:fs"), crypto = require("node:crypto");
50+
const [dmg, receipt] = process.argv.slice(1);
51+
const saved = JSON.parse(fs.readFileSync(receipt, "utf8"));
52+
if (!/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(saved.id) ||
53+
saved.sha256 !== crypto.createHash("sha256").update(fs.readFileSync(dmg)).digest("hex")) {
54+
throw new Error("Notary receipt does not match this DMG; refusing to reuse its submission");
55+
}
56+
console.log(saved.id);
57+
' "${dmg}" "${receipt}")"
58+
else
59+
if output="$(xcrun notarytool submit "${dmg}" "${auth[@]}" --no-wait --output-format json 2>&1)"; then
60+
printf '%s\n' "${output}"
61+
else
62+
exit_code=$?
63+
printf '%s\n' "${output}" >&2
64+
return "${exit_code}"
65+
fi
66+
submission_id="$(printf '%s' "${output}" | node -e '
67+
const fs = require("node:fs"), crypto = require("node:crypto");
68+
const [dmg, receipt] = process.argv.slice(1);
69+
const { id } = JSON.parse(fs.readFileSync(0, "utf8"));
70+
if (typeof id !== "string" || !/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(id)) {
71+
throw new Error("Apple did not return a valid submission ID; refusing to resubmit");
72+
}
73+
const sha256 = crypto.createHash("sha256").update(fs.readFileSync(dmg)).digest("hex");
74+
fs.writeFileSync(receipt + ".part", JSON.stringify({ id, sha256 }) + "\n", { mode: 0o600 });
75+
fs.renameSync(receipt + ".part", receipt);
76+
console.log(id);
77+
' "${dmg}" "${receipt}")"
78+
fi
79+
for attempt in 1 2 3; do
80+
if output="$(xcrun notarytool wait "${submission_id}" "${auth[@]}" --output-format json 2>&1)"; then
81+
printf '%s\n' "${output}"
82+
printf '%s' "${output}" | node -e '
83+
const result = JSON.parse(require("node:fs").readFileSync(0, "utf8"));
84+
if (result.id !== process.argv[1] || result.status !== "Accepted") {
85+
throw new Error("Apple notarization was not accepted: " + result.status);
86+
}
87+
' "${submission_id}"
88+
return
89+
else
90+
exit_code=$?
91+
fi
92+
printf '%s\n' "${output}" >&2
93+
if [[ "${attempt}" -eq 3 ]] ||
94+
! grep -Eq 'Error Domain=NSURLErrorDomain Code=-(1005|1009)([^0-9]|$)' <<<"${output}"; then
95+
return "${exit_code}"
96+
fi
97+
echo "Apple notary connection was lost; waiting again on submission ${submission_id} (${attempt}/3)." >&2
98+
sleep "$((attempt * 10))"
99+
done
100+
}
101+
42102
for app in "${apps[@]}"; do
43103
codesign --verify --deep --strict --verbose=2 "${app}"
44104
bash "$(dirname "$0")/verify-macos-microphone.sh" "${app}"
@@ -56,12 +116,18 @@ for app in "${apps[@]}"; do
56116
done
57117

58118
for dmg in "${dmgs[@]}"; do
59-
xcrun notarytool submit "${dmg}" \
60-
--issuer "${APPLE_API_ISSUER}" \
61-
--key-id "${APPLE_API_KEY}" \
62-
--key "${APPLE_API_KEY_PATH}" \
63-
--wait
119+
notarize_dmg "${dmg}"
64120
stapler_with_network_retry staple "${dmg}"
121+
# Stapling legitimately changes the accepted DMG. Bind a retained receipt to
122+
# those exact bytes before validation, so recovery can safely reuse its ID.
123+
node -e '
124+
const fs = require("node:fs"), crypto = require("node:crypto");
125+
const dmg = process.argv[1], receipt = dmg + ".notary-submission.json";
126+
const saved = JSON.parse(fs.readFileSync(receipt, "utf8"));
127+
saved.sha256 = crypto.createHash("sha256").update(fs.readFileSync(dmg)).digest("hex");
128+
fs.writeFileSync(receipt + ".part", JSON.stringify(saved) + "\n", { mode: 0o600 });
129+
fs.renameSync(receipt + ".part", receipt);
130+
' "${dmg}"
65131
stapler_with_network_retry validate "${dmg}"
66132
spctl --assess --type open --context context:primary-signature --verbose=4 "${dmg}"
67133
done

0 commit comments

Comments
 (0)