Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,40 @@ jobs:
run: |
node packages/scripts/verify-npm-pack-dist.mjs --all-public-dist-packages --build

# Stage downloadable release binaries so the GitHub Release carries more
# than just source archives (#11858): headline npm tarballs + standalone
# `elizaos` CLI executables for every platform + a combined checksum file.
# Runs while workspace:* refs are still replaced (packed dependency ranges
# match the registry) and before the "Restore workspace references" step.
# Gated to real release events — a workflow_dispatch beta (tag_created) or
# a stable GitHub `release`.
- name: Stage release binaries (npm tarballs + standalone CLI)
id: stage_artifacts
if: steps.tag.outputs.tag_created == 'true' || github.event_name == 'release'
run: |
set -euo pipefail
ARTIFACT_DIR="${GITHUB_WORKSPACE}/release-artifacts"
rm -rf "${ARTIFACT_DIR}"
mkdir -p "${ARTIFACT_DIR}"

echo "📦 Packing headline npm tarballs..."
node packages/scripts/pack-release-artifacts.mjs --out "${ARTIFACT_DIR}/npm"

echo "🛠 Compiling standalone elizaos CLI binaries (all platforms)..."
bun packages/elizaos/build-standalone.ts --out "${ARTIFACT_DIR}/cli"

echo "🔐 Writing combined SHA256SUMS.txt..."
(
cd "${ARTIFACT_DIR}"
find . -type f \( -name '*.tgz' -o -name '*.tar.gz' -o -name '*.zip' \) \
| sed 's|^\./||' | sort \
| while read -r f; do shasum -a 256 "$f"; done > SHA256SUMS.txt
)

echo "artifact_dir=${ARTIFACT_DIR}" >> "$GITHUB_OUTPUT"
echo "=== staged release artifacts ==="
find "${ARTIFACT_DIR}" -type f | sort

