diff --git a/.gitea/workflows/cutover.yml b/.gitea/workflows/cutover.yml index 06a7c94..4ec300c 100644 --- a/.gitea/workflows/cutover.yml +++ b/.gitea/workflows/cutover.yml @@ -92,8 +92,9 @@ env: jobs: cutover: runs-on: ubuntu-latest - # Carbon's restart and navmesh, the live half (about an hour), the PR checks. - timeout-minutes: 180 + # Carbon's restart and navmesh, the live half (about an hour), the PR checks: about 100 minutes, 200 at the + # worst. Room for that, so a timeout never cuts off the step that starts rust-carbon again. + timeout-minutes: 240 env: WORK: ${{ github.workspace }}/cutover-work STAGING_WORK: ${{ github.workspace }}/cutover-staging diff --git a/tools/drill/cutover.js b/tools/drill/cutover.js index 85d8746..0e8e13d 100644 --- a/tools/drill/cutover.js +++ b/tools/drill/cutover.js @@ -451,11 +451,21 @@ async function closesSinceCutOff(g, deps) { try { since = Date.parse(deps.git('log', '-1', '--format=%cI', deps.git('merge-base', 'origin/main', 'origin/edge'))) || 0 } catch {} + // A PR whose merge commit main already has shipped in an earlier cutover, even when its merged_at is a moment + // after the cut-off's committer date (edge's tip is often that very merge commit). + const shipped = (sha) => { + if (!sha) return false + try { + return /\borigin\/main\b/.test(deps.git('branch', '-r', '--contains', sha, '--list', 'origin/main')) + } catch { + return false + } + } const bodies = [] for (let page = 1; page <= 10; page++) { const prs = await g.call('GET', `/pulls?state=closed&limit=50&page=${page}`) for (const pr of prs) { - if (pr.merged && pr.base && pr.base.ref === 'edge' && Date.parse(pr.merged_at) > since) bodies.push(pr.body) + if (pr.merged && pr.base && pr.base.ref === 'edge' && Date.parse(pr.merged_at) > since && !shipped(pr.merge_commit_sha)) bodies.push(pr.body) } if (prs.length < 50) break } diff --git a/tools/drill/cutover.test.js b/tools/drill/cutover.test.js index 89ea734..5cda7b5 100644 --- a/tools/drill/cutover.test.js +++ b/tools/drill/cutover.test.js @@ -676,6 +676,24 @@ test('the Closes list is the PRs merged into edge after the newest edge commit a 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 = [] @@ -744,6 +762,13 @@ test('and the outputs the logic depends on are all read by the workflow', () => } }) +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) @@ -754,5 +779,5 @@ test('RUNICNPC_TOKEN is in the merge step alone, and a dispatch is dry unless dr 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: 180/) + assert.match(code, /timeout-minutes: 240/) })