Skip to content
Draft
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
25 changes: 19 additions & 6 deletions scripts/build/copy-version-module.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
/**
* Post-tsc finalization for the published build.
*
* 1. Copy `src/version.mjs` (the release-automation source of truth, imported
* by TypeScript sources) into `dist/`, since `tsc` does not emit `.mjs`
* source files itself.
* 1. Write `dist/version.mjs` from the release-automation source of truth
* (`src/version.mjs`), since `tsc` does not emit `.mjs` source files itself.
* VERSION is copied verbatim; BUILD_VERSION is resolved from `git describe`
* so a build made ahead of the release tag (or with a dirty tree) reports a
* distinguishable version like `0.2.9+8.gabc1234` while the tagged release
* stays exactly `0.2.9`. Falls back to VERSION when git is unavailable.
* 2. Mark `dist/cli.js` executable. The file ships with a `#!/usr/bin/env node`
* shebang and is the `bin` entry in package.json, so the published tarball
* must preserve mode 0o755 — otherwise `npm install -g` produces a binary
Expand All @@ -15,14 +18,24 @@
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { VERSION } from '../../src/version.mjs';
import { resolveBuildVersion } from './git-build-version.mjs';

const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
const distDir = path.join(repoRoot, 'dist');

const versionSource = path.join(repoRoot, 'src', 'version.mjs');
const versionTarget = path.join(distDir, 'version.mjs');

const buildVersion = resolveBuildVersion(repoRoot, VERSION);

fs.mkdirSync(distDir, { recursive: true });
fs.copyFileSync(versionSource, versionTarget);
fs.writeFileSync(
versionTarget,
[
'// Generated by scripts/build/copy-version-module.mjs. Do not edit.',
`export const VERSION = '${VERSION}';`,
`export const BUILD_VERSION = '${buildVersion}';`,
'',
].join('\n'),
);

fs.chmodSync(path.join(distDir, 'cli.js'), 0o755);
79 changes: 79 additions & 0 deletions scripts/build/git-build-version.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { spawnSync } from 'node:child_process';

/**
* Turn `git describe` output into a build version string.
*
* On an exact, clean release tag the base version is returned unchanged (the
* published release reports e.g. `0.2.9`). Anything ahead of the tag, or a dirty
* working tree, appends semver build metadata (the `+…` part, which §10 of the
* semver spec ignores for precedence) so dev builds are distinguishable:
* `0.2.9+8.gabc1234` — 8 commits past v0.2.9
* `0.2.9+8.gabc1234.dirty` — …with uncommitted changes
*
* The base version is taken from `baseVersion` (the release-automation source of
* truth), never from the tag name, so it stays correct even if a tag drifts.
* Parsing anchors at the end of the string because pre-release base versions can
* themselves contain hyphens (e.g. `v0.2.8-rc.0-3-gdef5678`).
*
* @param {string} baseVersion - clean semver, e.g. `0.2.9`.
* @param {string} describeOutput - raw `git describe --long --dirty --always` output.
* @returns {string}
*/
export function buildVersionFrom(baseVersion, describeOutput) {
const raw = (describeOutput || '').trim();
if (!raw) return baseVersion;

let dirty = false;
let rest = raw;
if (rest.endsWith('-dirty')) {
dirty = true;
rest = rest.slice(0, -'-dirty'.length);
}

// `--long` form: <tag>-<count>-g<sha>. Anchored at the end; the tag prefix
// (which may contain hyphens) is ignored in favor of baseVersion.
const long = rest.match(/-(\d+)-g([0-9a-f]+)$/i);
if (long) {
const count = Number(long[1]);
const sha = long[2];
if (count === 0 && !dirty) return baseVersion;
const meta = [String(count), `g${sha}`];
if (dirty) meta.push('dirty');
return `${baseVersion}+${meta.join('.')}`;
}

// `--always` fallback when no matching tag exists: a bare abbreviated sha.
const bare = rest.match(/^([0-9a-f]{4,40})$/i);
if (bare) {
const meta = [`g${bare[1]}`];
if (dirty) meta.push('dirty');
return `${baseVersion}+${meta.join('.')}`;
}

// Unrecognized shape — don't guess; report the clean base version.
return baseVersion;
}

/**
* Resolve the build version for a checkout by running `git describe`. Any
* failure (not a git repo, git missing, no output) falls back to `baseVersion`,
* which is the correct behavior for a published npm tarball that ships without a
* `.git` directory.
*
* @param {string} repoRoot - directory to run git in.
* @param {string} baseVersion - clean semver fallback.
* @returns {string}
*/
export function resolveBuildVersion(repoRoot, baseVersion) {
try {
const result = spawnSync(
'git',
['describe', '--tags', '--long', '--dirty', '--always', '--match', 'v*'],
{ cwd: repoRoot, encoding: 'utf8' },
);
if (result.status !== 0 || !result.stdout) return baseVersion;
return buildVersionFrom(baseVersion, result.stdout);
} catch {
return baseVersion;
}
}
34 changes: 24 additions & 10 deletions scripts/release/version-module-utils.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,29 @@ export function readVersionMetadata() {
};
}