# Publish to NPM (only after git operations succeed)
- name: Skip NPM publish on forks
if: github.repository != 'elizaOS/eliza'
Expand Down Expand Up @@ -793,6 +827,17 @@ jobs:
with:
tag_name: ${{ steps.tag.outputs.tag_name }}
name: ${{ steps.tag.outputs.tag_name }}
# Attach the staged downloadable binaries alongside the auto-generated
# source archives. fail_on_unmatched_files surfaces a broken staging
# step instead of silently publishing a source-only release again.
files: |
release-artifacts/npm/*.tgz
release-artifacts/npm/SHA256SUMS-npm.txt
release-artifacts/cli/*.tar.gz
release-artifacts/cli/*.zip
release-artifacts/cli/SHA256SUMS-cli.txt
release-artifacts/SHA256SUMS.txt
fail_on_unmatched_files: true
body: |
🔵 Beta Release

Expand All @@ -811,12 +856,38 @@ jobs:
bun add @elizaos/core@${{ steps.release_type.outputs.dist_tag }}
```

### Downloads

Prefer a self-contained binary? Grab a standalone `elizaos` CLI
(`elizaos-<platform>.tar.gz` / `.zip`) below — no npm or Bun required.
The `*.tgz` files are the exact npm tarballs; verify any download
against `SHA256SUMS.txt`.

---

> **Note:** This is a ${{ steps.release_type.outputs.type }} release. Production releases use the `latest` tag and are triggered by GitHub releases on tags matching `v*.*.*`.
draft: false
prerelease: true

# Stable production releases already exist (a human created the GitHub
# release before this workflow fired on the `release` event), so we append
# the staged binaries to that existing release instead of creating one.
- name: Attach release binaries to production release
if: github.event_name == 'release' && steps.stage_artifacts.outputs.artifact_dir != ''
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.event.release.tag_name }}
files: |
release-artifacts/npm/*.tgz
release-artifacts/npm/SHA256SUMS-npm.txt
release-artifacts/cli/*.tar.gz
release-artifacts/cli/*.zip
release-artifacts/cli/SHA256SUMS-cli.txt
release-artifacts/SHA256SUMS.txt
fail_on_unmatched_files: true

- name: Summary
if: always()
run: |
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -1518,3 +1518,8 @@ railway.json
.railwayignore
.dockerignore
packages/ui/src/components/shell/__e2e__/output-fused-wake-integration/

# Standalone CLI build output (packages/elizaos/build-standalone.ts)
packages/elizaos/dist-standalone/
# Release artifacts staged by release.yaml (npm tarballs + CLI binaries)
release-artifacts/
3 changes: 3 additions & 0 deletions packages/elizaos/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,12 @@ Token replacement (`scaffold.ts`): plugin templates substitute `${PLUGINNAME}`,

```bash
bun run --cwd packages/elizaos build # build.ts: prep templates + manifest, tsc, shebang
bun run --cwd packages/elizaos build:standalone # build-standalone.ts: cross-compile CLI binaries + archives
bun run --cwd packages/elizaos dev # build.ts --watch
bun run --cwd packages/elizaos typecheck # tsgo --noEmit
bun run --cwd packages/elizaos test # vitest run --passWithNoTests
bun run --cwd packages/elizaos test:packaged # scripts/packaged-smoke.mjs (packs + installs + create/upgrade)
bun run --cwd packages/elizaos test:standalone # scripts/standalone-smoke.mjs (compiles host binary, runs version/info/create)
bun run --cwd packages/elizaos lint # biome check --write (also lints templates/plugin + project/apps/app)
bun run --cwd packages/elizaos lint:check # biome check (no write)
```
Expand All @@ -78,6 +80,7 @@ Generated projects record state in `.elizaos/template.json` (`ProjectTemplateMet
## Conventions / gotchas

- `manifest.ts` and `getTemplatesDir` read `templates-manifest.json` and `templates/` relative to `getPackageRoot()` (one dir up from `dist/`), so the CLI only works after `build` and against the shipped `dist` + `templates` (both in the `files` allowlist). `loadManifest` throws if the manifest is missing — run `build` first.
- **Standalone binaries** (`build-standalone.ts`, released via `release.yaml`): a `bun build --compile` binary serves its embedded modules from the unreadable `/$bunfs` virtual root, so `getPackageRoot()` (`package-info.ts`) detects that case via `isStandaloneBinary()` and resolves assets from `path.dirname(process.execPath)` instead. The build script therefore stages `templates/`, `templates-manifest.json`, and `package.json` next to the executable inside each archive. `scripts/standalone-smoke.mjs` is the regression guard — if you change asset resolution, run `test:standalone`. The CLI still shells out to `git`/`gh`/`npm` at runtime, so the binary is self-contained for scaffolding but not for the git/registry steps.
- `cli.ts` ends with top-level `await program.parseAsync()`; the bin shebang is `#!/usr/bin/env node` (re-applied by `ensureCliShebang` in `build.ts`).
- Commands that interact with the user use `@clack/prompts` and call `process.exit(...)` directly on cancel/error; `deploy`/`capabilityRouterConnect` split a pure `run*` function (returns exit code) from the thin `process.exit` wrapper for testability.
- `plugins submit --dry-run` prints the generated `entries/third-party/<pkg>.json` metadata. Opening a PR requires an explicit `--registry owner/repo`; no public default registry repository is configured. `@elizaos/*` names are rejected (reserved for first-party).
Expand Down
3 changes: 3 additions & 0 deletions packages/elizaos/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,12 @@ Token replacement (`scaffold.ts`): plugin templates substitute `${PLUGINNAME}`,

```bash
bun run --cwd packages/elizaos build # build.ts: prep templates + manifest, tsc, shebang
bun run --cwd packages/elizaos build:standalone # build-standalone.ts: cross-compile CLI binaries + archives
bun run --cwd packages/elizaos dev # build.ts --watch
bun run --cwd packages/elizaos typecheck # tsgo --noEmit
bun run --cwd packages/elizaos test # vitest run --passWithNoTests
bun run --cwd packages/elizaos test:packaged # scripts/packaged-smoke.mjs (packs + installs + create/upgrade)
bun run --cwd packages/elizaos test:standalone # scripts/standalone-smoke.mjs (compiles host binary, runs version/info/create)
bun run --cwd packages/elizaos lint # biome check --write (also lints templates/plugin + project/apps/app)
bun run --cwd packages/elizaos lint:check # biome check (no write)
```
Expand All @@ -78,6 +80,7 @@ Generated projects record state in `.elizaos/template.json` (`ProjectTemplateMet
## Conventions / gotchas

- `manifest.ts` and `getTemplatesDir` read `templates-manifest.json` and `templates/` relative to `getPackageRoot()` (one dir up from `dist/`), so the CLI only works after `build` and against the shipped `dist` + `templates` (both in the `files` allowlist). `loadManifest` throws if the manifest is missing — run `build` first.
- **Standalone binaries** (`build-standalone.ts`, released via `release.yaml`): a `bun build --compile` binary serves its embedded modules from the unreadable `/$bunfs` virtual root, so `getPackageRoot()` (`package-info.ts`) detects that case via `isStandaloneBinary()` and resolves assets from `path.dirname(process.execPath)` instead. The build script therefore stages `templates/`, `templates-manifest.json`, and `package.json` next to the executable inside each archive. `scripts/standalone-smoke.mjs` is the regression guard — if you change asset resolution, run `test:standalone`. The CLI still shells out to `git`/`gh`/`npm` at runtime, so the binary is self-contained for scaffolding but not for the git/registry steps.
- `cli.ts` ends with top-level `await program.parseAsync()`; the bin shebang is `#!/usr/bin/env node` (re-applied by `ensureCliShebang` in `build.ts`).
- Commands that interact with the user use `@clack/prompts` and call `process.exit(...)` directly on cancel/error; `deploy`/`capabilityRouterConnect` split a pure `run*` function (returns exit code) from the thin `process.exit` wrapper for testability.
- `plugins submit --dry-run` prints the generated `entries/third-party/<pkg>.json` metadata. Opening a PR requires an explicit `--registry owner/repo`; no public default registry repository is configured. `@elizaos/*` names are rejected (reserved for first-party).
Expand Down
166 changes: 166 additions & 0 deletions packages/elizaos/build-standalone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
#!/usr/bin/env bun

/**
* Build standalone `elizaos` CLI executables for every release platform.
*
* `bun build --compile` embeds the JS module graph into a single native binary,
* but the CLI still reads its templates/, templates-manifest.json, and
* package.json from disk at runtime (see src/manifest.ts + src/package-info.ts).
* Inside a compiled binary those live in the unreadable `/$bunfs` virtual root,
* so `getPackageRoot()` falls back to the executable's directory
* (`isStandaloneBinary()`), and we stage those assets next to the binary here.
*
* Each target produces `<out>/elizaos-<label>/` containing the binary + assets,
* archived to `.tar.gz` (unix) or `.zip` (windows). A single runner can cross
* compile every target, so the whole matrix builds on Linux in one step.
*
* Usage:
* bun run build-standalone.ts [--out <dir>] [--target <label>[,<label>...]]
* Labels: linux-x64 linux-arm64 darwin-x64 darwin-arm64 windows-x64
*/

