From 424ceea07eab459a23a50b833606b221f493a4df Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 12 Apr 2026 23:41:44 -0700 Subject: [PATCH] Speed up release workflow (#579) * speed up release workflow * fix lint in manifest merge helper * remove release preflight gate * update CI to node 24 --- .github/workflows/pr.yml | 2 +- .github/workflows/release.yml | 108 ++++++- config/scripts/merge-mac-update-manifests.mjs | 271 ++++++++++++++++++ package.json | 1 + 4 files changed, 367 insertions(+), 15 deletions(-) create mode 100644 config/scripts/merge-mac-update-manifests.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 785564a7e..57de67944 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 - name: Setup pnpm uses: pnpm/action-setup@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76a700b3e..da8f6d607 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,12 +25,30 @@ jobs: fail-fast: false matrix: include: - - os: macos-latest + - os: macos-15 platform: mac + arch: arm64 + artifact_name: release-mac-arm64 + build_command: pnpm build:release + package_command: ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --arm64 --publish never + - os: macos-15-intel + platform: mac + arch: x64 + artifact_name: release-mac-x64 + build_command: pnpm build:release + package_command: ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --x64 --publish never - os: windows-latest platform: win + arch: x64 + artifact_name: release-win-x64 + build_command: pnpm build:release + package_command: pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never - os: ubuntu-latest platform: linux + arch: x64 + artifact_name: release-linux-x64 + build_command: pnpm build:release + package_command: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux --publish never runs-on: ${{ matrix.os }} @@ -44,7 +62,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -54,9 +72,15 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Build and release (macOS) + - name: Verify macOS signing environment if: matrix.platform == 'mac' - run: pnpm build:mac:release --publish always + run: node config/scripts/verify-macos-release-env.mjs + + - name: Build app + run: ${{ matrix.build_command }} + + - name: Package release artifacts + run: ${{ matrix.package_command }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CSC_LINK: ${{ secrets.MAC_CERTS }} @@ -65,17 +89,39 @@ jobs: APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - - name: Build and release (Windows) - if: matrix.platform == 'win' - run: pnpm build:win --publish always - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Collect release assets + shell: bash + run: | + set -euo pipefail + mkdir -p release-publish - - name: Build and release (Linux) - if: matrix.platform == 'linux' - run: pnpm build:linux --publish always - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shopt -s nullglob + for pattern in \ + "release/*.dmg" \ + "release/*.zip" \ + "release/*.AppImage" \ + "release/*.deb" \ + "release/*.exe" \ + "release/*.blockmap" \ + "release/latest*.yml"; do + for file in $pattern; do + cp "$file" release-publish/ + done + done + + # Why: each split macOS build emits a same-named latest-mac.yml. We + # keep the arm64 manifest as canonical and rename the Intel one so the + # publish job can merge both manifests before GitHub sees the assets. + if [[ "${{ matrix.platform }}" == "mac" && "${{ matrix.arch }}" == "x64" ]]; then + mv release-publish/latest-mac.yml release-publish/latest-mac-x64.yml + fi + + - name: Upload release assets + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact_name }} + path: release-publish/* + if-no-files-found: error publish-release: needs: release @@ -83,6 +129,40 @@ jobs: permissions: contents: write steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Download release assets + uses: actions/download-artifact@v4 + with: + pattern: release-* + path: release-assets + + - name: Merge macOS updater manifests + run: | + node config/scripts/merge-mac-update-manifests.mjs \ + release-assets/release-mac-arm64/latest-mac.yml \ + release-assets/release-mac-x64/latest-mac-x64.yml + rm release-assets/release-mac-x64/latest-mac-x64.yml + + - name: Upload release assets + shell: bash + run: | + set -euo pipefail + files=() + while IFS= read -r -d '' file; do + files+=("$file") + done < <(find release-assets -type f -print0) + + gh release upload "${{ github.ref_name }}" "${files[@]}" --clobber --repo "${{ github.repository }}" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Publish release run: gh release edit ${{ github.ref_name }} --draft=false --repo ${{ github.repository }} env: diff --git a/config/scripts/merge-mac-update-manifests.mjs b/config/scripts/merge-mac-update-manifests.mjs new file mode 100644 index 000000000..269ebd799 --- /dev/null +++ b/config/scripts/merge-mac-update-manifests.mjs @@ -0,0 +1,271 @@ +#!/usr/bin/env node + +import { readFileSync, writeFileSync } from 'node:fs' + +function stripSingleQuotes(value) { + if (value.startsWith("'") && value.endsWith("'")) { + return value.slice(1, -1).replace(/''/g, "'") + } + return value +} + +function parseScalarValue(rawValue) { + const trimmed = rawValue.trim() + const isQuoted = trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2 + const value = isQuoted ? trimmed.slice(1, -1).replace(/''/g, "'") : trimmed + + if (isQuoted) { + return value + } + if (value === 'true') { + return true + } + if (value === 'false') { + return false + } + if (/^-?\d+(?:\.\d+)?$/.test(value)) { + return Number(value) + } + return value +} + +function finalizeFileRecord(currentFile, sourcePath, lineNumber) { + if (currentFile == null) { + return null + } + + if ( + typeof currentFile.url !== 'string' || + typeof currentFile.sha512 !== 'string' || + typeof currentFile.size !== 'number' + ) { + throw new Error( + `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: incomplete file entry.` + ) + } + + return { + url: currentFile.url, + sha512: currentFile.sha512, + size: currentFile.size + } +} + +function parseMacUpdateManifest(raw, sourcePath) { + const lines = raw.split(/\r?\n/) + const files = [] + const extras = {} + let version = null + let releaseDate = null + let inFiles = false + let currentFile = null + + for (const [index, rawLine] of lines.entries()) { + const lineNumber = index + 1 + const line = rawLine.trimEnd() + if (line.length === 0) { + continue + } + + const fileUrlMatch = line.match(/^ - url:\s*(.+)$/) + if (fileUrlMatch?.[1]) { + const finalized = finalizeFileRecord(currentFile, sourcePath, lineNumber) + if (finalized) { + files.push(finalized) + } + currentFile = { url: stripSingleQuotes(fileUrlMatch[1].trim()) } + inFiles = true + continue + } + + const fileShaMatch = line.match(/^ sha512:\s*(.+)$/) + if (fileShaMatch?.[1]) { + if (currentFile == null) { + throw new Error( + `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: sha512 without a file entry.` + ) + } + currentFile.sha512 = stripSingleQuotes(fileShaMatch[1].trim()) + continue + } + + const fileSizeMatch = line.match(/^ size:\s*(\d+)$/) + if (fileSizeMatch?.[1]) { + if (currentFile == null) { + throw new Error( + `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: size without a file entry.` + ) + } + currentFile.size = Number(fileSizeMatch[1]) + continue + } + + if (line === 'files:') { + inFiles = true + continue + } + + if (inFiles && currentFile != null) { + const finalized = finalizeFileRecord(currentFile, sourcePath, lineNumber) + if (finalized) { + files.push(finalized) + } + currentFile = null + } + inFiles = false + + const topLevelMatch = line.match(/^([A-Za-z][A-Za-z0-9]*):\s*(.+)$/) + if (!topLevelMatch?.[1] || topLevelMatch[2] === undefined) { + throw new Error( + `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: unsupported line '${line}'.` + ) + } + + const [, key, rawValue] = topLevelMatch + const value = parseScalarValue(rawValue) + + if (key === 'version') { + if (typeof value !== 'string') { + throw new Error( + `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: version must be a string.` + ) + } + version = value + continue + } + + if (key === 'releaseDate') { + if (typeof value !== 'string') { + throw new Error( + `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: releaseDate must be a string.` + ) + } + releaseDate = value + continue + } + + if (key === 'path' || key === 'sha512') { + // Why: electron-updater only needs the expanded files[] entries to decide + // which mac ZIP to fetch for the current architecture. We intentionally + // drop the single-file legacy fields because keeping one arch-specific + // path here would make the merged manifest lie about the other arch. + continue + } + + extras[key] = value + } + + const finalized = finalizeFileRecord(currentFile, sourcePath, lines.length) + if (finalized) { + files.push(finalized) + } + + if (!version) { + throw new Error(`Invalid macOS update manifest at ${sourcePath}: missing version.`) + } + if (!releaseDate) { + throw new Error(`Invalid macOS update manifest at ${sourcePath}: missing releaseDate.`) + } + if (files.length === 0) { + throw new Error(`Invalid macOS update manifest at ${sourcePath}: missing files.`) + } + + return { + version, + releaseDate, + files, + extras + } +} + +function mergeExtras(primary, secondary) { + const merged = { ...primary } + + for (const [key, value] of Object.entries(secondary)) { + const existing = merged[key] + if (existing !== undefined && existing !== value) { + throw new Error( + `Cannot merge macOS update manifests: conflicting '${key}' values ('${existing}' vs '${value}').` + ) + } + merged[key] = value + } + + return merged +} + +function mergeMacUpdateManifests(primary, secondary) { + if (primary.version !== secondary.version) { + throw new Error( + `Cannot merge macOS update manifests with different versions (${primary.version} vs ${secondary.version}).` + ) + } + + const filesByUrl = new Map() + for (const file of [...primary.files, ...secondary.files]) { + const existing = filesByUrl.get(file.url) + if (existing && (existing.sha512 !== file.sha512 || existing.size !== file.size)) { + throw new Error( + `Cannot merge macOS update manifests: conflicting file entry for ${file.url}.` + ) + } + filesByUrl.set(file.url, file) + } + + return { + version: primary.version, + releaseDate: + primary.releaseDate >= secondary.releaseDate ? primary.releaseDate : secondary.releaseDate, + files: [...filesByUrl.values()], + extras: mergeExtras(primary.extras, secondary.extras) + } +} + +function quoteYamlString(value) { + return `'${value.replace(/'/g, "''")}'` +} + +function serializeScalarValue(value) { + if (typeof value === 'string') { + return quoteYamlString(value) + } + return String(value) +} + +function serializeMacUpdateManifest(manifest) { + const lines = [`version: ${manifest.version}`, 'files:'] + + for (const file of manifest.files) { + lines.push(` - url: ${file.url}`) + lines.push(` sha512: ${file.sha512}`) + lines.push(` size: ${file.size}`) + } + + for (const [key, value] of Object.entries(manifest.extras)) { + lines.push(`${key}: ${serializeScalarValue(value)}`) + } + + lines.push(`releaseDate: ${quoteYamlString(manifest.releaseDate)}`) + lines.push('') + + return lines.join('\n') +} + +function main() { + const [primaryPath, secondaryPath, outputPath = primaryPath] = process.argv.slice(2) + + if (!primaryPath || !secondaryPath) { + console.error( + 'Usage: node config/scripts/merge-mac-update-manifests.mjs [output-path]' + ) + process.exit(1) + } + + const primary = parseMacUpdateManifest(readFileSync(primaryPath, 'utf8'), primaryPath) + const secondary = parseMacUpdateManifest(readFileSync(secondaryPath, 'utf8'), secondaryPath) + const merged = mergeMacUpdateManifests(primary, secondary) + + writeFileSync(outputPath, serializeMacUpdateManifest(merged)) +} + +main() diff --git a/package.json b/package.json index 362e7e218..a49124833 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false", "build:electron-vite": "node config/scripts/run-electron-vite-build.mjs", "build": "pnpm run typecheck && pnpm run build:electron-vite && pnpm run build:cli", + "build:release": "pnpm run build:electron-vite && pnpm run build:cli", "postinstall": "pnpm rebuild electron && electron-builder install-app-deps", "build:unpack": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --dir", "build:win": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --win",