Skip to content
Merged
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
110 changes: 106 additions & 4 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ name: Publish
# One workflow publishes every channel, since npm trusted publishing names a single workflow file.
# - canary / dev: every push to that branch
# - latest: run by hand on `main`; publishes each package whose version is not on npm yet
# dev and latest also build the standalone binary and attach it to a GitHub Release: `devel`, one
# prerelease replaced on every dev push, or `cli-v<version>`, marked latest. canary stays npm-only.
on:
push:
branches: [canary, dev]
Expand All @@ -26,9 +28,20 @@ env:
jobs:
guard:
runs-on: ubuntu-latest
# The run's release id, computed once so every job publishes under the same version.
outputs:
id: ${{ steps.release.outputs.id }}
steps:
- if: github.event_name == 'workflow_dispatch' && github.ref_name != 'main'
run: echo "::error::latest is published from main only" && exit 1
- uses: actions/checkout@v6
- uses: voidzero-dev/setup-vp@v1.21.1
with:
node-version: 24
run-install: false
cache: false
- id: release
run: echo "id=$(node scripts/version.mjs "$CHANNEL")" >> "$GITHUB_OUTPUT"

tui:
needs: guard
Expand All @@ -41,9 +54,6 @@ jobs:
- package: tui-darwin-arm64
runner: macos-14
target: aarch64-apple-darwin
- package: tui-darwin-x64
runner: macos-14
target: x86_64-apple-darwin
# Linux binaries link musl statically, so one build runs on any distro.
- package: tui-linux-arm64
runner: ubuntu-24.04-arm
Expand Down Expand Up @@ -78,8 +88,10 @@ jobs:
retention-days: 1

publish:
needs: tui
needs: [guard, tui]
runs-on: ubuntu-latest
env:
CODEWORK_RELEASE_ID: ${{ needs.guard.outputs.id }}
steps:
- uses: actions/checkout@v6
- uses: voidzero-dev/setup-vp@v1.21.1
Expand All @@ -98,3 +110,93 @@ jobs:
pattern: tui-*
path: packages/tui/dist/bin
- run: vp run release "$CHANNEL"