/**
* Render the source-of-truth version module. Kept separate from the file write
* so it can be unit-tested without clobbering src/version.mjs.
*
* BUILD_VERSION defaults to VERSION here; the production build overwrites
* dist/version.mjs with a git-derived value (see
* scripts/build/copy-version-module.mjs) so dev builds are distinguishable from
* the published release. Emitting it here keeps the export alive across version
* bumps, which rewrite this whole file.
*/
export function renderVersionModule({ version }) {
return [
'// BEGIN AUTO-MANAGED VERSION',
'// This section is maintained by release automation. Do not edit manually.',
`export const VERSION = '${version}';`,
'// END AUTO-MANAGED VERSION',
'',
'// Overwritten with a git-derived build version in dist/ at build time.',
'export const BUILD_VERSION = VERSION;',
'',
].join('\n');
}

export function writeVersionModule({ version }) {
fs.writeFileSync(
versionModulePath,
[
'// BEGIN AUTO-MANAGED VERSION',
'// This section is maintained by release automation. Do not edit manually.',
`export const VERSION = '${version}';`,
'// END AUTO-MANAGED VERSION',
'',
].join('\n'),
);
fs.writeFileSync(versionModulePath, renderVersionModule({ version }));
}
8 changes: 4 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
SETTINGS_FILE,
MARKETPLACE_NAME,
PLUGIN_NAME,
VERSION,
BUILD_VERSION,
InstallSource,
MarketplaceStatus,
PluginStatus,
Expand All @@ -35,7 +35,7 @@ import { DEFAULT_AGENT_NAME } from './genaiSpans.js';
// ---------------------------------------------------------------------------

