From 651d9191462293dd6a83fba3ac5c649da264126f Mon Sep 17 00:00:00 2001 From: elkaix Date: Thu, 10 Sep 2026 12:12:39 -0400 Subject: [PATCH] fix: finalize the update manifest the desktop release channel produces The macOS finalize step always read latest-mac.yml, but electron-builder names the manifest after the release channel, so a Nightly build writes nightly-mac.yml. Every Nightly run failed with ENOENT after notarization had already succeeded, leaving the channel unpublished. Pass the channel manifest the prepare job already resolves. --- .github/workflows/desktop-release.yml | 6 +++-- .../desktop/scripts/finalize-mac-artifacts.ts | 18 +++++++++---- .../tests/finalize-mac-artifacts.spec.ts | 26 +++++++++++++++++++ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 55b532d84..79c968933 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -245,11 +245,13 @@ jobs: # electron-builder notarizes and staples the .app but never the disk # image around it; a quarantined, unnotarized DMG is what Gatekeeper # reports as "damaged". Stapling changes the DMG bytes, so the same - # script also rewrites its latest-mac.yml entry and drops the stale + # script also rewrites the channel's update manifest and drops the stale # blockmap, keeping the update manifest verification below honest. - name: Notarize and staple macOS DMG working-directory: apps/desktop - run: node --import tsx scripts/finalize-mac-artifacts.ts dist + env: + UPDATE_MANIFEST: ${{ needs.prepare.outputs.mac_manifest }} + run: node --import tsx scripts/finalize-mac-artifacts.ts dist "$UPDATE_MANIFEST" - name: Verify macOS update artifacts shell: bash diff --git a/apps/desktop/scripts/finalize-mac-artifacts.ts b/apps/desktop/scripts/finalize-mac-artifacts.ts index 634dd2ea2..8acdccf6c 100644 --- a/apps/desktop/scripts/finalize-mac-artifacts.ts +++ b/apps/desktop/scripts/finalize-mac-artifacts.ts @@ -26,9 +26,14 @@ export interface FinalizeMacArtifactsOptions { readonly distDir: string readonly env: NodeJS.ProcessEnv readonly log?: (message: string) => void + // electron-builder names the manifest after the release channel, so only the + // stable channel produces latest-mac.yml. + readonly manifestName?: string readonly runCommand?: CommandRunner } +const DEFAULT_MAC_MANIFEST = 'latest-mac.yml' + function requiredValue(env: NodeJS.ProcessEnv, name: string): string { return env[name]!.trim() } @@ -72,6 +77,7 @@ export function rewriteLatestMacYaml( filename: string, sha512: string, size: number, + manifestName: string = DEFAULT_MAC_MANIFEST, ): string { const lines = yaml.split('\n') let fileEntryIndent: number | undefined @@ -126,7 +132,7 @@ export function rewriteLatestMacYaml( } if (checksumUpdates === 0 || sizeUpdates === 0) { - throw new Error(`latest-mac.yml does not contain complete metadata for ${filename}`) + throw new Error(`${manifestName} does not contain complete metadata for ${filename}`) } return lines.join('\n') } @@ -147,7 +153,8 @@ export function finalizeMacArtifacts(options: FinalizeMacArtifactsOptions): void .sort() if (dmgs.length === 0) throw new Error(`No DMG artifacts found in ${options.distDir}`) - const metadataPath = join(options.distDir, 'latest-mac.yml') + const manifestName = options.manifestName ?? DEFAULT_MAC_MANIFEST + const metadataPath = join(options.distDir, manifestName) let metadata = readFileSync(metadataPath, 'utf8') const credentialArgs = buildNotarytoolArguments(options.env) @@ -181,7 +188,7 @@ export function finalizeMacArtifacts(options: FinalizeMacArtifactsOptions): void const size = statSync(dmgPath).size const sha512 = createHash('sha512').update(readFileSync(dmgPath)).digest('base64') - metadata = rewriteLatestMacYaml(metadata, filename, sha512, size) + metadata = rewriteLatestMacYaml(metadata, filename, sha512, size, manifestName) const blockmapPath = `${dmgPath}.blockmap` if (existsSync(blockmapPath)) { @@ -196,9 +203,10 @@ export function finalizeMacArtifacts(options: FinalizeMacArtifactsOptions): void const invokedPath = process.argv[1] if (invokedPath !== undefined && resolve(invokedPath) === fileURLToPath(import.meta.url)) { const distDir = process.argv[2] + const manifestName = process.argv[3] try { - if (distDir === undefined) throw new Error('Usage: finalize-mac-artifacts.ts ') - finalizeMacArtifacts({ distDir: resolve(distDir), env: process.env }) + if (distDir === undefined) throw new Error('Usage: finalize-mac-artifacts.ts [manifest-name]') + finalizeMacArtifacts({ distDir: resolve(distDir), env: process.env, manifestName }) } catch (error) { console.error(error instanceof Error ? error.message : String(error)) process.exitCode = 1 diff --git a/apps/desktop/tests/finalize-mac-artifacts.spec.ts b/apps/desktop/tests/finalize-mac-artifacts.spec.ts index 8d6194c2c..cb24ebbac 100644 --- a/apps/desktop/tests/finalize-mac-artifacts.spec.ts +++ b/apps/desktop/tests/finalize-mac-artifacts.spec.ts @@ -132,6 +132,32 @@ sha512: old expect(runCommand).toHaveBeenNthCalledWith(2, 'xcrun', ['stapler', 'staple', join(distDir, filename)]) }) + it('finalizes the channel manifest a prerelease build actually produces', () => { + const distDir = mkdtempSync(join(tmpdir(), 'pythinker-mac-artifacts-')) + directories.push(distDir) + const filename = 'Pythinker-0.11.1-nightly.304-arm64.dmg' + const dmg = Buffer.from('nightly dmg fixture') + writeFileSync(join(distDir, filename), dmg) + writeFileSync(join(distDir, 'nightly-mac.yml'), `files: + - url: ${filename} + sha512: old + size: 1 +path: ${filename} +sha512: old +`) + + finalizeMacArtifacts({ + distDir, + env: { APPLE_KEYCHAIN_PROFILE: 'pythinker-notary' }, + log: () => {}, + manifestName: 'nightly-mac.yml', + runCommand: () => ({ status: 0, stderr: '', stdout: '{"status":"Accepted"}' }), + }) + + const checksum = createHash('sha512').update(dmg).digest('base64') + expect(readFileSync(join(distDir, 'nightly-mac.yml'), 'utf8')).toContain(`sha512: ${checksum}`) + }) + it('prints and rejects a non-accepted notarytool result before stapling', () => { const distDir = mkdtempSync(join(tmpdir(), 'pythinker-mac-artifacts-')) directories.push(distDir)