binary:
needs: [guard, tui]
if: github.ref_name != 'canary'
name: binary (${{ matrix.binary }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: true
matrix:
# Each on its own platform: --build-sea runs that platform's Node, and macOS signs.
include:
- binary: cli-darwin-arm64
tui: tui-darwin-arm64
runner: macos-14
- binary: cli-linux-arm64
tui: tui-linux-arm64
runner: ubuntu-24.04-arm
- binary: cli-linux-x64
tui: tui-linux-x64
runner: ubuntu-24.04
env:
CODEWORK_RELEASE_ID: ${{ needs.guard.outputs.id }}
steps:
- uses: actions/checkout@v6
# node-liblzma builds against the system liblzma at install. The bundle rebuilds it with xz
# linked statically, which needs cmake; the runners ship it.
- if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y liblzma-dev
- uses: voidzero-dev/setup-vp@v1.21.1
with:
node-version: 24
run-install: false
cache: false
- run: vp install --frozen-lockfile --filter '!@codeworksh/www'
- uses: actions/download-artifact@v8
with:
name: ${{ matrix.tui }}
path: tui
- run: |
chmod +x tui/codework-tui
echo "CODEWORK_TUI_BIN=$GITHUB_WORKSPACE/tui/codework-tui" >> "$GITHUB_ENV"
echo "CODEWORK_VERSION=$(node scripts/version.mjs "$CHANNEL" packages/codework)" >> "$GITHUB_ENV"
- working-directory: packages/codework
run: vp run bundle && vp run sea
# Without API keys only the offline case runs: the binary with no Node on PATH unpacks its
# assets and runs a plugin through its own effect.
- working-directory: packages/codework
run: vp test test/sea.test.ts
- run: tar -czf ${{ matrix.binary }}.tar.gz -C packages/codework/dist/sea codework
- uses: actions/upload-artifact@v7
with:
name: ${{ matrix.binary }}
path: ${{ matrix.binary }}.tar.gz
if-no-files-found: error
retention-days: 1

# After npm, so a release never names a version npm does not have.
release:
needs: [guard, publish, binary]
if: github.ref_name != 'canary'
runs-on: ubuntu-latest
permissions:
contents: write
env:
CODEWORK_RELEASE_ID: ${{ needs.guard.outputs.id }}
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v6
- uses: voidzero-dev/setup-vp@v1.21.1
with:
node-version: 24
run-install: false
cache: false
- uses: actions/download-artifact@v8
with:
pattern: cli-*
path: release
merge-multiple: true
- run: |
version="$(node scripts/version.mjs "$CHANNEL" packages/codework)"
if [ "$CHANNEL" = dev ]; then
gh release delete devel --cleanup-tag --yes || true
gh release create devel release/*.tar.gz --prerelease --target "$GITHUB_SHA" \
--title "CLI $version" --notes "Development build $version, from $GITHUB_SHA."
elif gh release view "cli-v$version" > /dev/null 2>&1; then
echo "cli-v$version is already released"
else
gh release create "cli-v$version" release/*.tar.gz --latest --target "$GITHUB_SHA" \
--title "CLI $version" --generate-notes
fi
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ Every package is published by CI from `.github/workflows/publish.yml`, which run
- **dev**: every push to `dev`. Versions are `<version>-dev.<YYYYMMDDHHMMSS>` (UTC).
- **latest**: run the workflow by hand on `main`. It publishes each package whose manifest version is not on npm yet and skips the rest.

Versions are bumped locally with `vp run bump <aikit|plugin|harness|tui|cli> [version]`, which runs `bumpp` with the package's `bump.config.ts` and commits and tags `@codeworksh/<pkg>@<version>` without pushing. `vp run bump tui` moves the launcher and its four platform packages together.
Versions are bumped locally with `vp run bump <aikit|plugin|harness|tui|cli> [version]`, which runs `bumpp` with the package's `bump.config.ts` and commits and tags `@codeworksh/<pkg>@<version>` without pushing. `vp run bump tui` moves the launcher and its three platform packages together.

Flow:

Expand Down
5 changes: 4 additions & 1 deletion packages/codework/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
},
"scripts": {
"build": "vp pack",
"bundle": "node --conditions=development scripts/bundle.mjs",
"sea": "node scripts/sea.mjs",
"check": "vp check",
"check:effect": "effect-tsgo diagnostics --project tsconfig.json --format text",
"lint": "vp lint",
Expand All @@ -56,7 +58,8 @@
},
"devDependencies": {
"@types/node": "catalog:",
"bumpp": "^12.3.0"
"bumpp": "^12.3.0",
"node-gyp": "^13.0.2"
},
"optionalDependencies": {
"@daytona/sdk": "^0.211.2",
Expand Down
174 changes: 174 additions & 0 deletions packages/codework/scripts/bundle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// Bundles the CLI into one ESM file for the standalone binary, beside the assets the binary
// embeds and unpacks. Writes `dist/sea`:
//
// codework.mjs the CLI, entry `src/sea.ts`
// assets/ worker.mjs, codework-tui, and node_modules/ for the packages that load
// files from beside their own modules (WASM, workers, native addons)
//
// Run under `--conditions=development` (see `scripts/shared.mjs`). The TUI comes from
// `CODEWORK_TUI_BIN`, else the local release build. `CODEWORK_VERSION` replaces the version the CLI
// reports, which a canary or dev release computes (scripts/version.mjs). Rebuilding xz statically
// needs cmake.
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
chmodSync,
copyFileSync,
cpSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { createRequire } from "node:module";
import { basename, dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { build } from "vite-plus";
import { source } from "./shared.mjs";

const root = fileURLToPath(new URL("..", import.meta.url));
const out = join(root, "dist/sea");
const assets = join(out, "assets");
const harness = join(root, "../harness/node_modules");

// Resolved by the harness from `CODEWORK_PACKAGES` (see `src/sea.ts`) instead of being bundled.
const unpacked = ["just-bash", "@silvia-odwyer/photon-node"];
// Only run at install, where @mongodb-js/zstd fetches its prebuilt addon.
// Only run at install, where @mongodb-js/zstd fetches its prebuilt addon; or only serve just-bash's
// `js-exec`, which the harness leaves off (no `javascript` option).
const skipped = new Set(["prebuild-install", "node-addon-api", "quickjs-emscripten"]);
// Documentation and types, never loaded.
const unused = /\.(map|d\.[cm]?ts|md|zip)$/;
/** Per package, which of its files to ship, by path inside it: the rest serve what the harness leaves off. */
const ships = {
// `python` (CPython, in vendor/) is off, and the harness imports dist/bundle, not the CLI in dist/bin.
"just-bash": (file) => !file.startsWith("vendor") && !file.startsWith("dist/bin"),
// The Node WASM build; the rest are asm.js, debug, browser and web worker builds.
"sql.js": (file) => file === "dist" || !file.startsWith("dist/") || /^dist\/sql-wasm\.(js|wasm)$/.test(file),
};

/** Bundle `entry` and everything it imports into the single file `out/name`. */
const bundle = (entry, outDir, name, extra = {}) =>
build({
configFile: join(root, "vite.config.ts"),
root,
logLevel: "warn",
ssr: { noExternal: true },
...extra,
build: {
ssr: entry,
target: "node26",
outDir,
emptyOutDir: false,
minify: true,
rollupOptions: {
output: {
format: "esm",
entryFileNames: name,
codeSplitting: false,
// Inlined CommonJS dependencies (npm's installer among them) read these module globals.
banner: "const __filename = import.meta.filename, __dirname = import.meta.dirname;",
},
},
},
});

rmSync(out, { recursive: true, force: true });
mkdirSync(assets, { recursive: true });

await bundle(join(root, "../harness/src/tool/image/worker.ts"), assets, "worker.mjs");

/** Every package `names` needs at runtime, by name, with its directory in pnpm's store. */
function closure(names) {
const found = new Map();
const visit = (directory) => {
const { name, dependencies, optionalDependencies } = JSON.parse(
readFileSync(join(directory, "package.json"), "utf8"),
);
const seen = found.get(name);
if (seen !== undefined) {
if (seen !== directory) throw new Error(`two versions of ${name} to unpack: ${seen} and ${directory}`);
return;
}
found.set(name, directory);
// pnpm links a package's dependencies beside it, in the node_modules that holds it.
const modules = name.startsWith("@") ? dirname(dirname(directory)) : dirname(directory);
for (const dependency of Object.keys({ ...dependencies, ...optionalDependencies })) {
const linked = join(modules, dependency);
// An optional dependency for another platform is not installed.
if (!skipped.has(dependency) && existsSync(linked)) visit(realpathSync(linked));
}
};
for (const name of names) visit(realpathSync(join(harness, name)));
return found;
}

const packages = closure(unpacked);
for (const [name, directory] of packages) {
cpSync(directory, join(assets, "node_modules", name), {
recursive: true,
filter: (path) =>
path === directory ||
(basename(path) !== "node_modules" &&
!unused.test(path) &&
(ships[name]?.(relative(directory, path)) ?? true)),
});
}

// node-liblzma links the system liblzma by default, which a user's machine may not have. Link
// xz in statically, and keep only the addon from the build.
const lzma = join(assets, "node_modules/node-liblzma");
const store = packages.get("node-liblzma");
const gyp = createRequire(import.meta.url).resolve("node-gyp/bin/node-gyp.js");
// The build reads node-addon-api's headers, which the addon does not need at runtime.
cpSync(realpathSync(join(dirname(store), "node-addon-api")), join(lzma, "node_modules/node-addon-api"), {
recursive: true,
});
execFileSync(process.execPath, [gyp, "rebuild"], {
cwd: lzma,
stdio: "inherit",
env: { ...process.env, USE_GLOBAL: "false", RUNTIME_LINK: "static" },
});
const addon = readFileSync(join(lzma, "build/Release/node_lzma.node"));
// Its build tree, downloaded xz sources and borrowed headers are not part of the addon.
for (const directory of ["build", "deps", "node_modules"]) rmSync(join(lzma, directory), { recursive: true });
mkdirSync(join(lzma, "build/Release"), { recursive: true });
writeFileSync(join(lzma, "build/Release/node_lzma.node"), addon);
const [inspect, ...flags] = process.platform === "darwin" ? ["otool", "-L"] : ["ldd"];
const linked = execFileSync(inspect, [...flags, join(lzma, "build/Release/node_lzma.node")]).toString();
if (/liblzma[.\d]*\.(dylib|so)/.test(linked))
throw new Error(`node-liblzma still links liblzma dynamically:\n${linked}`);

const tui = process.env["CODEWORK_TUI_BIN"] ?? join(root, "../tui/target/release/codework-tui");
if (!existsSync(tui)) throw new Error(`missing TUI binary ${tui}: set CODEWORK_TUI_BIN or run cargo build --release`);
copyFileSync(tui, join(assets, "codework-tui"));
chmodSync(join(assets, "codework-tui"), 0o755);

const hash = createHash("sha256");
const files = readdirSync(assets, { recursive: true, withFileTypes: true }).filter((entry) => entry.isFile());
for (const file of files.map((entry) => join(entry.parentPath, entry.name)).sort()) {
hash.update(relative(assets, file));
hash.update(readFileSync(file));
}

await bundle(join(root, "src/sea.ts"), out, "codework.mjs", {
define: { CODEWORK_ASSETS: JSON.stringify(hash.digest("hex").slice(0, 16)) },
plugins: [
{
name: "codework:version",
enforce: "pre",
load: (id) =>
id === join(root, "package.json") && process.env["CODEWORK_VERSION"] !== undefined
? JSON.stringify({ ...JSON.parse(readFileSync(id, "utf8")), version: process.env["CODEWORK_VERSION"] })
: undefined,
},
{
name: "codework:shared",
resolveId: (id) => (id === "virtual:codework-shared" ? "\0virtual:codework-shared" : undefined),
load: (id) => (id === "\0virtual:codework-shared" ? source() : undefined),
},
],
});
Loading
Loading