Skip to content

Commit 405bb23

Browse files
sanchitrkclaude
andcommitted
ci: release the standalone binary from dev and main
dev and main now build the binary for darwin-arm64, linux-arm64 and linux-x64, each on its own runner, run the offline binary test, and attach cli-<os>-<arch>.tar.gz to a GitHub Release: `devel`, one prerelease replaced on every dev push, or `cli-v<version>`, marked latest. canary stays npm-only, and npm never waits on the binary. The run computes its release id once (scripts/version.mjs) and every job uses it, so the binary reports the version npm publishes. The binary test's artifact no longer depends on the platform. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3c6bef8 commit 405bb23

6 files changed

Lines changed: 175 additions & 28 deletions

File tree

‎.github/workflows/publish.yml‎

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ name: Publish
33
# One workflow publishes every channel, since npm trusted publishing names a single workflow file.
44
# - canary / dev: every push to that branch
55
# - latest: run by hand on `main`; publishes each package whose version is not on npm yet
6+
# dev and latest also build the standalone binary and attach it to a GitHub Release: `devel`, one
7+
# prerelease replaced on every dev push, or `cli-v<version>`, marked latest. canary stays npm-only.
68
on:
79
push:
810
branches: [canary, dev]
@@ -26,9 +28,20 @@ env:
2628
jobs:
2729
guard:
2830
runs-on: ubuntu-latest
31+
# The run's release id, computed once so every job publishes under the same version.
32+
outputs:
33+
id: ${{ steps.release.outputs.id }}
2934
steps:
3035
- if: github.event_name == 'workflow_dispatch' && github.ref_name != 'main'
3136
run: echo "::error::latest is published from main only" && exit 1
37+
- uses: actions/checkout@v6
38+
- uses: voidzero-dev/setup-vp@v1.21.1
39+
with:
40+
node-version: 24
41+
run-install: false
42+
cache: false
43+
- id: release
44+
run: echo "id=$(node scripts/version.mjs "$CHANNEL")" >> "$GITHUB_OUTPUT"
3245

3346
tui:
3447
needs: guard
@@ -75,8 +88,10 @@ jobs:
7588
retention-days: 1
7689

