diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bde2ad2..13e408d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,15 +1,43 @@ name: Release binaries -# Manual trigger only — never runs on a push, so a release only happens when someone deliberately clicks -# "Run workflow". Builds a standalone sessionforge binary per platform (Node's Single Executable -# Application feature — no Node.js needed on the machine that runs it) and attaches them to a GitHub -# Release matching packages/cli/package.json's current version. This is the only distribution channel — -# sessionforge is not published to npm (see packages/cli/package.json's "private" field). +# Triggered by pushing a version tag: `git tag v0.3.0 && git push origin v0.3.0`. The tag IS the version — +# the single source of truth, nothing to keep in sync with packages/cli/package.json (whose own version +# field is unrelated and never read for this; this package is never published to npm, see its "private" +# field). workflow_dispatch stays available for re-running the pipeline manually (e.g. while iterating on +# this workflow itself) via an explicit `version` input, without needing to push a throwaway tag. +# +# Builds a standalone sessionforge binary per platform (Node's Single Executable Application feature — no +# Node.js needed on the machine that runs it) and packages the Paseo plugin, attaching both to a GitHub +# Release. on: + push: + tags: + - "v*" workflow_dispatch: + inputs: + version: + description: "Version to release (no leading v), e.g. 0.3.0" + required: true jobs: + version: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.resolve.outputs.version }} + steps: + - name: Resolve version from the trigger (tag ref, or the manual input) + id: resolve + env: + MANUAL_VERSION: ${{ inputs.version }} + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "version=$MANUAL_VERSION" >> "$GITHUB_OUTPUT" + else + echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT" + fi + build-binaries: + needs: version strategy: fail-fast: false matrix: @@ -43,6 +71,8 @@ jobs: - run: npm install - run: npm run build:binary --workspace packages/cli + env: + SESSIONFORGE_VERSION: ${{ needs.version.outputs.version }} - uses: actions/upload-artifact@v4 with: @@ -73,7 +103,7 @@ jobs: if-no-files-found: error release: - needs: [build-binaries, package-plugin] + needs: [version, build-binaries, package-plugin] runs-on: ubuntu-latest permissions: contents: write @@ -84,14 +114,10 @@ jobs: with: path: artifacts - - name: Read version from packages/cli/package.json - id: version - run: echo "version=$(node -p "require('./packages/cli/package.json').version")" >> "$GITHUB_OUTPUT" - - name: Create release and upload binaries env: GH_TOKEN: ${{ github.token }} - VERSION: ${{ steps.version.outputs.version }} + VERSION: ${{ needs.version.outputs.version }} run: | gh release create "v$VERSION" \ --title "sessionforge v$VERSION" \ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 954df9a..aecb3a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,17 +49,30 @@ with nvm, not by reading release notes, so re-verify empirically before ever low ## Releasing (maintainers) -`.github/workflows/release.yml` is manual-trigger only (`workflow_dispatch` from the Actions tab) — it -never runs on a push. Builds a standalone `sessionforge` binary (Node SEA) natively per platform — Linux, -macOS Intel, macOS Apple Silicon, Windows — and also packages the Paseo plugin (`scripts/package-plugin.mjs` -— a self-contained directory with a real, non-symlinked copy of the built `@aadaa88/sessionforge` package -baked into `node_modules`, since npm workspace symlinks don't survive being extracted on someone else's -machine; verified by actually installing the packaged output via `paseo plugin install`, not just built). -All of these are attached to a new GitHub Release tagged from `packages/cli/package.json`'s current -version. This is the only distribution channel; there is no npm publish step. `sessionforge wire-paseo` -downloads that same plugin asset and installs it — keep its version-matched download URL (see -`packages/cli/src/core/paseo-wire.server.ts`) in sync with this workflow's release tag format if either -changes. +`.github/workflows/release.yml` triggers on pushing a version tag: + +```bash +git tag v0.3.0 +git push origin v0.3.0 +``` + +The tag *is* the version — the single source of truth, deliberately not synced with +`packages/cli/package.json`'s own version field (that field is unrelated and unused by the release +pipeline; this package is never published to npm). `workflow_dispatch` also works, for re-running the +pipeline manually (e.g. while iterating on the workflow itself) via an explicit `version` input, without +pushing a throwaway tag. + +It builds a standalone `sessionforge` binary (Node SEA) natively per platform — Linux, macOS Intel, macOS +Apple Silicon, Windows — with the version baked in via `SESSIONFORGE_VERSION` (see +`packages/cli/scripts/build-binary.mjs`; a build with that env var unset, e.g. a local dev build, reports +itself as `dev-main`). It also packages the Paseo plugin (`scripts/package-plugin.mjs` — a self-contained +directory with a real, non-symlinked copy of the built `@aadaa88/sessionforge` package baked into +`node_modules`, since npm workspace symlinks don't survive being extracted on someone else's machine; +verified by actually installing the packaged output via `paseo plugin install`, not just built). All of +these are attached to a new GitHub Release matching the tag. This is the only distribution channel; there +is no npm publish step. `sessionforge wire-paseo` downloads that same plugin asset and installs it — keep +its version-matched download URL (see `packages/cli/src/core/paseo-wire.server.ts`) in sync with this +workflow's release tag format if either changes. ## Making changes diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index ee7b6db..74b1ae7 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -59,9 +59,10 @@ main.client.tsx session browser UI (sidebar panel): search/filter/a scripts/package-plugin.mjs packages the Paseo plugin (this repo's root — see above) into a self-contained directory, then tars it — what `sessionforge wire-paseo` downloads and installs -.github/workflows/ CI (typecheck/test/build on Linux/macOS/Windows) + a manual-trigger release workflow - (builds standalone binaries per platform, packages the Paseo plugin, attaches - both to a GitHub Release) +.github/workflows/ CI (typecheck/test/build on Linux/macOS/Windows) + a tag-triggered release + workflow (git tag vX.Y.Z && git push origin vX.Y.Z — the tag is the version, + not packages/cli/package.json's own field; builds standalone binaries per + platform, packages the Paseo plugin, attaches both to a GitHub Release) ``` ## Why a plugin *and* a CLI — and why they're separate packages diff --git a/package-lock.json b/package-lock.json index 7de0e61..0c51618 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5605,7 +5605,7 @@ }, "packages/cli": { "name": "@aadaa88/sessionforge", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "tsx": "^4.19.0" diff --git a/packages/cli/package.json b/packages/cli/package.json index 3c1676a..d4fb78c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@aadaa88/sessionforge", - "version": "0.1.0", + "version": "0.2.0", "description": "Discover, classify, and safely clean up agent coding sessions (Claude Code, Codex, Gemini CLI, OpenCode, Aider) — as a CLI or as a library. No Paseo dependency.", "license": "MIT", "author": "aMgLn", diff --git a/packages/cli/scripts/build-binary.mjs b/packages/cli/scripts/build-binary.mjs index 3e17cd7..0398c25 100644 --- a/packages/cli/scripts/build-binary.mjs +++ b/packages/cli/scripts/build-binary.mjs @@ -7,7 +7,7 @@ // — SEA binaries aren't cross-compilable from one machine — see .github/workflows/release.yml, which runs // this once per matrix OS to cover Linux, both macOS architectures, and Windows. import { execFileSync } from "node:child_process"; -import { chmodSync, copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { arch, platform } from "node:os"; import { dirname, join } from "node:path"; @@ -51,7 +51,11 @@ async function main() { rmSync(buildDir, { recursive: true, force: true }); mkdirSync(buildDir, { recursive: true }); - const { version } = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")); + // The tag pushed to trigger release.yml is the single source of truth for the version — CI passes it + // through this env var. packages/cli/package.json's own version field is unrelated (this package is + // never published to npm) and deliberately not read here anymore, so there's nothing to keep in sync + // with the release tag. "dev-main" marks a build that didn't come from release.yml at all. + const version = process.env.SESSIONFORGE_VERSION ?? "dev-main"; console.log(`Bundling CLI entry point for ${triple}...`); await esbuild.build({ diff --git a/packages/cli/src/cli/bin.ts b/packages/cli/src/cli/bin.ts index 9088328..d8f6150 100755 --- a/packages/cli/src/cli/bin.ts +++ b/packages/cli/src/cli/bin.ts @@ -1,5 +1,4 @@ #!/usr/bin/env -S npx tsx -import { readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { AiderAdapter } from "../core/aider-adapter.server.js"; @@ -27,15 +26,16 @@ import { formatSessionDetail, formatSessionTable, parseOlderThan } from "./forma const ADAPTERS = [new ClaudeCodeAdapter(), new CodexAdapter(), new GeminiCliAdapter(), new OpenCodeAdapter(), new AiderAdapter()]; const ACTOR = "cli"; -// Baked in by esbuild's `define` when built into the standalone binary (see build-binary.mjs) — the -// binary has no package.json on disk at runtime to read its own version from otherwise. Falls back to -// reading packages/cli/package.json directly when running from raw source (tsx, no esbuild pass). +// Baked in by esbuild's `define` when built into the standalone binary (see build-binary.mjs), which in +// turn gets it from the git tag that triggered release.yml — the tag is the single source of truth for +// version, not packages/cli/package.json (whose own version field is unrelated and never read here). +// "dev-main" means this binary wasn't built by release.yml at all — a local/dev build, not a release. declare const __SESSIONFORGE_VERSION__: string | undefined; +const DEV_VERSION = "dev-main"; + function getVersion(): string { - if (typeof __SESSIONFORGE_VERSION__ !== "undefined") return __SESSIONFORGE_VERSION__; - const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version: string }; - return pkg.version; + return typeof __SESSIONFORGE_VERSION__ !== "undefined" ? __SESSIONFORGE_VERSION__ : DEV_VERSION; } interface ParsedArgs { @@ -265,7 +265,18 @@ async function cmdWirePaseo(args: ParsedArgs): Promise { } console.log("Plugins are enabled on this daemon."); - const version = flagString(args.flags, "version") ?? getVersion(); + const versionFlag = flagString(args.flags, "version"); + const version = versionFlag ?? getVersion(); + if (version === DEV_VERSION && !versionFlag) { + console.error( + `\nThis is a development build (version: ${DEV_VERSION}), not a tagged release — there's no ` + + "matching Paseo plugin asset to download.\nPass --version (e.g. --version 0.2.0) to install " + + "a specific release's plugin, or use a released sessionforge binary instead.", + ); + process.exitCode = 1; + return; + } + const archivePath = join(tmpdir(), PLUGIN_ARCHIVE_NAME); console.log(`Downloading the v${version} Paseo plugin release asset...`); await downloadPluginArchive(version, archivePath); @@ -313,6 +324,7 @@ Usage: sessionforge audit [id] [--json] show the audit trail for destructive operations sessionforge wire-paseo [--version ...] download and install the Paseo plugin for this CLI's version sessionforge paseo-status show whether the Paseo plugin is installed and running + sessionforge --version print this CLI's own version `); } @@ -320,6 +332,13 @@ async function main(): Promise { const args = parseArgs(process.argv.slice(2)); const command = args.positional.shift(); + // parseArgs treats any "--xxx" token as a flag regardless of position, so `sessionforge --version` never + // reaches the switch below as a positional "--version" — it lands here instead, with command undefined. + if (command === undefined && args.flags.get("version")) { + console.log(getVersion()); + return; + } + switch (command) { case "discover": return cmdDiscover(); @@ -341,6 +360,10 @@ async function main(): Promise { return cmdWirePaseo(args); case "paseo-status": return cmdPaseoStatus(); + case "version": + case "-v": + console.log(getVersion()); + return; case undefined: case "help": case "--help":