const HELP = `
weave-claude-code v${VERSION}
weave-claude-code v${BUILD_VERSION}

Track Claude Code sessions in Weave for observability and debugging.

Expand Down Expand Up @@ -432,7 +432,7 @@ interface StatusSnapshot {

async function gatherStatus(): Promise<StatusSnapshot> {
const report: StatusReport = {
version: VERSION,
version: BUILD_VERSION,
settings_file: SETTINGS_FILE,
cli_path: null,
weave_project: null,
Expand Down Expand Up @@ -833,7 +833,7 @@ async function main(): Promise<void> {
const cmd = args[0];

if (cmd === '--version' || cmd === '-v') {
console.log(VERSION);
console.log(BUILD_VERSION);
process.exit(0);
}

Expand Down
12 changes: 6 additions & 6 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
import { loadSettings, VERSION, type Settings } from './setup.js';
import { loadSettings, VERSION, BUILD_VERSION, type Settings } from './setup.js';
import { appendToLog, deepEqual } from './utils.js';
import {
parseSessionFd,
Expand Down Expand Up @@ -528,7 +528,7 @@ export class GlobalDaemon {
// service.name has always mirrored the agent name; keep that coupling
// so a custom agent_name renames the OTel service too.
'service.name': this.agentName,
'service.version': VERSION,
'service.version': BUILD_VERSION,
'wandb.entity': entity,
'wandb.project': project,
});
Expand All @@ -546,7 +546,7 @@ export class GlobalDaemon {
spanProcessors: [new IntegrationBaggageSpanProcessor(), new BatchSpanProcessor(exporter)],
});
this.provider.register();
this.tracer = this.provider.getTracer('weave-claude-code', VERSION);
this.tracer = this.provider.getTracer('weave-claude-code', BUILD_VERSION);
}

// ── connection handling ───────────────────────────────────────────────────
Expand Down Expand Up @@ -867,7 +867,7 @@ export class GlobalDaemon {
prompt,
cwd: session.cwd,
source: session.source,
pluginVersion: VERSION,
pluginVersion: BUILD_VERSION,
agentName: this.agentName,
requestModel: session.initialRequestModel,
displayName: `Turn ${session.turnNumber}: ${promptSnippet(prompt)}`,
Expand Down Expand Up @@ -927,7 +927,7 @@ export class GlobalDaemon {
const invokeAgentSpan = startInvokeAgentSpan(this.tracer, toolParent, {
agentType: subagentType,
conversationId: session.conversationId,
pluginVersion: VERSION,
pluginVersion: BUILD_VERSION,
inputMessages: prompt ? [{ role: 'user', content: prompt }] : undefined,
spawningToolCallId: toolUseId,
displayName: toolDisplayName(toolName, toolInput),
Expand Down Expand Up @@ -1335,7 +1335,7 @@ export class GlobalDaemon {
bestTracker.invokeAgentSpan = startInvokeAgentSpan(this.tracer, session.currentTurnSpan, {
agentType,
conversationId: session.conversationId,
pluginVersion: VERSION,
pluginVersion: BUILD_VERSION,
displayName: `Agent: ${agentType}`,
});
bestTracker.invokeAgentSpan.setAttribute(ATTR.WEAVE_ORPHAN_REASON, reason);
Expand Down
4 changes: 2 additions & 2 deletions src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import * as os from 'os';
import * as path from 'path';
import { spawnSync } from 'child_process';
import { findClaudeCLI, appendToLog } from './utils.js';
import { VERSION } from './version.mjs';
import { VERSION, BUILD_VERSION } from './version.mjs';

export { VERSION };
export { VERSION, BUILD_VERSION };

export interface Settings {
log_file: string;
Expand Down
1 change: 1 addition & 0 deletions src/version.d.mts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export const VERSION: string;
export const BUILD_VERSION: string;
3 changes: 3 additions & 0 deletions src/version.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@
// This section is maintained by release automation. Do not edit manually.
export const VERSION = '0.2.9';
// END AUTO-MANAGED VERSION

// Overwritten with a git-derived build version in dist/ at build time.
export const BUILD_VERSION = VERSION;
83 changes: 83 additions & 0 deletions tests/build-version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// SPDX-FileCopyrightText: 2026 CoreWeave, Inc.
// SPDX-License-Identifier: MIT
// SPDX-PackageName: weave-claude-code

// Dev builds made ahead of the release tag must be distinguishable from the
// published release. `buildVersionFrom` turns `git describe` output into a
// semver build-metadata suffix (the `+…` part, ignored for precedence) so a
// build off `main` reports e.g. `0.2.9+8.gabc1234` while the tagged release
// stays exactly `0.2.9`.

import { test, suite } from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'node:fs';
import * as path from 'node:path';

import { buildVersionFrom, resolveBuildVersion } from '../scripts/build/git-build-version.mjs';
import { renderVersionModule } from '../scripts/release/version-module-utils.mjs';

suite('buildVersionFrom', () => {
test('exact clean tag → base version unchanged', () => {
assert.equal(buildVersionFrom('0.2.9', 'v0.2.9-0-gabc1234'), '0.2.9');
});

test('commits ahead of tag → build metadata with count and sha', () => {
assert.equal(buildVersionFrom('0.2.9', 'v0.2.9-8-gabc1234'), '0.2.9+8.gabc1234');
});

test('dirty working tree on the tag → marked dirty', () => {
assert.equal(buildVersionFrom('0.2.9', 'v0.2.9-0-gabc1234-dirty'), '0.2.9+0.gabc1234.dirty');
});

test('commits ahead and dirty → both recorded', () => {
assert.equal(buildVersionFrom('0.2.9', 'v0.2.9-8-gabc1234-dirty'), '0.2.9+8.gabc1234.dirty');
});

test('base version with a pre-release tag is preserved (parses from the end)', () => {
assert.equal(
buildVersionFrom('0.2.8-rc.0', 'v0.2.8-rc.0-3-gdef5678'),
'0.2.8-rc.0+3.gdef5678',
);
});

test('no tags, bare sha from --always → metadata without a count', () => {
assert.equal(buildVersionFrom('0.2.9', 'abc1234'), '0.2.9+gabc1234');
});

test('no tags, bare sha and dirty', () => {
assert.equal(buildVersionFrom('0.2.9', 'abc1234-dirty'), '0.2.9+gabc1234.dirty');
});

test('empty describe output → clean fallback', () => {
assert.equal(buildVersionFrom('0.2.9', ''), '0.2.9');
});

test('unrecognized output → clean fallback, no guessing', () => {
assert.equal(buildVersionFrom('0.2.9', 'not-a-describe-string!!'), '0.2.9');
});
});

suite('resolveBuildVersion', () => {
test('outside a git repository → falls back to the base version', () => {
const dir = fs.mkdtempSync('/tmp/wcp-buildver-');
try {
assert.equal(resolveBuildVersion(dir, '0.2.9'), '0.2.9');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

suite('renderVersionModule', () => {
test('emits VERSION and BUILD_VERSION so release bumps preserve the build export', () => {
const dir = fs.mkdtempSync('/tmp/wcp-rendermod-');
try {
const modPath = path.join(dir, 'version.mjs');
fs.writeFileSync(modPath, renderVersionModule({ version: '9.9.9' }));
assert.match(fs.readFileSync(modPath, 'utf8'), /export const VERSION = '9\.9\.9';/);
assert.match(fs.readFileSync(modPath, 'utf8'), /export const BUILD_VERSION/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
Loading