From 58cdd561dafabcef239563b3716190f0739cdf8c Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 21 Oct 2026 08:22:21 -0500 Subject: [PATCH 01/27] feat(cli): ship standalone binaries for installs without Node.js Build per-platform `openspec` executables with `bun build --compile`, smoke-test them on Linux/macOS/Windows in CI, and attach every target to each published GitHub release with SHA256SUMS. The binary has no package directory, so the build bundles package.json (createRequire calls become static requires) and embeds schemas/, which the entry writes to a content-hashed data-dir folder on first run. Refs #2055 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/binaries.yml | 102 ++++++++++++++++++++++ .gitignore | 4 + scripts/README.md | 18 ++++ scripts/binary/build.mjs | 60 +++++++++++++ scripts/binary/entry.mjs | 38 ++++++++ scripts/binary/smoke.sh | 33 +++++++ src/core/artifact-graph/resolver.ts | 12 +++ test/core/artifact-graph/resolver.test.ts | 19 +++- 8 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/binaries.yml create mode 100644 scripts/binary/build.mjs create mode 100644 scripts/binary/entry.mjs create mode 100755 scripts/binary/smoke.sh diff --git a/.github/workflows/binaries.yml b/.github/workflows/binaries.yml new file mode 100644 index 0000000000..da29853d53 --- /dev/null +++ b/.github/workflows/binaries.yml @@ -0,0 +1,102 @@ +name: Binaries + +# Standalone `openspec` executables for people without Node.js (#2055). +# Pull requests smoke-test the host binary on each OS; a published release gets +# every platform's binary attached, cross-compiled from one Linux runner. +on: + pull_request: + branches: [main] + merge_group: + branches: [main] + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Existing release tag to build and attach binaries to + required: true + +permissions: + contents: read + +concurrency: + group: binaries-${{ github.ref }}-${{ inputs.tag }} + cancel-in-progress: true + +env: + BUN_VERSION: '1.4.3' + +jobs: + smoke: + name: Smoke (${{ matrix.os }}) + if: github.event_name == 'pull_request' || github.event_name == 'merge_group' + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '20.19.0' + cache: 'pnpm' + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: ${{ env.BUN_VERSION }} + - run: pnpm install --frozen-lockfile + - run: pnpm run build + - run: bun scripts/binary/build.mjs + - name: Smoke-test the binary + run: bash scripts/binary/smoke.sh dist-bin/openspec-* + + release: + name: Attach to release + if: github.repository == 'Fission-AI/OpenSpec' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch') + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write # upload release assets + env: + TAG: ${{ github.event.release.tag_name || inputs.tag }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.TAG }} + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '20.19.0' + cache: 'pnpm' + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: ${{ env.BUN_VERSION }} + - run: pnpm install --frozen-lockfile + - run: pnpm run build + - run: >- + bun scripts/binary/build.mjs + bun-linux-x64 bun-linux-arm64 bun-darwin-x64 bun-darwin-arm64 bun-windows-x64 + # Also checks the binary reports the version the tag names, so assets + # never land on the wrong release. + - name: Smoke-test the Linux binary + run: | + bash scripts/binary/smoke.sh dist-bin/openspec-linux-x64 + [[ "$TAG" == *"$(dist-bin/openspec-linux-x64 --version)" ]] || { + echo "::error::Binary version does not match release tag $TAG" + exit 1 + } + - name: Upload + env: + GH_TOKEN: ${{ github.token }} + run: | + cd dist-bin + sha256sum openspec-* > SHA256SUMS + gh release upload "$TAG" openspec-* SHA256SUMS --clobber --repo "$GITHUB_REPOSITORY" diff --git a/.gitignore b/.gitignore index 1fb5c4a26a..7ff3e1ecd3 100644 --- a/.gitignore +++ b/.gitignore @@ -166,3 +166,7 @@ opencode.json # Cursor .cursor/ + +# Standalone binaries (scripts/binary/build.mjs); Bun can leave .bun-build temp files +dist-bin/ +*.bun-build diff --git a/scripts/README.md b/scripts/README.md index 0fb25f7747..a56796f36e 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -70,3 +70,21 @@ test file mid-suite. ## pack-version-check.mjs Validates package version consistency before publishing. + +## binary/ + +Builds standalone `openspec` executables with Bun, for people without Node.js. +CI smoke-tests them on every PR and attaches them to each published release +(`.github/workflows/binaries.yml`). + +**Usage**: +```bash +pnpm build +bun scripts/binary/build.mjs # this machine → dist-bin/ +bun scripts/binary/build.mjs bun-linux-x64 # cross-compile any Bun target +bash scripts/binary/smoke.sh dist-bin/openspec-- +``` + +The binary has no package directory, so `build.mjs` bundles `package.json` +and embeds `schemas/`, which `entry.mjs` writes to +`/binary-schemas//` on first run. diff --git a/scripts/binary/build.mjs b/scripts/binary/build.mjs new file mode 100644 index 0000000000..f1fef0aa76 --- /dev/null +++ b/scripts/binary/build.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env bun +// Builds standalone `openspec` executables with `bun build --compile`, so the +// CLI can be installed without Node.js. Run `pnpm build` first. +// +// bun scripts/binary/build.mjs # host platform +// bun scripts/binary/build.mjs bun-linux-x64 ... # cross-compile +// +// Output: dist-bin/openspec--[.exe] +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = path.resolve(import.meta.dir, '../..'); +const targets = process.argv.slice(2); +if (targets.length === 0) targets.push(`bun-${process.platform}-${process.arch}`); + +// Node resolves `createRequire(import.meta.url)(...)` at runtime against the +// package directory, which a binary does not have. Turning those calls into +// plain `require` lets Bun bundle package.json and cross-spawn instead. +const staticRequire = { + name: 'static-require', + setup(build) { + build.onLoad({ filter: /[\\/]dist[\\/].*\.js$/ }, async (args) => { + const contents = (await Bun.file(args.path).text()) + .replace(/const require = createRequire\(import\.meta\.url\);/g, '') + .replaceAll('createRequire(import.meta.url)(', 'require('); + // A new call shape would otherwise ship a binary that fails at runtime. + if (contents.includes('createRequire(import.meta.url)')) { + throw new Error(`Unhandled createRequire(import.meta.url) in ${args.path}`); + } + return { contents, loader: 'js' }; + }); + }, +}; + +const files = {}; +const schemasDir = path.join(root, 'schemas'); +for (const entry of fs.readdirSync(schemasDir, { recursive: true, withFileTypes: true })) { + if (!entry.isFile()) continue; + const file = path.join(entry.parentPath, entry.name); + files[path.relative(schemasDir, file).split(path.sep).join('/')] = fs.readFileSync(file, 'utf8'); +} +const hash = createHash('sha256').update(JSON.stringify(files)).digest('hex').slice(0, 16); + +for (const target of targets) { + const name = target.replace(/^bun-/, 'openspec-'); + const outfile = path.join(root, 'dist-bin', target.includes('windows') ? `${name}.exe` : name); + const result = await Bun.build({ + entrypoints: [path.join(import.meta.dir, 'entry.mjs')], + compile: { target, outfile }, + minify: true, + define: { OPENSPEC_EMBEDDED_SCHEMAS: JSON.stringify({ hash, files }) }, + plugins: [staticRequire], + }); + if (!result.success) { + for (const log of result.logs) console.error(log); + process.exit(1); + } + console.log(`Built ${path.relative(root, outfile)}`); +} diff --git a/scripts/binary/entry.mjs b/scripts/binary/entry.mjs new file mode 100644 index 0000000000..ed5462bae0 --- /dev/null +++ b/scripts/binary/entry.mjs @@ -0,0 +1,38 @@ +// Entry point of the standalone `openspec` binary built by build.mjs. The +// binary has no package directory on disk, so the built-in schemas are +// embedded at build time and written to a real directory before the CLI runs. +import fs from 'node:fs'; +import path from 'node:path'; +import { getGlobalDataDir } from '../../dist/core/global-config.js'; +import { setPackageSchemasDir } from '../../dist/core/artifact-graph/resolver.js'; + +// Replaced by build.mjs: { hash, files: { 'spec-driven/schema.yaml': '...' } } +const { hash, files } = OPENSPEC_EMBEDDED_SCHEMAS; + +// Keyed by content, so a directory that exists is complete and current: it is +// only ever created by an atomic rename of a fully written one. +function extractSchemas() { + const dir = path.join(getGlobalDataDir(), 'binary-schemas', hash); + if (fs.existsSync(dir)) return dir; + + fs.mkdirSync(path.dirname(dir), { recursive: true }); + const tmp = fs.mkdtempSync(`${dir}.tmp-`); + for (const [relativePath, content] of Object.entries(files)) { + const file = path.join(tmp, relativePath); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + } + try { + fs.renameSync(tmp, dir); + } catch (error) { + fs.rmSync(tmp, { recursive: true, force: true }); + // Another process finished first; its copy is identical. + if (!fs.existsSync(dir)) throw error; + } + return dir; +} + +setPackageSchemasDir(extractSchemas()); +// No runCli() call: in a compiled binary process.argv[1] is the executable's +// own module path, so the CLI's "run when executed directly" check fires. +await import('../../dist/cli/index.js'); diff --git a/scripts/binary/smoke.sh b/scripts/binary/smoke.sh new file mode 100755 index 0000000000..88126ed947 --- /dev/null +++ b/scripts/binary/smoke.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Smoke-tests a standalone binary from build.mjs: version, built-in schemas, +# and a real init → change → status → instructions run in a scratch project. +# +# bash scripts/binary/smoke.sh dist-bin/openspec-- +set -euo pipefail + +bin=$(cd "$(dirname "$1")" && pwd)/$(basename "$1") +expected=$(sed -nE 's/^ "version": "([^"]+)".*/\1/p' "$(dirname "$0")/../../package.json") +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +# Keep the run off the user's real config and data, and offline. +export XDG_CONFIG_HOME="$work/config" XDG_DATA_HOME="$work/data" +export OPENSPEC_TELEMETRY=0 OPENSPEC_NO_UPDATE_CHECK=1 NO_COLOR=1 +mkdir "$work/project" && cd "$work/project" + +fail() { echo "FAIL: $*" >&2; exit 1; } + +version=$("$bin" --version) +[ "$version" = "$expected" ] || fail "--version printed '$version', expected '$expected'" + +"$bin" schemas --json | grep -q '"source": "package"' || fail "built-in schema not found" +"$bin" init --tools claude > /dev/null +[ -f openspec/config.yaml ] || fail "init did not write openspec/config.yaml" +"$bin" new change demo > /dev/null +"$bin" status --change demo --json | grep -q '"schemaName": "spec-driven"' || fail "status did not resolve the schema" +"$bin" instructions proposal --change demo | grep -q '