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
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:
@@ -5,19 +5,22 @@
|
||||
//
|
||||
// node tools/drill/cutover.js window [--force] [--now ISO] in the wipe weekend?
|
||||
// node tools/drill/cutover.js pending [--force] does edge have commits main lacks?
|
||||
// (tools/drill/static.sh with BRANCH=public, then BRANCH=staging) the static halves
|
||||
// (tools/drill/static.sh with BRANCH=public) the static half + Steam's app info
|
||||
// node tools/drill/cutover.js rig-note --work <dir> is rust-carbon running now?
|
||||
// node tools/drill/cutover.js gates --state <drill.json> --work <dir> [--staging-work <dir>] [--dry-run] [--force] [--after-live]
|
||||
// node tools/drill/cutover.js gates --state <drill.json> --work <dir> [--dry-run] [--force] [--after-live]
|
||||
// (--staging-work is still accepted and ignored: main's wiring passes it)
|
||||
// node tools/drill/drill.js live --work <dir> --branch public the live half (only when `gates` says)
|
||||
// node tools/drill/cutover.js merge --pr <n> --sha <sha> [--state f] MERGE_TOKEN only
|
||||
// node tools/drill/cutover.js after --state <drill.json> --pr <n> record it, comment the release
|
||||
// node tools/drill/cutover.js rig-restore --work <dir> start rust-carbon if the job stopped it
|
||||
//
|
||||
// The five gates, cheapest first; it merges only when all pass (the later ones need the earlier ones):
|
||||
// public Rust's public build is dated the wipe day or later AND names the same depot manifest as Rust's
|
||||
// CURRENT staging build: Facepunch promotes the staging build to public, so the two agree once the
|
||||
// cut over from staging to live went through. (static.sh runs a second time, BRANCH=staging, into
|
||||
// its own work dir; only its manifest is used, and when that run fails this gate blocks.)
|
||||
// public Steam set Rust's public branch to its build on the wipe day or later (branches.public.timeupdated
|
||||
// in steamcmd's app info, which static.sh keeps as appinfo.txt), AND the Linux depot manifest Steam
|
||||
// names for public is the one the static half downloaded. Not the depot manifest's date: that is
|
||||
// when Facepunch BUILT it (the 2026-11-05 wipe build was dated 11-04). Not staging's manifest:
|
||||
// public never shares one with staging (Facepunch promotes a hidden `release` branch, and staging
|
||||
// has moved on by then). Both were the first design and blocked the whole 2026-11-05 weekend.
|
||||
// oxide Oxide caught up, the swap's field list is unchanged, RunicNPC compiles (static.json).
|
||||
// carbon rust-carbon restarts on public Rust with edge's RunicNPC.cs. Its appmanifest must name the public
|
||||
// manifest (it really updated); then rnpc.status says framework=carbon, navmesh=ready, missing=-
|
||||
@@ -72,7 +75,7 @@ const PERSISTED = GATES.slice(0, 4) // checks is never kept
|
||||
const COOLING = ['carbon', 'live'] // the gates that cost a rig restart
|
||||
const COOL_DOWN_HOURS = 3
|
||||
const GATE_LABELS = {
|
||||
public: "1. Rust's public build is the promoted staging build, dated the wipe day",
|
||||
public: "1. Steam set Rust's public branch on the wipe day or later",
|
||||
oxide: '2. Oxide has caught up; RunicNPC compiles',
|
||||
carbon: '3. A Carbon boot on public Rust',
|
||||
live: '4. The drill again, on live Rust',
|
||||
@@ -112,44 +115,45 @@ function wipeWeekend(now) {
|
||||
}
|
||||
|
||||
/**
|
||||
* DepotDownloader's "Manifest N (10/31/2026 11:48:58)" date as UTC milliseconds: M/D/YYYY HH:mm:ss, 24 hours.
|
||||
* An AM/PM form and ISO are accepted too; anything else is NaN (unparsable, so the gate blocks).
|
||||
* One branch of Rust's dedicated server (app 258550) from steamcmd's `+app_info_print 258550` output (the
|
||||
* appinfo.txt static.sh keeps; steamcmd's own lines come before the `"258550"` block and are skipped):
|
||||
* `timeupdated`, when Steam set the branch to its build (Unix seconds); its `buildid`; and the manifest Steam
|
||||
* names for the Linux depot on that branch (`gid`, or the older bare-string form). null when there is no
|
||||
* 258550 block or no such branch.
|
||||
*/
|
||||
function manifestTime(text) {
|
||||
const s = String(text || '').trim()
|
||||
const m = /^(\d{1,2})\/(\d{1,2})\/(\d{4}) (\d{1,2}):(\d{2}):(\d{2})(?: ([AP]M))?$/i.exec(s)
|
||||
if (m) {
|
||||
const [mo, d, y, hh, mi, ss] = [+m[1], +m[2], +m[3], +m[4], +m[5], +m[6]]
|
||||
const ampm = m[7] && m[7].toUpperCase()
|
||||
if (mi > 59 || ss > 59 || (ampm ? hh < 1 || hh > 12 : hh > 23)) return NaN
|
||||
const h = ampm ? (hh % 12) + (ampm === 'PM' ? 12 : 0) : hh
|
||||
const t = Date.UTC(y, mo - 1, d, h, mi, ss)
|
||||
const back = new Date(t)
|
||||
return back.getUTCMonth() === mo - 1 && back.getUTCDate() === d ? t : NaN
|
||||
function steamBranch(text, branch = 'public', depot = LINUX_DEPOT) {
|
||||
const s = String(text || '')
|
||||
const at = s.search(/^"258550"[ \t]*\r?$/m)
|
||||
if (at < 0) return null
|
||||
const app = parseVdf(s.slice(at))['258550']
|
||||
const depots = app && typeof app === 'object' ? app.depots : null
|
||||
const b = depots && depots.branches && depots.branches[branch]
|
||||
if (!b || typeof b !== 'object') return null
|
||||
const m = depots[depot] && depots[depot].manifests && depots[depot].manifests[branch]
|
||||
return {
|
||||
buildid: b.buildid || null,
|
||||
timeupdated: b.timeupdated === undefined ? NaN : Number(b.timeupdated),
|
||||
manifest: (m && typeof m === 'object' ? m.gid : m) || null,
|
||||
}
|
||||
if (!/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(s)) return NaN
|
||||
return Date.parse(/(Z|[+-]\d\d:?\d\d)$/.test(s) ? s : s + 'Z')
|
||||
}
|
||||
|
||||
/** The public depot's manifest is dated the wipe day (a Date, or YYYY-MM-DD) or later (UTC). */
|
||||
function publicChanged(stat, wipe) {
|
||||
const t = manifestTime(stat && stat.manifestDate)
|
||||
const iso = (ms) => new Date(ms).toISOString().slice(0, 16).replace('T', ' ') + ' UTC'
|
||||
|
||||
/**
|
||||
* Gate public: Steam set the public branch on the wipe day (a Date, or YYYY-MM-DD) or later, UTC, and the
|
||||
* Linux depot manifest it names for public is the one the static half downloaded (`stat.manifest`): the two
|
||||
* are read a minute apart, so a build pushed in between blocks one hour rather than tests the wrong build.
|
||||
*/
|
||||
function publicGate(stat, steam, wipe) {
|
||||
const w = wipe instanceof Date ? wipe.getTime() : Date.parse(wipe)
|
||||
return Number.isFinite(t) && Number.isFinite(w) && t >= w
|
||||
}
|
||||
|
||||
function publicReason(stat, wipe) {
|
||||
const date = stat && stat.manifestDate
|
||||
if (!Number.isFinite(manifestTime(date))) return `the static half read no usable manifest date from Steam (${(stat && stat.note) || 'no note'})`
|
||||
return `Rust's public build is manifest ${stat.manifest} of ${date}, from before the wipe (${wipe instanceof Date ? day(wipe) : wipe} 00:00 UTC)`
|
||||
}
|
||||
|
||||
/** Gate public: dated the wipe day or later, and the same manifest as Rust's current staging build. */
|
||||
function publicGate(stat, staging, wipe) {
|
||||
if (!publicChanged(stat, wipe)) return publicReason(stat, wipe)
|
||||
if (!staging || !staging.manifest) return `Rust's staging build could not be read (${(staging && staging.note) || 'no static.json'}), so the promotion to public cannot be confirmed`
|
||||
if (staging.manifest !== stat.manifest) {
|
||||
return `Rust's public build (manifest ${stat.manifest}) is not its current staging build (manifest ${staging.manifest}): the cut over from staging to live has not gone through`
|
||||
if (!Number.isFinite(w)) return `no usable wipe date (${wipe})`
|
||||
if (!steam) return `Steam's app info for Rust could not be read (${(stat && stat.steamNote) || 'no appinfo.txt'})`
|
||||
const t = steam.timeupdated * 1000
|
||||
if (!Number.isFinite(t) || t <= 0) return "Steam's app info gives no time for Rust's public branch"
|
||||
if (t < w) return `Steam last set Rust's public branch (build ${steam.buildid || '?'}) at ${iso(t)}, before the wipe (${day(new Date(w))} 00:00 UTC)`
|
||||
if (!steam.manifest) return `Steam's app info names no ${LINUX_DEPOT} manifest for public`
|
||||
if (steam.manifest !== (stat && stat.manifest)) {
|
||||
return `Steam names manifest ${steam.manifest} for public, but the static half downloaded ${(stat && stat.manifest) || 'none'}: the next hour tries again`
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -575,7 +579,7 @@ async function checksGate(ctx) {
|
||||
}
|
||||
|
||||
const RUNNERS = {
|
||||
public: async (c) => publicGate(c.stat, c.staging, c.wipe),
|
||||
public: async (c) => publicGate(c.stat, c.steam, c.wipe),
|
||||
oxide: async (c) => oxideReady(c.stat),
|
||||
carbon: async (c) => carbonGate(c),
|
||||
live: async (c) => liveOk(readJson(path.join(c.work, 'live.json'), null)),
|
||||
@@ -644,7 +648,8 @@ async function gates(a, deps = realDeps()) {
|
||||
const now = deps.now()
|
||||
const w = wipeWeekend(now)
|
||||
const stat = readJson(path.join(work, 'static.json'), {})
|
||||
const staging = readJson(path.join(path.resolve(a['staging-work'] || work + '-staging'), 'static.json'), null)
|
||||
const appinfo = path.join(work, 'appinfo.txt')
|
||||
const steam = fs.existsSync(appinfo) ? steamBranch(fs.readFileSync(appinfo, 'utf8')) : null
|
||||
const g = gitea(env.GITEA_TOKEN, deps)
|
||||
const prev = (afterLive && readJson(path.join(work, 'cutover-run.json'), null)) || { rows: {} }
|
||||
|
||||
@@ -653,7 +658,7 @@ async function gates(a, deps = realDeps()) {
|
||||
const obs = { manifest: stat.manifest || '', edgeSha, ahead: aheadCount(a, deps), openPr: pr && pr.number, forced: !!a.force && !(w.open || w.final), checksPassed: !!(dryRun && prev.checks) }
|
||||
// A dry run starts from nothing: it is there to try every gate, not to trust an earlier hour's.
|
||||
let d = decide(dryRun ? undefined : file.cutover, obs, now, { dryRun })
|
||||
const ctx = { g, deps, stat, staging, work, now, dryRun, pr, edgeSha, wipe: w.wipe, wipeDate: day(w.wipe), state: d.state, rows: prev.rows, closes: null }
|
||||
const ctx = { g, deps, stat, steam, work, now, dryRun, pr, edgeSha, wipe: w.wipe, wipeDate: day(w.wipe), state: d.state, rows: prev.rows, closes: null }
|
||||
if (dryRun && prev.gates) ctx.state = { ...ctx.state, gates: prev.gates } // what the first half of this dry run passed
|
||||
else for (const k of Object.keys(ctx.state.gates)) ctx.rows[k] = { status: 'passed earlier', detail: ctx.state.gates[k].at }
|
||||
const note = (gate, r) => (ctx.rows[gate] = r === true ? { status: 'passed', detail: '' } : { status: 'failed', detail: r })
|
||||
@@ -831,7 +836,7 @@ async function after(a, deps = realDeps()) {
|
||||
|
||||
module.exports = {
|
||||
wipeWeekend,
|
||||
publicChanged,
|
||||
steamBranch,
|
||||
publicGate,
|
||||
oxideReady,
|
||||
parseVdf,
|
||||
@@ -851,7 +856,6 @@ module.exports = {
|
||||
fail,
|
||||
persistable,
|
||||
nextGate,
|
||||
manifestTime,
|
||||
gates,
|
||||
merge,
|
||||
after,
|
||||
|
||||
Reference in New Issue
Block a user