Build stamps its git sha into index.html (build-sha meta tag) #126
4 changed files with 113 additions and 0 deletions
|
|
@ -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`): `<meta name="build-sha" content="<40-hex>">`,
|
||||
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:
|
||||
|
||||
|
|
|
|||
37
scripts/buildSha.js
Normal file
37
scripts/buildSha.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// The commit a bundle was built from, stamped into its index.html as <meta name="build-sha">, 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';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const REPO_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
// 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', '--', '.', VITE_TEMP).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' },
|
||||
],
|
||||
});
|
||||
70
scripts/buildSha.test.js
Normal file
70
scripts/buildSha.test.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { execFileSync } from 'child_process';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { buildShaPlugin, gitIn, 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('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'));
|
||||
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);
|
||||
expect(plugin.transformIndexHtml()).toEqual([
|
||||
{ tag: 'meta', attrs: { name: 'build-sha', content: SHA }, injectTo: 'head-prepend' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue