Merge pull request 'fix(cutover): gate 1 asks Steam when public was set (D397)' (#28) from fix/cutover-public-gate into edge
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 41s

Reviewed-on: #28
This commit is contained in:
2026-11-06 10:44:09 +00:00
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:-}