feat(desktop): three-platform packaging and CI matrix (M3) (#177)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
# Desktop packages (design § "桌面端原型 · 打包与更新", milestone M3).
|
||||
#
|
||||
# Reusable three-OS matrix: stage the pnpm-deploy app tree, run electron-builder, and
|
||||
# upload the installers as workflow artifacts named desktop-<OS>. release.yml calls this
|
||||
# BEFORE creating the Release — assets are immutable once published, so the desktop
|
||||
# installers must exist at creation time. workflow_dispatch runs it standalone as a dry
|
||||
# run on any branch.
|
||||
#
|
||||
# M3 ships unsigned artifacts; macOS signing/notarization, Windows code signing and
|
||||
# electron-updater are milestone M4.
|
||||
name: Desktop packages
|
||||
|
||||
on:
|
||||
workflow_call: {}
|
||||
workflow_dispatch: {}
|
||||
|
||||
env:
|
||||
# Keep in sync with release.yml (its header comment is the source of truth; duplicated
|
||||
# here because reusable workflows do not inherit the caller's env).
|
||||
MINGIT_VERSION: 2.55.0.3
|
||||
MINGIT_TAG: v2.55.0.windows.3
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
args: --linux
|
||||
- os: macos-latest
|
||||
args: --mac
|
||||
- os: windows-latest
|
||||
args: --win
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
# pnpm version comes from package.json's packageManager field.
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm -r build
|
||||
|
||||
- name: Stage the app directory
|
||||
run: node packages/desktop/scripts/stage.mjs
|
||||
|
||||
# Windows carries MinGit under resources/git so the packaged agent shell has the
|
||||
# same deterministic POSIX bash as the npm package (release.yml bundles the
|
||||
# identical MinGit into the CLI win-x64 zip; the shell advertises it as
|
||||
# PENGUIN_BUNDLED_SHELL).
|
||||
- name: Bundle MinGit (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
mingit="MinGit-$MINGIT_VERSION-64-bit.zip"
|
||||
curl -fsSL -o "$mingit" \
|
||||
"https://github.com/git-for-windows/git/releases/download/$MINGIT_TAG/$mingit"
|
||||
unzip -q "$mingit" -d packages/desktop/stage/minigit
|
||||
|
||||
- name: Build packages
|
||||
run: pnpm --dir packages/desktop exec electron-builder ${{ matrix.args }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-${{ runner.os }}
|
||||
if-no-files-found: error
|
||||
path: |
|
||||
packages/desktop/stage/out/*.AppImage
|
||||
packages/desktop/stage/out/*.deb
|
||||
packages/desktop/stage/out/*.dmg
|
||||
packages/desktop/stage/out/*-mac.zip
|
||||
packages/desktop/stage/out/*.exe
|
||||
@@ -77,9 +77,17 @@ jobs:
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
release:
|
||||
# Desktop installers (Electron shell, three-OS matrix). Runs BEFORE the release job:
|
||||
# Release assets are immutable once published, so the installers must exist when the
|
||||
# Release is created. See design § "桌面端原型 · 打包与更新" (M3).
|
||||
desktop:
|
||||
needs: check-release
|
||||
if: needs.check-release.outputs.exists != 'true'
|
||||
uses: ./.github/workflows/desktop-build.yml
|
||||
|
||||
release:
|
||||
needs: [check-release, desktop]
|
||||
if: needs.check-release.outputs.exists != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -284,6 +292,22 @@ jobs:
|
||||
cd dist-artifacts
|
||||
sha256sum -- *.tar.gz *.zip > SHA256SUMS
|
||||
|
||||
# Desktop installers built by the desktop job (three-OS matrix): collected here so
|
||||
# they are part of the Release's initial (immutable) asset set. Their checksums go
|
||||
# in a separate SHA256SUMS.desktop — the OSS mirror script's canonical file list
|
||||
# stays untouched (desktop installers are not mirrored yet).
|
||||
- name: Collect desktop artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: desktop-*
|
||||
merge-multiple: true
|
||||
path: desktop-artifacts
|
||||
|
||||
- name: Generate desktop checksums
|
||||
run: |
|
||||
cd desktop-artifacts
|
||||
sha256sum -- * > SHA256SUMS.desktop
|
||||
|
||||
# Release notes come from changelog/<version>/RELEASE.md, written during release preparation and
|
||||
# committed BEFORE the tag (the release job runs on the tag's checkout, so a file added afterwards
|
||||
# is invisible here). Present and non-empty -> published verbatim as the body; absent -> GitHub
|
||||
@@ -320,6 +344,7 @@ jobs:
|
||||
dist-artifacts/*.zip
|
||||
dist-artifacts/*.sha256
|
||||
dist-artifacts/SHA256SUMS
|
||||
desktop-artifacts/*
|
||||
install.sh
|
||||
install.ps1
|
||||
|
||||
|
||||
+3
-2
@@ -3,7 +3,7 @@
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "PenguinHarness — TypeScript AI Agent (SDK + CLI).",
|
||||
"description": "PenguinHarness \u2014 TypeScript AI Agent (SDK + CLI).",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
@@ -21,7 +21,8 @@
|
||||
"dev:web": "node scripts/dev-prebuild.mjs && pnpm --filter @prismshadow/penguin-web dev",
|
||||
"dev:docs": "node scripts/dev-prebuild.mjs --install-only && pnpm --filter @prismshadow/penguin-docs dev",
|
||||
"dev:landing": "node scripts/dev-prebuild.mjs --install-only && pnpm --filter @prismshadow/penguin-landing dev",
|
||||
"build:site": "node scripts/build-site.mjs"
|
||||
"build:site": "node scripts/build-site.mjs",
|
||||
"desktop": "pnpm -r build && node scripts/run-with-env.mjs PENGUIN_HOME=~/.penguin/dev-data -- pnpm --dir packages/desktop start"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@prismshadow/penguin-cli": "workspace:*",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
stage/
|
||||
@@ -0,0 +1,59 @@
|
||||
# electron-builder config (design § "桌面端原型 · 打包与更新", milestone M3).
|
||||
#
|
||||
# The app directory is the pnpm-deploy staging tree assembled by scripts/stage.mjs —
|
||||
# a portable, self-contained node_modules (workspace packages materialized) plus the
|
||||
# shell bundle and the server's web-dist.
|
||||
#
|
||||
# asar is off on purpose: the shell forks the server as a child process, the skill
|
||||
# library reads its .md files from disk, and agent commands spawn real shells — plain
|
||||
# files sidestep every asar edge (fork-from-archive, unpacked-tree resolution splits)
|
||||
# at the cost of some inodes.
|
||||
#
|
||||
# M3 ships unsigned artifacts: macOS signing/notarization and Windows code signing are
|
||||
# milestone M4, together with electron-updater.
|
||||
appId: com.prismshadow.penguinharness
|
||||
productName: PenguinHarness
|
||||
directories:
|
||||
app: stage/app
|
||||
output: stage/out
|
||||
asar: false
|
||||
# The staged tree is complete and has no native modules; a rebuild would only fail
|
||||
# against the pruned package.json.
|
||||
npmRebuild: false
|
||||
nodeGypRebuild: false
|
||||
|
||||
mac:
|
||||
category: public.app-category.developer-tools
|
||||
# No Developer ID yet (M4): identity null skips codesign instead of failing.
|
||||
identity: null
|
||||
target:
|
||||
- target: dmg
|
||||
arch: [arm64, x64]
|
||||
- target: zip
|
||||
arch: [arm64, x64]
|
||||
|
||||
win:
|
||||
target:
|
||||
- target: nsis
|
||||
arch: [x64]
|
||||
# MinGit, downloaded by the CI job into stage/minigit (empty for local non-Windows
|
||||
# builds): lands under resources/git, and the shell advertises git/usr/bin/sh.exe as
|
||||
# PENGUIN_BUNDLED_SHELL so the agent shell behaves like the npm-installed package.
|
||||
extraResources:
|
||||
- from: stage/minigit
|
||||
to: git
|
||||
|
||||
nsis:
|
||||
oneClick: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
|
||||
linux:
|
||||
# The scoped package name is not a valid executable file name.
|
||||
executableName: penguin-harness
|
||||
category: Development
|
||||
maintainer: Prism Shadow <zheng@prismshadow.com>
|
||||
target:
|
||||
- target: AppImage
|
||||
arch: [x64]
|
||||
- target: deb
|
||||
arch: [x64]
|
||||
@@ -9,7 +9,12 @@
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"start": "electron ."
|
||||
"start": "node scripts/preflight.mjs && electron .",
|
||||
"stage": "node scripts/stage.mjs",
|
||||
"pack": "node scripts/stage.mjs && electron-builder",
|
||||
"pack:linux": "node scripts/stage.mjs && electron-builder --linux",
|
||||
"pack:mac": "node scripts/stage.mjs && electron-builder --mac",
|
||||
"pack:win": "node scripts/stage.mjs && electron-builder --win"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prismshadow/penguin-core": "workspace:*",
|
||||
@@ -20,6 +25,7 @@
|
||||
"electron": "^43.2.0",
|
||||
"tsup": "^8.3.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^3.2.6"
|
||||
"vitest": "^3.2.6",
|
||||
"electron-builder": "^26.0.12"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Dev preflight for `pnpm --dir packages/desktop start` (and the root `pnpm desktop`):
|
||||
* verify everything a source run needs, and fail with the actual fix instead of a bare
|
||||
* ERR_MODULE_NOT_FOUND. The classic trap: this package's node_modules holds pnpm
|
||||
* *injected copies* of the workspace packages, which only sync while building THROUGH
|
||||
* pnpm — a stale copy surfaces as `Cannot find module …/penguin-server/dist/lock.js`.
|
||||
*/
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const require = createRequire(path.join(pkgDir, "package.json"));
|
||||
|
||||
const problems = [];
|
||||
|
||||
for (const [what, rel] of [
|
||||
["the desktop shell build", "dist/main.js"],
|
||||
["the injected server copy", "node_modules/@prismshadow/penguin-server/dist/lock.js"],
|
||||
["the web frontend build", "../web/dist/index.html"],
|
||||
]) {
|
||||
if (!fs.existsSync(path.join(pkgDir, rel))) {
|
||||
problems.push(
|
||||
`Missing ${what} (${rel}). Run \`pnpm -r build\` at the repo root — builds through pnpm also sync the injected workspace copies under node_modules.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// `require("electron")` resolves to the platform binary path; the package exists even
|
||||
// when its postinstall (the binary download) was skipped by the package manager.
|
||||
try {
|
||||
const electronBinary = require("electron");
|
||||
if (typeof electronBinary !== "string" || !fs.existsSync(electronBinary)) {
|
||||
throw new Error("binary missing");
|
||||
}
|
||||
} catch {
|
||||
problems.push(
|
||||
"The Electron binary is missing. Run `node node_modules/electron/install.js` in packages/desktop (its postinstall was skipped; pnpm allows it via the workspace allowBuilds entry).",
|
||||
);
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
console.error("penguin-desktop preflight failed:\n");
|
||||
for (const p of problems) console.error(` - ${p}`);
|
||||
console.error("");
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Assemble the self-contained app directory electron-builder packs (stage/app).
|
||||
*
|
||||
* `pnpm deploy --prod` materializes this package plus its production dependency tree —
|
||||
* including the workspace packages — into a portable directory whose symlinks all stay
|
||||
* inside it (verified: the server boots from the deploy dir as-is). On top of that:
|
||||
* - prune dev files (sources, configs) so only dist/, node_modules/ and package.json ship;
|
||||
* - copy the web build to `node_modules/@prismshadow/penguin-server/web-dist`, the npm
|
||||
* package layout the server's static-hosting lookup checks first;
|
||||
* - ensure `stage/minigit` exists (may be empty): the Windows CI job downloads MinGit
|
||||
* into it, and electron-builder's win extraResources entry must always have a source.
|
||||
*
|
||||
* Run from anywhere; all paths are derived from this file's location.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const repoRoot = path.resolve(pkgDir, "..", "..");
|
||||
const stageDir = path.join(pkgDir, "stage");
|
||||
const appDir = path.join(stageDir, "app");
|
||||
|
||||
fs.rmSync(stageDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(stageDir, { recursive: true });
|
||||
|
||||
console.log("[stage] pnpm deploy --prod → stage/app");
|
||||
// Scrub inherited npm_config_* vars: when this script runs under `pnpm run`, they leak
|
||||
// into the child pnpm and derail its argument parsing.
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([k]) => !k.toLowerCase().startsWith("npm_config_")),
|
||||
);
|
||||
// Windows: pnpm is pnpm.cmd, which Node only spawns through a shell (and .cmd spawning
|
||||
// without one is blocked since the CVE-2024-27980 hardening). With a shell, args are
|
||||
// joined verbatim, so quote them — appDir may contain spaces.
|
||||
const isWindows = process.platform === "win32";
|
||||
const deployArgs = ["--filter", "@prismshadow/penguin-desktop", "deploy", "--prod", appDir];
|
||||
execFileSync(
|
||||
isWindows ? "pnpm.cmd" : "pnpm",
|
||||
isWindows ? deployArgs.map((a) => `"${a}"`) : deployArgs,
|
||||
{ cwd: repoRoot, stdio: "inherit", env, shell: isWindows },
|
||||
);
|
||||
|
||||
// Keep only what the packaged app runs.
|
||||
const keep = new Set(["dist", "node_modules", "package.json"]);
|
||||
for (const entry of fs.readdirSync(appDir)) {
|
||||
if (!keep.has(entry)) fs.rmSync(path.join(appDir, entry), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Strip scripts and devDependencies from the staged package.json but KEEP the
|
||||
// dependencies pnpm deploy wrote: electron-builder's node-module collector walks them
|
||||
// to decide what ships — with no dependencies it falls back to scanning the PROJECT
|
||||
// directory's node_modules (the injected copies, which lack web-dist) and rebuilds the
|
||||
// wrong tree.
|
||||
const stagedPkgPath = path.join(appDir, "package.json");
|
||||
const stagedPkg = JSON.parse(fs.readFileSync(stagedPkgPath, "utf8"));
|
||||
delete stagedPkg.scripts;
|
||||
delete stagedPkg.devDependencies;
|
||||
stagedPkg.private = true;
|
||||
// Unscoped name: deb/AppImage internals derive package and executable names from it,
|
||||
// and "@prismshadow/…" is invalid there. The app-root name plays no role in module
|
||||
// resolution, so the packaged name can differ from the workspace package name.
|
||||
stagedPkg.name = "penguin-harness-desktop";
|
||||
// fpm (deb) refuses to build without a homepage.
|
||||
stagedPkg.homepage = "https://github.com/Prism-Shadow/penguin-harness";
|
||||
fs.writeFileSync(stagedPkgPath, JSON.stringify(stagedPkg, null, 2) + "\n");
|
||||
|
||||
const webDist = path.join(repoRoot, "packages", "web", "dist");
|
||||
if (!fs.existsSync(path.join(webDist, "index.html"))) {
|
||||
console.error("[stage] packages/web/dist is missing — run `pnpm -r build` first.");
|
||||
process.exit(1);
|
||||
}
|
||||
// The @prismshadow/penguin-server entry is a symlink into .pnpm; copying THROUGH it
|
||||
// lands the files in the real package dir, which is exactly where the server looks.
|
||||
const serverPkg = path.join(appDir, "node_modules", "@prismshadow", "penguin-server");
|
||||
fs.cpSync(webDist, path.join(serverPkg, "web-dist"), { recursive: true, dereference: true });
|
||||
|
||||
fs.mkdirSync(path.join(stageDir, "minigit"), { recursive: true });
|
||||
|
||||
console.log("[stage] done:", appDir);
|
||||
@@ -7,8 +7,9 @@
|
||||
*/
|
||||
import { randomBytes } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { utilityProcess } from "electron";
|
||||
import { app, utilityProcess } from "electron";
|
||||
import type { UtilityProcess } from "electron";
|
||||
import { appOriginFor, parsePortFile } from "./util.js";
|
||||
|
||||
@@ -30,11 +31,39 @@ function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** The server package's entry file — forked by path, resolved through node_modules. */
|
||||
/**
|
||||
* The server package's entry file — forked by path. Packaged builds ship the staged
|
||||
* pnpm-deploy tree inside the app directory (asar is off, see electron-builder.yml),
|
||||
* so the path is a plain file; dev runs resolve through this package's node_modules.
|
||||
*/
|
||||
function serverEntryPath(): string {
|
||||
if (app.isPackaged) {
|
||||
return path.join(
|
||||
app.getAppPath(),
|
||||
"node_modules",
|
||||
"@prismshadow",
|
||||
"penguin-server",
|
||||
"dist",
|
||||
"index.js",
|
||||
);
|
||||
}
|
||||
return fileURLToPath(import.meta.resolve("@prismshadow/penguin-server"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra environment for the forked server. Windows packages carry MinGit under
|
||||
* resources/git (electron-builder extraResources): advertising its sh.exe as
|
||||
* PENGUIN_BUNDLED_SHELL gives the agent shell the same deterministic POSIX behavior as
|
||||
* the npm-installed package (core's shell resolver prefers a user-installed Git for
|
||||
* Windows from PATH, then this bundle, before falling back to PowerShell). An existing
|
||||
* value is respected.
|
||||
*/
|
||||
function bundledShellEnv(): Record<string, string> {
|
||||
if (process.platform !== "win32" || process.env.PENGUIN_BUNDLED_SHELL) return {};
|
||||
const sh = path.join(process.resourcesPath, "git", "usr", "bin", "sh.exe");
|
||||
return fs.existsSync(sh) ? { PENGUIN_BUNDLED_SHELL: sh } : {};
|
||||
}
|
||||
|
||||
async function waitForPortFile(file: string, exited: () => boolean): Promise<number> {
|
||||
const deadline = Date.now() + PORT_FILE_TIMEOUT_MS;
|
||||
for (;;) {
|
||||
@@ -90,6 +119,7 @@ export async function startEmbeddedServer(opts: {
|
||||
stdio: "pipe",
|
||||
env: {
|
||||
...process.env,
|
||||
...bundledShellEnv(),
|
||||
PENGUIN_HOME: opts.dataRoot,
|
||||
HOST: "127.0.0.1",
|
||||
PORT: "0",
|
||||
|
||||
Generated
+1807
File diff suppressed because it is too large
Load Diff
@@ -16,3 +16,6 @@ allowBuilds:
|
||||
# electron's postinstall downloads the platform runtime binary; without this allow
|
||||
# entry pnpm skips it and `electron .` has nothing to launch.
|
||||
electron: true
|
||||
# electron-winstaller's script fetches Squirrel.Windows binaries; the desktop package
|
||||
# targets NSIS only, so the script is deliberately not run.
|
||||
electron-winstaller: false
|
||||
|
||||
Reference in New Issue
Block a user