fix(cutover): gate 1 asks Steam when public was set, not the manifest's date or staging (D332)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 29s

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
This commit is contained in:
2026-11-06 04:37:50 -06:00
parent fa606e5a01
commit e5548472da
4 changed files with 213 additions and 108 deletions

View File

@@ -8,7 +8,7 @@ const fs = require('fs')
const os = require('os')
const path = require('path')
const c = require('./cutover.js')
const { wipeWeekend, publicChanged, publicGate, oxideReady, carbonOk, carbonBuild, acfDepotManifest, liveOk, checkState, collectCloses, cutoverBody, withBlock, decide, commentText, neutralise, isDry, pass, fail, persistable, nextGate, manifestTime, GATES, COOL_DOWN_HOURS } = c
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-'))
@@ -54,39 +54,108 @@ test('when the 1st is the Thursday, the weekend is the 1st to the 4th', () => {
assert.equal(wipeWeekend(at('2026-01-01T16:59:00Z')).open, false)
})
// ---- the manifest date, strictly
// ---- Steam's app info (steamcmd +app_info_print 258550), cut down from the real one of 2026-11-06
test('manifestTime reads M/D/YYYY HH:mm:ss in 24 hours, with AM/PM and ISO fallbacks, and nothing else', () => {
assert.equal(manifestTime('10/31/2026 11:48:58'), Date.UTC(2026, 9, 31, 11, 48, 58))
assert.equal(manifestTime('11/5/2026 13:00:00'), Date.UTC(2026, 10, 5, 13, 0, 0))
assert.equal(manifestTime('11/5/2026 12:00:00 AM'), Date.UTC(2026, 10, 5, 0, 0, 0))
assert.equal(manifestTime('11/5/2026 01:30:00 PM'), Date.UTC(2026, 10, 5, 13, 30, 0))
assert.equal(manifestTime('2026-11-05T09:00:00Z'), Date.UTC(2026, 10, 5, 9, 0, 0))
for (const bad of ['', undefined, '?', 'yesterday', '13/40/2026 01:00:00', '11/31/2026 01:00:00', '11/5/2026 25:00:00', '11/5/2026 13:00:00 PM', '5.11.2026 10:00:00', '11/5/26 10:00:00']) {
assert.ok(Number.isNaN(manifestTime(bad)), `${bad} should not parse`)
}
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('publicChanged: the manifest must be dated the wipe day or later (UTC)', () => {
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')
assert.equal(publicChanged({ manifestDate: '11/4/2026 23:59:59' }, wipe), false)
assert.equal(publicChanged({ manifestDate: '11/5/2026 00:00:00' }, wipe), true)
assert.equal(publicChanged({ manifestDate: '10/31/2026 11:48:58' }, wipe), false)
assert.equal(publicChanged({ manifestDate: 'yesterday' }, wipe), false)
assert.equal(publicChanged(undefined, wipe), false)
})
test('publicGate: public must also be the build staging has now (the promotion went through)', () => {
const wipe = at('2026-11-05T00:00:00Z')
const pub = { manifest: '8140', manifestDate: '11/5/2026 11:48:58' }
assert.equal(publicGate(pub, { manifest: '8140' }, wipe), true)
assert.match(publicGate(pub, { manifest: '8150' }, wipe), /not its current staging build \(manifest 8150\)/)
assert.match(publicGate(pub, { note: 'no Assembly' }, wipe), /staging build could not be read/)
assert.match(publicGate(pub, null, wipe), /staging build could not be read/)
assert.match(publicGate({ manifest: '8100', manifestDate: '10/31/2026 11:48:58' }, { manifest: '8100' }, wipe), /from before the wipe/)
assert.match(publicGate({ manifest: '8140', manifestDate: 'garbage', note: 'n' }, { manifest: '8140' }, wipe), /no usable manifest date/)
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', () => {
@@ -453,14 +522,17 @@ function fakeDeps({ now = '2026-11-05T18:11:00Z', handler, git = GIT, panel, env
const STAT = { branch: 'public', manifest: '8140', manifestDate: '11/5/2026 11:48:58', managed: 'ok', oxideBehind: 0, fields: 'same', compile: 'ok' }
function rig(stat = STAT, staging = { branch: 'staging', manifest: '8140' }) {
// 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.mkdirSync(path.join(dir, 'work-staging'))
fs.writeFileSync(path.join(dir, 'work', 'static.json'), JSON.stringify(stat))
fs.writeFileSync(path.join(dir, 'work-staging', 'static.json'), JSON.stringify(staging))
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')) }
}
@@ -572,17 +644,23 @@ test('a PR call that fails never loses a gate that passed', async () => {
assert.equal(s.pr, null)
})
test('gate 1 blocks, and nothing else runs, when public is not what staging has', async () => {
const r = rig(STAT, { branch: 'staging', manifest: '8999' })
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)
})
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: {} }) })
@@ -778,6 +856,7 @@ test('RUNICNPC_TOKEN is in the merge step alone, and a dispatch is dry unless dr
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, 2, `${key} on both static halves`)
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/)
})