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 | |
| 24 | import { createHash } from 'node:crypto'; |
| 25 | import { readdir, readFile, writeFile } from 'node:fs/promises'; |
| 26 | import { realpathSync } from 'node:fs'; |
| 27 | import { join, relative, normalize } from 'node:path'; |
| 28 | import { fileURLToPath } from 'node:url'; |
| 29 | |
| 30 | const ROOT = normalize(join(fileURLToPath(import.meta.url), '..', '..', 'www')); |
| 31 | const 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. |
| 35 | async 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. |
| 46 | async 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. |
| 82 | const ID_RE = /\b[0-9a-f]{12}\b/; |
| 83 | |
| 84 | /// The chip and the chain entry both show this much of the note. |
| 85 | export const NOTE_MAX = 120; |
| 86 | |
| 87 | /// Why `note` will not do as a release note, as a sentence, or null where it will. |
| 88 | export 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 | |
| 103 | const 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. |
| 108 | const isMain = !!process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); |
| 109 | |
| 110 | if (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 | } |