diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index 260a1250dda..c7cb51e5e67 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -36,15 +36,33 @@ on: - '.github/actions/install-node-dependencies/**' - '.github/workflows/node-server-tests.yml' workflow_dispatch: + inputs: + build_template: + description: Also merge every lane's slot into the desktop orcad template artifact + type: boolean + default: false + # Release packaging calls this to build the orcad template it ships (design D2). + workflow_call: + inputs: + ref: + description: Git ref every lane checks out, e.g. the release tag + type: string + default: '' + build_template: + description: Upload each lane's release slot and merge them into the orcad-template artifact + type: boolean + default: false schedule: - cron: '30 11 * * *' permissions: contents: read +# Why a run-scoped group for template builds: a release call shares github.ref with main's push +# runs, and cancelling either would drop a release's template or a main qualification. concurrency: - group: node-server-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: node-server-${{ inputs.build_template && format('template-{0}', github.run_id) || github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ !inputs.build_template }} jobs: changes: @@ -91,6 +109,7 @@ jobs: steps: - uses: actions/checkout@v6 with: + ref: ${{ inputs.ref }} persist-credentials: false - uses: ./.github/actions/install-node-dependencies with: @@ -125,6 +144,24 @@ jobs: echo "ORCA_BUN_ORCAD_SLOT=$RUNNER_TEMP/bun-orcad" >> "$GITHUB_ENV" echo "BUN_EXECUTABLE=$(command -v bun)" >> "$GITHUB_ENV" - run: pnpm test:node-server --artifact ${{ runner.os == 'Linux' && '--cross-runtime' || '' }} + # Only a Windows runner compiles it; arm64 cross-compiles here, as release-cut does for the relay. + # Before the Node 18 check below: the build script imports TypeScript, which Node 18 cannot load. + - name: Build the Windows process-table addons for the desktop template + if: inputs.build_template && matrix.os == 'windows-2022' + shell: bash + run: | + node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=x64 + node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=arm64 + - name: Keep the Windows process-table addons for the desktop template + if: inputs.build_template && matrix.os == 'windows-2022' + uses: actions/upload-artifact@v7 + with: + name: orcad-windows-process-tree + path: .build/windows-process-tree/ + include-hidden-files: true + if-no-files-found: error + retention-days: 7 + overwrite: true - uses: actions/setup-node@v6 if: runner.arch == 'X64' with: @@ -136,6 +173,17 @@ jobs: node out/orcad/orcad.js --orcad-smoke-load-check node out/orcad/orcad.js --orcad-profile-state-preflight 00000000-0000-4000-8000-000000000018 | tee "$RUNNER_TEMP/preflight.json" node -e "const r=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));if(r.runtime!=='node'||!/^24\./.test(r.runtimeVersion))process.exit(1)" "$RUNNER_TEMP/preflight.json" + # Linux release slots come from the floor and Alpine lanes; these runners own the rest. + - name: Keep this runner's qualified slot for the desktop template + if: inputs.build_template && runner.os != 'Linux' + uses: actions/upload-artifact@v7 + with: + name: orcad-prebuild-${{ matrix.os }} + path: out/orcad-prebuilds/ + if-no-files-found: error + retention-days: 7 + # A rerun attempt re-uploads under the same name, which v4 otherwise refuses. + overwrite: true linux_glibc_floor: needs: [changes, persistence] @@ -170,6 +218,7 @@ jobs: run: dnf install -y git procps-ng unzip which xz - uses: actions/checkout@v6 with: + ref: ${{ inputs.ref }} persist-credentials: false - name: Trust the checked-out workspace run: git config --global --add safe.directory "$GITHUB_WORKSPACE" @@ -181,6 +230,15 @@ jobs: pnpm build:orcad-prebuilds --smoke - run: pnpm build:orcad - run: pnpm test:node-server --artifact + - name: Keep this runner's glibc 2.28 slot for the desktop template + if: inputs.build_template + uses: actions/upload-artifact@v7 + with: + name: orcad-prebuild-glibc-${{ matrix.os }} + path: out/orcad-prebuilds/ + if-no-files-found: error + retention-days: 7 + overwrite: true linux_glibc217_compat: needs: [changes, persistence] @@ -196,6 +254,7 @@ jobs: steps: - uses: actions/checkout@v6 with: + ref: ${{ inputs.ref }} persist-credentials: false - uses: ./.github/actions/install-node-dependencies # Design D6 rung B: the opt-in linux-x64-glibc217 slot beside the unofficial glibc-217 Node. @@ -223,6 +282,15 @@ jobs: # The image's devtoolset LD_LIBRARY_PATH must not stand in for a host C++ runtime. env -u LD_LIBRARY_PATH node config/scripts/build-orcad-prebuilds.mjs --slot=linux-x64-glibc217 --smoke GLIBC217_COMPAT_SLOT + - name: Keep the glibc 2.17 compat slot for the desktop template + if: inputs.build_template + uses: actions/upload-artifact@v7 + with: + name: orcad-prebuild-glibc217 + path: out/orcad-prebuilds/ + if-no-files-found: error + retention-days: 7 + overwrite: true linux_musl: needs: [changes, persistence] @@ -242,6 +310,7 @@ jobs: steps: - uses: actions/checkout@v6 with: + ref: ${{ inputs.ref }} persist-credentials: false - name: Verify native Alpine artifact and persistence run: | @@ -261,3 +330,64 @@ jobs: pnpm build:orcad pnpm test:node-server --artifact NODE_SERVER_QUALIFICATION + - name: Keep this runner's musl slot for the desktop template + if: inputs.build_template + uses: actions/upload-artifact@v7 + with: + name: orcad-prebuild-musl-${{ matrix.os }} + path: out/orcad-prebuilds/ + if-no-files-found: error + retention-days: 7 + overwrite: true + + # Design D2: the desktop ships every target's addons, merged from the lanes that qualified them. + desktop_template: + needs: [persistence, linux_glibc_floor, linux_glibc217_compat, linux_musl] + if: >- + ${{ !cancelled() && inputs.build_template && + needs.persistence.result == 'success' && needs.linux_glibc_floor.result == 'success' && + needs.linux_glibc217_compat.result == 'success' && needs.linux_musl.result == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + - name: Collect every lane's slot + uses: actions/download-artifact@v8 + with: + pattern: orcad-prebuild-* + path: ${{ runner.temp }}/orcad-prebuild-lanes + - name: Collect the Windows process-table addons + uses: actions/download-artifact@v8 + with: + name: orcad-windows-process-tree + path: .build/windows-process-tree + - name: Merge the lanes and gate the full slot matrix + shell: bash + run: | + node config/scripts/merge-orcad-prebuilds.mjs "$RUNNER_TEMP"/orcad-prebuild-lanes/* + pnpm build:orcad-prebuilds --require-slots + pnpm build:orcad-prebuilds --require-slots linux-x64-glibc217 + - run: pnpm build:orcad-template + - name: Report the template size + shell: bash + run: | + { + echo '### orcad template' + echo '```' + du -sh out/orcad-template + du -sh out/orcad-template/targets/* + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@v7 + with: + name: orcad-template + path: out/orcad-template/ + # The per-target .server-target and .runtime-node markers are dotfiles. + include-hidden-files: true + if-no-files-found: error + retention-days: 7 + overwrite: true diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 39684a5a53a..b8315436d5e 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1149,6 +1149,19 @@ jobs: retention-days: 7 if-no-files-found: ignore + # Design D2: every desktop build ships the orcad template (server JS plus every target's + # addons), merged from the node-server lanes that qualified each slot at this tag. It needs no + # signing quota, so it runs beside the release gates instead of behind them. + orcad-template: + needs: cut + if: needs.cut.outputs.should_release == 'true' + permissions: + contents: read + uses: ./.github/workflows/node-server-tests.yml + with: + ref: refs/tags/${{ needs.cut.outputs.tag }} + build_template: true + # Why: artifact jobs submit Windows binaries to SignPath. Keep every # quota-consuming build behind all blocking release gates so a late test # failure cannot create signing requests that can never be published. @@ -1175,8 +1188,12 @@ jobs: needs: - cut - create-release + - orcad-template - release-preflight if: needs.cut.outputs.should_release == 'true' + env: + # beforePack and afterPack fail the package when the template is absent. + ORCA_REQUIRE_ORCAD_TEMPLATE: '1' strategy: fail-fast: false matrix: @@ -1435,6 +1452,13 @@ jobs: # the PowerShell scan on every Windows SSH host. ORCA_REQUIRE_RELAY_NATIVE_ADDONS: ${{ matrix.platform == 'win' && 'x64,arm64' || '' }} + # After the app build so nothing that cleans out/ can drop it; electron-builder ships it. + - name: Download the orcad deployment template + uses: actions/download-artifact@v8 + with: + name: orcad-template + path: out/orcad-template + - name: Gate runtime file-watcher process isolation if: runner.os == 'Linux' run: | @@ -1584,6 +1608,11 @@ jobs: Where-Object { $_.Extension -in '.exe', '.dll', '.node' } | ForEach-Object { $relative = [System.IO.Path]::GetRelativePath($root, $_.FullName) + # The orcad template's Linux/macOS addons are data for SSH hosts, not PE files. + if ($relative -match '^resources[\\/]orcad-template[\\/]targets[\\/](?!win32-)') { + $skipped.Add("$relative ") + return + } $signature = Get-AuthenticodeSignature -FilePath $_.FullName if ($signature.Status -eq 'Valid') { $skipped.Add("$relative ") @@ -1764,6 +1793,13 @@ jobs: throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)." } + # Why: SignPath rewrote the template's Windows binaries, and the client materializer checks + # each file against the template manifest, so it must record the signed bytes. + - name: Reseal the orcad template over its signed binaries + id: reseal-orcad-template + if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success' + run: node config/scripts/packaged-orcad-template.cjs --reseal-signed dist/win-unpacked inner-signing-list.txt + # The uninstaller must return signed before rebuilding the installer. - name: Restore signed uninstaller for the installer rebuild id: restore-signed-uninstaller @@ -2257,6 +2293,7 @@ jobs: needs: - cut - create-release + - orcad-template - release-preflight if: needs.cut.outputs.should_release == 'true' # Why: SignPath requires every job in this signing workflow to be diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index 7f0149fc74e..6cd660a0018 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -15,6 +15,8 @@ on: type: string permissions: + # actions: read downloads the orcad template the parent release-cut run built. + actions: read contents: write concurrency: @@ -137,6 +139,15 @@ jobs: ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }} + # Design D2: the parent release-cut run merged it from every node-server lane at this tag. + - name: Download the orcad deployment template from the release run + uses: actions/download-artifact@v8 + with: + name: orcad-template + path: out/orcad-template + run-id: ${{ inputs.release_run_id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Gate runtime file-watcher process isolation run: | # Why: #8212 is a native-process crash contract. Prove both the Node @@ -174,6 +185,7 @@ jobs: command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always -c.publish.releaseType=draft env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ORCA_REQUIRE_ORCAD_TEMPLATE: '1' CSC_LINK: ${{ secrets.MAC_CERTS }} CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} APPLE_ID: ${{ secrets.APPLE_ID }} diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 7117e64df6d..86bf988bff9 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -28,6 +28,13 @@ const { const { verifyPackagedWindowsNodePty } = require('./scripts/verify-packaged-node-pty-job-ownership.cjs') +const { + assertOrcadTemplateBuilt, + finalizePackagedOrcadTemplate, + orcadTemplateExtraResource, + orcadTemplateNodeModulesExtraResource, + orcadTemplateMacSignIgnore +} = require('./scripts/packaged-orcad-template.cjs') const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs') const { verifyStaticAppImagePackage } = require('./scripts/static-appimage-package-contract.cjs') const { signWindowsUninstallerViaSignPath } = require('./scripts/windows-uninstaller-signing.cjs') @@ -111,6 +118,8 @@ const emojiShortcodeDatasetResource = { } const commonExtraResources = [ relayExtraResource, + orcadTemplateExtraResource, + orcadTemplateNodeModulesExtraResource, ...bundledRipgrepExtraResources, bundledPluginResources, skillFreshnessResources, @@ -189,7 +198,7 @@ module.exports = { // extraResources. Shipping them in app.asar bloats the desktop bundle. '!src{,/**/*}', '!out/orcad{,/**/*}', - // Template, node-pty prebuilds and their work dirs: headless build outputs, not desktop code. + // Never in app.asar: the template ships via orcadTemplateExtraResource; prebuilds are build inputs. '!out/orcad-*{,/**/*}', '!out/.orcad-*{,/**/*}', // Why: the pinned Node a local orcad build references (~120 MB) and its download cache. @@ -322,6 +331,7 @@ module.exports = { beforePack: (context, mobileWebBundleDir = MOBILE_WEB_BUNDLE_DIR) => { assertPackagedNativeVariantsInstalled(context.electronPlatformName, context.arch) assertBundledRipgrepInstalled() + assertOrcadTemplateBuilt() assertMobileWebBundleBuilt(mobileWebBundleDir) }, afterPack: async (context) => { @@ -410,6 +420,11 @@ module.exports = { // mapping fails packaging before bundled content reaches users. verifyPackagedPluginResources(resourcesDir) finalizePackagedRipgrep(resourcesDir) + await finalizePackagedOrcadTemplate(resourcesDir, { + platform: context.electronPlatformName, + signMacBinary: (path) => + signMacStandaloneHelper(path, 'orcad template binary', context.packager) + }) chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName) for (const filename of readdirSync(resourcesDir)) { if (!filename.startsWith('agent-browser-')) { @@ -507,7 +522,7 @@ module.exports = { icon: 'resources/build/icon.icns', entitlements: 'resources/build/entitlements.mac.plist', entitlementsInherit: 'resources/build/entitlements.mac.plist', - signIgnore: bundledRipgrepMacSignIgnore, + signIgnore: [...bundledRipgrepMacSignIgnore, ...orcadTemplateMacSignIgnore], extendInfo: { NSAppleEventsUsageDescription: 'Orca allows terminal-launched developer tools to automate local apps when you request it.', diff --git a/config/scripts/build-orcad-template.mjs b/config/scripts/build-orcad-template.mjs index 26ed22c1065..d2ab24d6ef3 100644 --- a/config/scripts/build-orcad-template.mjs +++ b/config/scripts/build-orcad-template.mjs @@ -56,6 +56,9 @@ function buildTargetPackage(target) { packageDir ], cwd: root, + // Why no agent-browser: the template ships inside every desktop build (design D2), and seven + // ~10 MB browsers would outweigh everything else in it; a slot without one reports no browser. + env: { ...process.env, ORCAD_OMIT_AGENT_BROWSER: '1' }, stdio: 'inherit', timeoutMs: null }) diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index cbfb85e4733..2bd9933d33a 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -155,7 +155,8 @@ copyFileSync( createRequire(import.meta.url).resolve('emojibase-data/en/shortcodes/emojibase.json'), emojiDatasetOutput ) -if (existsSync(AGENT_BROWSER_SOURCE)) { +// The desktop template omits it: ~10 MB per target, and orcad already treats it as optional. +if (existsSync(AGENT_BROWSER_SOURCE) && process.env.ORCAD_OMIT_AGENT_BROWSER !== '1') { copyFileSync(AGENT_BROWSER_SOURCE, AGENT_BROWSER_OUTPUT) if (!targetIsWindows) { chmodSync(AGENT_BROWSER_OUTPUT, 0o755) diff --git a/config/scripts/merge-orcad-prebuilds.mjs b/config/scripts/merge-orcad-prebuilds.mjs new file mode 100644 index 00000000000..3234966d87d --- /dev/null +++ b/config/scripts/merge-orcad-prebuilds.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +/** + * Merge per-runner `out/orcad-prebuilds` trees into one matrix, as release CI collects them. + * Each CI lane builds only its own slot (build-orcad-prebuilds.mjs), so the desktop template + * build needs their union before `--require-slots` can pass. + * + * Usage: node config/scripts/merge-orcad-prebuilds.mjs [--out ] [ ...] + */ +import { cpSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import process from 'node:process' +import { findSlotProblems, mergeManifest, readManifest } from './orcad-prebuild-slot-contents.mjs' + +const ROOT = resolve(import.meta.dirname, '..', '..') + +/** Copies every verified slot from `sourceDirs` into a fresh `outDir`; returns the merged manifest. */ +export function mergeOrcadPrebuildTrees(sourceDirs, outDir) { + if (sourceDirs.length === 0) { + throw new Error('[merge-orcad-prebuilds] no prebuild trees to merge') + } + if (sourceDirs.some((dir) => resolve(dir) === resolve(outDir))) { + throw new Error(`[merge-orcad-prebuilds] ${outDir} is both a source and the output`) + } + const sources = sourceDirs.map((dir) => { + const manifest = readManifest(dir) + const slots = Object.keys(manifest?.slots ?? {}) + if (slots.length === 0) { + throw new Error(`[merge-orcad-prebuilds] ${dir} holds no prebuild slot manifest`) + } + const problems = findSlotProblems(manifest, dir, slots) + if (problems.length > 0) { + throw new Error(`[merge-orcad-prebuilds] ${dir}: ${problems.join('; ')}`) + } + return { dir, manifest, slots } + }) + // Why before any copy: a refused merge must not leave a half-built matrix behind. + const owners = new Map() + for (const { dir, manifest, slots } of sources) { + if (manifest.nodeHeaders !== sources[0].manifest.nodeHeaders) { + throw new Error( + `[merge-orcad-prebuilds] ${dir} was built against Node ${manifest.nodeHeaders} headers, ` + + `${sources[0].dir} against ${sources[0].manifest.nodeHeaders}` + ) + } + for (const slot of slots) { + if (owners.has(slot)) { + throw new Error( + `[merge-orcad-prebuilds] ${slot} appears in both ${owners.get(slot)} and ${dir}` + ) + } + owners.set(slot, dir) + } + } + + rmSync(outDir, { recursive: true, force: true }) + mkdirSync(outDir, { recursive: true }) + let merged = null + for (const { dir, manifest, slots } of sources) { + for (const slot of slots) { + cpSync(join(dir, slot), join(outDir, slot), { recursive: true }) + merged = mergeManifest(merged, { + slot, + version: manifest.version, + napi: manifest.napi, + nodeHeaders: manifest.nodeHeaders, + entry: manifest.slots[slot] + }) + } + } + writeFileSync(join(outDir, 'manifest.json'), `${JSON.stringify(merged, null, 2)}\n`) + const problems = findSlotProblems(merged, outDir, [...owners.keys()]) + if (problems.length > 0) { + throw new Error(`[merge-orcad-prebuilds] merged matrix: ${problems.join('; ')}`) + } + return merged +} + +function parseArgs(argv) { + const sources = [] + let outDir = join(ROOT, 'out', 'orcad-prebuilds') + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] === '--out') { + const value = argv[(index += 1)] + if (!value) { + throw new Error('[merge-orcad-prebuilds] --out needs a directory') + } + outDir = resolve(value) + } else { + sources.push(resolve(argv[index])) + } + } + return { sources, outDir } +} + +if (process.argv[1]?.endsWith('merge-orcad-prebuilds.mjs')) { + const { sources, outDir } = parseArgs(process.argv.slice(2)) + const merged = mergeOrcadPrebuildTrees(sources, outDir) + console.log( + `[merge-orcad-prebuilds] ${outDir}: node-pty ${merged.version}, N-API ${merged.napi}, ` + + `slots ${Object.keys(merged.slots).join(', ')}` + ) +} diff --git a/config/scripts/merge-orcad-prebuilds.test.mjs b/config/scripts/merge-orcad-prebuilds.test.mjs new file mode 100644 index 00000000000..430cf1bca1f --- /dev/null +++ b/config/scripts/merge-orcad-prebuilds.test.mjs @@ -0,0 +1,95 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { mergeOrcadPrebuildTrees } from './merge-orcad-prebuilds.mjs' +import { findSlotProblems, mergeManifest, sha256Of } from './orcad-prebuild-slot-contents.mjs' + +const dirs = [] +function temp() { + const dir = mkdtempSync(join(tmpdir(), 'orcad-prebuild-merge-')) + dirs.push(dir) + return dir +} +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +/** One CI lane's `out/orcad-prebuilds`: a single slot plus its manifest. */ +function laneTree(slot, { version = '1.1.0', nodeHeaders = '24.21.0', bytes = slot } = {}) { + const dir = temp() + mkdirSync(join(dir, slot), { recursive: true }) + const binary = join(dir, slot, 'pty.node') + writeFileSync(binary, bytes) + const manifest = mergeManifest(null, { + slot, + version, + napi: 8, + nodeHeaders, + entry: { napi: 8, files: { 'pty.node': sha256Of(binary) } } + }) + writeFileSync(join(dir, 'manifest.json'), JSON.stringify(manifest)) + return dir +} + +describe('mergeOrcadPrebuildTrees', () => { + it('unions one slot per lane into a matrix that --require-slots accepts', () => { + const out = join(temp(), 'orcad-prebuilds') + const merged = mergeOrcadPrebuildTrees( + [laneTree('darwin-arm64'), laneTree('linux-x64-musl'), laneTree('linux-x64-glibc217')], + out + ) + + expect(Object.keys(merged.slots)).toEqual([ + 'darwin-arm64', + 'linux-x64-glibc217', + 'linux-x64-musl' + ]) + const written = JSON.parse(readFileSync(join(out, 'manifest.json'), 'utf8')) + expect(findSlotProblems(written, out, Object.keys(merged.slots))).toEqual([]) + }) + + it('refuses a lane whose files no longer match its own manifest', () => { + const lane = laneTree('win32-x64') + writeFileSync(join(lane, 'win32-x64', 'pty.node'), 'tampered') + + expect(() => mergeOrcadPrebuildTrees([lane], join(temp(), 'out'))).toThrow( + 'win32-x64/pty.node: sha256 does not match the manifest' + ) + }) + + it('refuses the same slot from two lanes instead of letting the last one win', () => { + const out = join(temp(), 'out') + expect(() => + mergeOrcadPrebuildTrees( + [laneTree('linux-x64-glibc'), laneTree('linux-x64-glibc', { bytes: 'other' })], + out + ) + ).toThrow('linux-x64-glibc appears in both') + }) + + it('refuses lanes built against different node-pty or Node headers', () => { + expect(() => + mergeOrcadPrebuildTrees( + [laneTree('darwin-x64'), laneTree('darwin-arm64', { version: '1.2.0' })], + join(temp(), 'out') + ) + ).toThrow('refusing to merge node-pty 1.2.0') + expect(() => + mergeOrcadPrebuildTrees( + [laneTree('darwin-x64'), laneTree('darwin-arm64', { nodeHeaders: '24.20.0' })], + join(temp(), 'out') + ) + ).toThrow('Node 24.20.0 headers') + }) + + it('refuses an empty lane and an output that is also a source', () => { + expect(() => mergeOrcadPrebuildTrees([temp()], join(temp(), 'out'))).toThrow( + 'holds no prebuild slot manifest' + ) + const lane = laneTree('darwin-x64') + expect(() => mergeOrcadPrebuildTrees([lane], lane)).toThrow('both a source and the output') + }) +}) diff --git a/config/scripts/orcad-template-release-workflow.test.mjs b/config/scripts/orcad-template-release-workflow.test.mjs new file mode 100644 index 00000000000..ced4e6bd629 --- /dev/null +++ b/config/scripts/orcad-template-release-workflow.test.mjs @@ -0,0 +1,133 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +function readWorkflow(name) { + return parse(readFileSync(new URL(`../../.github/workflows/${name}`, import.meta.url), 'utf8')) +} + +const LANES = ['persistence', 'linux_glibc_floor', 'linux_glibc217_compat', 'linux_musl'] + +function stepIndex(steps, predicate) { + const index = steps.findIndex(predicate) + expect(index).toBeGreaterThanOrEqual(0) + return index +} + +describe('orcad template release wiring (design D2)', () => { + const nodeServer = readWorkflow('node-server-tests.yml') + const releaseCut = readWorkflow('release-cut.yml') + const releaseMac = readWorkflow('release-mac-build.yml') + + it('builds the template from the slots the node-server lanes qualified at the release ref', () => { + expect(nodeServer.on.workflow_call.inputs).toMatchObject({ + ref: { type: 'string' }, + build_template: { type: 'boolean', default: false } + }) + // A release call shares github.ref with main's push runs; neither may cancel the other. + expect(nodeServer.concurrency['cancel-in-progress']).toBe('${{ !inputs.build_template }}') + expect(nodeServer.concurrency.group).toContain('github.run_id') + for (const lane of LANES) { + const steps = nodeServer.jobs[lane].steps + const checkout = steps.find((step) => step.uses === 'actions/checkout@v6') + expect(checkout.with.ref).toBe('${{ inputs.ref }}') + const upload = steps.find( + (step) => + step.uses === 'actions/upload-artifact@v7' && + String(step.with.name).startsWith('orcad-prebuild-') + ) + expect(upload.if).toContain('inputs.build_template') + expect(upload.with.path).toBe('out/orcad-prebuilds/') + // A rerun of a flaky lane must be able to replace its earlier attempt's slot. + expect(upload.with.overwrite).toBe(true) + // Only qualified slots: the upload follows the lane's own gates and tests. + const gates = steps.filter((step) => /require-slots|test:node-server/.test(step.run ?? '')) + expect(gates.length).toBeGreaterThan(0) + for (const gate of gates) { + expect(steps.indexOf(upload)).toBeGreaterThan(steps.indexOf(gate)) + } + } + + // The addon build script imports TypeScript, which the lane's later Node 18 check cannot load. + const persistence = nodeServer.jobs.persistence.steps + const addons = stepIndex( + persistence, + (step) => step.name === 'Build the Windows process-table addons for the desktop template' + ) + const node18 = stepIndex(persistence, (step) => step.with?.['node-version'] === '18') + expect(addons).toBeLessThan(node18) + + const template = nodeServer.jobs.desktop_template + expect(template.needs).toEqual(LANES) + for (const lane of LANES) { + expect(template.if).toContain(`needs.${lane}.result == 'success'`) + } + const run = template.steps.map((step) => step.run ?? '').join('\n') + expect(run).toContain('merge-orcad-prebuilds.mjs "$RUNNER_TEMP"/orcad-prebuild-lanes/*') + expect(run).toContain('pnpm build:orcad-prebuilds --require-slots\n') + expect(run).toContain('pnpm build:orcad-prebuilds --require-slots linux-x64-glibc217') + expect(run).toContain('pnpm build:orcad-template') + const upload = template.steps.find((step) => step.uses === 'actions/upload-artifact@v7') + expect(upload.with).toMatchObject({ + name: 'orcad-template', + path: 'out/orcad-template/', + 'include-hidden-files': true, + overwrite: true + }) + }) + + it('makes every desktop release package wait for, download and require the template', () => { + const job = releaseCut.jobs['orcad-template'] + expect(job.uses).toBe('./.github/workflows/node-server-tests.yml') + expect(job.with).toEqual({ + ref: 'refs/tags/${{ needs.cut.outputs.tag }}', + build_template: true + }) + for (const name of ['build', 'build-mac']) { + expect(releaseCut.jobs[name].needs).toContain('orcad-template') + } + const build = releaseCut.jobs.build + expect(build.env.ORCA_REQUIRE_ORCAD_TEMPLATE).toBe('1') + const download = stepIndex( + build.steps, + (step) => step.name === 'Download the orcad deployment template' + ) + expect(build.steps[download].with).toEqual({ + name: 'orcad-template', + path: 'out/orcad-template' + }) + const packaging = build.steps.filter((step) => + /electron-builder|release_command/.test(`${step.run ?? ''}${step.with?.command ?? ''}`) + ) + expect(packaging.length).toBeGreaterThan(0) + for (const step of packaging) { + expect(build.steps.indexOf(step)).toBeGreaterThan(download) + } + + const macSteps = releaseMac.jobs['build-mac'].steps + const macDownload = stepIndex(macSteps, (step) => step.uses === 'actions/download-artifact@v8') + expect(macSteps[macDownload].with).toMatchObject({ + name: 'orcad-template', + path: 'out/orcad-template', + 'run-id': '${{ inputs.release_run_id }}' + }) + const publish = stepIndex(macSteps, (step) => step.name === 'Publish release artifacts (macOS)') + expect(publish).toBeGreaterThan(macDownload) + expect(macSteps[publish].env.ORCA_REQUIRE_ORCAD_TEMPLATE).toBe('1') + expect(releaseMac.permissions.actions).toBe('read') + }) + + it('signs only Windows template binaries and reseals the manifest before the installer rebuild', () => { + const steps = releaseCut.jobs.build.steps + const stage = steps.find((step) => step.id === 'stage-inner') + expect(stage.run).toContain("orcad-template[\\\\/]targets[\\\\/](?!win32-)')") + const restore = stepIndex(steps, (step) => step.id === 'restore-signed-inner') + const reseal = stepIndex(steps, (step) => step.id === 'reseal-orcad-template') + const rebuild = stepIndex(steps, (step) => step.id === 'rebuild-nsis-signed') + expect(restore).toBeLessThan(reseal) + expect(reseal).toBeLessThan(rebuild) + expect(steps[reseal].run).toBe( + 'node config/scripts/packaged-orcad-template.cjs --reseal-signed dist/win-unpacked inner-signing-list.txt' + ) + }) +}) diff --git a/config/scripts/packaged-orcad-template.cjs b/config/scripts/packaged-orcad-template.cjs new file mode 100644 index 00000000000..5ec3b3cf585 --- /dev/null +++ b/config/scripts/packaged-orcad-template.cjs @@ -0,0 +1,183 @@ +/** + * The orcad deployment template inside desktop builds (design D2): JS plus every target's + * addons, never a Node runtime. SSH relays and managed orcad deploys materialize a target's + * slot from it (src/main/ssh/orcad-artifact-materializer.ts, `process.resourcesPath`). + * + * node config/scripts/packaged-orcad-template.cjs --reseal-signed + * + * reseals after an out-of-band signer (SignPath) rewrote template binaries in ``. + */ +const { createHash } = require('node:crypto') +const { + closeSync, + existsSync, + openSync, + readFileSync, + readSync, + readdirSync, + writeFileSync +} = require('node:fs') +const { join, relative, resolve, sep } = require('node:path') +const { + ORCAD_TEMPLATE_MANIFEST_FILENAME, + ORCAD_TEMPLATE_TARGETS_DIR +} = require('../../src/shared/orcad-artifacts.ts') +const { verifyPackagedOrcadTemplate } = require('./verify-packaged-orcad-template.cjs') + +const ORCAD_TEMPLATE_RESOURCE_DIR = 'orcad-template' +const orcadTemplateExtraResource = { from: 'out/orcad-template', to: ORCAD_TEMPLATE_RESOURCE_DIR } +// Why a second entry: electron-builder's copy filter always drops a source's root node_modules. +const orcadTemplateNodeModulesExtraResource = { + from: `${orcadTemplateExtraResource.from}/node_modules`, + to: `${ORCAD_TEMPLATE_RESOURCE_DIR}/node_modules` +} +// Why the whole tree: codesign rejects its ELF/PE payloads, and the darwin ones are signed in +// afterPack so their new hashes can be resealed into the manifest before the app is sealed. +const orcadTemplateMacSignIgnore = ['/orcad-template/'] + +// Mach-O thin (both byte orders, 32/64-bit) and fat headers. +const MACH_O_MAGICS = new Set(['feedface', 'feedfacf', 'cefaedfe', 'cffaedfe', 'cafebabe']) + +/** Release packaging sets it; dev and local builds may ship without the template. */ +function isOrcadTemplateRequired(env = process.env) { + return env.ORCA_REQUIRE_ORCAD_TEMPLATE === '1' +} + +// Why: electron-builder only warns on a missing extraResources source. +function assertOrcadTemplateBuilt(projectDir = join(__dirname, '..', '..'), env = process.env) { + const manifest = join( + projectDir, + orcadTemplateExtraResource.from, + ORCAD_TEMPLATE_MANIFEST_FILENAME + ) + if (isOrcadTemplateRequired(env) && !existsSync(manifest)) { + throw new Error( + `ORCA_REQUIRE_ORCAD_TEMPLATE=1 but ${manifest} is missing; download the merged template ` + + 'from the release template job, or build it with `pnpm build:orcad-template`.' + ) + } +} + +function sha256(path) { + return createHash('sha256').update(readFileSync(path)).digest('hex') +} + +function isMachO(path) { + const fd = openSync(path, 'r') + try { + const header = Buffer.alloc(4) + return readSync(fd, header, 0, 4, 0) === 4 && MACH_O_MAGICS.has(header.toString('hex')) + } finally { + closeSync(fd) + } +} + +/** Template-relative paths of the darwin targets' Mach-O files, which macOS signing rewrites. */ +function findOrcadTemplateMachOFiles(templateDir) { + const targetsDir = join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR) + return readdirSync(targetsDir) + .filter((target) => target.startsWith('darwin-')) + .flatMap((target) => + readdirSync(join(targetsDir, target), { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && isMachO(join(entry.parentPath, entry.name))) + .map((entry) => + relative(templateDir, join(entry.parentPath, entry.name)).split(sep).join('/') + ) + ) + .sort() +} + +/** + * Re-records the hashes of files a platform signer rewrote. Only the named files move; every + * other file must still match what the template build recorded, which the verify after this + * enforces, so a reseal cannot launder an unrelated change. + */ +function resealOrcadTemplateManifest(templateDir, signedPaths) { + const manifestPath = join(templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME) + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + for (const path of signedPaths) { + const segments = path.split('/') + const target = + segments[0] === ORCAD_TEMPLATE_TARGETS_DIR ? manifest.targets?.[segments[1]] : undefined + const filename = segments.slice(2).join('/') + const digest = () => sha256(join(templateDir, ...segments)) + if (target?.files && Object.hasOwn(target.files, filename)) { + target.files[filename] = digest() + } else if (target && target.browserName === filename) { + target.browserSha256 = digest() + } else if (!target && Object.hasOwn(manifest.commonSha256 ?? {}, path)) { + manifest.commonSha256[path] = digest() + } else { + throw new Error(`[packaged-orcad-template] ${path} is not a template manifest entry`) + } + } + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) +} + +/** + * afterPack: sign the darwin payloads on macOS (notarization requires every nested Mach-O to + * carry the app's Developer ID), reseal, then verify the exact bytes that ship. + */ +async function finalizePackagedOrcadTemplate(resourcesDir, options) { + const { platform, env = process.env, signMacBinary } = options + const templateDir = join(resourcesDir, ORCAD_TEMPLATE_RESOURCE_DIR) + if (!existsSync(templateDir)) { + if (isOrcadTemplateRequired(env)) { + throw new Error(`Packaged app is missing the orcad deployment template: ${templateDir}`) + } + // SSH relays then keep the legacy host-Node path (ssh-relay-pinned-node.ts). + console.log('[packaged-orcad-template] skipped: this build ships no orcad template') + return + } + if (platform === 'darwin') { + const machO = findOrcadTemplateMachOFiles(templateDir) + for (const path of machO) { + await signMacBinary(join(templateDir, ...path.split('/'))) + } + resealOrcadTemplateManifest(templateDir, machO) + } + verifyPackagedOrcadTemplate(resourcesDir) +} + +/** `inner-signing-list.txt` lines are app-relative Windows paths; keep the template's. */ +function resealSignedWindowsApp(appDir, signedListFile) { + const prefix = `resources/${ORCAD_TEMPLATE_RESOURCE_DIR}/` + const signed = readFileSync(signedListFile, 'utf8') + .split(/\r?\n/) + .map((line) => line.trim().replaceAll('\\', '/')) + .filter((line) => line.startsWith(prefix)) + .map((line) => line.slice(prefix.length)) + const resourcesDir = join(appDir, 'resources') + if (!existsSync(join(resourcesDir, ORCAD_TEMPLATE_RESOURCE_DIR))) { + if (isOrcadTemplateRequired()) { + throw new Error(`Signed app is missing the orcad deployment template under ${resourcesDir}`) + } + console.log('[packaged-orcad-template] skipped reseal: this build ships no orcad template') + return + } + resealOrcadTemplateManifest(join(resourcesDir, ORCAD_TEMPLATE_RESOURCE_DIR), signed) + verifyPackagedOrcadTemplate(resourcesDir) + console.log(`[packaged-orcad-template] resealed ${signed.length} signed template file(s)`) +} + +module.exports = { + ORCAD_TEMPLATE_RESOURCE_DIR, + assertOrcadTemplateBuilt, + finalizePackagedOrcadTemplate, + findOrcadTemplateMachOFiles, + isOrcadTemplateRequired, + orcadTemplateExtraResource, + orcadTemplateNodeModulesExtraResource, + orcadTemplateMacSignIgnore, + resealOrcadTemplateManifest, + resealSignedWindowsApp +} + +if (require.main === module) { + const [flag, appDir, signedListFile] = process.argv.slice(2) + if (flag !== '--reseal-signed' || !appDir || !signedListFile) { + console.error('usage: packaged-orcad-template.cjs --reseal-signed ') + process.exit(2) + } + resealSignedWindowsApp(resolve(appDir), resolve(signedListFile)) +} diff --git a/config/scripts/packaged-orcad-template.test.mjs b/config/scripts/packaged-orcad-template.test.mjs new file mode 100644 index 00000000000..0f0fc3becd7 --- /dev/null +++ b/config/scripts/packaged-orcad-template.test.mjs @@ -0,0 +1,140 @@ +import { createHash } from 'node:crypto' +import { appendFile, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + ORCAD_TEMPLATE_MANIFEST_FILENAME, + ORCAD_TEMPLATE_TARGETS_DIR +} from '../../src/shared/orcad-artifacts.ts' +import { writeOrcadTemplateTestFixture } from './orcad-template-test-fixture.mjs' + +const require = createRequire(import.meta.url) +const { + assertOrcadTemplateBuilt, + finalizePackagedOrcadTemplate, + findOrcadTemplateMachOFiles, + resealOrcadTemplateManifest, + resealSignedWindowsApp +} = require('./packaged-orcad-template.cjs') + +const PTY = 'node_modules/node-pty/build/Release/pty.node' +const MACH_O_64 = Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 1, 2, 3, 4]) +const roots = [] + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function tempRoot() { + const root = await mkdtemp(join(tmpdir(), 'orca-packaged-orcad-')) + roots.push(root) + return root +} + +/** The fixture template, with darwin-arm64's pty.node made a real Mach-O as the build leaves it. */ +async function createResources() { + const resourcesDir = await tempRoot() + const templateDir = await writeOrcadTemplateTestFixture(resourcesDir) + const ptyPath = join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR, 'darwin-arm64', ...PTY.split('/')) + await writeFile(ptyPath, MACH_O_64) + const manifestPath = join(templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME) + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + manifest.targets['darwin-arm64'].files[PTY] = createHash('sha256').update(MACH_O_64).digest('hex') + await writeFile(manifestPath, JSON.stringify(manifest)) + return { resourcesDir, templateDir, ptyPath, manifestPath } +} + +const quietly = () => vi.spyOn(console, 'log').mockImplementation(() => {}) + +describe('packaged orcad template', () => { + it('signs only darwin Mach-O payloads on macOS and reseals their new bytes', async () => { + quietly() + const { resourcesDir, templateDir, ptyPath } = await createResources() + const signed = [] + const signMacBinary = async (path) => { + signed.push(path) + await appendFile(path, 'codesign-blob') + } + + await finalizePackagedOrcadTemplate(resourcesDir, { platform: 'darwin', signMacBinary }) + + expect(signed).toEqual([ptyPath]) + expect(findOrcadTemplateMachOFiles(templateDir)).toEqual([`targets/darwin-arm64/${PTY}`]) + }) + + it('verifies without signing on Windows and Linux packages', async () => { + quietly() + const { resourcesDir } = await createResources() + const signMacBinary = vi.fn() + + for (const platform of ['win32', 'linux']) { + await finalizePackagedOrcadTemplate(resourcesDir, { platform, signMacBinary }) + } + expect(signMacBinary).not.toHaveBeenCalled() + }) + + it('still rejects a changed file that no signer touched', async () => { + quietly() + const { resourcesDir, templateDir } = await createResources() + await writeFile(join(templateDir, 'orcad.js'), 'tampered') + + await expect( + finalizePackagedOrcadTemplate(resourcesDir, { + platform: 'darwin', + signMacBinary: async () => {} + }) + ).rejects.toThrow('orcad.js checksum mismatch') + }) + + it('refuses to reseal a path the manifest never listed', async () => { + const { templateDir } = await createResources() + + expect(() => + resealOrcadTemplateManifest(templateDir, ['targets/darwin-arm64/unlisted.node']) + ).toThrow('is not a template manifest entry') + expect(() => resealOrcadTemplateManifest(templateDir, ['targets/win32-x64'])).toThrow( + 'is not a template manifest entry' + ) + }) + + it('fails a required build without the template and lets a dev build skip it', async () => { + quietly() + const resourcesDir = await tempRoot() + const env = { ORCA_REQUIRE_ORCAD_TEMPLATE: '1' } + + await expect( + finalizePackagedOrcadTemplate(resourcesDir, { platform: 'linux', env }) + ).rejects.toThrow('missing the orcad deployment template') + await expect( + finalizePackagedOrcadTemplate(resourcesDir, { platform: 'linux', env: {} }) + ).resolves.toBeUndefined() + expect(() => assertOrcadTemplateBuilt(resourcesDir, env)).toThrow( + 'ORCA_REQUIRE_ORCAD_TEMPLATE=1' + ) + expect(() => assertOrcadTemplateBuilt(resourcesDir, {})).not.toThrow() + }) + + it('reseals the Windows files SignPath returned, by their app-relative list', async () => { + quietly() + const appDir = await tempRoot() + const templateDir = await writeOrcadTemplateTestFixture(join(appDir, 'resources')) + const conpty = 'node_modules/node-pty/build/Release/conpty.node' + await appendFile( + join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR, 'win32-x64', ...conpty.split('/')), + 'authenticode' + ) + const list = join(appDir, 'inner-signing-list.txt') + await writeFile( + list, + [ + 'Orca.exe', + `resources\\orcad-template\\targets\\win32-x64\\${conpty.replaceAll('/', '\\')}` + ].join('\r\n') + ) + + expect(() => resealSignedWindowsApp(appDir, list)).not.toThrow() + }) +}) diff --git a/config/scripts/release-cut-token-permissions.test.mjs b/config/scripts/release-cut-token-permissions.test.mjs index 0fc1e5f8448..489623262b4 100644 --- a/config/scripts/release-cut-token-permissions.test.mjs +++ b/config/scripts/release-cut-token-permissions.test.mjs @@ -16,7 +16,13 @@ const EXPECTED_MATRIX = { '.github/workflows/e2e.yml#ssh-localhost': { contents: 'read' }, '.github/workflows/e2e.yml#ssh-docker-watcher-isolation': { contents: 'read' }, '.github/workflows/homebrew-bump.yml#bump-cask': { contents: 'read' }, - '.github/workflows/release-mac-build.yml#build-mac': { contents: 'write' }, + '.github/workflows/node-server-tests.yml#changes': { contents: 'read' }, + '.github/workflows/node-server-tests.yml#desktop_template': { contents: 'read' }, + '.github/workflows/node-server-tests.yml#linux_glibc217_compat': { contents: 'read' }, + '.github/workflows/node-server-tests.yml#linux_glibc_floor': { contents: 'read' }, + '.github/workflows/node-server-tests.yml#linux_musl': { contents: 'read' }, + '.github/workflows/node-server-tests.yml#persistence': { contents: 'read' }, + '.github/workflows/release-mac-build.yml#build-mac': { actions: 'read', contents: 'write' }, [`${RELEASE_WORKFLOW}#build`]: { actions: 'read', contents: 'write' }, [`${RELEASE_WORKFLOW}#build-mac`]: { actions: 'write', contents: 'read' }, [`${RELEASE_WORKFLOW}#create-release`]: { contents: 'write' }, @@ -31,6 +37,28 @@ const EXPECTED_MATRIX = { { contents: 'read' }, + [`${RELEASE_WORKFLOW}#orcad-template`]: { contents: 'read' }, + [`${RELEASE_WORKFLOW}#orcad-template -> .github/workflows/node-server-tests.yml#changes`]: { + contents: 'read' + }, + [`${RELEASE_WORKFLOW}#orcad-template -> .github/workflows/node-server-tests.yml#desktop_template`]: + { + contents: 'read' + }, + [`${RELEASE_WORKFLOW}#orcad-template -> .github/workflows/node-server-tests.yml#linux_glibc217_compat`]: + { + contents: 'read' + }, + [`${RELEASE_WORKFLOW}#orcad-template -> .github/workflows/node-server-tests.yml#linux_glibc_floor`]: + { + contents: 'read' + }, + [`${RELEASE_WORKFLOW}#orcad-template -> .github/workflows/node-server-tests.yml#linux_musl`]: { + contents: 'read' + }, + [`${RELEASE_WORKFLOW}#orcad-template -> .github/workflows/node-server-tests.yml#persistence`]: { + contents: 'read' + }, [`${RELEASE_WORKFLOW}#post-release-e2e`]: { actions: 'write' }, [`${RELEASE_WORKFLOW}#publish-release`]: { contents: 'write' }, [`${RELEASE_WORKFLOW}#release-preflight`]: { contents: 'read' }, diff --git a/config/scripts/verify-packaged-orcad-template.test.mjs b/config/scripts/verify-packaged-orcad-template.test.mjs index 1b5e1b266a9..f533fcf7df7 100644 --- a/config/scripts/verify-packaged-orcad-template.test.mjs +++ b/config/scripts/verify-packaged-orcad-template.test.mjs @@ -132,15 +132,27 @@ describe('verifyPackagedOrcadTemplate', () => { ) }) - it('does not ship the unused deployment template in desktop packages', async () => { + // Design D2 reverses the old "unused, excluded" contract: SSH relays and managed orcad deploys + // materialize their slot from process.resourcesPath/orcad-template, so every desktop OS ships it. + it('ships the deployment template as a resource on every desktop OS, never its runtimes', async () => { for (const platform of ['win', 'mac', 'linux']) { + expect(builderConfig[platform].extraResources).toContainEqual({ + from: 'out/orcad-template', + to: 'orcad-template' + }) + // electron-builder's copy filter drops a source's root node_modules, so it needs its own entry. + expect(builderConfig[platform].extraResources).toContainEqual({ + from: 'out/orcad-template/node_modules', + to: 'orcad-template/node_modules' + }) expect( builderConfig[platform].extraResources.some( - (resource) => typeof resource === 'object' && resource.to.startsWith('orcad-template') + (resource) => + typeof resource === 'object' && /runtimes|node-runtime-cache/.test(resource.from) ) ).toBe(false) } - // The pinned Node a local build references is ~120 MB; none of these outputs is desktop code. + // The pinned Node a local build references is ~120 MB and is downloaded on demand instead. expect(builderConfig.files).toEqual( expect.arrayContaining([ '!out/orcad{,/**/*}', @@ -149,6 +161,8 @@ describe('verifyPackagedOrcadTemplate', () => { '!out/node-runtime-cache{,/**/*}' ]) ) + expect(builderConfig.mac.signIgnore).toContain('/orcad-template/') + // Release CI builds the template from every lane's slot; one host cannot build it alone. const { scripts } = JSON.parse(await readFile(join(process.cwd(), 'package.json'), 'utf8')) for (const name of ['build:desktop', 'build:release', 'build:release:parallel']) { expect(scripts[name]).not.toContain('build:orcad-template') diff --git a/src/main/ssh/orcad-artifact-materializer.test.ts b/src/main/ssh/orcad-artifact-materializer.test.ts index bbeeb3ea9f3..2bb102354fe 100644 --- a/src/main/ssh/orcad-artifact-materializer.test.ts +++ b/src/main/ssh/orcad-artifact-materializer.test.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + renameSync, rmSync, statSync, writeFileSync @@ -26,8 +27,15 @@ import { orcadTemplateTargetFilenames } from '../../shared/orcad-artifacts' import { readOrcadArtifactIdentity } from '../orcad/orcad-artifact-identity' +import { + getAppEnvironment, + hasAppEnvironment, + setAppEnvironment, + type AppEnvironment +} from '../../shared/app-environment' import { assembleOrcadArtifact, + getOrcadTemplateCandidates, materializeOrcadArtifact, resetOrcadArtifactMaterializationsForTests } from './orcad-artifact-materializer' @@ -273,3 +281,70 @@ describe('materializeOrcadArtifact cancellation', () => { ) }) }) + +describe('packaged template lookup', () => { + const originalResourcesPath = process.resourcesPath + const originalTemplatePath = process.env.ORCA_ORCAD_TEMPLATE_PATH + let previousEnvironment: AppEnvironment | null = null + + afterEach(() => { + Object.defineProperty(process, 'resourcesPath', { + value: originalResourcesPath, + configurable: true, + writable: true + }) + if (originalTemplatePath === undefined) { + delete process.env.ORCA_ORCAD_TEMPLATE_PATH + } else { + process.env.ORCA_ORCAD_TEMPLATE_PATH = originalTemplatePath + } + if (previousEnvironment) { + setAppEnvironment(previousEnvironment) + } + }) + + /** An installed app: electron-builder copies out/orcad-template to Resources/orcad-template. */ + function installPackagedApp(): { resourcesDir: string; userData: string } { + const fixture = createTemplate() + const root = dirname(fixture.templateDir) + const resourcesDir = join(root, 'Resources') + mkdirSync(resourcesDir) + renameSync(fixture.templateDir, join(resourcesDir, 'orcad-template')) + const userData = join(root, 'userData') + delete process.env.ORCA_ORCAD_TEMPLATE_PATH + Object.defineProperty(process, 'resourcesPath', { + value: resourcesDir, + configurable: true, + writable: true + }) + previousEnvironment = hasAppEnvironment() ? getAppEnvironment() : null + setAppEnvironment({ + getPath: () => userData, + getAppPath: () => join(resourcesDir, 'app.asar'), + getVersion: () => '0.0.0-test', + isPackaged: () => true, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) + return { resourcesDir, userData } + } + + it('materializes from Resources/orcad-template into userData with no explicit paths', async () => { + const { resourcesDir, userData } = installPackagedApp() + + expect(getOrcadTemplateCandidates()[0]).toBe(join(resourcesDir, 'orcad-template')) + const artifact = await materializeOrcadArtifact(TARGET) + expect(dirname(dirname(artifact))).toBe(join(userData, 'orcad-artifacts')) + expect(readFileSync(join(artifact, 'orcad.js'), 'utf8')).toBe('orcad-entry') + }) + + it('reports a build that shipped no template, which relays treat as a legacy fallback', async () => { + const { resourcesDir } = installPackagedApp() + rmSync(join(resourcesDir, 'orcad-template'), { recursive: true }) + + await expect(materializeOrcadArtifact(TARGET)).rejects.toThrow( + 'The packaged orcad deployment template is missing' + ) + }) +})