tools/release.mjs runs the three steps in order so none of them can be
skipped, and derives the version instead of incrementing it:
klotski-<date>-<sha256 of the precached shell>
The hash exists because of a bug that already shipped. The worker is
cache-first and keys on VERSION, so a build whose files changed but whose
VERSION did not is served from the old cache forever -- the deploy
succeeds, the URL is right, and every installed copy keeps showing the
previous build. Deriving the version from content makes forgetting to bump
impossible rather than unlikely.
The date leads it because a cache name is read in devtools, where a
date says what an ordinal never did, and because hand-counted versions
have no ceiling.
Checks run before the bump so a failure cannot leave the tree carrying a
version that was never deployed. A dirty tree warns rather than refuses.- commit
- 2ab7705312013b1f2da1fac93113e8008e6cca1a
- parent
- 56a7690cd4471d62b9658cc13687127694ac89bd
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 42 | +++++++++- |
| A | mockup/ | 135 | ++++++++++++++++++++++++++++++ |
@@ -379,8 +379,46 @@ every piece size — and so every colour — appears exactly once.
379379 walks a board down the distance field to the goal.380380 381381 The service worker is cache-first, so a stale build will be served to anything that has visited382-before — including automated screenshots, which is worth an hour of confusion exactly once. Bump383-`VERSION` in `sw.js` when shipping, and open test browser contexts with `serviceWorkers: 'block'`.382+before — including automated screenshots, which is worth an hour of confusion exactly once. Open383+test browser contexts with `serviceWorkers: 'block'`.384+385+### Shipping386+387+```sh388+cd mockup && node tools/release.mjs "what changed" --title "Klotski: short headline"389+```390+391+Bump, deploy, notify — in that order, and never by hand. `--dry` stops after the bump, `--no-notify`392+after the deploy.393+394+The version is not a number anyone increments. It is derived:395+396+```397+klotski-2026-08-06-29bd5bb5398+ │ └── sha256 of every file sw.js precaches, and of the list itself399+ └── the day it shipped400+```401+402+This exists because of a bug that already happened. The worker keys its cache on `VERSION`, so a403+build whose files changed but whose `VERSION` did not is served from the old cache forever: the404+deploy succeeds, the URL is right, and every installed copy keeps showing yesterday's app. Nothing405+warns you, and it is invisible on exactly the devices that matter most, because those are the ones406+that installed it. Deriving the version from content makes that impossible rather than unlikely —407+forgetting to bump is not a thing that can be done.408+409+The date leads because a cache name is something you read in devtools at two in the morning, and410+`2026-08-06` tells you what `v17` never did. It also sorts. Hand-counted versions were the411+alternative and they have no ceiling: a mockup under daily revision reaches v300 in a year, and not412+one of those numbers means anything beyond "later than the one before".413+414+`sw.js` is deliberately not hashed — it is not in `SHELL` and cannot be, since hashing the file that415+carries the hash never settles. That is also the correct behaviour rather than a compromise: a416+change to the worker's own logic ships as a byte-different worker script, which the browser installs417+on its own terms, and if no precached file moved then the old cache is still the right one to keep.418+419+Checks run before the bump, not after, so a failure cannot leave the tree carrying a version that420+was never deployed. A dirty working tree is a warning rather than a refusal — previewing uncommitted421+work is legitimate, but the live site silently matching no commit is not.384422 385423 ### On a phone386424 @@ -0,0 +1,135 @@
1+/* Release: bump, deploy, notify -- in that order, and never by hand.2+ *3+ * The order matters and the reason is a bug that already happened. The service4+ * worker is cache-first and keys its cache on VERSION, so a build whose files5+ * changed but whose VERSION did not is served from the old cache forever: the6+ * deploy succeeds, the URL is right, and every installed copy keeps showing7+ * yesterday's app. Nothing warns you. It is invisible precisely on the devices8+ * that matter most, because those are the ones that installed it.9+ *10+ * So the version is not a number anyone increments. It is derived:11+ *12+ * klotski-<date>-<hash of the precached shell>13+ *14+ * The hash makes the failure impossible rather than unlikely -- it changes when15+ * and only when a precached file changes, so forgetting to bump is not a thing16+ * that can be done. The date is in front of it because a cache name is17+ * something you read in devtools at two in the morning, and `2026-08-06` tells18+ * you what `v17` never did. It also sorts.19+ *20+ * Counting versions by hand was the alternative and it has no ceiling: a mockup21+ * under daily revision reaches v300 in a year and every one of those numbers22+ * means nothing except "later than the one before".23+ *24+ * sw.js is deliberately not part of what is hashed. It is not in SHELL, and it25+ * cannot be: hashing the file that carries the hash never settles. That is26+ * also correct behaviour rather than a compromise -- a change to the worker's27+ * own logic ships as a byte-different worker script, which the browser installs28+ * on its own terms, and if no precached file moved then the old cache is still29+ * the right cache to keep.30+ *31+ * node tools/release.mjs "what changed" [--title "..."] [--no-notify] [--dry]32+ */33+34+import { readFile, writeFile } from 'node:fs/promises';35+import { createHash } from 'node:crypto';36+import { execFileSync } from 'node:child_process';37+import { fileURLToPath } from 'node:url';38+import { dirname, join } from 'node:path';39+40+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');41+const TARGET = 'dev:klotski';42+43+const argv = process.argv.slice(2);44+const flag = n => argv.includes(n);45+const opt = n => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : null; };46+const body = argv.find(a => !a.startsWith('--') && argv[argv.indexOf(a) - 1] !== '--title');47+48+const DRY = flag('--dry');49+const NOTIFY = !flag('--no-notify');50+51+const run = (cmd, args, o = {}) =>52+ execFileSync(cmd, args, { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'], ...o });53+54+const die = m => { console.error(`\n ${m}\n`); process.exit(1); };55+56+if (NOTIFY && !body) die('A notification body is required. Pass one, or --no-notify.');57+58+/* ---------------------------------------------------------------- 0. guard */59+/* Deploying work that is not committed makes the live site a thing no commit60+ * describes. Not fatal -- previewing is legitimate -- but it should never61+ * happen silently. */62+try {63+ const dirty = run('git', ['status', '--porcelain']).trim();64+ if (dirty) {65+ console.log('\n ! working tree is dirty; the deploy will not match any commit:');66+ for (const l of dirty.split('\n').slice(0, 10)) console.log(` ${l}`);67+ }68+} catch { /* not a repo: nothing to compare against */ }69+70+/* ------------------------------------------------------------ 1. the checks */71+/* Before the version moves, not after. A failed check after a bump leaves the72+ * tree carrying a version that was never deployed. */73+console.log('\n checks');74+try {75+ const out = run('node', ['tools/check.mjs']);76+ console.log(' ' + out.trim().split('\n').pop());77+} catch {78+ die('checks failed; nothing was bumped or deployed.');79+}80+81+/* ------------------------------------------------------------- 2. the bump */82+const swPath = join(ROOT, 'sw.js');83+const sw = await readFile(swPath, 'utf8');84+85+const shellBlock = sw.match(/const SHELL = \[([\s\S]*?)\];/);86+if (!shellBlock) die('could not find the SHELL list in sw.js');87+const shell = [...shellBlock[1].matchAll(/'([^']+)'/g)]88+ .map(m => m[1])89+ .filter(f => f !== './'); // the same resource as index.html90+91+const h = createHash('sha256');92+for (const rel of shell) {93+ h.update(rel); // the list itself is part of the identity94+ try {95+ h.update(await readFile(join(ROOT, rel)));96+ } catch {97+ die(`sw.js precaches ${rel}, which does not exist`);98+ }99+}100+const stamp = new Date().toISOString().slice(0, 10);101+const version = `klotski-${stamp}-${h.digest('hex').slice(0, 8)}`;102+103+const current = (sw.match(/const VERSION = '([^']+)'/) || [])[1];104+const same = current && current.split('-').slice(-1)[0] === version.split('-').slice(-1)[0];105+106+console.log(`\n version\n ${current}\n -> ${version}${same ? ' (shell unchanged)' : ''}`);107+if (!DRY) await writeFile(swPath, sw.replace(/const VERSION = '[^']+'/, `const VERSION = '${version}'`));108+109+/* ----------------------------------------------------------- 3. the deploy */110+/* No --ttl: on an existing target that preserves whatever expiry it already111+ * has instead of silently resetting the review window. */112+if (DRY) { console.log('\n --dry: stopping before deploy\n'); process.exit(0); }113+114+console.log('\n deploy');115+let res;116+try {117+ res = JSON.parse(run('nzip', ['site', 'push', '.', TARGET, '--json']));118+} catch {119+ die('nzip push failed; sw.js has been bumped, so commit or revert it.');120+}121+console.log(` ${res.url} seq ${res.seq} ${res.files} files, ${res.newBlobs} new`);122+123+/* ----------------------------------------------------------- 4. the notify */124+if (!NOTIFY) { console.log('\n --no-notify\n'); process.exit(0); }125+126+console.log('\n notify');127+try {128+ const args = ['notify', 'send', body, '--open', TARGET, '--tag', 'klotski', '--json'];129+ const title = opt('--title');130+ if (title) args.push('--title', title);131+ const n = JSON.parse(run('nzip', args));132+ console.log(` queued to ${n.queuedDevices} device(s)\n`);133+} catch {134+ die('deployed, but the notification failed.');135+}