diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index e5058146..40904629 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -204,12 +204,28 @@ jobs: run: npm run prepare:native-runtime - name: Build Tauri app - if: matrix.target != 'windows-user' + if: matrix.target != 'windows-user' && matrix.target != 'macos' + uses: tauri-apps/tauri-action@action-v0.6.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + projectPath: open-pdf-studio + args: ${{ matrix.args }} + releaseId: ${{ needs.prepare-release.outputs.release_id }} + includeUpdaterJson: false + + # Compile once. Bundling is a separate phase so a temporary failure in + # the external notarization service can be retried without rebuilding + # both macOS architectures. + - name: Build macOS app without bundles + if: matrix.target == 'macos' + run: npm run tauri -- build --target universal-apple-darwin --no-bundle + + - name: Upload macOS release assets + if: matrix.target == 'macos' uses: tauri-apps/tauri-action@action-v0.6.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # macOS code signing (Developer ID) + notarization — ignored on - # Windows/Linux runners. Empty on forks without the secrets. APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} @@ -218,7 +234,8 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} with: projectPath: open-pdf-studio - args: ${{ matrix.args }} + tauriScript: node scripts/macos-notarization-retry.mjs + args: --target universal-apple-darwin releaseId: ${{ needs.prepare-release.outputs.release_id }} includeUpdaterJson: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3a17f956..a7cafc36 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -209,12 +209,28 @@ jobs: } - name: Build Tauri app (with release upload) - if: matrix.target != 'windows-user' + if: matrix.target != 'windows-user' && matrix.target != 'macos' + uses: tauri-apps/tauri-action@action-v0.6.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + projectPath: open-pdf-studio + args: ${{ matrix.args }} + releaseId: ${{ needs.create-release.outputs.release_id }} + includeUpdaterJson: true + + # Compile once. Bundling is a separate phase so a temporary failure in + # the external notarization service can be retried without rebuilding + # both macOS architectures. + - name: Build macOS app without bundles + if: matrix.target == 'macos' + run: npm run tauri -- build --target universal-apple-darwin --no-bundle + + - name: Upload macOS release assets + if: matrix.target == 'macos' uses: tauri-apps/tauri-action@action-v0.6.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # macOS code signing (Developer ID) + notarization — ignored on - # Windows/Linux runners. Empty on forks without the secrets. APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} @@ -223,7 +239,8 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} with: projectPath: open-pdf-studio - args: ${{ matrix.args }} + tauriScript: node scripts/macos-notarization-retry.mjs + args: --target universal-apple-darwin releaseId: ${{ needs.create-release.outputs.release_id }} includeUpdaterJson: true diff --git a/open-pdf-studio/scripts/macos-notarization-retry.mjs b/open-pdf-studio/scripts/macos-notarization-retry.mjs new file mode 100644 index 00000000..6a8f20e0 --- /dev/null +++ b/open-pdf-studio/scripts/macos-notarization-retry.mjs @@ -0,0 +1,103 @@ +import { spawn } from 'node:child_process'; +import { rm } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptPath = fileURLToPath(import.meta.url); +const projectDir = path.resolve(path.dirname(scriptPath), '..'); +const bundleOutputDir = path.resolve( + projectDir, + '..', + 'target', + 'universal-apple-darwin', + 'release', + 'bundle', +); + +export function isTransientNotarizationFailure(output) { + return /failed to notarize/i.test(output) + && (/HTTP status code:\s*5\d\d/i.test(output) || /please try again (?:at a )?later time/i.test(output)); +} + +export async function bundleMacOSWithRetry({ + runBundle, + cleanBundleOutput, + wait, + logger = console, + maxAttempts = 4, + retryDelayMs = 30_000, +}) { + if (!Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 10) { + throw new TypeError('maxAttempts must be a positive integer no greater than 10'); + } + if (!Number.isInteger(retryDelayMs) || retryDelayMs < 0 || retryDelayMs > 300_000) { + throw new TypeError('retryDelayMs must be a non-negative integer no greater than 300000'); + } + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + await cleanBundleOutput(); + logger.log(`macOS bundle/notarization attempt ${attempt}/${maxAttempts}`); + const result = await runBundle(attempt); + if (result.code === 0) { + return { attempts: attempt }; + } + + const transient = isTransientNotarizationFailure(result.output); + if (!transient || attempt === maxAttempts) { + throw new Error(result.output.trim() || `macOS bundle command exited with code ${result.code}`); + } + + const delay = retryDelayMs * (2 ** (attempt - 1)); + logger.error(`Temporary notarization service failure; retrying in ${delay} ms.`); + await wait(delay); + } + + throw new Error('macOS bundling exhausted all attempts'); +} + +function runBundleCommand() { + const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const args = ['run', 'tauri', '--', 'bundle', '--target', 'universal-apple-darwin']; + + return new Promise((resolve, reject) => { + const child = spawn(npm, args, { + cwd: projectDir, + env: process.env, + stdio: ['inherit', 'pipe', 'pipe'], + }); + let output = ''; + + child.stdout.on('data', (chunk) => { + const text = chunk.toString(); + output += text; + process.stdout.write(text); + }); + child.stderr.on('data', (chunk) => { + const text = chunk.toString(); + output += text; + process.stderr.write(text); + }); + child.on('error', reject); + child.on('close', (code) => resolve({ code: code ?? 1, output })); + }); +} + +async function main() { + const maxAttempts = Number.parseInt(process.env.MACOS_NOTARIZATION_MAX_ATTEMPTS ?? '4', 10); + const retryDelayMs = Number.parseInt(process.env.MACOS_NOTARIZATION_RETRY_DELAY_MS ?? '30000', 10); + + await bundleMacOSWithRetry({ + runBundle: runBundleCommand, + cleanBundleOutput: () => rm(bundleOutputDir, { recursive: true, force: true }), + wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), + maxAttempts, + retryDelayMs, + }); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/open-pdf-studio/scripts/release-config.test.mjs b/open-pdf-studio/scripts/release-config.test.mjs index 9222710a..8c1acbe6 100644 --- a/open-pdf-studio/scripts/release-config.test.mjs +++ b/open-pdf-studio/scripts/release-config.test.mjs @@ -126,6 +126,87 @@ test('release workflows verify macOS signatures and notarization', async () => { } }); +test('macOS bundling retries transient notarization service failures', async () => { + let retryModule; + try { + retryModule = await import('./macos-notarization-retry.mjs'); + } catch { + retryModule = null; + } + assert.equal(typeof retryModule?.bundleMacOSWithRetry, 'function'); + + const results = [ + { code: 1, output: 'failed to notarize app: HTTP status code: 500. Please try again later.' }, + { code: 0, output: 'bundle complete' }, + ]; + const attempts = []; + const cleanups = []; + const waits = []; + const result = await retryModule.bundleMacOSWithRetry({ + runBundle: async (attempt) => { + attempts.push(attempt); + return results.shift(); + }, + cleanBundleOutput: async () => cleanups.push('clean'), + wait: async (milliseconds) => waits.push(milliseconds), + logger: { error() {}, log() {} }, + retryDelayMs: 25, + }); + + assert.deepEqual(attempts, [1, 2]); + assert.deepEqual(cleanups, ['clean', 'clean']); + assert.deepEqual(waits, [25]); + assert.deepEqual(result, { attempts: 2 }); +}); + +test('macOS bundling does not retry non-transient failures', async () => { + const { bundleMacOSWithRetry } = await import('./macos-notarization-retry.mjs'); + let attempts = 0; + await assert.rejects( + bundleMacOSWithRetry({ + runBundle: async () => { + attempts += 1; + return { code: 2, output: 'configuration file is invalid' }; + }, + cleanBundleOutput: async () => {}, + wait: async () => {}, + logger: { error() {}, log() {} }, + }), + /configuration file is invalid/, + ); + assert.equal(attempts, 1); +}); + +test('macOS bundling rejects invalid retry configuration', async () => { + const { bundleMacOSWithRetry } = await import('./macos-notarization-retry.mjs'); + const options = { + runBundle: async () => ({ code: 0, output: '' }), + cleanBundleOutput: async () => {}, + wait: async () => {}, + logger: { error() {}, log() {} }, + }; + + await assert.rejects( + bundleMacOSWithRetry({ ...options, maxAttempts: 0 }), + /maxAttempts must be a positive integer/, + ); + await assert.rejects( + bundleMacOSWithRetry({ ...options, retryDelayMs: Number.NaN }), + /retryDelayMs must be a non-negative integer/, + ); +}); + +test('release workflows compile macOS once and retry only the bundle phase', async () => { + for (const name of ['release.yml', 'nightly.yml']) { + const workflow = await readFile(path.join(repoDir, '.github', 'workflows', name), 'utf8'); + assert.match(workflow, /Build macOS app without bundles/); + assert.match(workflow, /--target universal-apple-darwin --no-bundle/); + assert.match(workflow, /node scripts\/macos-notarization-retry\.mjs/); + assert.match(workflow, /Upload macOS release assets/); + assert.doesNotMatch(workflow, /retryAttempts:/); + } +}); + test('all release metadata targets version 1.78.0', async () => { const pkg = await readJson('package.json'); const packageLock = await readJson('package-lock.json');