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
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 embedded from their source checkout (`3.0.0-dev.20260803.f51f1e4`, plus `.dirty` for an unclean tree) instead of a flat `0.0.0-dev`. Dev-only: release binaries are compiled with an explicit 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, or `packages/cli-core`'s own `build:compile`) use the Bun macro in `src/lib/version.macro.ts` to derive and inline a version from the checkout during transpilation: `<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. The compiled CLI never runs Git to determine its version. Code that needs to know whether a build is versioned at all should use `resolveCliVersion()` / `isDevVersion()`, never an equality check against a literal.
50 changes: 26 additions & 24 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,29 +205,31 @@ 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 |
| `packages/cli-core/src/globals.d.ts` | TypeScript declaration for the `CLI_VERSION` compile-time define |
| `packages/cli-core/src/lib/version.ts` | Exposes the version inlined during transpilation or injected during release builds |
| `packages/cli-core/src/lib/version.macro.ts` | Derives a `-dev.<date>.<sha>` version from the checkout at transpile time |
| `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 +257,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 a Bun macro derives and inlines a dev version while transpiling or compiling the CLI: `<version in packages/cli/package.json>-dev.<YYYYMMDD>.<short sha>`, suffixed `.dirty` for an unclean tree. A local `build:compile:all` rehearsal leaves `CLI_VERSION` undefined and uses the same macro, so every binary retains the commit it was built from without running Git at runtime. The release workflow always passes an explicit `--version`, which replaces the macro fallback during compilation.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document the no-Git fallback.

When Git is unavailable or the CLI runs outside a checkout, the version falls back to <version>-dev without the date, SHA, or .dirty suffix. Add this case so the documentation matches the supported version-resolution behavior.

Proposed documentation update
 The `dev` and `start` commands do not inject a version, so a Bun macro derives and inlines a dev version while transpiling or compiling the CLI: `<version in packages/cli/package.json>-dev.<YYYYMMDD>.<short sha>`, suffixed `.dirty` for an unclean tree. A local `build:compile:all` rehearsal leaves `CLI_VERSION` undefined and uses the same macro, so every binary retains the commit it was built from without running Git at runtime. The release workflow always passes an explicit `--version`, which replaces the macro fallback during compilation.
+ If Git is unavailable or the CLI is not running from a checkout, the macro falls back to `<version in packages/cli/package.json>-dev`.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
The `dev` and `start` commands do not inject a version, so a Bun macro derives and inlines a dev version while transpiling or compiling the CLI: `<version in packages/cli/package.json>-dev.<YYYYMMDD>.<short sha>`, suffixed `.dirty` for an unclean tree. A local `build:compile:all` rehearsal leaves `CLI_VERSION` undefined and uses the same macro, so every binary retains the commit it was built from without running Git at runtime. The release workflow always passes an explicit `--version`, which replaces the macro fallback during compilation.
The `dev` and `start` commands do not inject a version, so a Bun macro derives and inlines a dev version while transpiling or compiling the CLI: `<version in packages/cli/package.json>-dev.<YYYYMMDD>.<short sha>`, suffixed `.dirty` for an unclean tree. A local `build:compile:all` rehearsal leaves `CLI_VERSION` undefined and uses the same macro, so every binary retains the commit it was built from without running Git at runtime. The release workflow always passes an explicit `--version`, which replaces the macro fallback during compilation. If Git is unavailable or the CLI is not running from a checkout, the macro falls back to `<version in packages/cli/package.json>-dev`.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/releasing.md` at line 260, Update the version-resolution documentation
around the dev/start commands to state that when Git is unavailable or the CLI
runs outside a checkout, the fallback is simply <version>-dev without a date,
short SHA, or .dirty suffix. Preserve the existing macro-derived version and
explicit release --version behavior.


> **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
2 changes: 1 addition & 1 deletion packages/cli-core/src/lib/credential-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ mock.module("@napi-rs/keyring", () => ({
}));

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

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
52 changes: 52 additions & 0 deletions packages/cli-core/src/lib/version.macro.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import cliPackage from "../../../cli/package.json";

const DEV_TAG = "dev";

type GitResult = {
exitCode: number;
stdout: string;
};

function git(args: string[]): GitResult | undefined {
try {
// Anchor the lookup on this source file rather than the user's current
// directory. Bun executes this module from the real checkout while it
// transpiles or bundles the CLI.
const process = Bun.spawnSync(["git", "--no-optional-locks", "-C", import.meta.dir, ...args], {
stdio: ["ignore", "pipe", "pipe"],
});
return { exitCode: process.exitCode, stdout: process.stdout.toString().trim() };
} catch {
return undefined;
}
}

function describeCheckout(): string | undefined {
const head = git(["log", "-1", "--format=%cs %h"]);
if (!head || head.exitCode !== 0) return undefined;

const [date, sha] = head.stdout.split(" ");
if (!date || !sha) return undefined;

// Semver forbids leading zeroes in an all-numeric prerelease identifier. The
// `g` prefix is the same escape used by git-describe.
const commit = /^0\d*$/.test(sha) ? `g${sha}` : sha;

// Include untracked files because a build can consume source that the commit
// does not describe.
const status = git(["status", "--porcelain", "--untracked-files=normal"]);
const dirty = status?.exitCode === 0 && status.stdout !== "" ? ".dirty" : "";

return `${date.replaceAll("-", "")}.${commit}${dirty}`;
}

/**
* Resolve the checkout-derived development version during Bun transpilation.
*
* Bun inlines the returned string at each macro call, so compiled binaries do
* not execute Git commands at runtime.
*/
export function resolveDevVersionAtBuildTime(): string {
const checkout = describeCheckout();
return `${cliPackage.version}-${DEV_TAG}${checkout ? `.${checkout}` : ""}`;
}
Loading