Files
runicnpc-rust/tools/drill/cutover.test.js
wtclaude e5548472da
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 29s
fix(cutover): gate 1 asks Steam when public was set, not the manifest's date or staging (D332)
The 2026-11-05 weekend never cut over: gate 1 blocked every hourly run. Both of
its checks rested on assumptions the wipe disproved:

- The wipe build went live Thursday evening, but its depot manifest is dated
  11-04: the date DepotDownloader prints is when Facepunch built it.
- Public never names staging's manifest. Facepunch promotes a hidden `release`
  branch ("Release, before public"), and staging had moved on before the wipe.

Gate 1 now reads Steam's app info (steamcmd +app_info_print 258550, kept by
static.sh as appinfo.txt with BRANCH=public): branches.public.timeupdated must be
on the wipe day or later, and the Linux depot manifest Steam names for public
must be the one the static half downloaded. The staging static half is gone from
edge's cutover.yml; --staging-work is still accepted, since main's copy passes it
until the cutover merge carries the new wiring over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014HJEmgsXtLR5kLfh7f9y8P
2026-11-06 04:37:50 -06:00

863 lines
42 KiB
JavaScript

// The wipe-day cutover (docs/runicnpc/PLAN.md D332): when it works, what each gate accepts, which issues the
// PR closes, what the hourly run does next, and the commands against a fake Gitea and a fake panel.
// node --test tools/drill/cutover.test.js
const test = require('node:test')
const assert = require('node:assert')
const fs = require('fs')
const os = require('os')
const path = require('path')
const c = require('./cutover.js')
const { wipeWeekend, steamBranch, publicGate, oxideReady, carbonOk, carbonBuild, acfDepotManifest, liveOk, checkState, collectCloses, cutoverBody, withBlock, decide, commentText, neutralise, isDry, pass, fail, persistable, nextGate, GATES, COOL_DOWN_HOURS } = c
const at = (s) => new Date(s)
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'cutover-test-'))
// ---- wipeWeekend: November 2026 has its wipe on Thursday the 5th, so the weekend ends Sunday the 8th.
test('the weekend opens at 17:00 UTC on the wipe Thursday and not a minute before', () => {
assert.equal(wipeWeekend(at('2026-11-05T16:59:00Z')).open, false)
assert.equal(wipeWeekend(at('2026-11-05T17:00:00Z')).open, true)
assert.equal(wipeWeekend(at('2026-11-05T17:00:00Z')).wipe.toISOString(), '2026-11-05T00:00:00.000Z')
})
test('it closes at Sunday 23:00 UTC, with one more hour to give up in', () => {
assert.equal(wipeWeekend(at('2026-11-08T22:59:00Z')).open, true)
const late = wipeWeekend(at('2026-11-08T23:00:00Z'))
assert.equal(late.open, false)
assert.equal(late.final, true)
assert.equal(late.deadline.toISOString(), '2026-11-08T23:00:00.000Z')
assert.equal(wipeWeekend(at('2026-11-08T23:59:00Z')).final, true)
assert.equal(wipeWeekend(at('2026-11-09T00:00:00Z')).final, false)
assert.equal(wipeWeekend(at('2026-11-05T17:00:00Z')).final, false)
})
test('a Thursday that is not the first of the month is closed, as is the morning of the wipe', () => {
assert.equal(wipeWeekend(at('2026-11-12T18:00:00Z')).open, false)
assert.equal(wipeWeekend(at('2026-11-05T09:00:00Z')).open, false)
assert.equal(wipeWeekend(at('2026-11-06T12:00:00Z')).open, true)
})
test('a month whose first Thursday is the 7th ends its weekend on the 10th', () => {
const w = wipeWeekend(at('2026-05-09T12:00:00Z'))
assert.equal(w.wipe.toISOString(), '2026-05-07T00:00:00.000Z')
assert.equal(w.deadline.toISOString(), '2026-05-10T23:00:00.000Z')
assert.equal(w.open, true)
assert.equal(wipeWeekend(at('2026-05-10T23:00:00Z')).open, false)
})
test('when the 1st is the Thursday, the weekend is the 1st to the 4th', () => {
const w = wipeWeekend(at('2026-01-02T00:00:00Z'))
assert.equal(w.wipe.toISOString(), '2026-01-01T00:00:00.000Z')
assert.equal(w.deadline.toISOString(), '2026-01-04T23:00:00.000Z')
assert.equal(w.open, true)
assert.equal(wipeWeekend(at('2026-01-01T16:59:00Z')).open, false)
})
// ---- Steam's app info (steamcmd +app_info_print 258550), cut down from the real one of 2026-11-06
const APPINFO = `Steam Console Client (c) Valve Corporation - version 1792617903
-- type 'quit' to exit --
Loading Steam API...OK
Connecting anonymously to Steam Public...OK
Waiting for client config...OK
Waiting for user info...OK
AppID : 258550, change number : 40663829/40663829, last change : Fri Nov 6 04:32:39 2026
"258550"
{
\t"common"
\t{
\t\t"name"\t\t"Rust Dedicated Server"
\t}
\t"depots"
\t{
\t\t"258552"
\t\t{
\t\t\t"config"
\t\t\t{
\t\t\t\t"oslist"\t\t"linux"
\t\t\t}
\t\t\t"manifests"
\t\t\t{
\t\t\t\t"public"
\t\t\t\t{
\t\t\t\t\t"gid"\t\t"547616323393184824"
\t\t\t\t\t"size"\t\t"769819750"
\t\t\t\t}
\t\t\t\t"last-month"
\t\t\t\t{
\t\t\t\t\t"gid"\t\t"1632958440323253420"
\t\t\t\t}
\t\t\t\t"staging"
\t\t\t\t{
\t\t\t\t\t"gid"\t\t"4006809933087453123"
\t\t\t\t}
\t\t\t}
\t\t}
\t\t"branches"
\t\t{
\t\t\t"public"
\t\t\t{
\t\t\t\t"buildid"\t\t"26479619"
\t\t\t\t"timeupdated"\t\t"1793959612"
\t\t\t\t"timebuildupdated"\t\t"1793954045"
\t\t\t}
\t\t\t"release"
\t\t\t{
\t\t\t\t"buildid"\t\t"26479619"
\t\t\t\t"description"\t\t"Release, before public"
\t\t\t\t"timeupdated"\t\t"1793954045"
\t\t\t}
\t\t\t"staging"
\t\t\t{
\t\t\t\t"buildid"\t\t"26480085"
\t\t\t\t"timeupdated"\t\t"1793955490"
\t\t\t}
\t\t}
\t\t"privatebranches"\t\t"1"
\t}
}
Unloading Steam API...OK
`
test("steamBranch reads a branch's set time, build id and Linux manifest, past steamcmd's own lines", () => {
assert.deepEqual(steamBranch(APPINFO), { buildid: '26479619', timeupdated: 1793959612, manifest: '547616323393184824' })
assert.deepEqual(steamBranch(APPINFO, 'staging'), { buildid: '26480085', timeupdated: 1793955490, manifest: '4006809933087453123' })
assert.deepEqual(steamBranch(APPINFO.replace(/\n/g, '\r\n')), { buildid: '26479619', timeupdated: 1793959612, manifest: '547616323393184824' })
// The older form names the manifest as a bare string.
assert.equal(steamBranch(APPINFO.replace(/"public"\n\t\t\t\t\{\n\t\t\t\t\t"gid"\t\t"(\d+)"\n\t\t\t\t\t"size"\t\t"\d+"\n\t\t\t\t\}/, '"public"\t\t"$1"')).manifest, '547616323393184824')
assert.equal(steamBranch(APPINFO, 'nope'), null)
assert.equal(steamBranch('Loading Steam API...FAILED'), null)
assert.equal(steamBranch(''), null)
assert.equal(steamBranch(undefined), null)
})
// ---- the gates
test('publicGate: Steam set public on the wipe day or later, and names the manifest the static half has', () => {
const wipe = at('2026-11-05T00:00:00Z')
const pub = { manifest: '547616323393184824', manifestDate: '11/06/2026 08:33:44' }
assert.equal(publicGate(pub, steamBranch(APPINFO), wipe), true)
// The 2026-11-05 case: the wipe build went live that evening, but its depot manifest was dated 11-04.
const thu = { buildid: '26427999', timeupdated: Date.UTC(2026, 10, 5, 18, 30) / 1000, manifest: '3647035647797862067' }
assert.equal(publicGate({ manifest: '3647035647797862067', manifestDate: '11/04/2026 12:57:41' }, thu, wipe), true)
assert.equal(publicGate(pub, { ...steamBranch(APPINFO), timeupdated: Date.UTC(2026, 10, 5) / 1000 }, wipe), true)
// Before the wipe: October's build still live.
const oct = { buildid: '26139947', timeupdated: Date.UTC(2026, 9, 22, 17, 40) / 1000, manifest: '1632958440323253420' }
assert.match(publicGate({ manifest: '1632958440323253420' }, oct, wipe), /at 2026-10-22 17:40 UTC, before the wipe \(2026-11-05 00:00 UTC\)/)
assert.match(publicGate(pub, { ...oct, timeupdated: Date.UTC(2026, 10, 4, 23, 59, 59) / 1000, manifest: pub.manifest }, wipe), /before the wipe/)
// Steam and DepotDownloader disagree (a push between the two reads): block this hour.
assert.match(publicGate({ manifest: '8150' }, steamBranch(APPINFO), wipe), /Steam names manifest 547616323393184824 for public, but the static half downloaded 8150/)
assert.match(publicGate({}, steamBranch(APPINFO), wipe), /downloaded none/)
assert.match(publicGate(pub, { ...steamBranch(APPINFO), manifest: null }, wipe), /names no 258552 manifest/)
assert.match(publicGate(pub, { ...steamBranch(APPINFO), timeupdated: NaN }, wipe), /gives no time/)
assert.match(publicGate({ ...pub, steamNote: 'steamcmd could not be fetched' }, null, wipe), /could not be read \(steamcmd could not be fetched\)/)
assert.match(publicGate(pub, null, wipe), /could not be read \(no appinfo.txt\)/)
assert.match(publicGate(pub, steamBranch(APPINFO), 'garbage'), /no usable wipe date/)
assert.equal(publicGate(pub, steamBranch(APPINFO), '2026-11-05'), true)
})
test('oxideReady: true, or the reason', () => {
const ok = { managed: 'ok', oxideBehind: 0, compile: 'ok', fields: 'same' }
assert.equal(oxideReady(ok), true)
assert.match(oxideReady({ ...ok, oxideBehind: 12 }), /Oxide's public build is 12 member\(s\) behind/)
assert.match(oxideReady({ ...ok, fields: 'differs' }), /field list differs from public Rust's/)
assert.match(oxideReady({ ...ok, compile: 'errors' }), /does not compile/)
assert.match(oxideReady({ ...ok, managed: 'error', note: 'DepotDownloader could not be fetched' }), /could not fetch Rust's public build \(DepotDownloader/)
assert.match(oxideReady({ ...ok, oxideBehind: -1 }), /could not be compared/)
assert.match(oxideReady({ ...ok, fields: 'error' }), /could not be checked/)
assert.match(oxideReady(undefined), /could not fetch/)
})
const ACF = `"AppState"
{
\t"appid"\t\t"258550"
\t"buildid"\t\t"22222222"
\t"InstalledDepots"
\t{
\t\t"258551"
\t\t{
\t\t\t"manifest"\t\t"111"
\t\t\t"size"\t\t"1"
\t\t}
\t\t"258552"
\t\t{
\t\t\t"manifest"\t\t"8140123456789"
\t\t\t"size"\t\t"9000000000"
\t\t}
\t}
\t"SharedDepots"
\t{
\t\t"228984"\t\t"228990"
\t}
}
`
test('the appmanifest: the Linux depot\'s manifest, and null when it is not there', () => {
assert.equal(acfDepotManifest(ACF), '8140123456789')
assert.equal(acfDepotManifest(ACF, '258551'), '111')
assert.equal(acfDepotManifest(ACF, '999'), null)
assert.equal(acfDepotManifest(''), null)
assert.equal(acfDepotManifest(undefined), null)
assert.equal(acfDepotManifest('"AppState" { "InstalledDepots" { "258552" "oops" } }'), null)
})
test('carbonBuild: the rig must run the public manifest', () => {
assert.equal(carbonBuild(ACF, '8140123456789'), true)
assert.match(carbonBuild(ACF, '9999'), /rust-carbon still runs manifest 8140123456789 \(public is 9999\)/)
assert.match(carbonBuild(null, '9999'), /could not be read/)
})
const STATUS = 'framework=carbon navmesh=ready swap fields: npc=58/58 brain=32/32 missing=- added=-'
test('carbonOk: a Carbon server with the navmesh ready and nothing missing or added', () => {
assert.equal(carbonOk(['RunicNPC 0.0.0 api=1 hooks=40 fired=3 silent=0', 'fired: x', STATUS, 'npcs=0']), true)
assert.equal(carbonOk(STATUS), true)
})
test('carbonOk: each way it can fail says why', () => {
assert.match(carbonOk([STATUS.replace('added=-', 'added=BasePlayer.x')]), /does not know: BasePlayer\.x/)
assert.match(carbonOk([STATUS.replace('missing=-', 'missing=BaseNpc.y,Brain.z')]), /missing on Carbon: BaseNpc\.y,Brain\.z/)
assert.match(carbonOk([STATUS.replace('framework=carbon', 'framework=oxide')]), /framework=oxide, not carbon/)
assert.match(carbonOk([STATUS.replace('navmesh=ready', 'navmesh=building')]), /navmesh=building/)
assert.match(carbonOk([]), /never answered/)
assert.match(carbonOk(['RunicNPC 0.0.0 api=1']), /never answered/)
})
test('liveOk: ran, harness done, some checks passed, no failed check and no finding', () => {
const good = { branch: 'public', ran: true, findings: [], harness: { done: true, pass: 266, fail: 0, failed: [] } }
assert.equal(liveOk(good), true)
assert.match(liveOk(null), /no live\.json/)
assert.match(liveOk({ ...good, ran: false, findings: ['The server did not start on Rust public 1'] }), /did not start/)
assert.match(liveOk({ ...good, ran: false, skipped: "Oxide's public build is 3 member(s) behind" }), /3 member/)
assert.match(liveOk({ ...good, harness: { done: false, pass: 5, fail: 0, failed: [] }, findings: ['The harness stopped after x'] }), /harness stopped after x/)
assert.match(liveOk({ ...good, harness: { done: true, pass: 5, fail: 1, failed: ['place.set: FAIL off the navmesh'] } }), /place\.set: FAIL off the navmesh/)
assert.match(liveOk({ ...good, findings: ['RunicNPC logged 2 error line(s) in Oxide\'s log during the run.'] }), /logged 2 error/)
assert.match(liveOk({ ...good, branch: 'staging' }), /staging branch/)
assert.match(liveOk({ ...good, harness: { done: true, pass: 0, fail: 0, failed: [] } }), /passed no check/)
})
test('checkState: success needs a plugin-checks context; a failure or a wait is said', () => {
const s = (context, status) => ({ context, status })
assert.equal(checkState({ state: 'success', statuses: [s('PR Checks / plugin-checks (pull_request)', 'success')] }).state, 'success')
assert.equal(checkState({ state: 'success', statuses: [] }).state, 'pending')
assert.equal(checkState({ state: 'success', statuses: [s('other', 'success')] }).state, 'pending')
assert.equal(checkState({ state: 'pending', statuses: [s('PR Checks / plugin-checks (pull_request)', 'pending')] }).state, 'pending')
const failed = checkState({ state: 'failure', statuses: [s('PR Checks / plugin-checks (pull_request)', 'failure')] })
assert.equal(failed.state, 'failed')
assert.match(failed.reason, /plugin-checks.*failure/)
})
// ---- what the PR closes, and what is written into it
test('collectCloses reads closing keywords before #N, in any case', () => {
assert.deepEqual(collectCloses(['Closes #20', 'fixes #3', 'RESOLVED #9', 'Fixed #12.', 'close #4', 'Resolves: #8']), [3, 4, 8, 9, 12, 20])
})
test('collectCloses takes several on a line, and dedupes and sorts', () => {
assert.deepEqual(collectCloses(['Closes #22, closes #20 and fixes #5', 'Closes #20\nCloses #5', 'closes #100']), [5, 20, 22, 100])
})
test('collectCloses ignores references, bare numbers, prose and other repositories', () => {
assert.deepEqual(collectCloses(['Refs #21', 'see #21', '#21', 'This closes the loop on #21', 'prefix #3', 'unfixes #4', 'Closes RunicGateway/docs#9', '']), [])
assert.deepEqual(collectCloses([undefined, null]), [])
})
test('cutoverBody names the wipe and the build, lists a Closes line per issue, and ends with the disclosure', () => {
const body = cutoverBody({
wipe: '2026-11-05',
manifest: '8140',
buildDate: '11/5/2026 11:48:58',
closes: [20, 22],
gates: [
{ name: '1. Rust public', status: 'passed', note: '2026-11-05 17:11 UTC' },
{ name: '2. Oxide', status: 'failed', note: 'a | b' },
],
})
assert.match(body, /D332/)
assert.match(body, /manifest \*\*8140\*\*, dated 11\/5\/2026 11:48:58/)
assert.match(body, /^Closes #20$/m)
assert.match(body, /^Closes #22$/m)
assert.match(body, /\| 2\. Oxide \| failed \| a \/ b \|/)
assert.ok(body.endsWith('Opened by the runicnpc-rust cutover workflow (D332).'))
assert.match(cutoverBody({ wipe: 'w', manifest: 'm', closes: [], gates: [] }), /No issue is closed/)
})
test('a reason can never close an issue: #123 is broken up in the body and in comments', () => {
assert.equal(neutralise('fixes #123 and #4'), 'fixes #&#8203;123 and #&#8203;4')
const body = cutoverBody({ wipe: 'w', manifest: 'm', closes: [], gates: [{ name: 'g', status: 'failed', note: 'Closes #5: fixes #123' }] })
assert.deepEqual(collectCloses([body]), [])
const text = commentText({ action: 'blocked', gate: 'oxide', reason: 'resolves #7' })
assert.deepEqual(collectCloses([text]), [])
// The deliberate lines still close.
assert.deepEqual(collectCloses([cutoverBody({ wipe: 'w', manifest: 'm', closes: [9], gates: [] })]), [9])
})
test('withBlock appends the marked block, replaces only what is between the markers, and is stable', () => {
const once = withBlock('A human wrote this.', 'one')
assert.equal(once, 'A human wrote this.\n\n<!-- cutover:start -->\none\n<!-- cutover:end -->')
const twice = withBlock(once + '\n\nAnd this after.', 'two')
assert.equal(twice, 'A human wrote this.\n\n<!-- cutover:start -->\ntwo\n<!-- cutover:end -->\n\nAnd this after.')
assert.equal(withBlock(twice, 'two'), twice)
assert.equal(withBlock('', 'x'), '<!-- cutover:start -->\nx\n<!-- cutover:end -->')
})
// ---- decide
const THU = at('2026-11-05T18:11:00Z')
const obs = (more = {}) => ({ manifest: '8140', edgeSha: 'sha1', ahead: 3, openPr: null, ...more })
const fresh = () => decide(undefined, obs(), THU)
const passed = (state, ...names) => names.reduce((s, g) => pass(s, g, THU), state)
test('a first run starts at the public gate and asks to be written', () => {
const d = fresh()
assert.equal(d.action, 'run')
assert.equal(d.gate, 'public')
assert.equal(d.write, true)
assert.equal(d.state.wipe, '2026-11-05')
assert.equal(d.state.manifest, '8140')
assert.equal(d.state.edgeSha, 'sha1')
})
test('the gates run in order, checks comes last and is never kept, and then it merges', () => {
assert.deepEqual(GATES, ['public', 'oxide', 'carbon', 'live', 'checks'])
let s = fresh().state
for (const g of GATES.slice(0, 4)) {
const d = decide(s, obs(), THU)
assert.equal(d.gate, g)
s = pass(d.state, g, THU)
}
const checks = decide(s, obs(), THU)
assert.equal(checks.action, 'run')
assert.equal(checks.gate, 'checks')
const done = decide(s, obs({ checksPassed: true }), THU)
assert.equal(done.action, 'merge')
assert.equal(done.merge, true)
assert.equal(nextGate({ public: {}, carbon: {} }), 'oxide')
// A checks entry found in a stored state is dropped.
const old = decide({ ...s, gates: { ...s.gates, checks: { at: 'x' } } }, obs(), THU)
assert.equal(old.state.gates.checks, undefined)
assert.equal(old.gate, 'checks')
assert.equal(persistable({ ...s, gates: { ...s.gates, checks: {} } }, { number: 4 }).gates.checks, undefined)
assert.equal(persistable(s, { number: 4 }).pr, 4)
})
test('a passed gate is never run again for the same build and the same code', () => {
const s = passed(fresh().state, 'public', 'oxide', 'carbon')
const d = decide(s, obs(), at('2026-11-05T19:11:00Z'))
assert.equal(d.action, 'run')
assert.equal(d.gate, 'live')
assert.ok(d.state.gates.carbon)
})
test('a new public manifest starts the gates again, keeping the open PR', () => {
const s = { ...passed(fresh().state, 'public', 'oxide', 'carbon'), pr: 9 }
const d = decide(s, obs({ manifest: '8141', openPr: 9 }), at('2026-11-06T10:11:00Z'))
assert.equal(d.gate, 'public')
assert.deepEqual(d.state.gates, {})
assert.equal(d.state.manifest, '8141')
assert.equal(d.state.pr, 9)
assert.equal(decide(s, obs({ manifest: '8141', openPr: null }), at('2026-11-06T10:11:00Z')).state.pr, null)
})
test('a new edge commit clears every gate and every cooled failure, so nothing proven on old code counts', () => {
const s = fail(passed(fresh().state, 'public', 'oxide'), 'carbon', 'x', THU)
const d = decide({ ...s, mergeError: 'old' }, obs({ edgeSha: 'sha2' }), at('2026-11-05T19:11:00Z'))
assert.equal(d.action, 'run')
assert.equal(d.gate, 'public')
assert.deepEqual(d.state.gates, {})
assert.deepEqual(d.state.failed, {})
assert.equal(d.state.edgeSha, 'sha2')
assert.equal(d.state.mergeError, undefined)
// A state written before edgeSha was kept counts as changed code.
const legacy = { wipe: '2026-11-05', manifest: '8140', gates: { public: { at: 'x' } } }
assert.equal(decide(legacy, obs(), THU).gate, 'public')
})
test('a new wipe forgets everything, comments and a give-up included', () => {
const s = { ...passed(fresh().state, 'public'), lastComment: '2026-11-05', gaveUp: true }
const d = decide(s, obs(), at('2026-12-03T18:00:00Z'))
assert.equal(d.action, 'run')
assert.equal(d.state.wipe, '2026-12-03')
assert.deepEqual(d.state.gates, {})
assert.equal(d.state.lastComment, undefined)
assert.equal(d.state.gaveUp, undefined)
})
test('nothing to do when edge has no commit main lacks, or when it already merged', () => {
const d = decide(undefined, obs({ ahead: 0 }), THU)
assert.equal(d.action, 'nothing')
assert.match(d.reason, /no commits/)
assert.equal(decide({ ...fresh().state, merged: true }, obs(), THU).action, 'nothing')
})
test('the cool-down: a failed carbon or live is not tried again for three hours, a new commit retries at once', () => {
assert.equal(COOL_DOWN_HOURS, 3)
const base = passed(fresh().state, 'public', 'oxide')
const failedCarbon = fail(base, 'carbon', 'did not come up', THU) // failed at 18:11
const at59 = decide(failedCarbon, obs(), at('2026-11-05T21:10:00Z')) // 2 h 59 min later
assert.equal(at59.action, 'cooling')
assert.equal(at59.gate, 'carbon')
assert.equal(at59.retryAt.toISOString(), '2026-11-05T21:11:00.000Z')
assert.match(at59.reason, /did not come up/)
assert.equal(decide(failedCarbon, obs(), at('2026-11-05T21:11:00Z')).action, 'run') // 3 h 00
assert.equal(decide(failedCarbon, obs(), at('2026-11-05T21:11:00Z')).gate, 'carbon')
assert.equal(decide(failedCarbon, obs({ edgeSha: 'sha2' }), at('2026-11-05T19:00:00Z')).gate, 'public')
assert.equal(decide(failedCarbon, obs({ manifest: '8141' }), at('2026-11-05T19:00:00Z')).gate, 'public')
// The same rule for live; public and oxide are cheap and never cool.
const failedLive = fail(passed(base, 'carbon'), 'live', 'a check failed', THU)
assert.equal(decide(failedLive, obs(), at('2026-11-05T20:00:00Z')).action, 'cooling')
assert.equal(decide(failedLive, obs(), at('2026-11-05T21:11:00Z')).gate, 'live')
assert.equal(decide(fail(fresh().state, 'oxide', 'x', THU), obs(), at('2026-11-05T18:30:00Z')).gate, 'public')
// A pass forgets the failure.
assert.equal(pass(failedCarbon, 'carbon', THU).failed.carbon, undefined)
})
test('a cooling gate comments once a day and says when it will try again', () => {
const s = fail(passed(fresh().state, 'public', 'oxide'), 'carbon', 'did not come up', THU)
const d = decide(s, obs({ openPr: 9 }), at('2026-11-05T19:11:00Z'))
assert.equal(d.comment, true)
assert.match(commentText(d, d.state), /not try this gate again before 2026-11-05 21:11 UTC/)
assert.match(commentText(d, d.state), /3\. A Carbon boot/)
assert.equal(decide(d.state, obs({ openPr: 9 }), at('2026-11-05T20:11:00Z')).comment, false)
})
test('a blocked gate comments once a day, and only after the public gate passed', () => {
const blocked = { gate: 'oxide', reason: "Oxide's public build is 12 member(s) behind" }
const s = passed(fresh().state, 'public')
const first = decide(s, obs({ openPr: 9, blocked }), THU)
assert.equal(first.action, 'blocked')
assert.equal(first.comment, true)
assert.equal(first.state.lastComment, '2026-11-05')
assert.equal(decide(first.state, obs({ openPr: 9, blocked }), at('2026-11-05T19:11:00Z')).comment, false)
assert.equal(decide(first.state, obs({ openPr: 9, blocked }), at('2026-11-06T09:11:00Z')).comment, true)
assert.equal(decide(fresh().state, obs({ openPr: 9, blocked: { gate: 'public', reason: 'old' } }), THU).comment, false)
assert.equal(decide(s, obs({ openPr: null, blocked }), THU).comment, false)
// A blocked carbon says when it will be tried again.
const carbon = decide(s, obs({ openPr: 9, blocked: { gate: 'carbon', reason: 'x' } }), THU)
assert.equal(carbon.retryAt.toISOString(), '2026-11-05T21:11:00.000Z')
})
test('after the deadline it gives up once, with a last comment', () => {
const s = passed(fresh().state, 'public', 'oxide')
const late = at('2026-11-08T23:11:00Z')
const d = decide(s, obs({ openPr: 9 }), late)
assert.equal(d.action, 'giveUp')
assert.equal(d.gate, 'carbon')
assert.equal(d.comment, true)
assert.equal(d.state.gaveUp, true)
assert.match(commentText(d, d.state), /Gate "3\. A Carbon boot on public Rust" had not passed/)
const again = decide(d.state, obs({ openPr: 9 }), at('2026-11-08T23:40:00Z'))
assert.equal(again.action, 'nothing')
assert.equal(again.comment, false)
assert.equal(decide(s, obs({ openPr: null }), late).comment, false)
assert.equal(decide(s, obs({ openPr: 9, forced: true }), late).action, 'run')
})
test('giving up with every gate passed and the merge failed says so, never gate "undefined"', () => {
const s = { ...passed(fresh().state, 'public', 'oxide', 'carbon', 'live'), mergeError: 'POST /pulls/5/merge: 405 not mergeable' }
const d = decide(s, obs({ openPr: 5 }), at('2026-11-08T23:11:00Z'))
assert.equal(d.action, 'giveUp')
assert.equal(d.gate, undefined)
const text = commentText(d, d.state)
assert.match(text, /Every gate passed; the merge failed: POST \/pulls\/5\/merge: 405 not mergeable/)
assert.ok(!text.includes('undefined'))
assert.ok(!commentText(d, { ...d.state, mergeError: undefined }).includes('undefined'))
})
test('a dry run never asks to write, comment or merge', () => {
const dry = { dryRun: true }
assert.equal(decide(undefined, obs(), THU, dry).write, false)
const s = passed(fresh().state, 'public')
const b = decide(s, obs({ openPr: 9, blocked: { gate: 'oxide', reason: 'x' } }), THU, dry)
assert.equal(b.comment, false)
assert.equal(b.write, false)
assert.equal(b.state.lastComment, undefined)
const m = decide(passed(fresh().state, ...GATES.slice(0, 4)), obs({ checksPassed: true }), THU, dry)
assert.equal(m.action, 'merge')
assert.equal(m.merge, false)
const g = decide(s, obs({ openPr: 9 }), at('2026-11-08T23:11:00Z'), dry)
assert.equal(g.action, 'giveUp')
assert.equal(g.comment, false)
assert.equal(decide(undefined, obs({ ahead: 0 }), THU, dry).action, 'run')
})
test('a dispatch is dry unless its dry_run input is exactly false', () => {
const dispatch = (input) => ({ GITHUB_EVENT_NAME: 'workflow_dispatch', DRY_RUN_INPUT: input })
assert.equal(isDry({}, dispatch(undefined)), true)
assert.equal(isDry({}, dispatch('')), true)
assert.equal(isDry({}, dispatch('true')), true)
assert.equal(isDry({}, dispatch('maybe')), true)
assert.equal(isDry({}, dispatch('false')), false)
assert.equal(isDry({}, { GITHUB_EVENT_NAME: 'schedule' }), false)
assert.equal(isDry({ 'dry-run': true }, { GITHUB_EVENT_NAME: 'schedule' }), true)
})
// ---- the commands, against a fake Gitea and a fake panel
const GIT = (...a) => ({ 'rev-parse': 'sha1', 'rev-list': '3', 'merge-base': 'mb', log: '2026-11-01T00:00:00+00:00' }[a[0]] || '')
/** deps with a fake fetch: handler(method, path, body) -> { status, json }; every call is recorded. */
function fakeDeps({ now = '2026-11-05T18:11:00Z', handler, git = GIT, panel, env = {} }) {
const deps = {
calls: [],
at: now,
env: { GITHUB_SERVER_URL: 'http://gitea.test', GITHUB_REPOSITORY: 'o/r', GITEA_TOKEN: 'reg-token', MERGE_TOKEN: 'merge-token', ...env },
now: () => new Date(deps.at),
sleep: async () => {},
git: (...a) => git(...a),
panel,
fetch: async (url, o) => {
const p = url.replace('http://gitea.test/api/v1/repos/o/r', '')
const body = o.body ? JSON.parse(o.body) : undefined
deps.calls.push({ method: o.method, p, body, auth: o.headers.Authorization })
const r = handler(o.method, p, body) || {}
const status = r.status || 200
return { ok: status < 400, status, text: async () => (r.json === undefined ? '' : JSON.stringify(r.json)) }
},
}
return deps
}
const STAT = { branch: 'public', manifest: '8140', manifestDate: '11/5/2026 11:48:58', managed: 'ok', oxideBehind: 0, fields: 'same', compile: 'ok' }
// Steam set public (2026-11-06 10:06 UTC) to the manifest STAT has.
const APPINFO_8140 = APPINFO.replace('"547616323393184824"', '"8140"')
function rig(stat = STAT, appinfo = APPINFO_8140) {
const dir = tmp()
fs.mkdirSync(path.join(dir, 'work'))
fs.writeFileSync(path.join(dir, 'work', 'static.json'), JSON.stringify(stat))
if (appinfo !== null) fs.writeFileSync(path.join(dir, 'work', 'appinfo.txt'), appinfo)
const state = path.join(dir, 'drill.json')
fs.writeFileSync(state, '{}')
// main's cutover.yml still passes --staging-work; it is ignored.
const a = { state, work: path.join(dir, 'work'), 'staging-work': path.join(dir, 'work-staging') }
return { dir, a, state, read: () => JSON.parse(fs.readFileSync(state, 'utf8')) }
}
/** A Gitea with one edge to main PR that exists once POSTed. */
function fakeGitea() {
const g = { pr: null, comments: [] }
g.handler = (method, p, body) => {
if (method === 'GET' && p.startsWith('/pulls?state=open')) return { json: g.pr ? [g.pr] : [] }
if (method === 'GET' && p.startsWith('/pulls?state=closed')) return { json: [] }
if (method === 'POST' && p === '/pulls') {
if (g.failCreate) return { status: 500, json: { message: 'boom' } }
g.pr = { number: 5, head: { ref: 'edge', sha: 'sha1' }, base: { ref: 'main' }, ...body, head_: undefined }
g.pr.head = { ref: 'edge', sha: 'sha1' }
g.pr.base = { ref: 'main' }
return { json: g.pr }
}
if (method === 'PATCH' && p === '/pulls/5') {
g.pr = { ...g.pr, ...body }
return { json: g.pr }
}
if (method === 'POST' && p === '/issues/5/comments') {
g.comments.push(body.body)
return { json: {} }
}
return { json: [] }
}
return g
}
test('gates in a dry run creates, edits, comments and writes nothing', async () => {
const r = rig()
const g = fakeGitea()
const deps = fakeDeps({ handler: g.handler, panel: () => ({ servers: async () => { throw new Error('panel down') } }) })
const d = await c.gates({ ...r.a, 'dry-run': true }, deps)
assert.ok(deps.calls.length > 0)
assert.ok(deps.calls.every((x) => x.method === 'GET'), JSON.stringify(deps.calls.filter((x) => x.method !== 'GET')))
assert.equal(fs.readFileSync(r.state, 'utf8'), '{}')
assert.equal(d.comment, false)
assert.equal(g.pr, null)
assert.equal(g.comments.length, 0)
// It ran past the failed carbon gate to try the others.
const run = JSON.parse(fs.readFileSync(path.join(r.dir, 'work', 'cutover-run.json'), 'utf8'))
assert.equal(run.rows.public.status, 'passed')
assert.equal(run.rows.oxide.status, 'passed')
assert.equal(run.rows.carbon.status, 'failed')
assert.equal(run.rows.checks.status, 'failed')
})
test('a workflow_dispatch without dry_run=false is dry even without the flag', async () => {
const r = rig()
const g = fakeGitea()
const deps = fakeDeps({ handler: g.handler, env: { GITHUB_EVENT_NAME: 'workflow_dispatch' }, panel: () => ({ servers: async () => { throw new Error('down') } }) })
await c.gates(r.a, deps)
assert.ok(deps.calls.every((x) => x.method === 'GET'))
assert.equal(fs.readFileSync(r.state, 'utf8'), '{}')
})
test('a real run keeps each gate, opens the PR, comments once, and cools a failed carbon for three hours', async () => {
const r = rig()
const g = fakeGitea()
let panelCalls = 0
const deps = fakeDeps({ handler: g.handler, panel: () => ({ servers: async () => { panelCalls++; throw new Error('panel down') } }) })
await c.gates(r.a, deps) // 18:11
let s = r.read().cutover
assert.equal(s.edgeSha, 'sha1')
assert.deepEqual(Object.keys(s.gates).sort(), ['oxide', 'public'])
assert.match(s.failed.carbon.reason, /panel down/)
assert.equal(s.pr, 5)
assert.equal(s.lastComment, '2026-11-05')
assert.equal(panelCalls, 1)
assert.ok(g.pr.title.startsWith('chore(release): RunicNPC cutover for the 2026-11-05 wipe, Rust 8140'))
assert.ok(g.pr.body.includes('<!-- cutover:start -->'))
assert.equal(g.comments.length, 1)
assert.match(g.comments[0], /not try this gate again before 2026-11-05 21:11 UTC/)
assert.ok(deps.calls.filter((x) => x.method !== 'GET').every((x) => x.auth === 'token reg-token'))
deps.at = '2026-11-05T19:11:00Z'
await c.gates(r.a, deps)
assert.equal(panelCalls, 1, 'cooling: the rig is not touched')
deps.at = '2026-11-05T21:10:00Z'
await c.gates(r.a, deps)
assert.equal(panelCalls, 1, '2 h 59 min')
assert.equal(g.comments.length, 1, 'one comment a day')
deps.at = '2026-11-05T21:11:00Z'
await c.gates(r.a, deps)
assert.equal(panelCalls, 2, '3 h 00 min: it tries again')
// New code: everything again, at once.
deps.git = (...a) => (a[0] === 'rev-parse' ? 'sha2' : GIT(...a))
deps.at = '2026-11-05T21:30:00Z'
await c.gates(r.a, deps)
s = r.read().cutover
assert.equal(panelCalls, 3)
assert.equal(s.edgeSha, 'sha2')
assert.ok(s.gates.public && s.gates.oxide)
})
test('a PR call that fails never loses a gate that passed', async () => {
const r = rig()
const g = fakeGitea()
g.failCreate = true
const deps = fakeDeps({ handler: g.handler, panel: () => ({ servers: async () => { throw new Error('panel down') } }) })
await c.gates(r.a, deps)
const s = r.read().cutover
assert.ok(s.gates.public)
assert.ok(s.gates.oxide)
assert.equal(s.pr, null)
})
for (const [why, appinfo] of [
['Steam names another manifest for public', APPINFO],
['Steam last set public before the wipe', APPINFO_8140.replace('"1793959612"', '"1792690223"')],
['steamcmd left no appinfo.txt', null],
]) {
test(`gate 1 blocks, and nothing else runs, when ${why}`, async () => {
const r = rig(STAT, appinfo)
const g = fakeGitea()
let panelCalls = 0
const deps = fakeDeps({ handler: g.handler, panel: () => ({ servers: async () => { panelCalls++; throw new Error('x') } }) })
await c.gates(r.a, deps)
const s = r.read().cutover
assert.deepEqual(s.gates, {})
assert.equal(panelCalls, 0)
assert.equal(g.pr, null)
})
}
test('checksGate refuses a PR whose head is not the commit the gates ran on', async () => {
const deps = fakeDeps({ handler: (m, p) => (p === '/pulls/3' ? { json: { number: 3, head: { sha: 'zzzzzzzz1' } } } : { json: {} }) })
const ctx = { g: c.gitea('t', deps), deps, dryRun: false, edgeSha: 'aaaaaaaa1', pr: { number: 3 } }
assert.match(await c.checksGate(ctx), /head zzzzzzzz is not the commit the gates ran on \(aaaaaaaa\)/)
assert.ok(!deps.calls.some((x) => x.p.includes('/status')))
})
test('merge sends Do=merge, keeps edge, names the head commit, and verifies it merged', async () => {
let merged = false
const deps = fakeDeps({
handler: (method, p, body) => {
if (method === 'POST' && p === '/pulls/7/merge') {
merged = true
return {}
}
if (method === 'GET' && p === '/pulls/7') return { json: { number: 7, head: { sha: 'abc123' }, merged } }
return {}
},
})
await c.merge({ pr: '7', sha: 'abc123' }, deps)
const post = deps.calls.find((x) => x.method === 'POST')
assert.deepEqual(post.body, { Do: 'merge', delete_branch_after_merge: false, head_commit_id: 'abc123' })
assert.equal(post.auth, 'token merge-token')
assert.equal(deps.calls[deps.calls.length - 1].method, 'GET')
})
test('merge refuses a moved head, and a merge that did not happen, and records why', async () => {
const r = rig()
fs.writeFileSync(r.state, JSON.stringify({ cutover: { wipe: '2026-11-05' } }))
const moved = fakeDeps({ handler: (m, p) => (p === '/pulls/7' ? { json: { head: { sha: 'other' }, merged: false } } : {}) })
await assert.rejects(c.merge({ pr: '7', sha: 'abc123', state: r.state }, moved), /not the commit the gates ran on/)
assert.ok(!moved.calls.some((x) => x.method === 'POST'))
assert.match(r.read().cutover.mergeError, /not the commit the gates ran on/)
assert.equal(r.read().cutover.wipe, '2026-11-05')
const unmerged = fakeDeps({ handler: (m, p) => (p === '/pulls/7' ? { json: { head: { sha: 'abc123' }, merged: false } } : {}) })
await assert.rejects(c.merge({ pr: '7', sha: 'abc123' }, unmerged), /not merged after the merge call/)
const refused = fakeDeps({ handler: (m, p) => (m === 'POST' ? { status: 409, json: { message: 'head changed' } } : { json: { head: { sha: 'abc123' } } }) })
await assert.rejects(c.merge({ pr: '7', sha: 'abc123' }, refused), /409/)
await assert.rejects(c.merge({ pr: '7' }, refused), /--sha/)
})
test('after records the merge, comments the release on each closed issue once, and is safe to repeat', async () => {
const r = rig()
fs.writeFileSync(r.state, JSON.stringify({ cutover: { wipe: '2026-11-05', mergeError: 'old', gates: {} } }))
const posted = {}
const deps = fakeDeps({
handler: (method, p, body) => {
if (p === '/pulls/5') return { json: { number: 5, merged: true, merged_at: '2026-11-05T20:00:00Z', body: 'Closes #20\nCloses #22' } }
if (p.startsWith('/releases')) return { json: [{ tag_name: 'v1.2.0', created_at: '2026-11-05T20:05:00Z' }, { tag_name: 'v1.1.0', created_at: '2026-10-01T00:00:00Z' }] }
if (p === '/issues/20') return { json: { state: 'closed' } }
if (p === '/issues/22') return { json: { state: 'open' } }
if (method === 'GET' && p === '/issues/20/comments') return { json: (posted[20] || []).map((b) => ({ body: b })) }
if (method === 'POST' && p === '/issues/20/comments') {
posted[20] = [...(posted[20] || []), body.body]
return {}
}
return { json: [] }
},
})
await c.after({ state: r.state, pr: '5' }, deps)
await c.after({ state: r.state, pr: '5' }, deps)
assert.deepEqual(posted[20], ['Released in v1.2.0 by the wipe cutover (D332).'])
assert.equal(deps.calls.filter((x) => x.method === 'POST').length, 1)
assert.ok(!deps.calls.some((x) => x.p.startsWith('/issues/22/comments')))
assert.equal(r.read().cutover.merged, true)
assert.equal(r.read().cutover.mergeError, undefined)
})
test('the Closes list is the PRs merged into edge after the newest edge commit already in main', async () => {
const prs = [
{ merged: true, base: { ref: 'edge' }, merged_at: '2026-10-20T00:00:00Z', body: 'Closes #1' },
{ merged: true, base: { ref: 'edge' }, merged_at: '2026-10-25T00:00:00Z', body: 'Closes #2, fixes #6' },
{ merged: true, base: { ref: 'main' }, merged_at: '2026-10-26T00:00:00Z', body: 'Closes #3' },
{ merged: false, base: { ref: 'edge' }, merged_at: null, body: 'Closes #4' },
{ merged: true, base: { ref: 'edge' }, merged_at: '2026-10-30T00:00:00Z', body: 'Refs #5' },
]
const asked = []
const deps = fakeDeps({
handler: (m, p) => (p.startsWith('/pulls?state=closed') ? { json: prs } : {}),
git: (...a) => {
asked.push(a.join(' '))
if (a[0] === 'merge-base') return 'deadbeef'
if (a[0] === 'log') return '2026-10-22T09:00:00+00:00'
return ''
},
})
assert.deepEqual(await c.closesSinceCutOff(c.gitea('t', deps), deps), [2, 6])
assert.ok(asked.includes('merge-base origin/main origin/edge'))
assert.ok(asked.includes('log -1 --format=%cI deadbeef'))
})
test('a PR whose merge commit main already has is not listed again, though merged a moment after the cut-off', async () => {
const prs = [
// edge's tip at the last cutover: its merged_at is a second after the merge commit's committer date.
{ merged: true, base: { ref: 'edge' }, merged_at: '2026-11-05T19:00:01Z', merge_commit_sha: 'shipped1', body: 'Closes #20' },
{ merged: true, base: { ref: 'edge' }, merged_at: '2026-11-20T00:00:00Z', merge_commit_sha: 'new1', body: 'Closes #30' },
]
const deps = fakeDeps({
handler: (m, p) => (p.startsWith('/pulls?state=closed') ? { json: prs } : {}),
git: (...a) => {
if (a[0] === 'merge-base') return 'shipped1'
if (a[0] === 'log') return '2026-11-05T19:00:00+00:00'
if (a[0] === 'branch') return a[3] === 'shipped1' ? ' origin/main' : ''
return ''
},
})
assert.deepEqual(await c.closesSinceCutOff(c.gitea('t', deps), deps), [30])
})
test('rust-carbon is started again only when it was running at the start and is offline now, and nothing else is touched', async () => {
const dir = tmp()
const powered = []
const mkPanel = (state) => () => ({
servers: async () => ({ 'rust-carbon': 'abc', 'rust-oxide': 'def' }),
state: async () => state,
power: async (id, signal) => powered.push([id, signal]),
})
await c.rigNote({ work: dir }, { panel: mkPanel('running') })
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'rig-carbon.json'), 'utf8')), { name: 'rust-carbon', state: 'running' })
await c.rigRestore({ work: dir }, { panel: mkPanel('running') })
await c.rigRestore({ work: dir }, { panel: mkPanel('starting') })
assert.deepEqual(powered, [])
await c.rigRestore({ work: dir }, { panel: mkPanel('offline') })
assert.deepEqual(powered, [['abc', 'start']])
// It was off at the start: left off.
await c.rigNote({ work: dir }, { panel: mkPanel('offline') })
await c.rigRestore({ work: dir }, { panel: mkPanel('offline') })
assert.equal(powered.length, 1)
})
// ---- the workflow and the scripts agree
const yml = fs.readFileSync(path.join(__dirname, '..', '..', '.gitea', 'workflows', 'cutover.yml'), 'utf8')
const src = fs.readFileSync(path.join(__dirname, 'cutover.js'), 'utf8')
function stepsOf(text) {
return text
.split(/\n {6}- (?=name:|uses:)/)
.slice(1)
.map((b) => ({ id: (/^\s+id: (\w+)/m.exec(b) || [])[1], command: (/cutover\.js (\S+)/.exec(b) || /drill\.js (\S+)/.exec(b) || [])[1], script: /drill\.js/.test(b) && !/cutover\.js/.test(b) ? 'drill' : 'cutover', text: b }))
}
const FUNCTIONS = { window: 'window_', pending: 'pending', gates: 'gates', merge: 'merge', after: 'after' }
function written(command) {
const m = new RegExp(`\\n(?:async )?function ${FUNCTIONS[command]}\\(`).exec(src)
assert.ok(m, `cutover.js has no function for ${command}`)
const body = src.slice(m.index, src.indexOf('\n}\n', m.index))
return new Set([...body.matchAll(/output\([^,]+,\s*'(\w+)'/g)].map((x) => x[1]))
}
test('every steps.<id>.outputs.<name> the workflow reads is written by the command that step runs', () => {
const steps = stepsOf(yml)
const used = [...yml.matchAll(/steps\.(\w+)\.outputs\.(\w+)/g)].map((m) => [m[1], m[2]])
assert.ok(used.length >= 8)
for (const [id, name] of used) {
const step = steps.find((s) => s.id === id)
assert.ok(step, `steps.${id} is not a step of cutover.yml`)
assert.equal(step.script, 'cutover', `steps.${id}.outputs.${name}: ${id} runs drill.js, which writes no output the workflow reads`)
assert.ok(written(step.command).has(name), `cutover.js ${step.command} never writes output ${name} (read as steps.${id}.outputs.${name})`)
}
})
test('and the outputs the logic depends on are all read by the workflow', () => {
const steps = stepsOf(yml)
const needed = { window: ['open'], pending: ['run'], gates: ['live', 'merge', 'pr', 'sha'], merge: ['merged'] }
for (const [command, names] of Object.entries(needed)) {
const ids = steps.filter((s) => s.script === 'cutover' && s.command === command && s.id).map((s) => s.id)
assert.ok(ids.length > 0, `no step with an id runs cutover.js ${command}`)
const read = new Set([...yml.matchAll(/steps\.(\w+)\.outputs\.(\w+)/g)].filter((m) => ids.includes(m[1])).map((m) => m[2]))
for (const n of names) {
assert.ok(read.has(n), `cutover.yml never reads ${n} from the ${command} step`)
assert.ok(written(command).has(n), `cutover.js ${command} no longer writes ${n}`)
}
}
})
test('the code checked out is edge, except on a dry dispatch that names code_ref', () => {
const refs = yml.split('\n').filter((l) => /^\s*ref:/.test(l)).map((l) => l.trim())
assert.deepEqual(refs, [
"ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'false' && github.event.inputs.code_ref || 'edge' }}",
])
})
test('RUNICNPC_TOKEN is in the merge step alone, and a dispatch is dry unless dry_run is exactly false', () => {
const uses = yml.split('\n').filter((l) => !l.trim().startsWith('#') && l.includes('RUNICNPC_TOKEN'))
assert.equal(uses.length, 1)
const merge = stepsOf(yml).find((s) => s.text.split('\n').some((l) => !l.trim().startsWith('#') && l.includes('RUNICNPC_TOKEN')))
assert.equal(merge.id, 'merge')
assert.match(merge.text, /MERGE_TOKEN: \$\{\{ secrets\.RUNICNPC_TOKEN \}\}/)
const code = yml.split('\n').filter((l) => !l.trim().startsWith('#')).join('\n')
assert.ok(!/dry_run\s*[=!]=\s*'true'/.test(code), 'dry_run must be compared with false, never true')
assert.ok((code.match(/dry_run != 'false'/g) || []).length >= 6)
for (const key of ['LC_ALL: C', "DOTNET_SYSTEM_GLOBALIZATION_INVARIANT: '1'"]) assert.equal(code.split(key).length - 1, 1, `${key} on the static half`)
assert.ok(!/BRANCH: staging/.test(code), 'gate 1 reads Steam, not a staging static half')
assert.match(code, /timeout-minutes: 240/)
})