feat(packaging): ship the orcad server template in desktop builds (#24155)

* build(orcad): merge per-runner prebuild slot trees into one matrix

Each node-server lane builds only its own node-pty slot. Release CI needs
their union before `build:orcad-prebuilds --require-slots` and the
template build can run; merge-orcad-prebuilds.mjs verifies every lane's
files against its own manifest, refuses duplicate slots and mismatched
node-pty/N-API/Node-header builds, then writes one merged manifest.

* build(orcad): keep agent-browser out of the desktop deployment template

The template rides inside every desktop build (design D2). Seven ~10 MB
agent-browser binaries would be ~76 MB, more than the rest of the template;
design D2's package contents never listed it, and a slot without one
already reports no headless browser. ORCAD_OMIT_AGENT_BROWSER=1 skips the
copy; standalone build:orcad still includes it.

* feat(packaging): ship the orcad deployment template in desktop builds

Design D2: the server JS and every target's addons ship inside the app,
as out/relay does; the ~120 MB Node runtimes stay excluded and are
downloaded on demand. electron-builder copies out/orcad-template to
Resources/orcad-template on every desktop OS, which is the first path
materializeOrcadArtifact tries (process.resourcesPath).

Platform signing rewrites native bytes the template manifest hashes:
- macOS: the tree is signIgnored (codesign rejects its ELF/PE payloads);
  afterPack signs the darwin targets' Mach-O files with the app identity,
  as notarization requires, then reseals only those manifest entries.
- Windows: SignPath signs after packaging, so release CI reseals from the
  inner-signing list (packaged-orcad-template.cjs --reseal-signed).
Every other file must still match the build's hashes; afterPack verifies.

ORCA_REQUIRE_ORCAD_TEMPLATE=1 makes a missing template fail beforePack and
afterPack; without it a build ships none and SSH relays keep the legacy
path. verify-packaged-orcad-template.test.mjs's "unused, excluded"
contract is reversed on purpose.

* ci(release): build the orcad template from qualified lanes and package it

node-server-tests.yml becomes callable with a ref and build_template.
With build_template, each lane that owns a release slot (macOS, Windows,
the glibc 2.28 and Alpine lanes, and the glibc 2.17 compat lane) uploads
its qualified out/orcad-prebuilds, the Windows lane also uploads both
process-table addons, and desktop_template merges them, gates the full
matrix plus the compat slot with --require-slots, runs
build:orcad-template and uploads the orcad-template artifact.

release-cut calls it at the release tag beside the other gates. The
build and build-mac jobs wait for it, download it into out/orcad-template
(the mac workflow from the parent run), and require it via
ORCA_REQUIRE_ORCAD_TEMPLATE. The Windows signing staging skips the
template's Linux/macOS payloads, and a reseal step records SignPath's
bytes before the installer rebuild. A template-scoped concurrency group
keeps a release call and main's push runs from cancelling each other.

* test(orcad): keep the packaged-lookup imports clear of the compat-slot import edits

* ci(orcad): let a rerun lane replace its template artifacts

upload-artifact v4 refuses a second upload under an existing name in the same
run, so rerunning a flaky node-server lane during a release would fail at the
upload instead of re-qualifying the slot.

* ci(node-server): build the template's Windows addons before the lane switches to Node 18

The addon build script imports TypeScript, which Node 18 cannot load, so every
build_template run (release-cut included) failed on windows-2022.

* fix(build): ship the orcad template's shared node_modules

electron-builder's extraResources filter always drops the root node_modules of
a source directory, so packaged apps lost orcad-template/node_modules and the
afterPack verify failed. Copy it through its own resource entry.

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
This commit is contained in:
OrcaWin
2026-10-01 04:01:26 -07:00
committed by GitHub
co-authored by m4air
parent c422936a71
commit 554f7f4ce5
14 changed files with 977 additions and 9 deletions
+132 -2
View File
@@ -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
+37
View File
@@ -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 <non-Windows orcad template payload>")
return
}
$signature = Get-AuthenticodeSignature -FilePath $_.FullName
if ($signature.Status -eq 'Valid') {
$skipped.Add("$relative <already signed: $($signature.SignerCertificate.Subject)>")
@@ -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
+12
View File
@@ -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 }}
+17 -2
View File
@@ -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.',
+3
View File
@@ -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
})
+2 -1
View File
@@ -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)
+102
View File
@@ -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 <dir>] <tree> [<tree> ...]
*/
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(', ')}`
)
}
@@ -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')
})
})
@@ -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'
)
})
})
+183
View File
@@ -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 <appDir> <signedListFile>
*
* reseals after an out-of-band signer (SignPath) rewrote template binaries in `<appDir>`.
*/
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 <appDir> <signedListFile>')
process.exit(2)
}
resealSignedWindowsApp(resolve(appDir), resolve(signedListFile))
}
@@ -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()
})
})
@@ -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' },
@@ -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')
@@ -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'
)
})
})