mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 08:02:02 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user