From ccb7a8ebb61b581ea8294502f597a83c09472d8a Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 01:40:05 +0000 Subject: [PATCH 1/3] Build stamps its git sha into index.html as a build-sha meta tag Co-Authored-By: Claude Sonnet 5.5 --- docs/gamecult/client-build-install.md | 4 +++ scripts/buildSha.js | 27 ++++++++++++++++++ scripts/buildSha.test.js | 40 +++++++++++++++++++++++++++ vite.config.js | 2 ++ 4 files changed, 73 insertions(+) create mode 100644 scripts/buildSha.js create mode 100644 scripts/buildSha.test.js diff --git a/docs/gamecult/client-build-install.md b/docs/gamecult/client-build-install.md index 33d3526f..d89541da 100644 --- a/docs/gamecult/client-build-install.md +++ b/docs/gamecult/client-build-install.md @@ -47,6 +47,10 @@ handoff states which config differs and the installer decides. From Yggdrasil th not resolve: pass `LIVE_CURL_ARGS="--resolve chat.gamecult.org:443:127.0.0.1"`. The folder, with the three pins (`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and the node and npm versions, is what the install request names. +Every build stamps its commit into `index.html` (`scripts/buildSha.js`): ``, +with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. The handoff +build refuses a dist whose stamp differs from the sha it was asked for, and prints the stamp in its block. +To read it from anywhere: `curl -s https://chat.gamecult.org/ | grep -o 'build-sha" content="[^"]*'`. Before handing off: diff --git a/scripts/buildSha.js b/scripts/buildSha.js new file mode 100644 index 00000000..741c5983 --- /dev/null +++ b/scripts/buildSha.js @@ -0,0 +1,27 @@ +// The commit a bundle was built from, stamped into its index.html as , so a built +// folder says what it is without a build log: `grep -o 'build-sha" content="[^"]*' dist/index.html`. +// BUILD_SHA wins when it is a full sha (a build from a source archive has no git); otherwise git's HEAD, +// suffixed -dirty when the tree has uncommitted changes; otherwise 'unknown'. +import { execFileSync } from 'child_process'; + +const git = (...args) => + execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); + +export const readBuildSha = (env = process.env, run = git) => { + const fromEnv = (env.BUILD_SHA ?? '').trim(); + if (/^[0-9a-f]{40}$/.test(fromEnv)) return fromEnv; + try { + const head = run('rev-parse', 'HEAD').trim(); + if (!/^[0-9a-f]{40}$/.test(head)) return 'unknown'; + return run('status', '--porcelain').trim() === '' ? head : `${head}-dirty`; + } catch { + return 'unknown'; + } +}; + +export const buildShaPlugin = (sha = readBuildSha()) => ({ + name: 'ratatoskr-build-sha', + transformIndexHtml: () => [ + { tag: 'meta', attrs: { name: 'build-sha', content: sha }, injectTo: 'head-prepend' }, + ], +}); diff --git a/scripts/buildSha.test.js b/scripts/buildSha.test.js new file mode 100644 index 00000000..e80f01b5 --- /dev/null +++ b/scripts/buildSha.test.js @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { buildShaPlugin, readBuildSha } from './buildSha'; + +const SHA = 'b6b1d522f1c0a9d3e2b4c5d6e7f8091a2b3c4d5e'; +const gitAt = + (head, status = '') => + (cmd) => + cmd === 'rev-parse' ? `${head}\n` : status; + +describe('readBuildSha', () => { + it('reads the checkout it is built from', () => { + expect(readBuildSha({}, gitAt(SHA))).toBe(SHA); + }); + it('marks a tree with uncommitted changes, so a stamp never vouches for code it does not hold', () => { + expect(readBuildSha({}, gitAt(SHA, ' M src/x.ts\n'))).toBe(`${SHA}-dirty`); + }); + it('lets BUILD_SHA stand in where there is no git, and ignores a value that is not a full sha', () => { + const noGit = () => { + throw new Error('not a git repository'); + }; + expect(readBuildSha({ BUILD_SHA: SHA }, noGit)).toBe(SHA); + expect(readBuildSha({ BUILD_SHA: 'main' }, noGit)).toBe('unknown'); + }); + it("says 'unknown' rather than failing the build when git is absent", () => { + expect( + readBuildSha({}, () => { + throw new Error('spawn git ENOENT'); + }) + ).toBe('unknown'); + }); +}); + +describe('buildShaPlugin', () => { + it("hands vite a meta tag for the page's head carrying the sha", () => { + const plugin = buildShaPlugin(SHA); + expect(plugin.transformIndexHtml()).toEqual([ + { tag: 'meta', attrs: { name: 'build-sha', content: SHA }, injectTo: 'head-prepend' }, + ]); + }); +}); diff --git a/vite.config.js b/vite.config.js index e9b67550..699c3148 100644 --- a/vite.config.js +++ b/vite.config.js @@ -10,6 +10,7 @@ import { VitePWA } from 'vite-plugin-pwa'; import fs from 'fs'; import path from 'path'; import buildConfig from './build.config'; +import { buildShaPlugin } from './scripts/buildSha'; const copyFiles = { targets: [ @@ -104,6 +105,7 @@ export default defineConfig({ }), viteStaticCopy(copyFiles), vanillaExtractPlugin(), + buildShaPlugin(), wasm(), react(), VitePWA({ -- 2.47.3 From 46e96500e495483626bcfc5d708e94df845ed86d Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 03:20:59 +0000 Subject: [PATCH 2/3] buildSha: ignore vite's temp config file, run git in the repo dir Co-Authored-By: Claude Sonnet 5.5 --- scripts/buildSha.js | 18 ++++++++++++++---- scripts/buildSha.test.js | 27 ++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/scripts/buildSha.js b/scripts/buildSha.js index 741c5983..d6c4870f 100644 --- a/scripts/buildSha.js +++ b/scripts/buildSha.js @@ -3,17 +3,27 @@ // BUILD_SHA wins when it is a full sha (a build from a source archive has no git); otherwise git's HEAD, // suffixed -dirty when the tree has uncommitted changes; otherwise 'unknown'. import { execFileSync } from 'child_process'; +import { dirname, resolve } from 'path'; +import { fileURLToPath } from 'url'; -const git = (...args) => - execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); +const REPO_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -export const readBuildSha = (env = process.env, run = git) => { +// Vite writes vite.config.js.timestamp-*.mjs next to the config while it loads it; that file is the +// build's own, not source, so it must not mark the tree dirty. Any other untracked file still does. +const VITE_TEMP = ':(exclude,glob)**/vite.config.*.timestamp-*.mjs'; + +export const gitIn = + (cwd) => + (...args) => + execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); + +export const readBuildSha = (env = process.env, run = gitIn(REPO_DIR)) => { const fromEnv = (env.BUILD_SHA ?? '').trim(); if (/^[0-9a-f]{40}$/.test(fromEnv)) return fromEnv; try { const head = run('rev-parse', 'HEAD').trim(); if (!/^[0-9a-f]{40}$/.test(head)) return 'unknown'; - return run('status', '--porcelain').trim() === '' ? head : `${head}-dirty`; + return run('status', '--porcelain', '--', '.', VITE_TEMP).trim() === '' ? head : `${head}-dirty`; } catch { return 'unknown'; } diff --git a/scripts/buildSha.test.js b/scripts/buildSha.test.js index e80f01b5..368cc960 100644 --- a/scripts/buildSha.test.js +++ b/scripts/buildSha.test.js @@ -1,5 +1,9 @@ +import { execFileSync } from 'child_process'; +import { mkdtempSync, writeFileSync, mkdirSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { describe, expect, it } from 'vitest'; -import { buildShaPlugin, readBuildSha } from './buildSha'; +import { buildShaPlugin, gitIn, readBuildSha } from './buildSha'; const SHA = 'b6b1d522f1c0a9d3e2b4c5d6e7f8091a2b3c4d5e'; const gitAt = @@ -30,6 +34,27 @@ describe('readBuildSha', () => { }); }); +describe('readBuildSha against a real repository', () => { + const repo = () => { + const dir = mkdtempSync(join(tmpdir(), 'buildsha-')); + const g = (...a) => execFileSync('git', a, { cwd: dir, stdio: 'ignore' }); + g('init', '-q'); + mkdirSync(join(dir, 'src')); + writeFileSync(join(dir, 'src', 'a.ts'), 'export {};\n'); + g('add', '.'); + g('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x'); + return dir; + }; + it("stays clean with vite's temp config file present, and is dirty with a stray source file", () => { + const dir = repo(); + const run = gitIn(dir); + writeFileSync(join(dir, 'vite.config.js.timestamp-1700000000-abc123.mjs'), ''); + expect(readBuildSha({}, run)).toMatch(/^[0-9a-f]{40}$/); + writeFileSync(join(dir, 'src', 'zz-stray.ts'), ''); + expect(readBuildSha({}, run)).toMatch(/^[0-9a-f]{40}-dirty$/); + }); +}); + describe('buildShaPlugin', () => { it("hands vite a meta tag for the page's head carrying the sha", () => { const plugin = buildShaPlugin(SHA); -- 2.47.3 From 917f9a0da2c36e4152775daa99bb2d5f87e2e0ec Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 03:48:22 +0000 Subject: [PATCH 3/3] buildSha test: remove the temp repos after each test Co-Authored-By: Claude Sonnet 5.5 --- scripts/buildSha.test.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/buildSha.test.js b/scripts/buildSha.test.js index 368cc960..c45c8747 100644 --- a/scripts/buildSha.test.js +++ b/scripts/buildSha.test.js @@ -1,8 +1,8 @@ import { execFileSync } from 'child_process'; -import { mkdtempSync, writeFileSync, mkdirSync } from 'fs'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { buildShaPlugin, gitIn, readBuildSha } from './buildSha'; const SHA = 'b6b1d522f1c0a9d3e2b4c5d6e7f8091a2b3c4d5e'; @@ -35,8 +35,13 @@ describe('readBuildSha', () => { }); describe('readBuildSha against a real repository', () => { + const made = []; + afterEach(() => { + made.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true })); + }); const repo = () => { const dir = mkdtempSync(join(tmpdir(), 'buildsha-')); + made.push(dir); const g = (...a) => execFileSync('git', a, { cwd: dir, stdio: 'ignore' }); g('init', '-q'); mkdirSync(join(dir, 'src')); -- 2.47.3