import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import { copyDir } from "./safe-copy-dir.ts";

const PACKAGE_DIR = import.meta.dir;
const DIST_CLI = path.join(PACKAGE_DIR, "dist", "cli.js");

interface Target {
/** Human-facing label used in archive names and --target selection. */
label: string;
/** `bun build --compile --target=` value. */
bunTarget: string;
os: "linux" | "darwin" | "windows";
}

const TARGETS: Target[] = [
{ label: "linux-x64", bunTarget: "bun-linux-x64", os: "linux" },
{ label: "linux-arm64", bunTarget: "bun-linux-arm64", os: "linux" },
{ label: "darwin-x64", bunTarget: "bun-darwin-x64", os: "darwin" },
{ label: "darwin-arm64", bunTarget: "bun-darwin-arm64", os: "darwin" },
{ label: "windows-x64", bunTarget: "bun-windows-x64", os: "windows" },
];

function parseArgs(argv: string[]): { outDir: string; selected: Target[] } {
let outDir = path.join(PACKAGE_DIR, "dist-standalone");
let selected = TARGETS;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--out") {
outDir = path.resolve(argv[++i] ?? outDir);
} else if (arg === "--target") {
const labels = new Set((argv[++i] ?? "").split(",").map((s) => s.trim()));
selected = TARGETS.filter((t) => labels.has(t.label));
if (selected.length === 0) {
throw new Error(
`No matching targets for --target; valid: ${TARGETS.map((t) => t.label).join(", ")}`,
);
}
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return { outDir, selected };
}

function run(bin: string, args: string[], cwd = PACKAGE_DIR): void {
const result = spawnSync(bin, args, { cwd, stdio: "inherit" });
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(
`${bin} ${args.join(" ")} exited with code ${result.status}`,
);
}
}

function ensureBuilt(): void {
// The compiled binary embeds dist/cli.js; the archive ships templates/ and
// templates-manifest.json. `bun run build` produces all three.
if (
!fs.existsSync(DIST_CLI) ||
!fs.existsSync(path.join(PACKAGE_DIR, "templates-manifest.json"))
) {
run("bun", ["run", "build"]);
}
}

function sha256(file: string): string {
return createHash("sha256").update(fs.readFileSync(file)).digest("hex");
}

function stageAssets(stageDir: string): void {
// Ship exactly what getPackageRoot() looks for next to the binary.
copyDir(
path.join(PACKAGE_DIR, "templates"),
path.join(stageDir, "templates"),
);
fs.copyFileSync(
path.join(PACKAGE_DIR, "templates-manifest.json"),
path.join(stageDir, "templates-manifest.json"),
);
fs.copyFileSync(
path.join(PACKAGE_DIR, "package.json"),
path.join(stageDir, "package.json"),
);
}

function archive(target: Target, stageDir: string, outDir: string): string {
const stageName = path.basename(stageDir);
if (target.os === "windows") {
const zipPath = path.join(outDir, `${stageName}.zip`);
fs.rmSync(zipPath, { force: true });
// -r recurse, -q quiet, -X strip extra file attributes for reproducibility.
run("zip", ["-r", "-q", "-X", zipPath, stageName], outDir);
return zipPath;
}
const tarPath = path.join(outDir, `${stageName}.tar.gz`);
fs.rmSync(tarPath, { force: true });
run("tar", ["-czf", tarPath, "-C", outDir, stageName]);
return tarPath;
}

