Skip to content
Merged
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
25 changes: 21 additions & 4 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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

Expand Down
25 changes: 21 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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

Expand Down
103 changes: 103 additions & 0 deletions open-pdf-studio/scripts/macos-notarization-retry.mjs
Original file line number Diff line number Diff line change
@@ -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;
});
}
81 changes: 81 additions & 0 deletions open-pdf-studio/scripts/release-config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down