All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 14s
From the second review: - A PR whose merge commit main already has is not listed again. edge's tip at a cutover is often that PR's merge commit, and its merged_at can be a moment after the cut-off's committer date, so its issues would have had the next release's comment too. - timeout-minutes 240: the worst case is about 200, and a timeout must never cut off the step that starts rust-carbon again. - A test pins the checkout: edge, except on a dry dispatch that names code_ref. Dry run (CI run 54, before these): every gate refused for the right reason. Public Rust is still the 22 October build, which lacks the three fields edge carries for November, and gates 1 to 4 each saw it; the panel key wrote to, restarted and read rust-carbon; the live half ran 266 pass, 0 fail; nothing was opened, edited, commented or merged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
784 lines
40 KiB
JavaScript
784 lines
40 KiB
JavaScript
// The wipe-day cutover (docs/runicnpc/PLAN.md D332): when it works, what each gate accepts, which issues the
|
|
// PR closes, what the hourly run does next, and the commands against a fake Gitea and a fake panel.
|
|
// node --test tools/drill/cutover.test.js
|
|
|
|
const test = require('node:test')
|
|
const assert = require('node:assert')
|
|
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 at = (s) => new Date(s)
|
|
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'cutover-test-'))
|
|
|
|
// ---- wipeWeekend: November 2026 has its wipe on Thursday the 5th, so the weekend ends Sunday the 8th.
|
|
|
|
test('the weekend opens at 17:00 UTC on the wipe Thursday and not a minute before', () => {
|
|
assert.equal(wipeWeekend(at('2026-11-05T16:59:00Z')).open, false)
|
|
assert.equal(wipeWeekend(at('2026-11-05T17:00:00Z')).open, true)
|
|
assert.equal(wipeWeekend(at('2026-11-05T17:00:00Z')).wipe.toISOString(), '2026-11-05T00:00:00.000Z')
|
|
})
|
|
|
|
test('it closes at Sunday 23:00 UTC, with one more hour to give up in', () => {
|
|
assert.equal(wipeWeekend(at('2026-11-08T22:59:00Z')).open, true)
|
|
const late = wipeWeekend(at('2026-11-08T23:00:00Z'))
|
|
assert.equal(late.open, false)
|
|
assert.equal(late.final, true)
|
|
assert.equal(late.deadline.toISOString(), '2026-11-08T23:00:00.000Z')
|
|
assert.equal(wipeWeekend(at('2026-11-08T23:59:00Z')).final, true)
|
|
assert.equal(wipeWeekend(at('2026-11-09T00:00:00Z')).final, false)
|
|
assert.equal(wipeWeekend(at('2026-11-05T17:00:00Z')).final, false)
|
|
})
|
|
|
|
test('a Thursday that is not the first of the month is closed, as is the morning of the wipe', () => {
|
|
assert.equal(wipeWeekend(at('2026-11-12T18:00:00Z')).open, false)
|
|
assert.equal(wipeWeekend(at('2026-11-05T09:00:00Z')).open, false)
|
|
assert.equal(wipeWeekend(at('2026-11-06T12:00:00Z')).open, true)
|
|
})
|
|
|
|
test('a month whose first Thursday is the 7th ends its weekend on the 10th', () => {
|
|
const w = wipeWeekend(at('2026-05-09T12:00:00Z'))
|
|
assert.equal(w.wipe.toISOString(), '2026-05-07T00:00:00.000Z')
|
|
assert.equal(w.deadline.toISOString(), '2026-05-10T23:00:00.000Z')
|
|
assert.equal(w.open, true)
|
|
assert.equal(wipeWeekend(at('2026-05-10T23:00:00Z')).open, false)
|
|
})
|
|
|
|
test('when the 1st is the Thursday, the weekend is the 1st to the 4th', () => {
|
|
const w = wipeWeekend(at('2026-01-02T00:00:00Z'))
|
|
assert.equal(w.wipe.toISOString(), '2026-01-01T00:00:00.000Z')
|
|
assert.equal(w.deadline.toISOString(), '2026-01-04T23:00:00.000Z')
|
|
assert.equal(w.open, true)
|
|
assert.equal(wipeWeekend(at('2026-01-01T16:59:00Z')).open, false)
|
|
})
|
|
|
|
// ---- the manifest date, strictly
|
|
|
|
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`)
|
|
}
|
|
})
|
|
|
|
// ---- the gates
|
|
|
|
test('publicChanged: the manifest must be dated the wipe day or later (UTC)', () => {
|
|
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/)
|
|
})
|
|
|
|
test('oxideReady: true, or the reason', () => {
|
|
const ok = { managed: 'ok', oxideBehind: 0, compile: 'ok', fields: 'same' }
|
|
assert.equal(oxideReady(ok), true)
|
|
assert.match(oxideReady({ ...ok, oxideBehind: 12 }), /Oxide's public build is 12 member\(s\) behind/)
|
|
assert.match(oxideReady({ ...ok, fields: 'differs' }), /field list differs from public Rust's/)
|
|
assert.match(oxideReady({ ...ok, compile: 'errors' }), /does not compile/)
|
|
assert.match(oxideReady({ ...ok, managed: 'error', note: 'DepotDownloader could not be fetched' }), /could not fetch Rust's public build \(DepotDownloader/)
|
|
assert.match(oxideReady({ ...ok, oxideBehind: -1 }), /could not be compared/)
|
|
assert.match(oxideReady({ ...ok, fields: 'error' }), /could not be checked/)
|
|
assert.match(oxideReady(undefined), /could not fetch/)
|
|
})
|
|
|
|
const ACF = `"AppState"
|
|
{
|
|
\t"appid"\t\t"258550"
|
|
\t"buildid"\t\t"22222222"
|
|
\t"InstalledDepots"
|
|
\t{
|
|
\t\t"258551"
|
|
\t\t{
|
|
\t\t\t"manifest"\t\t"111"
|
|
\t\t\t"size"\t\t"1"
|
|
\t\t}
|
|
\t\t"258552"
|
|
\t\t{
|
|
\t\t\t"manifest"\t\t"8140123456789"
|
|
\t\t\t"size"\t\t"9000000000"
|
|
\t\t}
|
|
\t}
|
|
\t"SharedDepots"
|
|
\t{
|
|
\t\t"228984"\t\t"228990"
|
|
\t}
|
|
}
|
|
`
|
|
|
|
test('the appmanifest: the Linux depot\'s manifest, and null when it is not there', () => {
|
|
assert.equal(acfDepotManifest(ACF), '8140123456789')
|
|
assert.equal(acfDepotManifest(ACF, '258551'), '111')
|
|
assert.equal(acfDepotManifest(ACF, '999'), null)
|
|
assert.equal(acfDepotManifest(''), null)
|
|
assert.equal(acfDepotManifest(undefined), null)
|
|
assert.equal(acfDepotManifest('"AppState" { "InstalledDepots" { "258552" "oops" } }'), null)
|
|
})
|
|
|
|
test('carbonBuild: the rig must run the public manifest', () => {
|
|
assert.equal(carbonBuild(ACF, '8140123456789'), true)
|
|
assert.match(carbonBuild(ACF, '9999'), /rust-carbon still runs manifest 8140123456789 \(public is 9999\)/)
|
|
assert.match(carbonBuild(null, '9999'), /could not be read/)
|
|
})
|
|
|
|
const STATUS = 'framework=carbon navmesh=ready swap fields: npc=58/58 brain=32/32 missing=- added=-'
|
|
|
|
test('carbonOk: a Carbon server with the navmesh ready and nothing missing or added', () => {
|
|
assert.equal(carbonOk(['RunicNPC 0.0.0 api=1 hooks=40 fired=3 silent=0', 'fired: x', STATUS, 'npcs=0']), true)
|
|
assert.equal(carbonOk(STATUS), true)
|
|
})
|
|
|
|
test('carbonOk: each way it can fail says why', () => {
|
|
assert.match(carbonOk([STATUS.replace('added=-', 'added=BasePlayer.x')]), /does not know: BasePlayer\.x/)
|
|
assert.match(carbonOk([STATUS.replace('missing=-', 'missing=BaseNpc.y,Brain.z')]), /missing on Carbon: BaseNpc\.y,Brain\.z/)
|
|
assert.match(carbonOk([STATUS.replace('framework=carbon', 'framework=oxide')]), /framework=oxide, not carbon/)
|
|
assert.match(carbonOk([STATUS.replace('navmesh=ready', 'navmesh=building')]), /navmesh=building/)
|
|
assert.match(carbonOk([]), /never answered/)
|
|
assert.match(carbonOk(['RunicNPC 0.0.0 api=1']), /never answered/)
|
|
})
|
|
|
|
test('liveOk: ran, harness done, some checks passed, no failed check and no finding', () => {
|
|
const good = { branch: 'public', ran: true, findings: [], harness: { done: true, pass: 266, fail: 0, failed: [] } }
|
|
assert.equal(liveOk(good), true)
|
|
assert.match(liveOk(null), /no live\.json/)
|
|
assert.match(liveOk({ ...good, ran: false, findings: ['The server did not start on Rust public 1'] }), /did not start/)
|
|
assert.match(liveOk({ ...good, ran: false, skipped: "Oxide's public build is 3 member(s) behind" }), /3 member/)
|
|
assert.match(liveOk({ ...good, harness: { done: false, pass: 5, fail: 0, failed: [] }, findings: ['The harness stopped after x'] }), /harness stopped after x/)
|
|
assert.match(liveOk({ ...good, harness: { done: true, pass: 5, fail: 1, failed: ['place.set: FAIL off the navmesh'] } }), /place\.set: FAIL off the navmesh/)
|
|
assert.match(liveOk({ ...good, findings: ['RunicNPC logged 2 error line(s) in Oxide\'s log during the run.'] }), /logged 2 error/)
|
|
assert.match(liveOk({ ...good, branch: 'staging' }), /staging branch/)
|
|
assert.match(liveOk({ ...good, harness: { done: true, pass: 0, fail: 0, failed: [] } }), /passed no check/)
|
|
})
|
|
|
|
test('checkState: success needs a plugin-checks context; a failure or a wait is said', () => {
|
|
const s = (context, status) => ({ context, status })
|
|
assert.equal(checkState({ state: 'success', statuses: [s('PR Checks / plugin-checks (pull_request)', 'success')] }).state, 'success')
|
|
assert.equal(checkState({ state: 'success', statuses: [] }).state, 'pending')
|
|
assert.equal(checkState({ state: 'success', statuses: [s('other', 'success')] }).state, 'pending')
|
|
assert.equal(checkState({ state: 'pending', statuses: [s('PR Checks / plugin-checks (pull_request)', 'pending')] }).state, 'pending')
|
|
const failed = checkState({ state: 'failure', statuses: [s('PR Checks / plugin-checks (pull_request)', 'failure')] })
|
|
assert.equal(failed.state, 'failed')
|
|
assert.match(failed.reason, /plugin-checks.*failure/)
|
|
})
|
|
|
|
// ---- what the PR closes, and what is written into it
|
|
|
|
test('collectCloses reads closing keywords before #N, in any case', () => {
|
|
assert.deepEqual(collectCloses(['Closes #20', 'fixes #3', 'RESOLVED #9', 'Fixed #12.', 'close #4', 'Resolves: #8']), [3, 4, 8, 9, 12, 20])
|
|
})
|
|
|
|
test('collectCloses takes several on a line, and dedupes and sorts', () => {
|
|
assert.deepEqual(collectCloses(['Closes #22, closes #20 and fixes #5', 'Closes #20\nCloses #5', 'closes #100']), [5, 20, 22, 100])
|
|
})
|
|
|
|
test('collectCloses ignores references, bare numbers, prose and other repositories', () => {
|
|
assert.deepEqual(collectCloses(['Refs #21', 'see #21', '#21', 'This closes the loop on #21', 'prefix #3', 'unfixes #4', 'Closes RunicGateway/docs#9', '']), [])
|
|
assert.deepEqual(collectCloses([undefined, null]), [])
|
|
})
|
|
|
|
test('cutoverBody names the wipe and the build, lists a Closes line per issue, and ends with the disclosure', () => {
|
|
const body = cutoverBody({
|
|
wipe: '2026-11-05',
|
|
manifest: '8140',
|
|
buildDate: '11/5/2026 11:48:58',
|
|
closes: [20, 22],
|
|
gates: [
|
|
{ name: '1. Rust public', status: 'passed', note: '2026-11-05 17:11 UTC' },
|
|
{ name: '2. Oxide', status: 'failed', note: 'a | b' },
|
|
],
|
|
})
|
|
assert.match(body, /D332/)
|
|
assert.match(body, /manifest \*\*8140\*\*, dated 11\/5\/2026 11:48:58/)
|
|
assert.match(body, /^Closes #20$/m)
|
|
assert.match(body, /^Closes #22$/m)
|
|
assert.match(body, /\| 2\. Oxide \| failed \| a \/ b \|/)
|
|
assert.ok(body.endsWith('Opened by the runicnpc-rust cutover workflow (D332).'))
|
|
assert.match(cutoverBody({ wipe: 'w', manifest: 'm', closes: [], gates: [] }), /No issue is closed/)
|
|
})
|
|
|
|
test('a reason can never close an issue: #123 is broken up in the body and in comments', () => {
|
|
assert.equal(neutralise('fixes #123 and #4'), 'fixes #​123 and #​4')
|
|
const body = cutoverBody({ wipe: 'w', manifest: 'm', closes: [], gates: [{ name: 'g', status: 'failed', note: 'Closes #5: fixes #123' }] })
|
|
assert.deepEqual(collectCloses([body]), [])
|
|
const text = commentText({ action: 'blocked', gate: 'oxide', reason: 'resolves #7' })
|
|
assert.deepEqual(collectCloses([text]), [])
|
|
// The deliberate lines still close.
|
|
assert.deepEqual(collectCloses([cutoverBody({ wipe: 'w', manifest: 'm', closes: [9], gates: [] })]), [9])
|
|
})
|
|
|
|
test('withBlock appends the marked block, replaces only what is between the markers, and is stable', () => {
|
|
const once = withBlock('A human wrote this.', 'one')
|
|
assert.equal(once, 'A human wrote this.\n\n<!-- cutover:start -->\none\n<!-- cutover:end -->')
|
|
const twice = withBlock(once + '\n\nAnd this after.', 'two')
|
|
assert.equal(twice, 'A human wrote this.\n\n<!-- cutover:start -->\ntwo\n<!-- cutover:end -->\n\nAnd this after.')
|
|
assert.equal(withBlock(twice, 'two'), twice)
|
|
assert.equal(withBlock('', 'x'), '<!-- cutover:start -->\nx\n<!-- cutover:end -->')
|
|
})
|
|
|
|
// ---- decide
|
|
|
|
const THU = at('2026-11-05T18:11:00Z')
|
|
const obs = (more = {}) => ({ manifest: '8140', edgeSha: 'sha1', ahead: 3, openPr: null, ...more })
|
|
const fresh = () => decide(undefined, obs(), THU)
|
|
const passed = (state, ...names) => names.reduce((s, g) => pass(s, g, THU), state)
|
|
|
|
test('a first run starts at the public gate and asks to be written', () => {
|
|
const d = fresh()
|
|
assert.equal(d.action, 'run')
|
|
assert.equal(d.gate, 'public')
|
|
assert.equal(d.write, true)
|
|
assert.equal(d.state.wipe, '2026-11-05')
|
|
assert.equal(d.state.manifest, '8140')
|
|
assert.equal(d.state.edgeSha, 'sha1')
|
|
})
|
|
|
|
test('the gates run in order, checks comes last and is never kept, and then it merges', () => {
|
|
assert.deepEqual(GATES, ['public', 'oxide', 'carbon', 'live', 'checks'])
|
|
let s = fresh().state
|
|
for (const g of GATES.slice(0, 4)) {
|
|
const d = decide(s, obs(), THU)
|
|
assert.equal(d.gate, g)
|
|
s = pass(d.state, g, THU)
|
|
}
|
|
const checks = decide(s, obs(), THU)
|
|
assert.equal(checks.action, 'run')
|
|
assert.equal(checks.gate, 'checks')
|
|
const done = decide(s, obs({ checksPassed: true }), THU)
|
|
assert.equal(done.action, 'merge')
|
|
assert.equal(done.merge, true)
|
|
assert.equal(nextGate({ public: {}, carbon: {} }), 'oxide')
|
|
// A checks entry found in a stored state is dropped.
|
|
const old = decide({ ...s, gates: { ...s.gates, checks: { at: 'x' } } }, obs(), THU)
|
|
assert.equal(old.state.gates.checks, undefined)
|
|
assert.equal(old.gate, 'checks')
|
|
assert.equal(persistable({ ...s, gates: { ...s.gates, checks: {} } }, { number: 4 }).gates.checks, undefined)
|
|
assert.equal(persistable(s, { number: 4 }).pr, 4)
|
|
})
|
|
|
|
test('a passed gate is never run again for the same build and the same code', () => {
|
|
const s = passed(fresh().state, 'public', 'oxide', 'carbon')
|
|
const d = decide(s, obs(), at('2026-11-05T19:11:00Z'))
|
|
assert.equal(d.action, 'run')
|
|
assert.equal(d.gate, 'live')
|
|
assert.ok(d.state.gates.carbon)
|
|
})
|
|
|
|
test('a new public manifest starts the gates again, keeping the open PR', () => {
|
|
const s = { ...passed(fresh().state, 'public', 'oxide', 'carbon'), pr: 9 }
|
|
const d = decide(s, obs({ manifest: '8141', openPr: 9 }), at('2026-11-06T10:11:00Z'))
|
|
assert.equal(d.gate, 'public')
|
|
assert.deepEqual(d.state.gates, {})
|
|
assert.equal(d.state.manifest, '8141')
|
|
assert.equal(d.state.pr, 9)
|
|
assert.equal(decide(s, obs({ manifest: '8141', openPr: null }), at('2026-11-06T10:11:00Z')).state.pr, null)
|
|
})
|
|
|
|
test('a new edge commit clears every gate and every cooled failure, so nothing proven on old code counts', () => {
|
|
const s = fail(passed(fresh().state, 'public', 'oxide'), 'carbon', 'x', THU)
|
|
const d = decide({ ...s, mergeError: 'old' }, obs({ edgeSha: 'sha2' }), at('2026-11-05T19:11:00Z'))
|
|
assert.equal(d.action, 'run')
|
|
assert.equal(d.gate, 'public')
|
|
assert.deepEqual(d.state.gates, {})
|
|
assert.deepEqual(d.state.failed, {})
|
|
assert.equal(d.state.edgeSha, 'sha2')
|
|
assert.equal(d.state.mergeError, undefined)
|
|
// A state written before edgeSha was kept counts as changed code.
|
|
const legacy = { wipe: '2026-11-05', manifest: '8140', gates: { public: { at: 'x' } } }
|
|
assert.equal(decide(legacy, obs(), THU).gate, 'public')
|
|
})
|
|
|
|
test('a new wipe forgets everything, comments and a give-up included', () => {
|
|
const s = { ...passed(fresh().state, 'public'), lastComment: '2026-11-05', gaveUp: true }
|
|
const d = decide(s, obs(), at('2026-12-03T18:00:00Z'))
|
|
assert.equal(d.action, 'run')
|
|
assert.equal(d.state.wipe, '2026-12-03')
|
|
assert.deepEqual(d.state.gates, {})
|
|
assert.equal(d.state.lastComment, undefined)
|
|
assert.equal(d.state.gaveUp, undefined)
|
|
})
|
|
|
|
test('nothing to do when edge has no commit main lacks, or when it already merged', () => {
|
|
const d = decide(undefined, obs({ ahead: 0 }), THU)
|
|
assert.equal(d.action, 'nothing')
|
|
assert.match(d.reason, /no commits/)
|
|
assert.equal(decide({ ...fresh().state, merged: true }, obs(), THU).action, 'nothing')
|
|
})
|
|
|
|
test('the cool-down: a failed carbon or live is not tried again for three hours, a new commit retries at once', () => {
|
|
assert.equal(COOL_DOWN_HOURS, 3)
|
|
const base = passed(fresh().state, 'public', 'oxide')
|
|
const failedCarbon = fail(base, 'carbon', 'did not come up', THU) // failed at 18:11
|
|
const at59 = decide(failedCarbon, obs(), at('2026-11-05T21:10:00Z')) // 2 h 59 min later
|
|
assert.equal(at59.action, 'cooling')
|
|
assert.equal(at59.gate, 'carbon')
|
|
assert.equal(at59.retryAt.toISOString(), '2026-11-05T21:11:00.000Z')
|
|
assert.match(at59.reason, /did not come up/)
|
|
assert.equal(decide(failedCarbon, obs(), at('2026-11-05T21:11:00Z')).action, 'run') // 3 h 00
|
|
assert.equal(decide(failedCarbon, obs(), at('2026-11-05T21:11:00Z')).gate, 'carbon')
|
|
assert.equal(decide(failedCarbon, obs({ edgeSha: 'sha2' }), at('2026-11-05T19:00:00Z')).gate, 'public')
|
|
assert.equal(decide(failedCarbon, obs({ manifest: '8141' }), at('2026-11-05T19:00:00Z')).gate, 'public')
|
|
// The same rule for live; public and oxide are cheap and never cool.
|
|
const failedLive = fail(passed(base, 'carbon'), 'live', 'a check failed', THU)
|
|
assert.equal(decide(failedLive, obs(), at('2026-11-05T20:00:00Z')).action, 'cooling')
|
|
assert.equal(decide(failedLive, obs(), at('2026-11-05T21:11:00Z')).gate, 'live')
|
|
assert.equal(decide(fail(fresh().state, 'oxide', 'x', THU), obs(), at('2026-11-05T18:30:00Z')).gate, 'public')
|
|
// A pass forgets the failure.
|
|
assert.equal(pass(failedCarbon, 'carbon', THU).failed.carbon, undefined)
|
|
})
|
|
|
|
test('a cooling gate comments once a day and says when it will try again', () => {
|
|
const s = fail(passed(fresh().state, 'public', 'oxide'), 'carbon', 'did not come up', THU)
|
|
const d = decide(s, obs({ openPr: 9 }), at('2026-11-05T19:11:00Z'))
|
|
assert.equal(d.comment, true)
|
|
assert.match(commentText(d, d.state), /not try this gate again before 2026-11-05 21:11 UTC/)
|
|
assert.match(commentText(d, d.state), /3\. A Carbon boot/)
|
|
assert.equal(decide(d.state, obs({ openPr: 9 }), at('2026-11-05T20:11:00Z')).comment, false)
|
|
})
|
|
|
|
test('a blocked gate comments once a day, and only after the public gate passed', () => {
|
|
const blocked = { gate: 'oxide', reason: "Oxide's public build is 12 member(s) behind" }
|
|
const s = passed(fresh().state, 'public')
|
|
const first = decide(s, obs({ openPr: 9, blocked }), THU)
|
|
assert.equal(first.action, 'blocked')
|
|
assert.equal(first.comment, true)
|
|
assert.equal(first.state.lastComment, '2026-11-05')
|
|
assert.equal(decide(first.state, obs({ openPr: 9, blocked }), at('2026-11-05T19:11:00Z')).comment, false)
|
|
assert.equal(decide(first.state, obs({ openPr: 9, blocked }), at('2026-11-06T09:11:00Z')).comment, true)
|
|
assert.equal(decide(fresh().state, obs({ openPr: 9, blocked: { gate: 'public', reason: 'old' } }), THU).comment, false)
|
|
assert.equal(decide(s, obs({ openPr: null, blocked }), THU).comment, false)
|
|
// A blocked carbon says when it will be tried again.
|
|
const carbon = decide(s, obs({ openPr: 9, blocked: { gate: 'carbon', reason: 'x' } }), THU)
|
|
assert.equal(carbon.retryAt.toISOString(), '2026-11-05T21:11:00.000Z')
|
|
})
|
|
|
|
test('after the deadline it gives up once, with a last comment', () => {
|
|
const s = passed(fresh().state, 'public', 'oxide')
|
|
const late = at('2026-11-08T23:11:00Z')
|
|
const d = decide(s, obs({ openPr: 9 }), late)
|
|
assert.equal(d.action, 'giveUp')
|
|
assert.equal(d.gate, 'carbon')
|
|
assert.equal(d.comment, true)
|
|
assert.equal(d.state.gaveUp, true)
|
|
assert.match(commentText(d, d.state), /Gate "3\. A Carbon boot on public Rust" had not passed/)
|
|
const again = decide(d.state, obs({ openPr: 9 }), at('2026-11-08T23:40:00Z'))
|
|
assert.equal(again.action, 'nothing')
|
|
assert.equal(again.comment, false)
|
|
assert.equal(decide(s, obs({ openPr: null }), late).comment, false)
|
|
assert.equal(decide(s, obs({ openPr: 9, forced: true }), late).action, 'run')
|
|
})
|
|
|
|
test('giving up with every gate passed and the merge failed says so, never gate "undefined"', () => {
|
|
const s = { ...passed(fresh().state, 'public', 'oxide', 'carbon', 'live'), mergeError: 'POST /pulls/5/merge: 405 not mergeable' }
|
|
const d = decide(s, obs({ openPr: 5 }), at('2026-11-08T23:11:00Z'))
|
|
assert.equal(d.action, 'giveUp')
|
|
assert.equal(d.gate, undefined)
|
|
const text = commentText(d, d.state)
|
|
assert.match(text, /Every gate passed; the merge failed: POST \/pulls\/5\/merge: 405 not mergeable/)
|
|
assert.ok(!text.includes('undefined'))
|
|
assert.ok(!commentText(d, { ...d.state, mergeError: undefined }).includes('undefined'))
|
|
})
|
|
|
|
test('a dry run never asks to write, comment or merge', () => {
|
|
const dry = { dryRun: true }
|
|
assert.equal(decide(undefined, obs(), THU, dry).write, false)
|
|
const s = passed(fresh().state, 'public')
|
|
const b = decide(s, obs({ openPr: 9, blocked: { gate: 'oxide', reason: 'x' } }), THU, dry)
|
|
assert.equal(b.comment, false)
|
|
assert.equal(b.write, false)
|
|
assert.equal(b.state.lastComment, undefined)
|
|
const m = decide(passed(fresh().state, ...GATES.slice(0, 4)), obs({ checksPassed: true }), THU, dry)
|
|
assert.equal(m.action, 'merge')
|
|
assert.equal(m.merge, false)
|
|
const g = decide(s, obs({ openPr: 9 }), at('2026-11-08T23:11:00Z'), dry)
|
|
assert.equal(g.action, 'giveUp')
|
|
assert.equal(g.comment, false)
|
|
assert.equal(decide(undefined, obs({ ahead: 0 }), THU, dry).action, 'run')
|
|
})
|
|
|
|
test('a dispatch is dry unless its dry_run input is exactly false', () => {
|
|
const dispatch = (input) => ({ GITHUB_EVENT_NAME: 'workflow_dispatch', DRY_RUN_INPUT: input })
|
|
assert.equal(isDry({}, dispatch(undefined)), true)
|
|
assert.equal(isDry({}, dispatch('')), true)
|
|
assert.equal(isDry({}, dispatch('true')), true)
|
|
assert.equal(isDry({}, dispatch('maybe')), true)
|
|
assert.equal(isDry({}, dispatch('false')), false)
|
|
assert.equal(isDry({}, { GITHUB_EVENT_NAME: 'schedule' }), false)
|
|
assert.equal(isDry({ 'dry-run': true }, { GITHUB_EVENT_NAME: 'schedule' }), true)
|
|
})
|
|
|
|
// ---- the commands, against a fake Gitea and a fake panel
|
|
|
|
const GIT = (...a) => ({ 'rev-parse': 'sha1', 'rev-list': '3', 'merge-base': 'mb', log: '2026-11-01T00:00:00+00:00' }[a[0]] || '')
|
|
|
|
/** deps with a fake fetch: handler(method, path, body) -> { status, json }; every call is recorded. */
|
|
function fakeDeps({ now = '2026-11-05T18:11:00Z', handler, git = GIT, panel, env = {} }) {
|
|
const deps = {
|
|
calls: [],
|
|
at: now,
|
|
env: { GITHUB_SERVER_URL: 'http://gitea.test', GITHUB_REPOSITORY: 'o/r', GITEA_TOKEN: 'reg-token', MERGE_TOKEN: 'merge-token', ...env },
|
|
now: () => new Date(deps.at),
|
|
sleep: async () => {},
|
|
git: (...a) => git(...a),
|
|
panel,
|
|
fetch: async (url, o) => {
|
|
const p = url.replace('http://gitea.test/api/v1/repos/o/r', '')
|
|
const body = o.body ? JSON.parse(o.body) : undefined
|
|
deps.calls.push({ method: o.method, p, body, auth: o.headers.Authorization })
|
|
const r = handler(o.method, p, body) || {}
|
|
const status = r.status || 200
|
|
return { ok: status < 400, status, text: async () => (r.json === undefined ? '' : JSON.stringify(r.json)) }
|
|
},
|
|
}
|
|
return deps
|
|
}
|
|
|
|
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' }) {
|
|
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))
|
|
const state = path.join(dir, 'drill.json')
|
|
fs.writeFileSync(state, '{}')
|
|
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')) }
|
|
}
|
|
|
|
/** A Gitea with one edge to main PR that exists once POSTed. */
|
|
function fakeGitea() {
|
|
const g = { pr: null, comments: [] }
|
|
g.handler = (method, p, body) => {
|
|
if (method === 'GET' && p.startsWith('/pulls?state=open')) return { json: g.pr ? [g.pr] : [] }
|
|
if (method === 'GET' && p.startsWith('/pulls?state=closed')) return { json: [] }
|
|
if (method === 'POST' && p === '/pulls') {
|
|
if (g.failCreate) return { status: 500, json: { message: 'boom' } }
|
|
g.pr = { number: 5, head: { ref: 'edge', sha: 'sha1' }, base: { ref: 'main' }, ...body, head_: undefined }
|
|
g.pr.head = { ref: 'edge', sha: 'sha1' }
|
|
g.pr.base = { ref: 'main' }
|
|
return { json: g.pr }
|
|
}
|
|
if (method === 'PATCH' && p === '/pulls/5') {
|
|
g.pr = { ...g.pr, ...body }
|
|
return { json: g.pr }
|
|
}
|
|
if (method === 'POST' && p === '/issues/5/comments') {
|
|
g.comments.push(body.body)
|
|
return { json: {} }
|
|
}
|
|
return { json: [] }
|
|
}
|
|
return g
|
|
}
|
|
|
|
test('gates in a dry run creates, edits, comments and writes nothing', async () => {
|
|
const r = rig()
|
|
const g = fakeGitea()
|
|
const deps = fakeDeps({ handler: g.handler, panel: () => ({ servers: async () => { throw new Error('panel down') } }) })
|
|
const d = await c.gates({ ...r.a, 'dry-run': true }, deps)
|
|
assert.ok(deps.calls.length > 0)
|
|
assert.ok(deps.calls.every((x) => x.method === 'GET'), JSON.stringify(deps.calls.filter((x) => x.method !== 'GET')))
|
|
assert.equal(fs.readFileSync(r.state, 'utf8'), '{}')
|
|
assert.equal(d.comment, false)
|
|
assert.equal(g.pr, null)
|
|
assert.equal(g.comments.length, 0)
|
|
// It ran past the failed carbon gate to try the others.
|
|
const run = JSON.parse(fs.readFileSync(path.join(r.dir, 'work', 'cutover-run.json'), 'utf8'))
|
|
assert.equal(run.rows.public.status, 'passed')
|
|
assert.equal(run.rows.oxide.status, 'passed')
|
|
assert.equal(run.rows.carbon.status, 'failed')
|
|
assert.equal(run.rows.checks.status, 'failed')
|
|
})
|
|
|
|
test('a workflow_dispatch without dry_run=false is dry even without the flag', async () => {
|
|
const r = rig()
|
|
const g = fakeGitea()
|
|
const deps = fakeDeps({ handler: g.handler, env: { GITHUB_EVENT_NAME: 'workflow_dispatch' }, panel: () => ({ servers: async () => { throw new Error('down') } }) })
|
|
await c.gates(r.a, deps)
|
|
assert.ok(deps.calls.every((x) => x.method === 'GET'))
|
|
assert.equal(fs.readFileSync(r.state, 'utf8'), '{}')
|
|
})
|
|
|
|
test('a real run keeps each gate, opens the PR, comments once, and cools a failed carbon for three hours', async () => {
|
|
const r = rig()
|
|
const g = fakeGitea()
|
|
let panelCalls = 0
|
|
const deps = fakeDeps({ handler: g.handler, panel: () => ({ servers: async () => { panelCalls++; throw new Error('panel down') } }) })
|
|
|
|
await c.gates(r.a, deps) // 18:11
|
|
let s = r.read().cutover
|
|
assert.equal(s.edgeSha, 'sha1')
|
|
assert.deepEqual(Object.keys(s.gates).sort(), ['oxide', 'public'])
|
|
assert.match(s.failed.carbon.reason, /panel down/)
|
|
assert.equal(s.pr, 5)
|
|
assert.equal(s.lastComment, '2026-11-05')
|
|
assert.equal(panelCalls, 1)
|
|
assert.ok(g.pr.title.startsWith('chore(release): RunicNPC cutover for the 2026-11-05 wipe, Rust 8140'))
|
|
assert.ok(g.pr.body.includes('<!-- cutover:start -->'))
|
|
assert.equal(g.comments.length, 1)
|
|
assert.match(g.comments[0], /not try this gate again before 2026-11-05 21:11 UTC/)
|
|
assert.ok(deps.calls.filter((x) => x.method !== 'GET').every((x) => x.auth === 'token reg-token'))
|
|
|
|
deps.at = '2026-11-05T19:11:00Z'
|
|
await c.gates(r.a, deps)
|
|
assert.equal(panelCalls, 1, 'cooling: the rig is not touched')
|
|
deps.at = '2026-11-05T21:10:00Z'
|
|
await c.gates(r.a, deps)
|
|
assert.equal(panelCalls, 1, '2 h 59 min')
|
|
assert.equal(g.comments.length, 1, 'one comment a day')
|
|
deps.at = '2026-11-05T21:11:00Z'
|
|
await c.gates(r.a, deps)
|
|
assert.equal(panelCalls, 2, '3 h 00 min: it tries again')
|
|
|
|
// New code: everything again, at once.
|
|
deps.git = (...a) => (a[0] === 'rev-parse' ? 'sha2' : GIT(...a))
|
|
deps.at = '2026-11-05T21:30:00Z'
|
|
await c.gates(r.a, deps)
|
|
s = r.read().cutover
|
|
assert.equal(panelCalls, 3)
|
|
assert.equal(s.edgeSha, 'sha2')
|
|
assert.ok(s.gates.public && s.gates.oxide)
|
|
})
|
|
|
|
test('a PR call that fails never loses a gate that passed', async () => {
|
|
const r = rig()
|
|
const g = fakeGitea()
|
|
g.failCreate = true
|
|
const deps = fakeDeps({ handler: g.handler, panel: () => ({ servers: async () => { throw new Error('panel down') } }) })
|
|
await c.gates(r.a, deps)
|
|
const s = r.read().cutover
|
|
assert.ok(s.gates.public)
|
|
assert.ok(s.gates.oxide)
|
|
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)
|
|
})
|
|
|
|
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: {} }) })
|
|
const ctx = { g: c.gitea('t', deps), deps, dryRun: false, edgeSha: 'aaaaaaaa1', pr: { number: 3 } }
|
|
assert.match(await c.checksGate(ctx), /head zzzzzzzz is not the commit the gates ran on \(aaaaaaaa\)/)
|
|
assert.ok(!deps.calls.some((x) => x.p.includes('/status')))
|
|
})
|
|
|
|
test('merge sends Do=merge, keeps edge, names the head commit, and verifies it merged', async () => {
|
|
let merged = false
|
|
const deps = fakeDeps({
|
|
handler: (method, p, body) => {
|
|
if (method === 'POST' && p === '/pulls/7/merge') {
|
|
merged = true
|
|
return {}
|
|
}
|
|
if (method === 'GET' && p === '/pulls/7') return { json: { number: 7, head: { sha: 'abc123' }, merged } }
|
|
return {}
|
|
},
|
|
})
|
|
await c.merge({ pr: '7', sha: 'abc123' }, deps)
|
|
const post = deps.calls.find((x) => x.method === 'POST')
|
|
assert.deepEqual(post.body, { Do: 'merge', delete_branch_after_merge: false, head_commit_id: 'abc123' })
|
|
assert.equal(post.auth, 'token merge-token')
|
|
assert.equal(deps.calls[deps.calls.length - 1].method, 'GET')
|
|
})
|
|
|
|
test('merge refuses a moved head, and a merge that did not happen, and records why', async () => {
|
|
const r = rig()
|
|
fs.writeFileSync(r.state, JSON.stringify({ cutover: { wipe: '2026-11-05' } }))
|
|
const moved = fakeDeps({ handler: (m, p) => (p === '/pulls/7' ? { json: { head: { sha: 'other' }, merged: false } } : {}) })
|
|
await assert.rejects(c.merge({ pr: '7', sha: 'abc123', state: r.state }, moved), /not the commit the gates ran on/)
|
|
assert.ok(!moved.calls.some((x) => x.method === 'POST'))
|
|
assert.match(r.read().cutover.mergeError, /not the commit the gates ran on/)
|
|
assert.equal(r.read().cutover.wipe, '2026-11-05')
|
|
|
|
const unmerged = fakeDeps({ handler: (m, p) => (p === '/pulls/7' ? { json: { head: { sha: 'abc123' }, merged: false } } : {}) })
|
|
await assert.rejects(c.merge({ pr: '7', sha: 'abc123' }, unmerged), /not merged after the merge call/)
|
|
const refused = fakeDeps({ handler: (m, p) => (m === 'POST' ? { status: 409, json: { message: 'head changed' } } : { json: { head: { sha: 'abc123' } } }) })
|
|
await assert.rejects(c.merge({ pr: '7', sha: 'abc123' }, refused), /409/)
|
|
await assert.rejects(c.merge({ pr: '7' }, refused), /--sha/)
|
|
})
|
|
|
|
test('after records the merge, comments the release on each closed issue once, and is safe to repeat', async () => {
|
|
const r = rig()
|
|
fs.writeFileSync(r.state, JSON.stringify({ cutover: { wipe: '2026-11-05', mergeError: 'old', gates: {} } }))
|
|
const posted = {}
|
|
const deps = fakeDeps({
|
|
handler: (method, p, body) => {
|
|
if (p === '/pulls/5') return { json: { number: 5, merged: true, merged_at: '2026-11-05T20:00:00Z', body: 'Closes #20\nCloses #22' } }
|
|
if (p.startsWith('/releases')) return { json: [{ tag_name: 'v1.2.0', created_at: '2026-11-05T20:05:00Z' }, { tag_name: 'v1.1.0', created_at: '2026-10-01T00:00:00Z' }] }
|
|
if (p === '/issues/20') return { json: { state: 'closed' } }
|
|
if (p === '/issues/22') return { json: { state: 'open' } }
|
|
if (method === 'GET' && p === '/issues/20/comments') return { json: (posted[20] || []).map((b) => ({ body: b })) }
|
|
if (method === 'POST' && p === '/issues/20/comments') {
|
|
posted[20] = [...(posted[20] || []), body.body]
|
|
return {}
|
|
}
|
|
return { json: [] }
|
|
},
|
|
})
|
|
await c.after({ state: r.state, pr: '5' }, deps)
|
|
await c.after({ state: r.state, pr: '5' }, deps)
|
|
assert.deepEqual(posted[20], ['Released in v1.2.0 by the wipe cutover (D332).'])
|
|
assert.equal(deps.calls.filter((x) => x.method === 'POST').length, 1)
|
|
assert.ok(!deps.calls.some((x) => x.p.startsWith('/issues/22/comments')))
|
|
assert.equal(r.read().cutover.merged, true)
|
|
assert.equal(r.read().cutover.mergeError, undefined)
|
|
})
|
|
|
|
test('the Closes list is the PRs merged into edge after the newest edge commit already in main', async () => {
|
|
const prs = [
|
|
{ merged: true, base: { ref: 'edge' }, merged_at: '2026-10-20T00:00:00Z', body: 'Closes #1' },
|
|
{ merged: true, base: { ref: 'edge' }, merged_at: '2026-10-25T00:00:00Z', body: 'Closes #2, fixes #6' },
|
|
{ merged: true, base: { ref: 'main' }, merged_at: '2026-10-26T00:00:00Z', body: 'Closes #3' },
|
|
{ merged: false, base: { ref: 'edge' }, merged_at: null, body: 'Closes #4' },
|
|
{ merged: true, base: { ref: 'edge' }, merged_at: '2026-10-30T00:00:00Z', body: 'Refs #5' },
|
|
]
|
|
const asked = []
|
|
const deps = fakeDeps({
|
|
handler: (m, p) => (p.startsWith('/pulls?state=closed') ? { json: prs } : {}),
|
|
git: (...a) => {
|
|
asked.push(a.join(' '))
|
|
if (a[0] === 'merge-base') return 'deadbeef'
|
|
if (a[0] === 'log') return '2026-10-22T09:00:00+00:00'
|
|
return ''
|
|
},
|
|
})
|
|
assert.deepEqual(await c.closesSinceCutOff(c.gitea('t', deps), deps), [2, 6])
|
|
assert.ok(asked.includes('merge-base origin/main origin/edge'))
|
|
assert.ok(asked.includes('log -1 --format=%cI deadbeef'))
|
|
})
|
|
|
|
test('a PR whose merge commit main already has is not listed again, though merged a moment after the cut-off', async () => {
|
|
const prs = [
|
|
// edge's tip at the last cutover: its merged_at is a second after the merge commit's committer date.
|
|
{ merged: true, base: { ref: 'edge' }, merged_at: '2026-11-05T19:00:01Z', merge_commit_sha: 'shipped1', body: 'Closes #20' },
|
|
{ merged: true, base: { ref: 'edge' }, merged_at: '2026-11-20T00:00:00Z', merge_commit_sha: 'new1', body: 'Closes #30' },
|
|
]
|
|
const deps = fakeDeps({
|
|
handler: (m, p) => (p.startsWith('/pulls?state=closed') ? { json: prs } : {}),
|
|
git: (...a) => {
|
|
if (a[0] === 'merge-base') return 'shipped1'
|
|
if (a[0] === 'log') return '2026-11-05T19:00:00+00:00'
|
|
if (a[0] === 'branch') return a[3] === 'shipped1' ? ' origin/main' : ''
|
|
return ''
|
|
},
|
|
})
|
|
assert.deepEqual(await c.closesSinceCutOff(c.gitea('t', deps), deps), [30])
|
|
})
|
|
|
|
test('rust-carbon is started again only when it was running at the start and is offline now, and nothing else is touched', async () => {
|
|
const dir = tmp()
|
|
const powered = []
|
|
const mkPanel = (state) => () => ({
|
|
servers: async () => ({ 'rust-carbon': 'abc', 'rust-oxide': 'def' }),
|
|
state: async () => state,
|
|
power: async (id, signal) => powered.push([id, signal]),
|
|
})
|
|
await c.rigNote({ work: dir }, { panel: mkPanel('running') })
|
|
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'rig-carbon.json'), 'utf8')), { name: 'rust-carbon', state: 'running' })
|
|
await c.rigRestore({ work: dir }, { panel: mkPanel('running') })
|
|
await c.rigRestore({ work: dir }, { panel: mkPanel('starting') })
|
|
assert.deepEqual(powered, [])
|
|
await c.rigRestore({ work: dir }, { panel: mkPanel('offline') })
|
|
assert.deepEqual(powered, [['abc', 'start']])
|
|
// It was off at the start: left off.
|
|
await c.rigNote({ work: dir }, { panel: mkPanel('offline') })
|
|
await c.rigRestore({ work: dir }, { panel: mkPanel('offline') })
|
|
assert.equal(powered.length, 1)
|
|
})
|
|
|
|
// ---- the workflow and the scripts agree
|
|
|
|
const yml = fs.readFileSync(path.join(__dirname, '..', '..', '.gitea', 'workflows', 'cutover.yml'), 'utf8')
|
|
const src = fs.readFileSync(path.join(__dirname, 'cutover.js'), 'utf8')
|
|
|
|
function stepsOf(text) {
|
|
return text
|
|
.split(/\n {6}- (?=name:|uses:)/)
|
|
.slice(1)
|
|
.map((b) => ({ id: (/^\s+id: (\w+)/m.exec(b) || [])[1], command: (/cutover\.js (\S+)/.exec(b) || /drill\.js (\S+)/.exec(b) || [])[1], script: /drill\.js/.test(b) && !/cutover\.js/.test(b) ? 'drill' : 'cutover', text: b }))
|
|
}
|
|
|
|
const FUNCTIONS = { window: 'window_', pending: 'pending', gates: 'gates', merge: 'merge', after: 'after' }
|
|
|
|
function written(command) {
|
|
const m = new RegExp(`\\n(?:async )?function ${FUNCTIONS[command]}\\(`).exec(src)
|
|
assert.ok(m, `cutover.js has no function for ${command}`)
|
|
const body = src.slice(m.index, src.indexOf('\n}\n', m.index))
|
|
return new Set([...body.matchAll(/output\([^,]+,\s*'(\w+)'/g)].map((x) => x[1]))
|
|
}
|
|
|
|
test('every steps.<id>.outputs.<name> the workflow reads is written by the command that step runs', () => {
|
|
const steps = stepsOf(yml)
|
|
const used = [...yml.matchAll(/steps\.(\w+)\.outputs\.(\w+)/g)].map((m) => [m[1], m[2]])
|
|
assert.ok(used.length >= 8)
|
|
for (const [id, name] of used) {
|
|
const step = steps.find((s) => s.id === id)
|
|
assert.ok(step, `steps.${id} is not a step of cutover.yml`)
|
|
assert.equal(step.script, 'cutover', `steps.${id}.outputs.${name}: ${id} runs drill.js, which writes no output the workflow reads`)
|
|
assert.ok(written(step.command).has(name), `cutover.js ${step.command} never writes output ${name} (read as steps.${id}.outputs.${name})`)
|
|
}
|
|
})
|
|
|
|
test('and the outputs the logic depends on are all read by the workflow', () => {
|
|
const steps = stepsOf(yml)
|
|
const needed = { window: ['open'], pending: ['run'], gates: ['live', 'merge', 'pr', 'sha'], merge: ['merged'] }
|
|
for (const [command, names] of Object.entries(needed)) {
|
|
const ids = steps.filter((s) => s.script === 'cutover' && s.command === command && s.id).map((s) => s.id)
|
|
assert.ok(ids.length > 0, `no step with an id runs cutover.js ${command}`)
|
|
const read = new Set([...yml.matchAll(/steps\.(\w+)\.outputs\.(\w+)/g)].filter((m) => ids.includes(m[1])).map((m) => m[2]))
|
|
for (const n of names) {
|
|
assert.ok(read.has(n), `cutover.yml never reads ${n} from the ${command} step`)
|
|
assert.ok(written(command).has(n), `cutover.js ${command} no longer writes ${n}`)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('the code checked out is edge, except on a dry dispatch that names code_ref', () => {
|
|
const refs = yml.split('\n').filter((l) => /^\s*ref:/.test(l)).map((l) => l.trim())
|
|
assert.deepEqual(refs, [
|
|
"ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'false' && github.event.inputs.code_ref || 'edge' }}",
|
|
])
|
|
})
|
|
|
|
test('RUNICNPC_TOKEN is in the merge step alone, and a dispatch is dry unless dry_run is exactly false', () => {
|
|
const uses = yml.split('\n').filter((l) => !l.trim().startsWith('#') && l.includes('RUNICNPC_TOKEN'))
|
|
assert.equal(uses.length, 1)
|
|
const merge = stepsOf(yml).find((s) => s.text.split('\n').some((l) => !l.trim().startsWith('#') && l.includes('RUNICNPC_TOKEN')))
|
|
assert.equal(merge.id, 'merge')
|
|
assert.match(merge.text, /MERGE_TOKEN: \$\{\{ secrets\.RUNICNPC_TOKEN \}\}/)
|
|
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`)
|
|
assert.match(code, /timeout-minutes: 240/)
|
|
})
|