7790
publish:
78-
needs: tui
91+
needs: [guard, tui]
7992
runs-on: ubuntu-latest
93+
env:
94+
CODEWORK_RELEASE_ID: ${{ needs.guard.outputs.id }}
8095
steps:
8196
- uses: actions/checkout@v6
8297
- uses: voidzero-dev/setup-vp@v1.21.1
@@ -95,3 +110,93 @@ jobs:
95110
pattern: tui-*
96111
path: packages/tui/dist/bin
97112
- run: vp run release "$CHANNEL"
113+
114+
binary:
115+
needs: [guard, tui]
116+
if: github.ref_name != 'canary'
117+
name: binary (${{ matrix.binary }})
118+
runs-on: ${{ matrix.runner }}
119+
strategy:
120+
fail-fast: true
121+
matrix:
122+
# Each on its own platform: --build-sea runs that platform's Node, and macOS signs.
123+
include:
124+
- binary: cli-darwin-arm64
125+
tui: tui-darwin-arm64
126+
runner: macos-14
127+
- binary: cli-linux-arm64
128+
tui: tui-linux-arm64
129+
runner: ubuntu-24.04-arm
130+
- binary: cli-linux-x64
131+
tui: tui-linux-x64
132+
runner: ubuntu-24.04
133+
env:
134+
CODEWORK_RELEASE_ID: ${{ needs.guard.outputs.id }}
135+
steps:
136+
- uses: actions/checkout@v6
137+
# node-liblzma builds against the system liblzma at install. The bundle rebuilds it with xz
138+
# linked statically, which needs cmake; the runners ship it.
139+
- if: runner.os == 'Linux'
140+
run: sudo apt-get update && sudo apt-get install -y liblzma-dev
141+
- uses: voidzero-dev/setup-vp@v1.21.1
142+
with:
143+
node-version: 24
144+
run-install: false
145+
cache: false
146+
- run: vp install --frozen-lockfile --filter '!@codeworksh/www'
147+
- uses: actions/download-artifact@v8
148+
with:
149+
name: ${{ matrix.tui }}
150+
path: tui
151+
- run: |
152+
chmod +x tui/codework-tui
153+
echo "CODEWORK_TUI_BIN=$GITHUB_WORKSPACE/tui/codework-tui" >> "$GITHUB_ENV"
154+
echo "CODEWORK_VERSION=$(node scripts/version.mjs "$CHANNEL" packages/codework)" >> "$GITHUB_ENV"
155+
- working-directory: packages/codework
156+
run: vp run bundle && vp run sea
157+
# Without API keys only the offline case runs: the binary with no Node on PATH unpacks its
158+
# assets and runs a plugin through its own effect.
159+
- working-directory: packages/codework
160+
run: vp test test/sea.test.ts
161+
- run: tar -czf ${{ matrix.binary }}.tar.gz -C packages/codework/dist/sea codework
162+
- uses: actions/upload-artifact@v7
163+
with:
164+
name: ${{ matrix.binary }}
165+
path: ${{ matrix.binary }}.tar.gz
166+
if-no-files-found: error
167+
retention-days: 1
168+
169+
# After npm, so a release never names a version npm does not have.
170+
release:
171+
needs: [guard, publish, binary]
172+
if: github.ref_name != 'canary'
173+
runs-on: ubuntu-latest
174+
permissions:
175+
contents: write
176+
env:
177+
CODEWORK_RELEASE_ID: ${{ needs.guard.outputs.id }}
178+
GH_TOKEN: ${{ github.token }}
179+
steps:
180+
- uses: actions/checkout@v6
181+
- uses: voidzero-dev/setup-vp@v1.21.1
182+
with:
183+
node-version: 24
184+
run-install: false
185+
cache: false
186+
- uses: actions/download-artifact@v8
187+
with:
188+
pattern: cli-*
189+
path: release
190+
merge-multiple: true
191+
- run: |
192+
version="$(node scripts/version.mjs "$CHANNEL" packages/codework)"
193+
if [ "$CHANNEL" = dev ]; then
194+
gh release delete devel --cleanup-tag --yes || true
195+
gh release create devel release/*.tar.gz --prerelease --target "$GITHUB_SHA" \
196+
--title "CLI $version" --notes "Development build $version, from $GITHUB_SHA."
197+
elif gh release view "cli-v$version" > /dev/null 2>&1; then
198+
echo "cli-v$version is already released"
199+
else
200+
gh release create "cli-v$version" release/*.tar.gz --latest --target "$GITHUB_SHA" \
201+
--title "CLI $version" --generate-notes
202+
fi

‎packages/codework/scripts/bundle.mjs‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
// files from beside their own modules (WASM, workers, native addons)
77
//
88
// Run under `--conditions=development` (see `scripts/shared.mjs`). The TUI comes from
9-
// `CODEWORK_TUI_BIN`, else the local release build. Rebuilding xz statically needs cmake.
9+
// `CODEWORK_TUI_BIN`, else the local release build. `CODEWORK_VERSION` replaces the version the CLI
10+
// reports, which a canary or dev release computes (scripts/version.mjs). Rebuilding xz statically
11+
// needs cmake.
1012
import { execFileSync } from "node:child_process";
1113
import { createHash } from "node:crypto";
1214
import {
@@ -155,6 +157,14 @@ for (const file of files.map((entry) => join(entry.parentPath, entry.name)).sort
155157
await bundle(join(root, "src/sea.ts"), out, "codework.mjs", {
156158
define: { CODEWORK_ASSETS: JSON.stringify(hash.digest("hex").slice(0, 16)) },
157159
plugins: [
160+
{
161+
name: "codework:version",
162+
enforce: "pre",
163+
load: (id) =>
164+
id === join(root, "package.json") && process.env["CODEWORK_VERSION"] !== undefined
165+
? JSON.stringify({ ...JSON.parse(readFileSync(id, "utf8")), version: process.env["CODEWORK_VERSION"] })
166+
: undefined,
167+
},
158168
{
159169
name: "codework:shared",
160170
resolveId: (id) => (id === "virtual:codework-shared" ? "\0virtual:codework-shared" : undefined),

packages/codework/test/__artifacts__/sea.darwin-arm64.json renamed to packages/codework/test/__artifacts__/sea.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
{
2-
"version": "codework v0.2.0",
3-
"cache": "<hash>-darwin-arm64",
42
"assets": [
53
"codework-tui",
64
"node_modules",

‎packages/codework/test/sea.test.ts‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ describe.skipIf(!existsSync(binary))("the standalone binary", () => {
9595

9696
const version = spawn(binary, ["--version"]);
9797
expect(version.status, version.stderr).toBe(0);
98-
expect(version.stdout.trim()).toBe(`codework v${pkg.version}`);
98+
// A canary or dev release builds in the version it publishes under (scripts/version.mjs).
99+
expect(version.stdout.trim()).toBe(`codework v${process.env["CODEWORK_VERSION"] ?? pkg.version}`);
99100

100101
expect(added.status, added.stderr).toBe(0);
101102
const listed = spawn(binary, ["plugin", "list"]);
@@ -109,18 +110,16 @@ describe.skipIf(!existsSync(binary))("the standalone binary", () => {
109110
const assets = join(cache, unpacked!);
110111
expect(statSync(join(assets, "codework-tui")).mode & 0o111).not.toBe(0);
111112

113+
expect(unpacked).toMatch(new RegExp(`^[0-9a-f]{16}-${process.platform}-${process.arch}$`));
114+
115+
// One artifact for every platform: what is unpacked does not depend on it.
112116
await expect(
113117
`${JSON.stringify(
114-
{
115-
version: version.stdout.trim(),
116-
cache: unpacked!.replace(/^[0-9a-f]{16}-/, "<hash>-"),
117-
assets: readdirSync(assets).sort(),
118-
packages: readdirSync(join(assets, "node_modules")).sort(),
119-
},
118+
{ assets: readdirSync(assets).sort(), packages: readdirSync(join(assets, "node_modules")).sort() },
120119
null,
121120
"\t",
122121
)}\n`,
123-
).toMatchFileSnapshot(`./__artifacts__/sea.${process.platform}-${process.arch}.json`);
122+
).toMatchFileSnapshot("./__artifacts__/sea.json");
124123
});
125124

126125
describe.each(LIVE)("against live $name", (live) => {

‎scripts/release.mjs‎

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
// Publishes the package chain for one channel: `canary`, `dev` or `latest`. CI runs it (see
22
// .github/workflows/publish.yml) after the TUI binaries are staged under packages/tui/dist/bin.
33
//
4-
// canary/dev publish every package as a prerelease of its version (see previewId).
4+
// canary/dev publish every package as a prerelease of its version (see scripts/version.mjs).
55
// latest publishes each package whose manifest version is not on npm yet, so a release is a
66
// local `vp run bump` followed by a run on main.
77
import { spawnSync } from "node:child_process";
88
import { resolve } from "node:path";
99
import { setTimeout } from "node:timers/promises";
1010

1111
import { distTags, isPublished } from "./registry.mjs";
12+
import { releaseId, releaseVersion } from "./version.mjs";
1213
import { publishablePackages, repoRoot as root, workspaceDependencies } from "./workspace.mjs";
1314

1415
const [channel, ...forwardArgs] = process.argv.slice(2);
@@ -18,24 +19,11 @@ if (!["canary", "dev", "latest"].includes(channel)) {
1819
}
1920
const dryRun = forwardArgs.includes("--dry-run");
2021

21-
// dev: `<YYYYMMDDHHMMSS>` (UTC). canary: `<run>.<attempt>.g<sha>`.
22-
function previewId() {
23-
const { GITHUB_RUN_NUMBER: run = "0", GITHUB_RUN_ATTEMPT: attempt = "0" } = process.env;
24-
if (!process.env.GITHUB_RUN_NUMBER && !dryRun) {
25-
throw new Error(`A ${channel} release runs in CI; locally only --dry-run is supported`);
26-
}
27-
if (channel === "dev") return new Date().toISOString().slice(0, 19).replace(/\D/g, "");
28-
const sha = spawnSync("git", ["rev-parse", "--short=8", "HEAD"], { cwd: root }).stdout.toString().trim();
29-
return `${run}.${attempt}.g${sha}`;
30-
}
31-
32-
const id = channel === "latest" ? undefined : previewId();
22+
const id = releaseId(channel, { dryRun });
3323
const packages = [];
3424
for (const { manifest } of publishablePackages()) {
35-
const base = /^\d+\.\d+\.\d+/.exec(manifest.version)?.[0];
36-
if (!base) throw new Error(`Invalid package version: ${manifest.version}`);
3725
if (id) {
38-
packages.push({ manifest, version: `${base}-${channel}.${id}` });
26+
packages.push({ manifest, version: releaseVersion(manifest.version, channel, id) });
3927
} else if (await isPublished(manifest.name, manifest.version)) {
4028
console.error(`Skipping ${manifest.name}@${manifest.version}: already published`);
4129
} else {

‎scripts/version.mjs‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// The versions one publish run gives its packages. canary and dev publish each package as a
2+
// prerelease of its manifest version; latest publishes the manifest version as is.
3+
//
4+
// node scripts/version.mjs <channel> the run's release id (empty for latest)
5+
// node scripts/version.mjs <channel> <package> that package's version, e.g. packages/codework
6+
//
7+
// The first job of .github/workflows/publish.yml computes the id once and hands it to the later
8+
// jobs as CODEWORK_RELEASE_ID, so the npm packages and the standalone binary carry one version.
9+
import { spawnSync } from "node:child_process";
10+
import { readFileSync } from "node:fs";
11+
import { resolve } from "node:path";
12+
13+
import { repoRoot as root } from "./workspace.mjs";
14+
15+
/** dev: `<YYYYMMDDHHMMSS>` (UTC). canary: `<run>.<attempt>.g<sha>`. latest: none. */
16+
export function releaseId(channel, { dryRun = false } = {}) {
17+
if (channel === "latest") return undefined;
18+
if (process.env.CODEWORK_RELEASE_ID) return process.env.CODEWORK_RELEASE_ID;
19+
const { GITHUB_RUN_NUMBER: run = "0", GITHUB_RUN_ATTEMPT: attempt = "0" } = process.env;
20+
if (!process.env.GITHUB_RUN_NUMBER && !dryRun) {
21+
throw new Error(`A ${channel} release runs in CI; locally only --dry-run is supported`);
22+
}
23+
if (channel === "dev") return new Date().toISOString().slice(0, 19).replace(/\D/g, "");
24+
const sha = spawnSync("git", ["rev-parse", "--short=8", "HEAD"], { cwd: root }).stdout.toString().trim();
25+
return `${run}.${attempt}.g${sha}`;
26+
}
27+
28+
/** `version` as `channel` publishes it under the run's `id`. */
29+
export function releaseVersion(version, channel, id) {
30+
const base = /^\d+\.\d+\.\d+/.exec(version)?.[0];
31+
if (!base) throw new Error(`Invalid package version: ${version}`);
32+
return id ? `${base}-${channel}.${id}` : version;
33+
}
34+
35+
if (import.meta.main) {
36+
const [channel, pkg] = process.argv.slice(2);
37+
if (!["canary", "dev", "latest"].includes(channel)) {
38+
console.error("Usage: node scripts/version.mjs <canary|dev|latest> [package directory]");
39+
process.exit(1);
40+
}
41+
const id = releaseId(channel);
42+
if (pkg === undefined) console.log(id ?? "");
43+
else {
44+
const { version } = JSON.parse(readFileSync(resolve(root, pkg, "package.json"), "utf8"));
45+
console.log(releaseVersion(version, channel, id));
46+
}
47+
}

0 commit comments

Comments
 (0)