diff --git a/.github/workflows/build-and-upload.yml b/.github/workflows/build-and-upload.yml index f2d1c39ec..ca582d3fa 100644 --- a/.github/workflows/build-and-upload.yml +++ b/.github/workflows/build-and-upload.yml @@ -52,9 +52,6 @@ on: # Permissions are intentionally omitted here so callers can choose # least-privilege (e.g. dev CI uses read-only; releases grant write). -env: - NODE_VERSION: 20 - jobs: build-macos: runs-on: macos-15-intel @@ -71,7 +68,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: .node-version cache: npm - name: Set workspace versions @@ -242,7 +239,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: .node-version cache: npm - name: Set workspace versions @@ -292,7 +289,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: .node-version cache: npm - name: Set workspace versions @@ -426,7 +423,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: .node-version cache: npm - name: Setup Rust (Tauri) @@ -499,7 +496,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: .node-version cache: npm - name: Setup Rust (Tauri) @@ -572,7 +569,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: .node-version cache: npm - name: Setup Rust (Tauri) @@ -649,7 +646,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: .node-version cache: npm - name: Setup Rust (Tauri) @@ -836,7 +833,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: .node-version cache: npm - name: Setup Rust (Tauri) diff --git a/.github/workflows/manual-npm-publish.yml b/.github/workflows/manual-npm-publish.yml index 3dddd580e..ead7bd61f 100644 --- a/.github/workflows/manual-npm-publish.yml +++ b/.github/workflows/manual-npm-publish.yml @@ -46,7 +46,6 @@ jobs: publish: runs-on: ubuntu-latest env: - NODE_VERSION: 22 PUBLISH_NPM_VERSION: 11.5.1 steps: - name: Checkout @@ -57,7 +56,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: .node-version registry-url: https://registry.npmjs.org - name: Prepare pinned npm CLI diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 1ba4651e8..cd2b51ae0 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -78,7 +78,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - uses: actions/setup-node@v4 with: - node-version: 22 + node-version-file: .node-version cache: npm - run: npm ci --ignore-scripts - name: Install browser for native pruning UI checks @@ -130,7 +130,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: 22 + node-version-file: .node-version cache: npm - name: Setup Rust @@ -157,7 +157,7 @@ jobs: run: npm run typecheck - name: Test desktop packaging invariants - run: node --test scripts/desktop-server-resources.test.cjs + run: node --test scripts/desktop-server-resources.test.cjs scripts/prepare-node-runtime.test.cjs - name: Test Electron client state env: @@ -274,7 +274,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: 22 + node-version-file: .node-version cache: npm - name: Setup Rust @@ -314,7 +314,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: 22 + node-version-file: .node-version cache: npm - name: Install dependencies @@ -345,7 +345,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: 22 + node-version-file: .node-version cache: npm - name: Setup Rust diff --git a/.github/workflows/release-ui.yml b/.github/workflows/release-ui.yml index 2a021832e..71484a07c 100644 --- a/.github/workflows/release-ui.yml +++ b/.github/workflows/release-ui.yml @@ -13,9 +13,6 @@ on: permissions: contents: read -env: - NODE_VERSION: 20 - jobs: release-ui: # Automated via reusable call (main releases); manual runs allowed on dev/main. @@ -30,7 +27,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: .node-version cache: npm - name: Install dependencies diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index 9f5e3b0b9..a55811fb3 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -43,9 +43,6 @@ permissions: id-token: write contents: write -env: - NODE_VERSION: 20 - jobs: prepare-release: runs-on: ubuntu-latest @@ -62,7 +59,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: .node-version - name: Compute release versions id: versions diff --git a/.github/workflows/update-winget.yml b/.github/workflows/update-winget.yml index 0fb40207c..3df6d3b9a 100644 --- a/.github/workflows/update-winget.yml +++ b/.github/workflows/update-winget.yml @@ -92,7 +92,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: 20 + node-version-file: .node-version - name: Wait for Windows Tauri release asset id: release_asset diff --git a/.node-version b/.node-version new file mode 100644 index 000000000..b7179aed0 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24.20.0 diff --git a/BUILD.md b/BUILD.md index 60c2415a6..05896d8ee 100644 --- a/BUILD.md +++ b/BUILD.md @@ -5,9 +5,25 @@ This guide explains how to build distributable binaries for CodeNomad. ## Prerequisites - **Bun** - Package manager and runtime -- **Node.js** - For electron-builder +- **Node.js 24 LTS** - Use the exact release pinned in `.node-version` for builds and tests - **Electron Builder** - Installed via devDependencies +### Bundled server runtime + +Both Tauri and Electron ship a separate Node.js executable for the CodeNomad +server. `scripts/prepare-node-runtime.cjs` reads the same `.node-version` pin as +CI, downloads the official target archive, and verifies its SHA-256 before +packaging. Only the executable is bundled, not npm or Corepack. Updating this +pin does not update Electron's internal Node/Chromium, WebView2, the system Node +installation, or the independently managed OpenCode daemon. + +For a runtime update, run the server and desktop tests with the pinned Node, +then rebuild and smoke-test the packaged applications on Windows, macOS, and +Linux. Check the upstream Node platform requirements before changing the pin; +Node 24 requires macOS 13.5 or later. Keep both desktop bundle minimum versions +aligned. Keep the version exact rather than resolving `latest` or `lts/*` at +build time so release inputs remain reproducible. + ## Quick Start All commands now run inside the workspace packages. From the repo root you can target the Electron app package directly: @@ -111,8 +127,8 @@ The Tauri build directory uses Tauri's native Debian filename. CI renames the pa ### macOS -- **Build on:** macOS 10.13+ -- **Run on:** macOS 10.13+ +- **Build on:** macOS 13.5+ with the required Xcode tools (CI uses macOS 15) +- **Run on:** macOS 13.5+ (Intel and Apple Silicon; required by bundled Node 24) - **Code signing:** Optional (recommended for distribution) ### Windows diff --git a/packages/electron-app/electron/main/client-state-process-identity.ts b/packages/electron-app/electron/main/client-state-process-identity.ts index 749bfc57e..752d5cddb 100644 --- a/packages/electron-app/electron/main/client-state-process-identity.ts +++ b/packages/electron-app/electron/main/client-state-process-identity.ts @@ -63,7 +63,7 @@ export function getProcessStartIdentity(pid: number): string | undefined { "-NoProfile", "-NonInteractive", "-Command", - `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction Stop).CreationDate.ToUniversalTime().Ticks`, + `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`, ], "win32", ) @@ -99,7 +99,7 @@ export async function getProcessStartIdentityAsync( "-NoProfile", "-NonInteractive", "-Command", - `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction Stop).CreationDate.ToUniversalTime().Ticks`, + `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`, ], "win32", timeoutMs) } } catch { diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index e0da85c41..402607de4 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -83,6 +83,7 @@ } ], "mac": { + "minimumSystemVersion": "13.5", "entitlements": "electron/resources/entitlements.mac.plist", "entitlementsInherit": "electron/resources/entitlements.mac.plist", "extendInfo": { diff --git a/packages/tauri-app/src-tauri/src/client_state/cross_host.rs b/packages/tauri-app/src-tauri/src/client_state/cross_host.rs index 749d60960..c68b10ad5 100644 --- a/packages/tauri-app/src-tauri/src/client_state/cross_host.rs +++ b/packages/tauri-app/src-tauri/src/client_state/cross_host.rs @@ -924,7 +924,7 @@ fn process_start_identity(pid: u32) -> Option { "-NoProfile", "-NonInteractive", "-Command", - &format!("(Get-CimInstance Win32_Process -Filter \"ProcessId = {pid}\" -ErrorAction Stop).CreationDate.ToUniversalTime().Ticks"), + &format!("(Get-Process -Id {pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks"), ], ) .map(|value| format!("win32:{value}")) diff --git a/packages/tauri-app/src-tauri/tauri.conf.json b/packages/tauri-app/src-tauri/tauri.conf.json index 66b0b7e56..ed5a7f2ee 100644 --- a/packages/tauri-app/src-tauri/tauri.conf.json +++ b/packages/tauri-app/src-tauri/tauri.conf.json @@ -22,6 +22,9 @@ }, "bundle": { "active": true, + "macOS": { + "minimumSystemVersion": "13.5" + }, "linux": { "appimage": { "files": { diff --git a/scripts/prepare-node-runtime.cjs b/scripts/prepare-node-runtime.cjs index 528893943..7a44b1416 100644 --- a/scripts/prepare-node-runtime.cjs +++ b/scripts/prepare-node-runtime.cjs @@ -4,7 +4,12 @@ const os = require("os") const path = require("path") const { spawnSync } = require("child_process") -const MANAGED_NODE_VERSION = "v22.22.2" +// Keep packaged runtimes and CI on the same exact LTS release. +const nodeVersion = fs.readFileSync(path.join(__dirname, "..", ".node-version"), "utf8").trim() +if (!/^\d+\.\d+\.\d+$/.test(nodeVersion)) { + throw new Error(".node-version must pin an exact Node.js release") +} +const MANAGED_NODE_VERSION = `v${nodeVersion}` const ARTIFACTS = { "darwin-x64": { archive: `node-${MANAGED_NODE_VERSION}-darwin-x64.tar.gz`, root: `node-${MANAGED_NODE_VERSION}-darwin-x64`, binary: path.join("bin", "node") }, diff --git a/scripts/prepare-node-runtime.test.cjs b/scripts/prepare-node-runtime.test.cjs new file mode 100644 index 000000000..3f5e05989 --- /dev/null +++ b/scripts/prepare-node-runtime.test.cjs @@ -0,0 +1,42 @@ +const assert = require("node:assert/strict") +const fs = require("node:fs") +const path = require("node:path") +const test = require("node:test") +const yaml = require("yaml") +const { MANAGED_NODE_VERSION } = require("./prepare-node-runtime.cjs") + +const root = path.resolve(__dirname, "..") +const read = (file) => fs.readFileSync(path.join(root, file), "utf8") + +test("pins the bundled runtime to the exact Node 24 LTS used by CI", () => { + const version = read(".node-version").trim() + assert.match(version, /^24\.\d+\.\d+$/) + assert.equal(MANAGED_NODE_VERSION, `v${version}`) +}) + +test("all Node setup steps use the checked-out runtime pin", () => { + const workflows = fs.readdirSync(path.join(root, ".github/workflows")) + .filter((file) => /\.ya?ml$/.test(file)) + let checked = 0 + for (const file of workflows) { + const workflow = yaml.parse(read(`.github/workflows/${file}`)) + for (const job of Object.values(workflow.jobs ?? {})) { + const steps = job.steps ?? [] + for (const [index, step] of steps.entries()) { + if (!step.uses?.startsWith("actions/setup-node@")) continue + assert.equal(step.with?.["node-version-file"], ".node-version", file) + assert.equal(step.with?.["node-version"], undefined, file) + assert.ok(steps.slice(0, index).some((earlier) => earlier.uses?.startsWith("actions/checkout@")), file) + checked++ + } + } + } + assert.ok(checked > 0) +}) + +test("both macOS bundles require the OS supported by Node 24", () => { + const electron = JSON.parse(read("packages/electron-app/package.json")) + const tauri = JSON.parse(read("packages/tauri-app/src-tauri/tauri.conf.json")) + assert.equal(electron.build.mac.minimumSystemVersion, "13.5") + assert.equal(tauri.bundle.macOS.minimumSystemVersion, "13.5") +}) diff --git a/scripts/smoke-packaged-resources.cjs b/scripts/smoke-packaged-resources.cjs index 224d04a8d..bc6a83c10 100644 --- a/scripts/smoke-packaged-resources.cjs +++ b/scripts/smoke-packaged-resources.cjs @@ -2,6 +2,7 @@ const fs = require("fs") const path = require("path") const { spawnSync } = require("child_process") +const { MANAGED_NODE_VERSION } = require("./prepare-node-runtime.cjs") const requiredPackages = [ "yaml", @@ -9,6 +10,7 @@ const requiredPackages = [ "@fastify/static", "@fastify/cors", "@fastify/reply-from", + "@opencode-ai/client", "openai", "pino", "undici", @@ -58,6 +60,7 @@ function run(command, args, options = {}) { if (result.status !== 0) { throw new Error(`${command} exited with code ${result.status ?? 1}`) } + return result } function smokeServer(resourcesRoot, target) { @@ -81,18 +84,20 @@ function smokeServer(resourcesRoot, target) { return } + const version = run(node, ["--version"], { stdio: "pipe", encoding: "utf8" }).stdout.trim() + if (version !== MANAGED_NODE_VERSION) { + throw new Error(`Packaged Node version ${version} does not match ${MANAGED_NODE_VERSION}`) + } + console.log(`packaged Node version ok: ${version}`) run(node, [entrypoint, "--version"]) - const requireScript = [ - "import { createRequire } from 'module';", - "import path from 'path';", - `const root = ${JSON.stringify(serverRoot)};`, - "const req = createRequire(path.join(root, 'dist/bin.js'));", - `${JSON.stringify(requiredPackages)}.forEach((name) => req(name));`, + const importScript = [ + `for (const name of ${JSON.stringify(requiredPackages)}) await import(name);`, "console.log('packaged dependency imports ok');", ].join(" ") - run(node, ["--input-type=module", "-e", requireScript]) + // Resolve from the packaged server, not the build checkout. The V2 client is ESM-only. + run(node, ["--input-type=module", "-e", importScript], { cwd: serverRoot }) } function smokeLoadingAssets(loadingRoot) {