function main(): void {
const { outDir, selected } = parseArgs(process.argv.slice(2));
ensureBuilt();
fs.mkdirSync(outDir, { recursive: true });

const checksums: string[] = [];
for (const target of selected) {
const stageName = `elizaos-${target.label}`;
const stageDir = path.join(outDir, stageName);
fs.rmSync(stageDir, { recursive: true, force: true });
fs.mkdirSync(stageDir, { recursive: true });

const binName = target.os === "windows" ? "elizaos.exe" : "elizaos";
console.log(`\n▶ Compiling ${target.label} (${target.bunTarget})...`);
run("bun", [
"build",
DIST_CLI,
"--compile",
`--target=${target.bunTarget}`,
"--outfile",
path.join(stageDir, binName),
]);

stageAssets(stageDir);
const archivePath = archive(target, stageDir, outDir);
fs.rmSync(stageDir, { recursive: true, force: true });

const digest = sha256(archivePath);
checksums.push(`${digest} ${path.basename(archivePath)}`);
console.log(
` ✅ ${path.basename(archivePath)} (${(fs.statSync(archivePath).size / 1e6).toFixed(1)} MB)`,
);
}

fs.writeFileSync(
path.join(outDir, "SHA256SUMS-cli.txt"),
`${checksums.join("\n")}\n`,
);
console.log(`\n✅ Standalone CLI archives written to ${outDir}`);
}

main();
6 changes: 4 additions & 2 deletions packages/elizaos/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@
],
"scripts": {
"build": "bun run build.ts",
"build:standalone": "bun run build-standalone.ts",
"dev": "bun run build.ts --watch",
"lint": "bunx @biomejs/biome check --write README.md build.ts package.json scripts src templates-manifest.json templates/min-plugin templates/min-project templates/project/.env.example templates/project/.gitignore templates/project/README.md templates/project/bunfig.toml templates/project/package.json templates/project/scripts templates/project/template.json templates/project/test tsconfig.json vitest.config.ts && cd templates/plugin && bunx @biomejs/biome check --write --unsafe . --config-path ./biome.json --vcs-enabled=false && cd ../project/apps/app && bunx @biomejs/biome check --write --unsafe . --config-path ./biome.json --vcs-enabled=false",
"lint:check": "bunx @biomejs/biome check README.md build.ts package.json scripts src templates-manifest.json templates/min-plugin templates/min-project templates/project/.env.example templates/project/.gitignore templates/project/README.md templates/project/bunfig.toml templates/project/package.json templates/project/scripts templates/project/template.json templates/project/test tsconfig.json vitest.config.ts && cd templates/plugin && bunx @biomejs/biome check . --config-path ./biome.json --vcs-enabled=false && cd ../project/apps/app && bunx @biomejs/biome check . --config-path ./biome.json --vcs-enabled=false",
"lint": "bunx @biomejs/biome check --write README.md build.ts build-standalone.ts package.json scripts src templates-manifest.json templates/min-plugin templates/min-project templates/project/.env.example templates/project/.gitignore templates/project/README.md templates/project/bunfig.toml templates/project/package.json templates/project/scripts templates/project/template.json templates/project/test tsconfig.json vitest.config.ts && cd templates/plugin && bunx @biomejs/biome check --write --unsafe . --config-path ./biome.json --vcs-enabled=false && cd ../project/apps/app && bunx @biomejs/biome check --write --unsafe . --config-path ./biome.json --vcs-enabled=false",
"lint:check": "bunx @biomejs/biome check README.md build.ts build-standalone.ts package.json scripts src templates-manifest.json templates/min-plugin templates/min-project templates/project/.env.example templates/project/.gitignore templates/project/README.md templates/project/bunfig.toml templates/project/package.json templates/project/scripts templates/project/template.json templates/project/test tsconfig.json vitest.config.ts && cd templates/plugin && bunx @biomejs/biome check . --config-path ./biome.json --vcs-enabled=false && cd ../project/apps/app && bunx @biomejs/biome check . --config-path ./biome.json --vcs-enabled=false",
"typecheck": "tsgo --noEmit",
"test": "vitest run --passWithNoTests",
"test:packaged": "node ./scripts/packaged-smoke.mjs",
"test:standalone": "node ./scripts/standalone-smoke.mjs",
"lint:bin-exports": "node ./scripts/lint-bin-exports.mjs",
"prepublishOnly": "bun run build && bun run lint:bin-exports"
},
Expand Down
Loading