Merge pull request 'buildSha: an explicit BUILD_SHA no longer masks a dirty tree; temp-file exclusion is untracked-only' (#128) from void/build-sha-untracked-filter into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m39s
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m39s
This commit is contained in:
commit
b77efddcdc
3 changed files with 129 additions and 10 deletions
|
|
@ -48,7 +48,7 @@ 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
|
||||
with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. Where git governs the tree it decides: an explicit `BUILD_SHA` naming a different commit than HEAD stamps `<sha>-mismatch`, a git failure stamps `unknown`. A full-sha `BUILD_SHA` stands only for a tree git does not govern (a source archive) or cannot read (unusable git, for example a dubious-ownership refusal). 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="[^"]*'`.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
// 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'.
|
||||
// Where git governs the tree (REPO_DIR is a repository root) git decides: HEAD, suffixed -dirty when the tree has
|
||||
// uncommitted changes, or `<BUILD_SHA>-mismatch` when an explicit BUILD_SHA names a different commit, so a stamp
|
||||
// never vouches for code it does not hold; a git failure there is 'unknown'. Where git does not govern (a source
|
||||
// archive), a full-sha BUILD_SHA stands, else 'unknown'.
|
||||
import { execFileSync } from 'child_process';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
|
@ -9,21 +11,38 @@ 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';
|
||||
// build's own, not source, so an UNTRACKED one must not mark the tree dirty. A tracked file of that name,
|
||||
// or any other change, still does. Filtering status lines (not a pathspec) is what keeps it untracked-only.
|
||||
const VITE_TEMP = /^\?\? (?:.*\/)?vite\.config\.[a-z]+\.timestamp-\d+-[0-9a-f]+\.mjs$/;
|
||||
|
||||
export const gitIn =
|
||||
(cwd) =>
|
||||
(...args) =>
|
||||
execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
|
||||
const FULL_SHA = /^[0-9a-f]{40}$/;
|
||||
|
||||
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;
|
||||
const explicit = FULL_SHA.test(fromEnv) ? fromEnv : null;
|
||||
// Git governs this tree only when REPO_DIR is its root: a source archive unpacked inside some other
|
||||
// repository has no git of its own, and that repository's HEAD says nothing about it.
|
||||
let governed;
|
||||
try {
|
||||
governed = run('rev-parse', '--show-prefix').trim() === '';
|
||||
} catch {
|
||||
governed = false;
|
||||
}
|
||||
if (!governed) return explicit ?? 'unknown';
|
||||
// Git governs and answers: from here a failure is 'unknown', never the bare explicit sha.
|
||||
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`;
|
||||
if (!FULL_SHA.test(head)) return 'unknown';
|
||||
if (explicit && explicit !== head) return `${explicit}-mismatch`;
|
||||
const dirty = run('status', '--porcelain', '-uall', '--', '.')
|
||||
.split('\n')
|
||||
.some((l) => l.trim() !== '' && !VITE_TEMP.test(l));
|
||||
return dirty ? `${head}-dirty` : head;
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import { buildShaPlugin, gitIn, readBuildSha } from './buildSha';
|
|||
const SHA = 'b6b1d522f1c0a9d3e2b4c5d6e7f8091a2b3c4d5e';
|
||||
const gitAt =
|
||||
(head, status = '') =>
|
||||
(cmd) =>
|
||||
cmd === 'rev-parse' ? `${head}\n` : status;
|
||||
(cmd, ...args) =>
|
||||
cmd === 'rev-parse' ? (args[0] === '--show-prefix' ? '\n' : `${head}\n`) : status;
|
||||
|
||||
describe('readBuildSha', () => {
|
||||
it('reads the checkout it is built from', () => {
|
||||
|
|
@ -23,6 +23,25 @@ describe('readBuildSha', () => {
|
|||
throw new Error('not a git repository');
|
||||
};
|
||||
expect(readBuildSha({ BUILD_SHA: SHA }, noGit)).toBe(SHA);
|
||||
expect(readBuildSha({ BUILD_SHA: SHA }, gitAt(SHA))).toBe(SHA);
|
||||
// a different HEAD is a mismatch, not a clean stamp of the explicit sha
|
||||
expect(readBuildSha({ BUILD_SHA: SHA }, gitAt('c'.repeat(40)))).toBe(`${SHA}-mismatch`);
|
||||
// a value that is not a lowercase full sha is ignored
|
||||
['b6b1d52', SHA.toUpperCase(), `${SHA}0`].forEach((v) =>
|
||||
expect(readBuildSha({ BUILD_SHA: v }, noGit)).toBe('unknown')
|
||||
);
|
||||
// git that answers but cannot say what the tree holds fails closed
|
||||
const brokenStatus = (cmd, ...args) => {
|
||||
if (cmd === 'status') throw new Error('index file smaller than expected');
|
||||
return args[0] === '--show-prefix' ? '\n' : `${SHA}\n`;
|
||||
};
|
||||
expect(readBuildSha({ BUILD_SHA: SHA }, brokenStatus)).toBe('unknown');
|
||||
expect(readBuildSha({}, brokenStatus)).toBe('unknown');
|
||||
expect(readBuildSha({}, gitAt('nope'))).toBe('unknown');
|
||||
// inside another repository's subfolder git does not govern: the explicit sha stands
|
||||
const nested = (cmd, ...args) => (args[0] === '--show-prefix' ? 'vendor/ratatoskr/\n' : SHA);
|
||||
expect(readBuildSha({ BUILD_SHA: SHA }, nested)).toBe(SHA);
|
||||
expect(readBuildSha({}, nested)).toBe('unknown');
|
||||
expect(readBuildSha({ BUILD_SHA: 'main' }, noGit)).toBe('unknown');
|
||||
});
|
||||
it("says 'unknown' rather than failing the build when git is absent", () => {
|
||||
|
|
@ -60,6 +79,87 @@ describe('readBuildSha against a real repository', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('readBuildSha dirty rules against a real repository', () => {
|
||||
const made2 = [];
|
||||
afterEach(() => {
|
||||
made2.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true }));
|
||||
});
|
||||
const TEMP = 'vite.config.js.timestamp-1700000000-abc123.mjs';
|
||||
const repo2 = (tracked = []) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'buildsha-'));
|
||||
made2.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');
|
||||
tracked.forEach((f) => writeFileSync(join(dir, f), 'x\n'));
|
||||
g('add', '.');
|
||||
g('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x');
|
||||
return {
|
||||
dir,
|
||||
head: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim(),
|
||||
};
|
||||
};
|
||||
it('does not let an explicit BUILD_SHA mask a dirty tree', () => {
|
||||
const { dir, head } = repo2();
|
||||
const run = gitIn(dir);
|
||||
expect(readBuildSha({ BUILD_SHA: head }, run)).toBe(head);
|
||||
writeFileSync(join(dir, 'src', 'zz-stray.ts'), '');
|
||||
expect(readBuildSha({ BUILD_SHA: head }, run)).toBe(`${head}-dirty`);
|
||||
});
|
||||
it('lets git speak only for a tree whose root it is: an archive unpacked inside another repository keeps its explicit sha', () => {
|
||||
const { dir } = repo2();
|
||||
const inner = join(dir, 'vendor', 'ratatoskr');
|
||||
mkdirSync(inner, { recursive: true });
|
||||
writeFileSync(join(inner, 'x.ts'), '');
|
||||
expect(readBuildSha({ BUILD_SHA: SHA }, gitIn(inner))).toBe(SHA);
|
||||
expect(readBuildSha({}, gitIn(inner))).toBe('unknown');
|
||||
});
|
||||
it("notices an untracked .mjs that is not vite's temp file", () => {
|
||||
const { dir, head } = repo2();
|
||||
writeFileSync(join(dir, 'foo.mjs'), '');
|
||||
expect(readBuildSha({}, gitIn(dir))).toBe(`${head}-dirty`);
|
||||
rmSync(join(dir, 'foo.mjs'));
|
||||
// only vite's exact shape is its own: vite.config.<ext>.timestamp-<digits>-<hex>.mjs
|
||||
[
|
||||
'vite.config.js.timestamp-1-ab.mjs.bak',
|
||||
'vite.config.js.timestamp-x-ab.mjs',
|
||||
'vite.config.js.timestamp-1-zz.mjs',
|
||||
'vite.config.a.b.timestamp-1-ab.mjs',
|
||||
'vite.config.js.timestamp-1-ab-c.mjs',
|
||||
].forEach((name) => {
|
||||
writeFileSync(join(dir, name), '');
|
||||
expect(readBuildSha({}, gitIn(dir)), name).toBe(`${head}-dirty`);
|
||||
rmSync(join(dir, name));
|
||||
});
|
||||
expect(readBuildSha({}, gitIn(dir))).toBe(head);
|
||||
});
|
||||
it('treats a stray source file as dirty even beside the vite temp file, in the root or a subfolder', () => {
|
||||
const { dir, head } = repo2();
|
||||
const run = gitIn(dir);
|
||||
writeFileSync(join(dir, TEMP), '');
|
||||
writeFileSync(join(dir, 'src', TEMP), '');
|
||||
expect(readBuildSha({}, run)).toBe(head);
|
||||
writeFileSync(join(dir, 'src', 'zz-stray.ts'), '');
|
||||
expect(readBuildSha({}, run)).toBe(`${head}-dirty`);
|
||||
});
|
||||
it('marks a tracked file of the temp name dirty once it changes', () => {
|
||||
const { dir, head } = repo2([TEMP]);
|
||||
const run = gitIn(dir);
|
||||
expect(readBuildSha({}, run)).toBe(head);
|
||||
writeFileSync(join(dir, TEMP), 'changed\n');
|
||||
expect(readBuildSha({}, run)).toBe(`${head}-dirty`);
|
||||
});
|
||||
it('marks an untracked file in a new folder dirty (status lists files, not the folder)', () => {
|
||||
const { dir, head } = repo2();
|
||||
mkdirSync(join(dir, 'new'));
|
||||
writeFileSync(join(dir, 'new', TEMP), '');
|
||||
expect(readBuildSha({}, gitIn(dir))).toBe(head);
|
||||
writeFileSync(join(dir, 'new', 'b.ts'), '');
|
||||
expect(readBuildSha({}, gitIn(dir))).toBe(`${head}-dirty`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildShaPlugin', () => {
|
||||
it("hands vite a meta tag for the page's head carrying the sha", () => {
|
||||
const plugin = buildShaPlugin(SHA);
|
||||
|
|
|
|||
Loading…
Reference in a new issue