Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/stamp-build.mjs

6.6 KiB, 1 run

created by r2519314175:215, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// dev/stamp-build.mjs — stamp the bundle with a version and a written note.
2//
3// A browser that loaded Daimond an hour ago has no way to know a new build was deployed. The
4// fix is a tiny file, `www/build.json`, whose `build` id changes whenever the shipped code
5// changes; `updater.js` reads it, remembers it, and re-reads it to notice a new version.
6//
7// The id is a content hash over everything under `www/` (the whole bundle, JS + CSS + wasm),
8// so it changes if and only if the bundle changes: redeploying identical files does not nag a
9// user with a version that is not new. The `note` beside it is the one-line "what changed",
10// shown on the chip and carried into the PUBLIC transparency chain.
11//
12// node dev/stamp-build.mjs "Faster mail" write build.json with that note
13// node dev/stamp-build.mjs --check-note "Faster mail" say whether it will do, write nothing
14//
15// THE NOTE IS REQUIRED, since 2026-08-21. It used to fall back to the latest commit subject,
16// and on a release day the latest commit is the one that sealed the LAST release -- so the
17// public chain filled up with "The seal as shipped: build 4b59501ce711", a sentence about the
18// previous release's bookkeeping, standing where the description of this release should be.
19// The fallback is gone rather than fixed: a default that is right some of the time is what
20// stops anyone noticing it is wrong the rest of the time.
21//
22// Run this immediately before bundling www/ for deploy. No dependencies; plain Node.
23
24import { createHash } from 'node:crypto';
25import { readdir, readFile, writeFile } from 'node:fs/promises';
26import { realpathSync } from 'node:fs';
27import { join, relative, normalize } from 'node:path';
28import { fileURLToPath } from 'node:url';
29
30const ROOT = normalize(join(fileURLToPath(import.meta.url), '..', '..', 'www'));
31const STAMP = join(ROOT, 'build.json');
32
33/// Every file under www/, sorted, so the hash is deterministic regardless of walk order. The
34/// stamp itself is excluded -- its own id must not depend on the last id.
35async function walk(dir, out) {
36 for (const ent of (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name < b.name ? -1 : 1)) {
37 const p = join(dir, ent.name);
38 if (ent.isDirectory()) { await walk(p, out); }
39 else if (p !== STAMP) { out.push(p); }
40 }
41 return out;
42}
43
44/// A short content hash: the relative path and bytes of every file fold into one digest, so a
45/// change to any file -- or a rename -- moves the id.
46async function contentHash() {
47 const files = (await walk(ROOT, [])).sort();
48 const h = createHash('sha256');
49 for (const f of files) {
50 h.update(relative(ROOT, f));
51 h.update('\0');
52 h.update(await readFile(f));
53 h.update('\0');
54 }
55 return h.digest('hex').slice(0, 12);
56}
57
58// ── What will not do as a release note ──────────────────────────────────────
59//
60// WHY A PREDICATE AND NOT A JUDGEMENT. The note is the sentence the public
61// transparency chain gives for what a release changed, and the failure it has
62// actually suffered is mechanical: an id copied out of the previous release
63// standing where a description should be. SIX of the last twenty commit subjects
64// in this repo (at fb69f76, 2026-08-21) are `The seal as shipped: build <id>`,
65// written by the commit that seals a release -- and every one of them was a
66// candidate to become the next release's note while the note defaulted to the
67// latest subject.
68//
69// So the rule is drawn around THAT, and no wider. A note is refused when it
70// names a build id, when it is too short to be a sentence, or when it is longer
71// than the chip shows. It is NOT refused for containing a word from a list of
72// housekeeping words: "Fixed the typo" is a perfectly good release note on the
73// release that fixed a typo, and a checker that argues about wording is a
74// checker somebody routes around. Measured against this repo's own last twenty
75// subjects, the rule refuses those six and accepts the other fourteen; that
76// measurement is `dev/verify_deploy.mjs` check 3, run against the real log.
77//
78// The note may still be a commit subject -- these are good ones -- but it has to
79// be CHOSEN. What is gone is the default that chose for you.
80
81/// The build id `contentHash` produces: exactly twelve hex characters.
82const ID_RE = /\b[0-9a-f]{12}\b/;
83
84/// The chip and the chain entry both show this much of the note.
85export const NOTE_MAX = 120;
86
87/// Why `note` will not do as a release note, as a sentence, or null where it will.
88export function noteFault(note) {
89 const n = typeof note === 'string' ? note.trim() : '';
90 if (!n) return 'is empty. It is the one line that says what this release changed.';
91 // Before the length rules, so `Build 424677355732` is refused for the reason it
92 // is really wrong rather than for being short.
93 const id = n.match(ID_RE);
94 if (id) return `names a build id (${id[0]}). build.json already carries the id in its own `
95 + '`build` field, so a note repeating it says nothing the file does not. Say what changed.';
96 if (n.length > NOTE_MAX) return `is ${n.length} characters; the chip and the chain entry show ${NOTE_MAX}. `
97 + 'A note silently cut in half is a transparency chain telling half a truth.';
98 if (n.length < 12) return `is ${n.length} characters. A release note is a sentence, not a tag.`;
99 if (n.split(/\s+/).length < 2) return 'is one word. A release note is a sentence, not a tag.';
100 return null;
101}
102
103const GUIDANCE = 'A release note is what a stranger reading the public transparency chain\n'
104 + 'learns about this release. One line, in a person\'s language, about what changed.';
105
106/// Run only when this file IS the command, so a test can import `noteFault` without
107/// stamping a build over the top of the bundle.
108const isMain = !!process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
109
110if (isMain) {
111 const args = process.argv.slice(2);
112 const check = args[0] === '--check-note';
113 const rest = check ? args.slice(1) : args;
114 if (rest.length > 1) {
115 console.error(`stamp-build: ${rest.length} arguments where one note was wanted. Quote it:`);
116 console.error(` node dev/stamp-build.mjs ${check ? '--check-note ' : ''}"${rest.join(' ')}"`);
117 process.exit(2);
118 }
119 const note = rest[0] ?? '';
120 const fault = noteFault(note);
121 if (fault) {
122 console.error(`stamp-build: that note ${fault}`);
123 console.error(GUIDANCE);
124 process.exit(1);
125 }
126 if (check) {
127 console.log(`stamp-build: that note will do — ${note}`);
128 } else {
129 const build = await contentHash();
130 await writeFile(STAMP, JSON.stringify({ build, note }) + '\n');
131 console.log(`build.json → ${build} (${note})`);
132 }
133}