mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-06 19:58:43 +02:00
feat: add cross-platform release automation
Add a GitHub Actions workflow that validates the app on pushes and pull requests, and publishes Windows, macOS, and Linux release assets when a tag is pushed. Also replace the Windows-only packaging flow with cross-platform Bun scripts for sidecar publishing and Electron packaging so the release job can package each runner natively. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
|||||||
|
name: CI and release
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
name: Validate (${{ matrix.label }})
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: windows-latest
|
||||||
|
label: Windows
|
||||||
|
- os: macos-13
|
||||||
|
label: macOS
|
||||||
|
- os: ubuntu-latest
|
||||||
|
label: Linux
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: 1.3.6
|
||||||
|
|
||||||
|
- name: Set up .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: 9.0.x
|
||||||
|
|
||||||
|
- name: Install Linux native dependencies
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libsecret-1-dev
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run web tests
|
||||||
|
run: bun run test
|
||||||
|
|
||||||
|
- name: Run sidecar tests
|
||||||
|
run: bun run sidecar:test
|
||||||
|
|
||||||
|
- name: Build the application
|
||||||
|
run: bun run build
|
||||||
|
|
||||||
|
create-release:
|
||||||
|
name: Create GitHub release
|
||||||
|
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||||
|
needs: validate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Create release if needed
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
TAG_NAME: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
if gh release view "$TAG_NAME" >/dev/null 2>&1; then
|
||||||
|
echo "Release $TAG_NAME already exists."
|
||||||
|
else
|
||||||
|
gh release create "$TAG_NAME" --title "$TAG_NAME" --generate-notes
|
||||||
|
fi
|
||||||
|
|
||||||
|
publish-release-assets:
|
||||||
|
name: Publish release asset (${{ matrix.label }})
|
||||||
|
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||||
|
needs: create-release
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: windows-latest
|
||||||
|
label: Windows
|
||||||
|
asset_path: release/Eryx-windows-x64.zip
|
||||||
|
- os: macos-13
|
||||||
|
label: macOS
|
||||||
|
asset_path: release/Eryx-macos-x64.zip
|
||||||
|
- os: ubuntu-latest
|
||||||
|
label: Linux
|
||||||
|
asset_path: release/Eryx-linux-x64.tar.gz
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: 1.3.6
|
||||||
|
|
||||||
|
- name: Set up .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: 9.0.x
|
||||||
|
|
||||||
|
- name: Install Linux native dependencies
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libsecret-1-dev
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Package current platform
|
||||||
|
run: bun run package
|
||||||
|
|
||||||
|
- name: Ad-hoc sign macOS app bundle
|
||||||
|
if: runner.os == 'macOS'
|
||||||
|
run: codesign --force --deep --sign - release/Eryx-macos-x64/Eryx.app
|
||||||
|
|
||||||
|
- name: Create Windows release archive
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: pwsh
|
||||||
|
run: Compress-Archive -Path 'release\Eryx-windows-x64\*' -DestinationPath 'release\Eryx-windows-x64.zip' -Force
|
||||||
|
|
||||||
|
- name: Create macOS release archive
|
||||||
|
if: runner.os == 'macOS'
|
||||||
|
run: ditto -c -k --sequesterRsrc --keepParent release/Eryx-macos-x64/Eryx.app release/Eryx-macos-x64.zip
|
||||||
|
|
||||||
|
- name: Create Linux release archive
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: tar -czf release/Eryx-linux-x64.tar.gz -C release Eryx-linux-x64
|
||||||
|
|
||||||
|
- name: Upload asset to GitHub release
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
TAG_NAME: ${{ github.ref_name }}
|
||||||
|
ASSET_PATH: ${{ matrix.asset_path }}
|
||||||
|
run: gh release upload "$TAG_NAME" "$ASSET_PATH" --clobber
|
||||||
@@ -97,6 +97,20 @@ Eryx shines when you want to:
|
|||||||
- reuse patterns for recurring workflows
|
- reuse patterns for recurring workflows
|
||||||
- maintain a history of meaningful sessions instead of disposable chats
|
- maintain a history of meaningful sessions instead of disposable chats
|
||||||
|
|
||||||
|
## Build and release automation
|
||||||
|
|
||||||
|
For local validation, run:
|
||||||
|
|
||||||
|
- `bun run test`
|
||||||
|
- `bun run sidecar:test`
|
||||||
|
- `bun run build`
|
||||||
|
|
||||||
|
To package the current platform into `release/`, run:
|
||||||
|
|
||||||
|
- `bun run package`
|
||||||
|
|
||||||
|
GitHub Actions now runs validation on pushes and pull requests, and pushing a git tag creates a GitHub release with Windows, macOS, and Linux assets uploaded directly to the release.
|
||||||
|
|
||||||
## Current focus
|
## Current focus
|
||||||
|
|
||||||
Eryx is focused on local, project-based work with your GitHub Copilot account. It already covers the essentials for working with projects, sessions, and reusable orchestration patterns, and it is growing toward a fuller AI workstation experience over time.
|
Eryx is focused on local, project-based work with your GitHub Copilot account. It already covers the essentials for working with projects, sessions, and reusable orchestration patterns, and it is growing toward a fuller AI workstation experience over time.
|
||||||
|
|||||||
+5
-5
@@ -8,15 +8,15 @@
|
|||||||
"dev": "electron-vite dev",
|
"dev": "electron-vite dev",
|
||||||
"build:electron": "electron-vite build",
|
"build:electron": "electron-vite build",
|
||||||
"build": "bun run build:electron && bun run sidecar:build",
|
"build": "bun run build:electron && bun run sidecar:build",
|
||||||
"package": "bun run build:electron && bun run sidecar:publish && node \".\\scripts\\package-electron.mjs\"",
|
"package": "bun run build:electron && bun run sidecar:publish && bun run scripts/package-electron.ts",
|
||||||
"preview": "electron-vite preview",
|
"preview": "electron-vite preview",
|
||||||
"lsp:typescript": "typescript-language-server --stdio",
|
"lsp:typescript": "typescript-language-server --stdio",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
"test": "bun run typecheck && bun test",
|
"test": "bun run typecheck && bun test",
|
||||||
"sidecar:restore": "powershell -NoProfile -Command \"dotnet restore 'sidecar\\\\Eryx.AgentHost.slnx'\"",
|
"sidecar:restore": "dotnet restore sidecar/Eryx.AgentHost.slnx",
|
||||||
"sidecar:build": "powershell -NoProfile -Command \"dotnet build 'sidecar\\\\Eryx.AgentHost.slnx'\"",
|
"sidecar:build": "dotnet build sidecar/Eryx.AgentHost.slnx",
|
||||||
"sidecar:publish": "powershell -NoProfile -Command \"if (Test-Path 'dist-sidecar\\\\win-x64') { Remove-Item 'dist-sidecar\\\\win-x64' -Recurse -Force }; dotnet publish 'sidecar\\\\src\\\\Eryx.AgentHost\\\\Eryx.AgentHost.csproj' -c Release -r win-x64 --self-contained true -p:DebugType=None -p:DebugSymbols=false -o 'dist-sidecar\\\\win-x64'\"",
|
"sidecar:publish": "bun run scripts/publish-sidecar.ts",
|
||||||
"sidecar:test": "powershell -NoProfile -Command \"dotnet test 'sidecar\\\\Eryx.AgentHost.slnx'\""
|
"sidecar:test": "dotnet test sidecar/Eryx.AgentHost.slnx"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
import { access, cp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
||||||
import { constants } from 'node:fs';
|
|
||||||
import { dirname, join, resolve } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { rcedit } from 'rcedit';
|
|
||||||
|
|
||||||
const productName = 'Eryx';
|
|
||||||
const sidecarRuntime = 'win-x64';
|
|
||||||
const outputDirectoryName = 'win-unpacked';
|
|
||||||
|
|
||||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const repositoryRoot = resolve(scriptDirectory, '..');
|
|
||||||
const assetDirectory = join(repositoryRoot, 'assets');
|
|
||||||
const sourceIconPath = join(assetDirectory, 'icons', 'icon.png');
|
|
||||||
const windowsIconPath = join(assetDirectory, 'icons', 'windows', 'icon.ico');
|
|
||||||
const electronDistributionDirectory = join(repositoryRoot, 'node_modules', 'electron', 'dist');
|
|
||||||
const rendererBuildDirectory = join(repositoryRoot, 'dist');
|
|
||||||
const electronBuildDirectory = join(repositoryRoot, 'dist-electron');
|
|
||||||
const publishedSidecarDirectory = join(repositoryRoot, 'dist-sidecar', sidecarRuntime);
|
|
||||||
const outputDirectory = join(repositoryRoot, 'release', outputDirectoryName);
|
|
||||||
const outputResourcesDirectory = join(outputDirectory, 'resources');
|
|
||||||
const packagedAppDirectory = join(outputResourcesDirectory, 'app');
|
|
||||||
const packagedExecutablePath = join(outputDirectory, `${productName}.exe`);
|
|
||||||
|
|
||||||
async function ensurePathExists(path, label) {
|
|
||||||
try {
|
|
||||||
await access(path, constants.F_OK);
|
|
||||||
} catch {
|
|
||||||
throw new Error(`${label} was not found at ${path}.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readJson(path) {
|
|
||||||
return JSON.parse(await readFile(path, 'utf8'));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function collectRuntimeDependencies() {
|
|
||||||
const rootPackageJson = await readJson(join(repositoryRoot, 'package.json'));
|
|
||||||
const dependencies = new Set(Object.keys(rootPackageJson.dependencies ?? {}));
|
|
||||||
const queue = [...dependencies];
|
|
||||||
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const dependencyName = queue.shift();
|
|
||||||
const dependencyPackageJson = await readJson(
|
|
||||||
join(repositoryRoot, 'node_modules', ...dependencyName.split('/'), 'package.json'),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const transitiveDependency of Object.keys({
|
|
||||||
...(dependencyPackageJson.dependencies ?? {}),
|
|
||||||
...(dependencyPackageJson.optionalDependencies ?? {}),
|
|
||||||
})) {
|
|
||||||
if (!dependencies.has(transitiveDependency)) {
|
|
||||||
dependencies.add(transitiveDependency);
|
|
||||||
queue.push(transitiveDependency);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...dependencies].sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyRuntimeDependencies(dependencyNames) {
|
|
||||||
const packagedNodeModulesDirectory = join(packagedAppDirectory, 'node_modules');
|
|
||||||
await mkdir(packagedNodeModulesDirectory, { recursive: true });
|
|
||||||
|
|
||||||
for (const dependencyName of dependencyNames) {
|
|
||||||
const dependencyPathParts = dependencyName.split('/');
|
|
||||||
const sourceDirectory = join(repositoryRoot, 'node_modules', ...dependencyPathParts);
|
|
||||||
const targetDirectory = join(packagedNodeModulesDirectory, ...dependencyPathParts);
|
|
||||||
await mkdir(dirname(targetDirectory), { recursive: true });
|
|
||||||
await cp(sourceDirectory, targetDirectory, { recursive: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writePackagedManifest() {
|
|
||||||
const sourcePackageJson = await readJson(join(repositoryRoot, 'package.json'));
|
|
||||||
const packagedManifest = {
|
|
||||||
name: sourcePackageJson.name,
|
|
||||||
productName,
|
|
||||||
version: sourcePackageJson.version,
|
|
||||||
description: sourcePackageJson.description,
|
|
||||||
main: sourcePackageJson.main,
|
|
||||||
author: sourcePackageJson.author,
|
|
||||||
license: sourcePackageJson.license,
|
|
||||||
};
|
|
||||||
|
|
||||||
await writeFile(join(packagedAppDirectory, 'package.json'), `${JSON.stringify(packagedManifest, null, 2)}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
if (process.platform !== 'win32') {
|
|
||||||
throw new Error('This packaging script currently targets Windows only.');
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
ensurePathExists(assetDirectory, 'Application assets'),
|
|
||||||
ensurePathExists(sourceIconPath, 'Source application icon'),
|
|
||||||
ensurePathExists(windowsIconPath, 'Windows application icon'),
|
|
||||||
ensurePathExists(electronDistributionDirectory, 'Electron runtime'),
|
|
||||||
ensurePathExists(rendererBuildDirectory, 'Renderer build output'),
|
|
||||||
ensurePathExists(electronBuildDirectory, 'Electron build output'),
|
|
||||||
ensurePathExists(publishedSidecarDirectory, 'Published sidecar output'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const runtimeDependencies = await collectRuntimeDependencies();
|
|
||||||
|
|
||||||
await rm(outputDirectory, { recursive: true, force: true });
|
|
||||||
await mkdir(outputDirectory, { recursive: true });
|
|
||||||
await cp(electronDistributionDirectory, outputDirectory, { recursive: true });
|
|
||||||
await rename(join(outputDirectory, 'electron.exe'), packagedExecutablePath);
|
|
||||||
|
|
||||||
await mkdir(packagedAppDirectory, { recursive: true });
|
|
||||||
await Promise.all([
|
|
||||||
writePackagedManifest(),
|
|
||||||
cp(assetDirectory, join(packagedAppDirectory, 'assets'), { recursive: true }),
|
|
||||||
cp(rendererBuildDirectory, join(packagedAppDirectory, 'dist'), { recursive: true }),
|
|
||||||
cp(electronBuildDirectory, join(packagedAppDirectory, 'dist-electron'), { recursive: true }),
|
|
||||||
cp(publishedSidecarDirectory, join(outputResourcesDirectory, 'sidecar'), { recursive: true }),
|
|
||||||
]);
|
|
||||||
await copyRuntimeDependencies(runtimeDependencies);
|
|
||||||
await rcedit(packagedExecutablePath, { icon: windowsIconPath });
|
|
||||||
|
|
||||||
console.log(`Packaged ${productName} to ${outputDirectory}`);
|
|
||||||
console.log(`Bundled ${runtimeDependencies.length} runtime dependencies and the self-contained .NET sidecar.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await main();
|
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import { constants } from 'node:fs';
|
||||||
|
import { access, chmod, cp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import {
|
||||||
|
macBundleIdentifier,
|
||||||
|
productName,
|
||||||
|
resolveReleaseTarget,
|
||||||
|
type ReleaseTarget,
|
||||||
|
} from './releaseTarget';
|
||||||
|
|
||||||
|
interface PackageManifest {
|
||||||
|
readonly name: string;
|
||||||
|
readonly productName: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly description?: string;
|
||||||
|
readonly main: string;
|
||||||
|
readonly author?: string;
|
||||||
|
readonly license?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RootPackageJson {
|
||||||
|
readonly name: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly description?: string;
|
||||||
|
readonly main: string;
|
||||||
|
readonly author?: string;
|
||||||
|
readonly license?: string;
|
||||||
|
readonly dependencies?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repositoryRoot = resolve(scriptDirectory, '..');
|
||||||
|
const assetDirectory = join(repositoryRoot, 'assets');
|
||||||
|
const genericIconPath = join(assetDirectory, 'icons', 'icon.png');
|
||||||
|
const windowsIconPath = join(assetDirectory, 'icons', 'windows', 'icon.ico');
|
||||||
|
const macosIconPath = join(assetDirectory, 'icons', 'macos', 'icon.icns');
|
||||||
|
const rendererBuildDirectory = join(repositoryRoot, 'dist');
|
||||||
|
const electronBuildDirectory = join(repositoryRoot, 'dist-electron');
|
||||||
|
const releaseTarget = resolveReleaseTarget(process.platform, process.arch);
|
||||||
|
const releaseRootDirectory = join(repositoryRoot, 'release');
|
||||||
|
const outputDirectory = join(releaseRootDirectory, releaseTarget.outputDirectoryName);
|
||||||
|
const electronDistributionDirectory = releaseTarget.platform === 'darwin'
|
||||||
|
? join(repositoryRoot, 'node_modules', 'electron', 'dist', 'Electron.app')
|
||||||
|
: join(repositoryRoot, 'node_modules', 'electron', 'dist');
|
||||||
|
const publishedSidecarDirectory = join(repositoryRoot, 'dist-sidecar', releaseTarget.dotnetRuntime);
|
||||||
|
|
||||||
|
async function ensurePathExists(path: string, label: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await access(path, constants.F_OK);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label} was not found at ${path}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathExists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(path, constants.F_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson<T>(path: string): Promise<T> {
|
||||||
|
return JSON.parse(await readFile(path, 'utf8')) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectRuntimeDependencies(): Promise<string[]> {
|
||||||
|
const rootPackageJson = await readJson<RootPackageJson>(join(repositoryRoot, 'package.json'));
|
||||||
|
const dependencies = new Set(Object.keys(rootPackageJson.dependencies ?? {}));
|
||||||
|
const queue = [...dependencies];
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const dependencyName = queue.shift();
|
||||||
|
if (!dependencyName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dependencyPackageJsonPath = join(
|
||||||
|
repositoryRoot,
|
||||||
|
'node_modules',
|
||||||
|
...dependencyName.split('/'),
|
||||||
|
'package.json',
|
||||||
|
);
|
||||||
|
if (!(await pathExists(dependencyPackageJsonPath))) {
|
||||||
|
dependencies.delete(dependencyName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dependencyPackageJson = await readJson<{
|
||||||
|
readonly dependencies?: Record<string, string>;
|
||||||
|
readonly optionalDependencies?: Record<string, string>;
|
||||||
|
}>(dependencyPackageJsonPath);
|
||||||
|
|
||||||
|
for (const transitiveDependency of Object.keys({
|
||||||
|
...(dependencyPackageJson.dependencies ?? {}),
|
||||||
|
...(dependencyPackageJson.optionalDependencies ?? {}),
|
||||||
|
})) {
|
||||||
|
if (!dependencies.has(transitiveDependency)) {
|
||||||
|
dependencies.add(transitiveDependency);
|
||||||
|
queue.push(transitiveDependency);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...dependencies].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyRuntimeDependencies(
|
||||||
|
packagedAppDirectory: string,
|
||||||
|
dependencyNames: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const packagedNodeModulesDirectory = join(packagedAppDirectory, 'node_modules');
|
||||||
|
await mkdir(packagedNodeModulesDirectory, { recursive: true });
|
||||||
|
|
||||||
|
for (const dependencyName of dependencyNames) {
|
||||||
|
const dependencyPathParts = dependencyName.split('/');
|
||||||
|
const sourceDirectory = join(repositoryRoot, 'node_modules', ...dependencyPathParts);
|
||||||
|
const targetDirectory = join(packagedNodeModulesDirectory, ...dependencyPathParts);
|
||||||
|
await mkdir(dirname(targetDirectory), { recursive: true });
|
||||||
|
await cp(sourceDirectory, targetDirectory, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writePackagedManifest(packagedAppDirectory: string): Promise<PackageManifest> {
|
||||||
|
const sourcePackageJson = await readJson<RootPackageJson>(join(repositoryRoot, 'package.json'));
|
||||||
|
const packagedManifest: PackageManifest = {
|
||||||
|
name: sourcePackageJson.name,
|
||||||
|
productName,
|
||||||
|
version: sourcePackageJson.version,
|
||||||
|
description: sourcePackageJson.description,
|
||||||
|
main: sourcePackageJson.main,
|
||||||
|
author: sourcePackageJson.author,
|
||||||
|
license: sourcePackageJson.license,
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
join(packagedAppDirectory, 'package.json'),
|
||||||
|
`${JSON.stringify(packagedManifest, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return packagedManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyApplicationPayload(
|
||||||
|
packagedAppDirectory: string,
|
||||||
|
outputResourcesDirectory: string,
|
||||||
|
dependencyNames: string[],
|
||||||
|
): Promise<PackageManifest> {
|
||||||
|
await mkdir(packagedAppDirectory, { recursive: true });
|
||||||
|
|
||||||
|
const manifest = await writePackagedManifest(packagedAppDirectory);
|
||||||
|
await Promise.all([
|
||||||
|
cp(assetDirectory, join(packagedAppDirectory, 'assets'), { recursive: true }),
|
||||||
|
cp(rendererBuildDirectory, join(packagedAppDirectory, 'dist'), { recursive: true }),
|
||||||
|
cp(electronBuildDirectory, join(packagedAppDirectory, 'dist-electron'), { recursive: true }),
|
||||||
|
cp(publishedSidecarDirectory, join(outputResourcesDirectory, 'sidecar'), { recursive: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await copyRuntimeDependencies(packagedAppDirectory, dependencyNames);
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureExecutable(path: string, mode = 0o755): Promise<void> {
|
||||||
|
await chmod(path, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replacePlistValue(plistContents: string, key: string, value: string): string {
|
||||||
|
const pattern = new RegExp(`(<key>${key}</key>\\s*<string>)([^<]*)(</string>)`);
|
||||||
|
if (!pattern.test(plistContents)) {
|
||||||
|
throw new Error(`Could not find ${key} in macOS Info.plist.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return plistContents.replace(pattern, `$1${value}$3`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyMacMetadata(appBundleDirectory: string, version: string): Promise<void> {
|
||||||
|
const infoPlistPath = join(appBundleDirectory, 'Contents', 'Info.plist');
|
||||||
|
let infoPlistContents = await readFile(infoPlistPath, 'utf8');
|
||||||
|
infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleDisplayName', productName);
|
||||||
|
infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleExecutable', productName);
|
||||||
|
infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleIconFile', 'icon.icns');
|
||||||
|
infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleIdentifier', macBundleIdentifier);
|
||||||
|
infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleName', productName);
|
||||||
|
infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleShortVersionString', version);
|
||||||
|
infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleVersion', version);
|
||||||
|
await writeFile(infoPlistPath, infoPlistContents);
|
||||||
|
|
||||||
|
const sourceExecutablePath = join(appBundleDirectory, 'Contents', 'MacOS', 'Electron');
|
||||||
|
const targetExecutablePath = join(appBundleDirectory, 'Contents', 'MacOS', productName);
|
||||||
|
await rename(sourceExecutablePath, targetExecutablePath);
|
||||||
|
await ensureExecutable(targetExecutablePath);
|
||||||
|
await cp(macosIconPath, join(appBundleDirectory, 'Contents', 'Resources', 'icon.icns'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function packageWindows(dependencyNames: string[]): Promise<void> {
|
||||||
|
const packagedExecutablePath = join(outputDirectory, `${productName}.exe`);
|
||||||
|
const packagedAppDirectory = join(outputDirectory, 'resources', 'app');
|
||||||
|
const outputResourcesDirectory = join(outputDirectory, 'resources');
|
||||||
|
|
||||||
|
await cp(electronDistributionDirectory, outputDirectory, { recursive: true });
|
||||||
|
await rename(join(outputDirectory, 'electron.exe'), packagedExecutablePath);
|
||||||
|
await copyApplicationPayload(packagedAppDirectory, outputResourcesDirectory, dependencyNames);
|
||||||
|
|
||||||
|
const { rcedit } = await import('rcedit');
|
||||||
|
await rcedit(packagedExecutablePath, { icon: windowsIconPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function packageMac(dependencyNames: string[]): Promise<void> {
|
||||||
|
const appBundleName = releaseTarget.appBundleName;
|
||||||
|
if (!appBundleName) {
|
||||||
|
throw new Error('macOS packaging requires an app bundle name.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const appBundleDirectory = join(outputDirectory, appBundleName);
|
||||||
|
const packagedAppDirectory = join(appBundleDirectory, 'Contents', 'Resources', 'app');
|
||||||
|
const outputResourcesDirectory = join(appBundleDirectory, 'Contents', 'Resources');
|
||||||
|
|
||||||
|
await cp(electronDistributionDirectory, appBundleDirectory, { recursive: true });
|
||||||
|
const manifest = await copyApplicationPayload(packagedAppDirectory, outputResourcesDirectory, dependencyNames);
|
||||||
|
await applyMacMetadata(appBundleDirectory, manifest.version);
|
||||||
|
await ensureExecutable(join(outputResourcesDirectory, 'sidecar', releaseTarget.sidecarExecutableName));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function packageLinux(dependencyNames: string[]): Promise<void> {
|
||||||
|
const packagedExecutableName = releaseTarget.packagedExecutableName;
|
||||||
|
if (!packagedExecutableName) {
|
||||||
|
throw new Error('Linux packaging requires a packaged executable name.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const packagedExecutablePath = join(outputDirectory, packagedExecutableName);
|
||||||
|
const packagedAppDirectory = join(outputDirectory, 'resources', 'app');
|
||||||
|
const outputResourcesDirectory = join(outputDirectory, 'resources');
|
||||||
|
const chromeSandboxPath = join(outputDirectory, 'chrome-sandbox');
|
||||||
|
|
||||||
|
await cp(electronDistributionDirectory, outputDirectory, { recursive: true });
|
||||||
|
await rename(join(outputDirectory, 'electron'), packagedExecutablePath);
|
||||||
|
await ensureExecutable(packagedExecutablePath);
|
||||||
|
await copyApplicationPayload(packagedAppDirectory, outputResourcesDirectory, dependencyNames);
|
||||||
|
await ensureExecutable(join(outputResourcesDirectory, 'sidecar', releaseTarget.sidecarExecutableName));
|
||||||
|
|
||||||
|
if (await pathExists(chromeSandboxPath)) {
|
||||||
|
await chmod(chromeSandboxPath, 0o4755);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function packageCurrentPlatform(target: ReleaseTarget, dependencyNames: string[]): Promise<void> {
|
||||||
|
switch (target.platform) {
|
||||||
|
case 'win32':
|
||||||
|
await packageWindows(dependencyNames);
|
||||||
|
return;
|
||||||
|
case 'darwin':
|
||||||
|
await packageMac(dependencyNames);
|
||||||
|
return;
|
||||||
|
case 'linux':
|
||||||
|
await packageLinux(dependencyNames);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
ensurePathExists(assetDirectory, 'Application assets'),
|
||||||
|
ensurePathExists(genericIconPath, 'Source application icon'),
|
||||||
|
ensurePathExists(electronDistributionDirectory, 'Electron runtime'),
|
||||||
|
ensurePathExists(rendererBuildDirectory, 'Renderer build output'),
|
||||||
|
ensurePathExists(electronBuildDirectory, 'Electron build output'),
|
||||||
|
ensurePathExists(publishedSidecarDirectory, 'Published sidecar output'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (releaseTarget.platform === 'win32') {
|
||||||
|
await ensurePathExists(windowsIconPath, 'Windows application icon');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (releaseTarget.platform === 'darwin') {
|
||||||
|
await ensurePathExists(macosIconPath, 'macOS application icon');
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeDependencies = await collectRuntimeDependencies();
|
||||||
|
|
||||||
|
await rm(outputDirectory, { recursive: true, force: true });
|
||||||
|
await mkdir(releaseRootDirectory, { recursive: true });
|
||||||
|
await packageCurrentPlatform(releaseTarget, runtimeDependencies);
|
||||||
|
|
||||||
|
console.log(`Packaged ${productName} for ${releaseTarget.platformLabel} to ${outputDirectory}`);
|
||||||
|
console.log(`Bundled ${runtimeDependencies.length} runtime dependencies and the self-contained .NET sidecar.`);
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { rm } from 'node:fs/promises';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { resolveReleaseTarget } from './releaseTarget';
|
||||||
|
|
||||||
|
function runCommand(command: string, args: string[], cwd: string): Promise<void> {
|
||||||
|
return new Promise((resolvePromise, rejectPromise) => {
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
cwd,
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', rejectPromise);
|
||||||
|
child.on('exit', (code, signal) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolvePromise();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
rejectPromise(new Error(`${command} exited because of signal ${signal}.`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectPromise(new Error(`${command} exited with code ${code ?? 'unknown'}.`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repositoryRoot = resolve(scriptDirectory, '..');
|
||||||
|
const releaseTarget = resolveReleaseTarget(process.platform, process.arch);
|
||||||
|
const sidecarProjectPath = join(
|
||||||
|
repositoryRoot,
|
||||||
|
'sidecar',
|
||||||
|
'src',
|
||||||
|
'Eryx.AgentHost',
|
||||||
|
'Eryx.AgentHost.csproj',
|
||||||
|
);
|
||||||
|
const outputDirectory = join(repositoryRoot, 'dist-sidecar', releaseTarget.dotnetRuntime);
|
||||||
|
|
||||||
|
await rm(outputDirectory, { recursive: true, force: true });
|
||||||
|
|
||||||
|
await runCommand(
|
||||||
|
'dotnet',
|
||||||
|
[
|
||||||
|
'publish',
|
||||||
|
sidecarProjectPath,
|
||||||
|
'-c',
|
||||||
|
'Release',
|
||||||
|
'-r',
|
||||||
|
releaseTarget.dotnetRuntime,
|
||||||
|
'--self-contained',
|
||||||
|
'true',
|
||||||
|
'-p:DebugType=None',
|
||||||
|
'-p:DebugSymbols=false',
|
||||||
|
'-o',
|
||||||
|
outputDirectory,
|
||||||
|
],
|
||||||
|
repositoryRoot,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Published sidecar for ${releaseTarget.platformLabel} (${releaseTarget.dotnetRuntime}) to ${outputDirectory}`);
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
export const productName = 'Eryx';
|
||||||
|
export const macBundleIdentifier = 'com.davidkaya.eryx';
|
||||||
|
|
||||||
|
type SupportedPlatform = 'win32' | 'darwin' | 'linux';
|
||||||
|
type SupportedArch = 'x64' | 'arm64';
|
||||||
|
|
||||||
|
export interface ReleaseTarget {
|
||||||
|
readonly platform: SupportedPlatform;
|
||||||
|
readonly arch: SupportedArch;
|
||||||
|
readonly platformLabel: 'windows' | 'macos' | 'linux';
|
||||||
|
readonly dotnetRuntime: `${string}-${SupportedArch}`;
|
||||||
|
readonly outputDirectoryName: string;
|
||||||
|
readonly archiveBaseName: string;
|
||||||
|
readonly sidecarExecutableName: string;
|
||||||
|
readonly packagedExecutableName?: string;
|
||||||
|
readonly appBundleName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSupportedArch(
|
||||||
|
platform: SupportedPlatform,
|
||||||
|
arch: NodeJS.Architecture,
|
||||||
|
): SupportedArch {
|
||||||
|
if (arch === 'x64' || arch === 'arm64') {
|
||||||
|
return arch;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unsupported architecture for ${platform}: ${arch}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveReleaseTarget(
|
||||||
|
platform: NodeJS.Platform,
|
||||||
|
arch: NodeJS.Architecture,
|
||||||
|
): ReleaseTarget {
|
||||||
|
switch (platform) {
|
||||||
|
case 'win32': {
|
||||||
|
const supportedArch = resolveSupportedArch(platform, arch);
|
||||||
|
const archiveBaseName = `${productName}-windows-${supportedArch}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
arch: supportedArch,
|
||||||
|
platformLabel: 'windows',
|
||||||
|
dotnetRuntime: `win-${supportedArch}`,
|
||||||
|
outputDirectoryName: archiveBaseName,
|
||||||
|
archiveBaseName,
|
||||||
|
sidecarExecutableName: 'Eryx.AgentHost.exe',
|
||||||
|
packagedExecutableName: `${productName}.exe`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'darwin': {
|
||||||
|
const supportedArch = resolveSupportedArch(platform, arch);
|
||||||
|
const archiveBaseName = `${productName}-macos-${supportedArch}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
arch: supportedArch,
|
||||||
|
platformLabel: 'macos',
|
||||||
|
dotnetRuntime: `osx-${supportedArch}`,
|
||||||
|
outputDirectoryName: archiveBaseName,
|
||||||
|
archiveBaseName,
|
||||||
|
sidecarExecutableName: 'Eryx.AgentHost',
|
||||||
|
appBundleName: `${productName}.app`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'linux': {
|
||||||
|
const supportedArch = resolveSupportedArch(platform, arch);
|
||||||
|
const archiveBaseName = `${productName}-linux-${supportedArch}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
arch: supportedArch,
|
||||||
|
platformLabel: 'linux',
|
||||||
|
dotnetRuntime: `linux-${supportedArch}`,
|
||||||
|
outputDirectoryName: archiveBaseName,
|
||||||
|
archiveBaseName,
|
||||||
|
sidecarExecutableName: 'Eryx.AgentHost',
|
||||||
|
packagedExecutableName: productName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported release platform: ${platform}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
macBundleIdentifier,
|
||||||
|
productName,
|
||||||
|
resolveReleaseTarget,
|
||||||
|
} from '../../scripts/releaseTarget';
|
||||||
|
|
||||||
|
describe('resolveReleaseTarget', () => {
|
||||||
|
test('maps Windows x64 to the expected runtime and output names', () => {
|
||||||
|
expect(resolveReleaseTarget('win32', 'x64')).toEqual({
|
||||||
|
platform: 'win32',
|
||||||
|
arch: 'x64',
|
||||||
|
platformLabel: 'windows',
|
||||||
|
dotnetRuntime: 'win-x64',
|
||||||
|
outputDirectoryName: 'Eryx-windows-x64',
|
||||||
|
archiveBaseName: 'Eryx-windows-x64',
|
||||||
|
sidecarExecutableName: 'Eryx.AgentHost.exe',
|
||||||
|
packagedExecutableName: 'Eryx.exe',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maps macOS arm64 to the expected bundle metadata', () => {
|
||||||
|
expect(resolveReleaseTarget('darwin', 'arm64')).toEqual({
|
||||||
|
platform: 'darwin',
|
||||||
|
arch: 'arm64',
|
||||||
|
platformLabel: 'macos',
|
||||||
|
dotnetRuntime: 'osx-arm64',
|
||||||
|
outputDirectoryName: 'Eryx-macos-arm64',
|
||||||
|
archiveBaseName: 'Eryx-macos-arm64',
|
||||||
|
sidecarExecutableName: 'Eryx.AgentHost',
|
||||||
|
appBundleName: 'Eryx.app',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maps Linux x64 to the expected runtime and output names', () => {
|
||||||
|
expect(resolveReleaseTarget('linux', 'x64')).toEqual({
|
||||||
|
platform: 'linux',
|
||||||
|
arch: 'x64',
|
||||||
|
platformLabel: 'linux',
|
||||||
|
dotnetRuntime: 'linux-x64',
|
||||||
|
outputDirectoryName: 'Eryx-linux-x64',
|
||||||
|
archiveBaseName: 'Eryx-linux-x64',
|
||||||
|
sidecarExecutableName: 'Eryx.AgentHost',
|
||||||
|
packagedExecutableName: 'Eryx',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exports the expected product metadata constants', () => {
|
||||||
|
expect(productName).toBe('Eryx');
|
||||||
|
expect(macBundleIdentifier).toBe('com.davidkaya.eryx');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects unsupported platforms', () => {
|
||||||
|
expect(() => resolveReleaseTarget('freebsd', 'x64')).toThrow(
|
||||||
|
'Unsupported release platform: freebsd',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects unsupported architectures', () => {
|
||||||
|
expect(() => resolveReleaseTarget('linux', 'ia32')).toThrow(
|
||||||
|
'Unsupported architecture for linux: ia32',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"electron.vite.config.ts",
|
"electron.vite.config.ts",
|
||||||
|
"scripts/**/*.ts",
|
||||||
"src/**/*.ts",
|
"src/**/*.ts",
|
||||||
"src/**/*.tsx",
|
"src/**/*.tsx",
|
||||||
"tests/**/*.ts"
|
"tests/**/*.ts"
|
||||||
|
|||||||
Reference in New Issue
Block a user