Skip to content
Open
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
4 changes: 4 additions & 0 deletions .changeset/silent-weeks-decide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Unversioned builds now report a version derived from the checkout they run from (`3.0.0-dev.20260803.f51f1e4`, `.dirty` for an unclean tree) instead of a flat `0.0.0-dev`. Dev-only: release binaries are compiled with an explicit `CLI_VERSION`, so published behavior is unchanged.
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,6 @@ These flags require Bun >= 1.3.13 — older versions silently ignore them and lo

## Versioning

The `CLI_VERSION` global is injected at compile time via `bun build --compile --define "CLI_VERSION=..."`. Local `build:compile` omits it, so the binary reports `0.0.0-dev`. The CI release workflow injects the real version.
The `CLI_VERSION` global is injected at compile time via `bun build --compile --define "CLI_VERSION=..."`. The CI release workflow injects the real version.

Builds without that define — `bun run dev`, a `bun link`ed checkout, `packages/cli-core`'s own `build:compile` — fall back to a version derived from the checkout by `src/lib/version.ts`: `<version in packages/cli/package.json>-dev.<YYYYMMDD>.<short sha>`, plus `.dirty` when the working tree has uncommitted changes (e.g. `3.0.0-dev.20260803.f51f1e4.dirty`). The commit segment moves on every pull, so `clerk --version` tells you whether the linked binary is the code you just fetched. It degrades to `<version>-dev` when git isn't available. Code that needs to know whether a build is versioned at all should use `resolveCliVersion()` / `isDevVersion()`, never an equality check against a literal.
49 changes: 25 additions & 24 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,29 +205,30 @@ Install: `brew install clerk/stable/clerk`

## Key Files

| File | Purpose |
| -------------------------------------- | ------------------------------------------------------------------------------ |
| `packages/cli/bin/clerk` | CJS shim that resolves and spawns the platform binary |
| `packages/cli/package.json` | Wrapper package (has `prepublishOnly` guard against accidental direct publish) |
| `packages/cli-core/src/cli.ts` | CLI entrypoint (reads `CLI_VERSION` global at runtime) |
| `packages/cli-core/src/globals.d.ts` | TypeScript declaration for the `CLI_VERSION` compile-time define |
| `install.sh` | Shell install script, downloads binary from GitHub Releases |
| `scripts/releaser.ts` | Generates platform packages and publishes everything to npm |
| `.github/release-notes/vX.Y.Z.md` | Optional version-specific intro prepended to stable GitHub Release notes |
| `scripts/lib/targets.ts` | Target definitions (used by both releaser and build.ts) |
| `scripts/build.ts` | Cross-compiles CLI binaries for all 8 platform targets |
| `scripts/sign-macos.ts` | Signs and notarizes macOS binaries (keychain, codesign, notarytool) |
| `scripts/entitlements.plist` | macOS entitlements for Bun's JIT engine (used by codesign) |
| `scripts/canary.ts` | Versions packages for canary channel using Changesets snapshots |
| `scripts/snapshot.ts` | Versions packages for snapshot channel using Changesets snapshots |
| `scripts/check-release.ts` | Detects if a stable release is needed (compares version to npm registry) |
| `scripts/homebrew.ts` | Creates Homebrew archives, uploads to release, renders and pushes formula |
| `scripts/lib/homebrew.ts` | Homebrew formula renderer, target list, and helper utilities |
| `.changeset/config.json` | Changesets configuration |
| `.github/workflows/build-binaries.yml` | Reusable workflow for cross-compiling binaries (called by release + snapshot) |
| `.github/workflows/sign-macos.yml` | Reusable workflow for macOS code signing and notarization |
| `.github/workflows/smoke-test.yml` | Reusable workflow for smoke-testing binaries (called by release + snapshot) |
| `.github/workflows/release.yml` | GitHub Actions release, canary, and snapshot workflow |
| File | Purpose |
| -------------------------------------- | ---------------------------------------------------------------------------------- |
| `packages/cli/bin/clerk` | CJS shim that resolves and spawns the platform binary |
| `packages/cli/package.json` | Wrapper package (has `prepublishOnly` guard against accidental direct publish) |
| `packages/cli-core/src/cli.ts` | CLI entrypoint (reads `CLI_VERSION` global at runtime) |
| `packages/cli-core/src/globals.d.ts` | TypeScript declaration for the `CLI_VERSION` compile-time define |
| `packages/cli-core/src/lib/version.ts` | Resolves `CLI_VERSION`, or derives a `-dev.<date>.<sha>` version from the checkout |
| `install.sh` | Shell install script, downloads binary from GitHub Releases |
| `scripts/releaser.ts` | Generates platform packages and publishes everything to npm |
| `.github/release-notes/vX.Y.Z.md` | Optional version-specific intro prepended to stable GitHub Release notes |
| `scripts/lib/targets.ts` | Target definitions (used by both releaser and build.ts) |
| `scripts/build.ts` | Cross-compiles CLI binaries for all 8 platform targets |
| `scripts/sign-macos.ts` | Signs and notarizes macOS binaries (keychain, codesign, notarytool) |
| `scripts/entitlements.plist` | macOS entitlements for Bun's JIT engine (used by codesign) |
| `scripts/canary.ts` | Versions packages for canary channel using Changesets snapshots |
| `scripts/snapshot.ts` | Versions packages for snapshot channel using Changesets snapshots |
| `scripts/check-release.ts` | Detects if a stable release is needed (compares version to npm registry) |
| `scripts/homebrew.ts` | Creates Homebrew archives, uploads to release, renders and pushes formula |
| `scripts/lib/homebrew.ts` | Homebrew formula renderer, target list, and helper utilities |
| `.changeset/config.json` | Changesets configuration |
| `.github/workflows/build-binaries.yml` | Reusable workflow for cross-compiling binaries (called by release + snapshot) |
| `.github/workflows/sign-macos.yml` | Reusable workflow for macOS code signing and notarization |
| `.github/workflows/smoke-test.yml` | Reusable workflow for smoke-testing binaries (called by release + snapshot) |
| `.github/workflows/release.yml` | GitHub Actions release, canary, and snapshot workflow |

