diff --git a/xbox/live/MARKETING.md b/xbox/live/MARKETING.md index 16cedae8cc..4c5b635181 100644 --- a/xbox/live/MARKETING.md +++ b/xbox/live/MARKETING.md @@ -144,9 +144,11 @@ at all. `reel.mjs --report` rolls it up per market. `node xbox/live/marketing/trim.mjs` reports underperformers without contacting Instagram: live reels from the last 30 days, at least 24 hours old, with fewer -than 500 measured views. Missing insights are protected. After a human has -confirmed and completed deletion in Instagram's own activity tool, record the -exact media ids locally: +than 1,000 measured views (`trimPolicy.maxViews`). Missing insights are +protected, and each candidate prints its `permalink` — the address the +deletion actually happens at, which `reel.mjs --insights` fills in. After a +human has confirmed and completed deletion in Instagram's own activity tool, +record the exact media ids locally: ```sh node xbox/live/marketing/trim.mjs --record-deleted 123,456 --confirmed-web-delete diff --git a/xbox/tools/oskiewar-release.mjs b/xbox/tools/oskiewar-release.mjs index e20c74e0b1..dfe64dd8eb 100644 --- a/xbox/tools/oskiewar-release.mjs +++ b/xbox/tools/oskiewar-release.mjs @@ -151,6 +151,69 @@ async function verifyWeb(hash) { if (actual !== hash) throw new Error(`web hash ${actual.slice(0, 12)} != ${hash.slice(0, 12)}`); } +// The version stamp, stamped. +// +// `buildVersion` is the piece's own count of committed revisions to itself, +// the release gate compares it against `git rev-list --count`, and nothing +// moved it — so it drifted every time anybody committed to oskiewar.js without +// remembering, and the deploy refused. Twice in one afternoon. Both times the +// piece had been telling players an old version in the corner of the title +// screen while running new code, which is the part that actually matters: the +// number is on screen, and a stale one makes every bug report cite a build +// that is not the build. +// +// The gate already knew both numbers at the moment it refused. So it stamps. +// +// A NEW commit rather than an amend, which is what I first reached for: the +// count is a count of commits, so an amend has to reason about whether HEAD +// already touched this file, and — the real objection — HEAD here is usually +// already pushed, and rewriting pushed history in a checkout that has another +// session committing into it is a way to lose somebody's work. +// +// It pushes, and it has to. `lith/deploy.fish` deploys from pushed git state +// only — it resets the box to `origin/main` — so a bump that stayed local +// would ship the old bytes and then fail its own hash verification, which is +// a worse failure than the one being fixed. +function stampBuildVersion(current, previous = null) { + if (current.build === current.expectedBuild) return current; + if (current.build > current.expectedBuild) + throw new Error(`Oskiewar build v${current.build} is AHEAD of its ` + + `revision count v${current.expectedBuild}; that is not drift, and it ` + + "wants a person"); + + // Counted from the clean tree, before the write below dirties it: the + // stamp's own commit is the one that takes the count to this number. + const next = current.expectedBuild + 1; + console.log(`→ stamping oskiewar v${current.build ?? "?"} → v${next}`); + const bytes = readFileSync(sourcePath, "utf8"); + const stamped = bytes.replace(/const buildVersion = \d+;/, + `const buildVersion = ${next};`); + if (stamped === bytes) + throw new Error("could not find `const buildVersion = ;` to stamp"); + writeFileSync(sourcePath, stamped); + + // The social preview is hash-bound to these bytes and the deploy checks it, + // so the stamp has to carry it along or it just trades one refusal for + // another. + run("node", ["xbox/live/render-social-preview.mjs"]); + run("git", ["add", "xbox/live/oskiewar.js", "xbox/live/social"]); + run("git", ["commit", "-m", `oskiewar v${next}: release stamp`, "-m", + "`buildVersion` is the piece's count of committed revisions to itself and " + + "the number the title screen shows. Stamped by the release rather than " + + "by remembering, so the corner of the screen cannot disagree with the " + + "code behind it.\n\n" + + "Co-Authored-By: Claude Opus 5 (1M context) "]); + run("git", ["push"]); + + // Restated against the SAME baseline, so the stamp commit cannot quietly + // change the severity the release was classified at. + const restated = sourceState(previous); + if (restated.build !== restated.expectedBuild) + throw new Error(`stamp landed at v${restated.build} against an expected ` + + `v${restated.expectedBuild}`); + return restated; +} + async function reconcile(receipt, { dryRun = false } = {}) { const hash = receipt.desired.hash; if (receipt.channels.web.status !== "current") { @@ -211,14 +274,19 @@ async function main() { const [command = "status", ...args] = process.argv.slice(2); const dryRun = args.includes("--dry-run"); const previous = readReceipt(); - const current = sourceState(previous); + let current = sourceState(previous); if (command === "status") return print(readReceipt(), current); if (command === "deploy") { if (!current.tracked || current.dirty) throw new Error("Oskiewar source must be tracked and committed before a unified release"); - if (current.build !== current.expectedBuild) - throw new Error(`Oskiewar build v${current.build ?? "?"} does not match ` + - `its committed source revision count v${current.expectedBuild}`); + // `--no-bump` keeps the old behaviour: refuse, and let a person decide. + const stamped = args.includes("--no-bump") ? current + : dryRun ? current : stampBuildVersion(current, previous); + if (stamped.build !== stamped.expectedBuild) + throw new Error(`Oskiewar build v${stamped.build ?? "?"} does not match ` + + `its committed source revision count v${stamped.expectedBuild}` + + (args.includes("--no-bump") ? " (drop --no-bump to stamp it)" : "")); + current = stamped; const receipt = newRelease(current.hash, current.commit, current.severity, previous); save(receipt); await reconcile(receipt, { dryRun }); diff --git a/xbox/tools/oskiewar-release.test.mjs b/xbox/tools/oskiewar-release.test.mjs index c5e2f55abb..1cdc83a025 100644 --- a/xbox/tools/oskiewar-release.test.mjs +++ b/xbox/tools/oskiewar-release.test.mjs @@ -79,3 +79,57 @@ test("asking for the Xbox explicitly still fails when it is asleep", () => { // is a failure of what was asked for — it just says which kind. assert.match(source, /throw new Error\(`Xbox is offline: \$\{probe\.reason\}`\)/); }); + +// ── The release stamp ────────────────────────────────────────────────────── +// `stampBuildVersion` writes, reburns, commits and pushes, so it is not +// callable from a test without doing all four to this checkout. What IS +// testable is the arithmetic and the ordering it rests on, which is where the +// mistakes live: an off-by-one here publishes a version number that disagrees +// with the code behind it, which is the exact fault the stamp exists to fix. + +test("the stamp counts its own commit", () => { + const stamp = source.match(/function stampBuildVersion[\s\S]*?\n}\n/)[0]; + // Counted from the CLEAN tree before the write dirties it, and +1 because + // the stamp's own commit is the one that takes the count there. + assert.match(stamp, /const next = current\.expectedBuild \+ 1;/); + // `expectedBuild` itself already adds 1 when the tree is dirty, so reading + // it after the write would double-count. + const wroteAt = stamp.indexOf("writeFileSync(sourcePath"); + const countedAt = stamp.indexOf("current.expectedBuild + 1"); + assert.ok(countedAt < wroteAt, + "the count must be taken before the file is written"); +}); + +test("the stamp refuses to move a version backwards", () => { + const stamp = source.match(/function stampBuildVersion[\s\S]*?\n}\n/)[0]; + assert.match(stamp, /current\.build > current\.expectedBuild/); + assert.match(stamp, /is AHEAD of its/); + // And does nothing at all when there is nothing to do. + assert.match(stamp, /if \(current\.build === current\.expectedBuild\) return current;/); +}); + +test("the stamp carries the hash-bound social preview with it", () => { + const stamp = source.match(/function stampBuildVersion[\s\S]*?\n}\n/)[0]; + const burnedAt = stamp.indexOf("render-social-preview.mjs"); + const committedAt = stamp.indexOf('"commit"'); + assert.ok(burnedAt > 0 && burnedAt < committedAt, + "the preview is reburned before the commit, or the deploy trades one refusal for another"); + assert.match(stamp, /"xbox\/live\/oskiewar\.js", "xbox\/live\/social"/); +}); + +test("the stamp pushes, because lith deploys pushed state only", () => { + const stamp = source.match(/function stampBuildVersion[\s\S]*?\n}\n/)[0]; + assert.match(stamp, /run\("git", \["push"\]\)/); + // A new commit, never an amend: HEAD here is usually already pushed, and + // another session commits into this same checkout. + assert.doesNotMatch(stamp, /--amend/); + // And it verifies the stamp actually landed rather than assuming. + assert.match(stamp, /stamp landed at v/); +}); + +test("--no-bump keeps the old refusal available", () => { + assert.match(source, /args\.includes\("--no-bump"\) \? current/); + assert.match(source, /drop --no-bump to stamp it/); + // A dry run never writes, commits or pushes anything. + assert.match(source, /: dryRun \? current : stampBuildVersion\(current, previous\)/); +});