diff --git a/.gitea/workflows/cutover.yml b/.gitea/workflows/cutover.yml index 4ec300c..4181a9e 100644 --- a/.gitea/workflows/cutover.yml +++ b/.gitea/workflows/cutover.yml @@ -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' || '' }} diff --git a/tools/drill/cutover.js b/tools/drill/cutover.js index 0e8e13d..da09d4b 100644 --- a/tools/drill/cutover.js +++ b/tools/drill/cutover.js @@ -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 is rust-carbon running now? -// node tools/drill/cutover.js gates --state --work [--staging-work ] [--dry-run] [--force] [--after-live] +// node tools/drill/cutover.js gates --state --work [--dry-run] [--force] [--after-live] +// (--staging-work is still accepted and ignored: main's wiring passes it) // node tools/drill/drill.js live --work --branch public the live half (only when `gates` says) // node tools/drill/cutover.js merge --pr --sha [--state f] MERGE_TOKEN only // node tools/drill/cutover.js after --state --pr record it, comment the release // node tools/drill/cutover.js rig-restore --work 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, diff --git a/tools/drill/cutover.test.js b/tools/drill/cutover.test.js index 5cda7b5..54bac1c 100644 --- a/tools/drill/cutover.test.js +++ b/tools/drill/cutover.test.js @@ -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/) }) diff --git a/tools/drill/static.sh b/tools/drill/static.sh index 5cf7d47..cf8033c 100644 --- a/tools/drill/static.sh +++ b/tools/drill/static.sh @@ -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..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:-}