## Keeping Targets in Sync

Expand Down Expand Up @@ -255,7 +256,7 @@ bun run build:compile:all
bun run scripts/build.ts --target=bun-darwin-arm64
```

The `dev` and `start` commands do not inject a version (falls back to `0.0.0-dev`). The release workflow handles version injection.
The `dev` and `start` commands do not inject a version, so the CLI falls back to a checkout-derived dev version — `<version in packages/cli/package.json>-dev.<YYYYMMDD>.<short sha>`, suffixed `.dirty` for an unclean tree. `scripts/build.ts` uses the same string as its `--version` default, so a local `build:compile:all` rehearsal stamps binaries with the commit they were built from. The release workflow always passes an explicit `--version`.

> **Bun version for local rehearsal:** CI pins Bun `1.3.11` in [`build-binaries.yml`](../.github/workflows/build-binaries.yml) because `1.3.12` produces darwin-arm64 binaries that macOS codesign rejects. If you are rehearsing a release locally and plan to execute the compiled darwin-arm64 binary, match the CI pin. This will stop being relevant once the pin is lifted. Note the pin only covers compiling binaries — it sits below the workspace's `engines.bun` floor (`>=1.3.13`), which is what running the test suite requires (`bun test --parallel` support).

Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/mcp/probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import { isRecord } from "../../lib/objects.ts";
import { errorMessage } from "../../lib/errors.ts";
import { loggedFetch } from "../../lib/fetch.ts";
import { DEV_CLI_VERSION, resolveCliVersion } from "../../lib/version.ts";
import { getCurrentVersion } from "../../lib/version.ts";
import { sseEventData } from "./sse.ts";
// Type-only: erased at compile, so the SDK stays a devDependency and is never
// bundled — it exists purely as a TS gate keeping this request spec-valid.
Expand All @@ -38,7 +38,7 @@ const INITIALIZE_REQUEST = {
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "clerk-cli", version: resolveCliVersion() ?? DEV_CLI_VERSION },
clientInfo: { name: "clerk-cli", version: getCurrentVersion() },
},
} satisfies JSONRPCRequest & InitializeRequest;

Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/update/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ export async function update(options: UpdateOptions): Promise<void> {
const currentVersion = getCurrentVersion();

if (isDevVersion(currentVersion)) {
log.info("Running development build (0.0.0-dev); update not applicable.");
log.info(`Running development build (${currentVersion}); update not applicable.`);
return;
}

Expand Down
3 changes: 2 additions & 1 deletion packages/cli-core/src/lib/credential-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ mock.module("@napi-rs/keyring", () => ({
}));

mock.module("./version.ts", () => ({
DEV_CLI_VERSION: "0.0.0-dev",
isDevVersion: (version: string) => version.includes("-dev"),
resolveCliVersion: () => undefined,
resolveDevVersion: () => "0.0.0-dev",
}));

mock.module("./token-exchange.ts", () => ({
Expand Down
12 changes: 9 additions & 3 deletions packages/cli-core/src/lib/update-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,15 @@ describe("getUpdateChannel", () => {

test("falls through to version inference when env var is empty string", () => {
process.env.CLERK_UPDATE_CHANNEL = "";
// CLI_VERSION is undefined in tests, so getCurrentVersion() = "0.0.0-dev"
// inferChannelFromVersion("0.0.0-dev") = "dev"
// CLI_VERSION is undefined in tests, so getCurrentVersion() returns the
// checkout-derived dev version ("<base>-dev[.<date>.<sha>]"), whose first
// prerelease identifier — and therefore inferred channel — is "dev"
expect(getUpdateChannel()).toBe("dev");
});

test("falls through to version inference when env var is unset", () => {
delete process.env.CLERK_UPDATE_CHANNEL;
// CLI_VERSION is undefined in tests → "0.0.0-dev" → "dev"
// CLI_VERSION is undefined in tests → "<base>-dev.…" → "dev"
expect(getUpdateChannel()).toBe("dev");
});
});
Expand Down Expand Up @@ -169,6 +170,11 @@ describe("shouldCheckForUpdates", () => {
expect(shouldCheckForUpdates("0.0.0-dev")).toBe(false);
});

test("returns false for a dev version carrying a commit", () => {
expect(shouldCheckForUpdates("3.0.0-dev.20260803.f51f1e4")).toBe(false);
expect(shouldCheckForUpdates("3.0.0-dev.20260803.f51f1e4.dirty")).toBe(false);
});

test("returns false when CI is set", () => {
process.env.CI = "1";
expect(shouldCheckForUpdates("1.0.0")).toBe(false);
Expand Down
11 changes: 3 additions & 8 deletions packages/cli-core/src/lib/update-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from "./constants.ts";
import { loggedFetch } from "./fetch.ts";
import { log } from "./log.ts";
import { DEV_CLI_VERSION } from "./version.ts";
import { getCurrentVersion, isDevVersion } from "./version.ts";

// ── Types ─────────────────────────────────────────────────────────────────────

Expand All @@ -37,13 +37,8 @@ export function getUpdateChannel(): string {

// ── Version helpers ───────────────────────────────────────────────────────────

export function getCurrentVersion(): string {
return typeof CLI_VERSION !== "undefined" ? CLI_VERSION : DEV_CLI_VERSION;
}

export function isDevVersion(version: string): boolean {
return version === DEV_CLI_VERSION;
}
// Re-exported so callers can pull the whole version/update surface from here.
export { getCurrentVersion, isDevVersion };

export function compareSemver(a: string, b: string): number {
return semver.compare(a, b);
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/lib/user-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@
* - `ci` segment is appended when running under a recognized CI environment.
*/

import { DEV_CLI_VERSION, resolveCliVersion } from "./version.ts";
import { getCurrentVersion } from "./version.ts";

export function buildUserAgent(): string {
const version = resolveCliVersion() ?? DEV_CLI_VERSION;
const version = getCurrentVersion();
const segments = [`Bun/${Bun.version}`, `${process.platform}-${process.arch}`];
if (process.env.CI) segments.push("ci");
return `Clerk-CLI/${version} (${segments.join("; ")})`;
Expand Down
119 changes: 119 additions & 0 deletions packages/cli-core/src/lib/version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { test, expect, describe, afterEach } from "bun:test";
import semver from "semver";
import cliPackage from "../../../cli/package.json";
import {
getCurrentVersion,
isDevVersion,
resolveCliVersion,
resolveDevVersion,
} from "./version.ts";

// `CLI_VERSION` is a compile-time define, absent under the test runner. Setting
// the global stands in for a binary built with one, since the unresolved
// identifier reads through to `globalThis`.
const globals = globalThis as unknown as { CLI_VERSION?: string };

// ── isDevVersion ──────────────────────────────────────────────────────────────

describe("isDevVersion", () => {
test("recognizes the bare dev version", () => {
expect(isDevVersion("0.0.0-dev")).toBe(true);
expect(isDevVersion("3.0.0-dev")).toBe(true);
});

test("recognizes a dev version carrying a commit", () => {
expect(isDevVersion("3.0.0-dev.20260803.f51f1e4")).toBe(true);
expect(isDevVersion("3.0.0-dev.20260803.f51f1e4.dirty")).toBe(true);
});

test("does not treat stable versions as dev", () => {
expect(isDevVersion("3.0.0")).toBe(false);
});

test("does not treat real prereleases as dev", () => {
expect(isDevVersion("0.0.2-canary.v20260409211526")).toBe(false);
expect(isDevVersion("3.1.0-snapshot.abc1234")).toBe(false);
// A channel that merely starts with the same letters is not the dev channel
expect(isDevVersion("3.1.0-development.1")).toBe(false);
});
});

// ── resolveCliVersion ─────────────────────────────────────────────────────────

describe("resolveCliVersion", () => {
afterEach(() => {
delete globals.CLI_VERSION;
});

test("returns undefined under the test runner, where CLI_VERSION is undefined", () => {
expect(resolveCliVersion()).toBeUndefined();
});

test("returns an injected release version", () => {
globals.CLI_VERSION = "3.1.0";
expect(resolveCliVersion()).toBe("3.1.0");
});

test("treats an injected dev version as unversioned", () => {
globals.CLI_VERSION = "3.0.0-dev.20260803.f51f1e4";
expect(resolveCliVersion()).toBeUndefined();
});
});

// ── getCurrentVersion ─────────────────────────────────────────────────────────

describe("getCurrentVersion", () => {
afterEach(() => {
delete globals.CLI_VERSION;
});

test("falls back to the checkout when nothing was injected", () => {
expect(getCurrentVersion()).toBe(resolveDevVersion());
});

test("reports an injected release version", () => {
globals.CLI_VERSION = "3.1.0";
expect(getCurrentVersion()).toBe("3.1.0");
});

test("keeps the commit segment of an injected dev version", () => {
// A locally compiled binary can't reach git at runtime — `import.meta.dir`
// points into its embedded filesystem — so the version stamped in at build
// time is the only thing that still knows which commit it came from.
globals.CLI_VERSION = "3.0.0-dev.20260803.f51f1e4.dirty";
expect(getCurrentVersion()).toBe("3.0.0-dev.20260803.f51f1e4.dirty");
});
});

// ── resolveDevVersion ─────────────────────────────────────────────────────────

describe("resolveDevVersion", () => {
const version = resolveDevVersion();

test("is built on the version packages/cli publishes at", () => {
expect(version.startsWith(`${cliPackage.version}-dev`)).toBe(true);
});

test("classifies as a dev version", () => {
expect(isDevVersion(version)).toBe(true);
});

test("is valid semver, so update-check comparisons can never throw on it", () => {
expect(semver.valid(version)).not.toBeNull();
});

test("sorts below the release it is based on", () => {
expect(semver.lt(version, cliPackage.version)).toBe(true);
});

test("is memoized", () => {
expect(resolveDevVersion()).toBe(version);
});

test("carries a YYYYMMDD.<sha> commit segment when run from a git checkout", () => {
// The suite always runs from a checkout, but guard anyway: an exported
// tarball with no .git must still produce a usable version.
const suffix = version.slice(`${cliPackage.version}-dev`.length);
expect(suffix === "" || /^\.\d{8}\.[0-9a-fg]+(\.dirty)?$/.test(suffix)).toBe(true);
});
});
Loading