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
48 changes: 37 additions & 11 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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" \
Expand Down
35 changes: 24 additions & 11 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/scripts/build-binary.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand Down
39 changes: 31 additions & 8 deletions packages/cli/src/cli/bin.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -265,7 +265,18 @@ async function cmdWirePaseo(args: ParsedArgs): Promise<void> {
}
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 <tag> (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);
Expand Down Expand Up @@ -313,13 +324,21 @@ 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
`);
}

async function main(): Promise<void> {
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();
Expand All @@ -341,6 +360,10 @@ async function main(): Promise<void> {
return cmdWirePaseo(args);
case "paseo-status":
return cmdPaseoStatus();
case "version":
case "-v":
console.log(getVersion());
return;
case undefined:
case "help":
case "--help":
Expand Down
Loading