Skip to content

Commit 6fde764

Browse files
dorlugasigalCopilot
andcommitted
fix(version): mark dev builds dirty for untracked files; freeze /api/version at startup
git describe --dirty only flags tracked-file modifications, so adding a new untracked file (e.g. hi.txt) left the version label clean. Now also runs git status --porcelain and treats any untracked entries as dirty so local dev builds are visibly distinct from a tagged release. Also makes /api/version return the version captured at process startup (matching /api/update-check) instead of re-spawning git describe per request. The version label now reliably reflects the code that's actually running — restart the service to pick up new commits/changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1a04a14 commit 6fde764

4 files changed

Lines changed: 65 additions & 6 deletions

File tree

packages/site/src/content/docs/api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,7 @@ Returned when the server was started with `--no-password`.
528528

529529
#### `GET /api/version`
530530

531-
Get the server version.
531+
Get the server version. The value is captured once at process startup and reflects the running code (not the latest git state on disk). Restart the service to pick up a new version.
532532

533533
**Response:**
534534

src/server/routes.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,12 @@ function setupRoutes(
163163
}
164164
});
165165

166-
// Version API
166+
// Version API — uses the version captured at server startup so dev/prod
167+
// builds stay stable for the lifetime of the process. Restart the service
168+
// to pick up a new git tag or dirty-state change.
167169
app.get('/api/version', (_req, res) => {
168170
log.debug('Version requested');
169-
const { getVersion } = require('../utils/version');
170-
res.json({ version: getVersion() });
171+
res.json({ version: config.version });
171172
});
172173

173174
// Changelog — served from repo CHANGELOG.md (bundled with the npm package).

src/utils/version.js

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ function getVersion() {
1818

1919
// Running from source — git tags are the version source of truth.
2020
// This avoids drift between package.json and tagged releases.
21+
const repoRoot = path.join(__dirname, '..', '..');
2122
try {
2223
const gitDesc = execSync('git describe --tags --always --dirty', {
23-
cwd: path.join(__dirname, '..', '..'),
24+
cwd: repoRoot,
2425
encoding: 'utf-8',
2526
stdio: ['pipe', 'pipe', 'pipe'],
2627
windowsHide: true,
@@ -31,7 +32,10 @@ function getVersion() {
3132
const gitVersion = tagMatch[1];
3233
const commits = tagMatch[2];
3334
const hash = tagMatch[3];
34-
const dirty = tagMatch[4];
35+
// `git describe --dirty` only flags tracked-file modifications. Treat any
36+
// untracked files (e.g. a fresh `hi.txt`) as dirty too so dev builds are
37+
// always visibly distinct from the tagged release.
38+
const dirty = tagMatch[4] || (hasUntrackedChanges(repoRoot) ? '-dirty' : '');
3539

3640
// Exactly on a clean tag — return the tag version
3741
if (!commits && !dirty) {
@@ -69,4 +73,18 @@ function isInstalledGlobally() {
6973
return __dirname.includes('node_modules');
7074
}
7175

76+
function hasUntrackedChanges(cwd) {
77+
try {
78+
const status = execSync('git status --porcelain', {
79+
cwd,
80+
encoding: 'utf-8',
81+
stdio: ['pipe', 'pipe', 'pipe'],
82+
windowsHide: true,
83+
});
84+
return status.trim().length > 0;
85+
} catch {
86+
return false;
87+
}
88+
}
89+
7290
module.exports = { getVersion };

test/utils/version.test.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ describe('Version', () => {
4444
const origExecSync = child_process.execSync;
4545
child_process.execSync = (cmd, opts) => {
4646
if (cmd.includes('git describe')) return 'v2.5.0\n';
47+
if (cmd.includes('git status --porcelain')) return '';
4748
return origExecSync(cmd, opts);
4849
};
4950
try {
@@ -62,6 +63,7 @@ describe('Version', () => {
6263
const origExecSync = child_process.execSync;
6364
child_process.execSync = (cmd, opts) => {
6465
if (cmd.includes('git describe')) return 'v2.5.0-3-gabcdef1\n';
66+
if (cmd.includes('git status --porcelain')) return '';
6567
return origExecSync(cmd, opts);
6668
};
6769
try {
@@ -92,6 +94,44 @@ describe('Version', () => {
9294
}
9395
});
9496

97+
it('should mark version dirty when only untracked files are present', () => {
98+
delete process.env.npm_package_version;
99+
const child_process = require('child_process');
100+
const origExecSync = child_process.execSync;
101+
child_process.execSync = (cmd, opts) => {
102+
if (cmd.includes('git describe')) return 'v2.5.0\n';
103+
if (cmd.includes('git status --porcelain')) return '?? hi.txt\n';
104+
return origExecSync(cmd, opts);
105+
};
106+
try {
107+
delete require.cache[require.resolve('../../src/utils/version')];
108+
const { getVersion } = require('../../src/utils/version');
109+
assert.equal(getVersion(), '2.5.0-dev+dirty');
110+
} finally {
111+
child_process.execSync = origExecSync;
112+
delete require.cache[require.resolve('../../src/utils/version')];
113+
}
114+
});
115+
116+
it('should stay clean when no untracked or modified files are present', () => {
117+
delete process.env.npm_package_version;
118+
const child_process = require('child_process');
119+
const origExecSync = child_process.execSync;
120+
child_process.execSync = (cmd, opts) => {
121+
if (cmd.includes('git describe')) return 'v2.5.0\n';
122+
if (cmd.includes('git status --porcelain')) return '';
123+
return origExecSync(cmd, opts);
124+
};
125+
try {
126+
delete require.cache[require.resolve('../../src/utils/version')];
127+
const { getVersion } = require('../../src/utils/version');
128+
assert.equal(getVersion(), '2.5.0');
129+
} finally {
130+
child_process.execSync = origExecSync;
131+
delete require.cache[require.resolve('../../src/utils/version')];
132+
}
133+
});
134+
95135
it('should fall back to package.json when git has no semver tag', () => {
96136
delete process.env.npm_package_version;
97137
const child_process = require('child_process');

0 commit comments

Comments
 (0)