From 5d7e2cf41540265accba3e2810f4ee932afbfbe5 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Wed, 17 Jun 2026 17:36:55 -0700 Subject: [PATCH] feat(version): derive a git-based build version for dev builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds made ahead of the release tag now report a distinguishable version. The build bakes a BUILD_VERSION into dist/version.mjs from `git describe`, using semver build metadata (the +… part, ignored for precedence): an exact clean tag stays `0.2.9`, anything ahead reads `0.2.9+8.gabc1234[.dirty]`. BUILD_VERSION flows into the surfaces that identify the running build: CLI --version/help banner, status `version`, daemon service.version, the tracer, and the plugin/agent span attributes. The clean VERSION is kept where it must match the release tag (MARKETPLACE_REF, install metadata, weave.integration.version). Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/build/copy-version-module.mjs | 25 +++++-- scripts/build/git-build-version.mjs | 79 ++++++++++++++++++++++ scripts/release/version-module-utils.mjs | 34 +++++++--- src/cli.ts | 8 +-- src/daemon.ts | 12 ++-- src/setup.ts | 4 +- src/version.d.mts | 1 + src/version.mjs | 3 + tests/build-version.test.ts | 83 ++++++++++++++++++++++++ 9 files changed, 221 insertions(+), 28 deletions(-) create mode 100644 scripts/build/git-build-version.mjs create mode 100644 tests/build-version.test.ts diff --git a/scripts/build/copy-version-module.mjs b/scripts/build/copy-version-module.mjs index 7ef8b0c..6c11912 100644 --- a/scripts/build/copy-version-module.mjs +++ b/scripts/build/copy-version-module.mjs @@ -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 @@ -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); diff --git a/scripts/build/git-build-version.mjs b/scripts/build/git-build-version.mjs new file mode 100644 index 0000000..f24e4da --- /dev/null +++ b/scripts/build/git-build-version.mjs @@ -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: --g. 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; + } +} diff --git a/scripts/release/version-module-utils.mjs b/scripts/release/version-module-utils.mjs index 8ef43f1..a29b412 100644 --- a/scripts/release/version-module-utils.mjs +++ b/scripts/release/version-module-utils.mjs @@ -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 })); } diff --git a/src/cli.ts b/src/cli.ts index 18e91e4..6fce149 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,7 +12,7 @@ import { SETTINGS_FILE, MARKETPLACE_NAME, PLUGIN_NAME, - VERSION, + BUILD_VERSION, InstallSource, MarketplaceStatus, PluginStatus, @@ -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. @@ -432,7 +432,7 @@ interface StatusSnapshot { async function gatherStatus(): Promise { const report: StatusReport = { - version: VERSION, + version: BUILD_VERSION, settings_file: SETTINGS_FILE, cli_path: null, weave_project: null, @@ -833,7 +833,7 @@ async function main(): Promise { const cmd = args[0]; if (cmd === '--version' || cmd === '-v') { - console.log(VERSION); + console.log(BUILD_VERSION); process.exit(0); } diff --git a/src/daemon.ts b/src/daemon.ts index da2cf1f..1136db7 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -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, @@ -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, }); @@ -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 ─────────────────────────────────────────────────── @@ -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)}`, @@ -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), @@ -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); diff --git a/src/setup.ts b/src/setup.ts index 969c25d..1057080 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -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; diff --git a/src/version.d.mts b/src/version.d.mts index 5ca42bd..84caeea 100644 --- a/src/version.d.mts +++ b/src/version.d.mts @@ -1 +1,2 @@ export const VERSION: string; +export const BUILD_VERSION: string; diff --git a/src/version.mjs b/src/version.mjs index b3122fb..9f6af8e 100644 --- a/src/version.mjs +++ b/src/version.mjs @@ -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; diff --git a/tests/build-version.test.ts b/tests/build-version.test.ts new file mode 100644 index 0000000..c33d8c3 --- /dev/null +++ b/tests/build-version.test.ts @@ -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 }); + } + }); +});