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

@@ -6,10 +6,10 @@
# gates have passed, cheapest first (the decisions are pure functions in tools/drill/cutover.js, tested
# by cutover.test.js, which PR Checks runs):
#
# 1. 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 staging to public, so the two agree once the
# cut over from staging to live went through). The static half runs twice, BRANCH=public and
# BRANCH=staging; only the staging run's manifest is used.
# 1. public Steam set Rust's public branch to its build on the wipe day or later (steamcmd's app info,
# branches.public.timeupdated, which the static half keeps as appinfo.txt) AND the Linux depot
# manifest Steam names for public is the one the static half downloaded. (The first design used
# the manifest's date and staging's manifest; neither works, see tools/drill/cutover.js.)
# 2. oxide Oxide's public build has caught up (0 members behind), the swap's field list is unchanged
# and RunicNPC compiles.
# 3. carbon `rust-carbon` restarts on public Rust with edge's RunicNPC.cs: its appmanifest names the
@@ -97,7 +97,6 @@ jobs:
timeout-minutes: 240
env:
WORK: ${{ github.workspace }}/cutover-work
STAGING_WORK: ${{ github.workspace }}/cutover-staging
# cutover.js treats a dispatch as dry unless this is exactly 'false'.
DRY_RUN_INPUT: ${{ github.event.inputs.dry_run }}
steps:
@@ -163,6 +162,7 @@ jobs:
[ -f drill-state/drill.json ] || echo '{}' > drill-state/drill.json
cat drill-state/drill.json
# With BRANCH=public it also keeps Steam's app info (appinfo.txt) for gate 1.
- name: Static half (Rust's public build)
if: ${{ steps.pending.outputs.run == 'true' }}
env:
@@ -171,16 +171,6 @@ jobs:
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT: '1'
run: bash tools/drill/static.sh
# Only this run's manifest is used (gate 1): the build Rust has on staging now.
- name: Static half (Rust's staging build, for its manifest)
if: ${{ steps.pending.outputs.run == 'true' }}
env:
BRANCH: staging
WORK: ${{ github.workspace }}/cutover-staging
LC_ALL: C
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT: '1'
run: bash tools/drill/static.sh
- name: Gates 1-3 (public build, Oxide, Carbon)
id: gates
if: ${{ steps.pending.outputs.run == 'true' }}
@@ -188,7 +178,7 @@ jobs:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
PANEL_KEY: ${{ secrets.PTERODACTYL_DRILL_KEY }}
run: >-
node tools/drill/cutover.js gates --state drill-state/drill.json --work "$WORK" --staging-work "$STAGING_WORK"
node tools/drill/cutover.js gates --state drill-state/drill.json --work "$WORK"
${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'false') && '--dry-run' || '' }}
${{ github.event.inputs.force == 'true' && '--force' || '' }}
@@ -223,7 +213,7 @@ jobs:
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: >-
node tools/drill/cutover.js gates --after-live --state drill-state/drill.json --work "$WORK" --staging-work "$STAGING_WORK"
node tools/drill/cutover.js gates --after-live --state drill-state/drill.json --work "$WORK"
${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'false') && '--dry-run' || '' }}
${{ github.event.inputs.force == 'true' && '--force' || '' }}

View File

@@ -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,

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/)
})

View File

@@ -35,7 +35,7 @@ DOTNET=${DOTNET:-dotnet}
mkdir -p "$WORK"
rm -rf "$WORK/depot" "$WORK/oxide" "$WORK/refs" "$WORK/fields"
rm -f "$WORK"/static.json "$WORK"/oxide-behind.txt "$WORK"/fields.diff "$WORK"/compile.txt
rm -f "$WORK"/static.json "$WORK"/oxide-behind.txt "$WORK"/fields.diff "$WORK"/compile.txt "$WORK"/appinfo.txt
managed=error
behind=-1
@@ -45,10 +45,11 @@ note=""
oxide_modified=""
manifest=""
manifest_date=""
steam_note=""
finish() {
printf '{"branch":"%s","manifest":"%s","manifestDate":"%s","managed":"%s","oxideBehind":%s,"fields":"%s","compile":"%s","oxideModified":"%s","note":"%s"}\n' \
"$BRANCH" "$manifest" "$manifest_date" "$managed" "$behind" "$fields" "$compile" "$oxide_modified" "$note" > "$WORK/static.json"
printf '{"branch":"%s","manifest":"%s","manifestDate":"%s","managed":"%s","oxideBehind":%s,"fields":"%s","compile":"%s","oxideModified":"%s","note":"%s","steamNote":"%s"}\n' \
"$BRANCH" "$manifest" "$manifest_date" "$managed" "$behind" "$fields" "$compile" "$oxide_modified" "$note" "$steam_note" > "$WORK/static.json"
cat "$WORK/static.json"
exit 0
}
@@ -56,6 +57,37 @@ finish() {
# Git Bash does not translate paths inside a response file; elsewhere this is the path as it is.
native() { if command -v cygpath > /dev/null; then cygpath -m "$1"; else printf '%s' "$1"; fi; }
# ---- When Steam set the branch to its build (BRANCH=public only: the cutover's gate 1) ----
# The depot manifest's date below is when Facepunch BUILT the build, not when it went live (the
# 2026-11-05 wipe build was dated 11-04). Steam's app info says when the branch was SET to it
# (depots.branches.<branch>.timeupdated) and which Linux depot manifest that is. steamcmd's whole
# output goes to appinfo.txt; tools/drill/cutover.js reads it. A failure only leaves steamNote set,
# and gate 1 blocks. STEAMCMD points at another steamcmd (steamcmd.sh, or steamcmd.exe on Windows).
steam_info() {
local sc=${STEAMCMD:-} i
if [ -z "$sc" ]; then
mkdir -p "$WORK/steamcmd"
curl -sSL --fail https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz | tar -xz -C "$WORK/steamcmd" \
|| { steam_note="steamcmd could not be fetched"; return; }
sc=$WORK/steamcmd/steamcmd.sh
fi
# steamcmd updates itself on its first start and then may print nothing, so it gets three tries.
# It is 32-bit: a bare runner image lacks its libgcc, which is installed after the first miss.
for i in 1 2 3; do
"$sc" +login anonymous +app_info_update 1 +app_info_print 258550 +quit > "$WORK/appinfo.txt" 2>&1
grep -q '^"258550"' "$WORK/appinfo.txt" && return
if [ "$i" = 1 ] && command -v apt-get > /dev/null && [ "$(id -u)" = 0 ]; then
{ apt-get update -qq && apt-get install -y -qq lib32gcc-s1; } > "$WORK/steamcmd-deps.txt" 2>&1
fi
done
steam_note="steamcmd printed no app info: $(tail -2 "$WORK/appinfo.txt" | tr '\n"\\' ' ')"
}
if [ "$BRANCH" = public ]; then
steam_info
fi
# ---- Rust's managed assemblies, from Steam (no login: the dedicated server is anonymous) ----
DD=${DEPOTDOWNLOADER:-}