From 77f141e223461b21ff0ded9fb6b0d779aaa200c1 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 15 Oct 2026 02:31:15 +0800 Subject: [PATCH] release: a cancelled parent run is not a failure (a version-only child runs everything instead of stopping at the gate); a queued run gets 20 min before 'no ci run'; release.ts --resume continues a release that stopped after its push (the history rewrite re-pushed every old tag, their release.yml runs took the runners and cancelled 0.8.5's parent) --- tests/release-pipeline.test.ts | 4 +++- tools/release-candidate.ts | 12 +++++++----- tools/release.ts | 28 +++++++++++++++++++--------- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/tests/release-pipeline.test.ts b/tests/release-pipeline.test.ts index 0714f9e..9022051 100644 --- a/tests/release-pipeline.test.ts +++ b/tests/release-pipeline.test.ts @@ -38,7 +38,9 @@ describe('candidate state from the ci.yml jobs', () => { test('none, pending, red; the newest run counts', () => { expect(taskState([t('ci', 'success', 5, 'b'.repeat(40))], SHA, url)).toEqual({ state: 'none' }); expect(taskState([t('ci', 'success'), t('windows', 'running')], SHA, url)).toEqual({ state: 'pending', run: 'run/5', running: ['windows (running)'] }); - expect(taskState([t('ci', 'success'), t('acceptance (bun)', 'failure')], SHA, url)).toEqual({ state: 'red', run: 'run/5', failed: ['acceptance (bun)'] }); + expect(taskState([t('ci', 'success'), t('acceptance (bun)', 'failure')], SHA, url)).toEqual({ state: 'red', run: 'run/5', failed: ['acceptance (bun)'], cancelledOnly: false }); + // a cancelled run (old-tag runs freed, superseded) is not a failure: a version-only child runs everything + expect(taskState([t('ci', 'success'), t('linux', 'cancelled')], SHA, url)).toEqual({ state: 'red', run: 'run/5', failed: ['linux'], cancelledOnly: true }); expect(taskState([t('ci', 'failure', 4), t('ci', 'running', 6)], SHA, url).state).toBe('pending'); expect(taskState([t('promote', 'failure', 9, SHA, 'release.yml')], SHA, url)).toEqual({ state: 'none' }); }); diff --git a/tools/release-candidate.ts b/tools/release-candidate.ts index e955429..170ca43 100644 --- a/tools/release-candidate.ts +++ b/tools/release-candidate.ts @@ -86,7 +86,7 @@ export function versionOnlyProblems(changes: Change[]): { from: string | null; t export type Status = | { state: 'green'; record: GreenRecord; release: Release } - | { state: 'red'; run: string; failed: string[] } + | { state: 'red'; run: string; failed: string[]; cancelledOnly?: boolean } | { state: 'pending'; run: string; running: string[] } | { state: 'none' }; @@ -99,7 +99,8 @@ export function taskState(tasks: Task[], sha: string, runUrl: (n: number) => str const latest = Math.max(...mine.map(t => t.run_number)); const jobs = mine.filter(t => t.run_number === latest); const failed = jobs.filter(t => ['failure', 'cancelled'].includes(t.status)).map(t => t.name); - if (failed.length) return { state: 'red', run: runUrl(latest), failed }; + // a run that was only cancelled (superseded, runners freed) proves nothing either way + if (failed.length) return { state: 'red', run: runUrl(latest), failed, cancelledOnly: !jobs.some(t => t.status === 'failure') }; const running = jobs.filter(t => !['success', 'skipped'].includes(t.status)).map(t => `${t.name} (${t.status})`); // every job done and green but no green.json yet: the record job is still uploading, or ran in an older run return { state: 'pending', run: runUrl(latest), running }; @@ -158,16 +159,16 @@ export async function status(sha: string): Promise { } /** Waits for a commit's candidate: green returns, red and none (after `graceMin`) fail at once. */ -export async function waitGreen(sha: string, timeoutMin: number, graceMin = 3): Promise> { +export async function waitGreen(sha: string, timeoutMin: number, graceMin = 20): Promise> { const end = Date.now() + timeoutMin * 60_000; let last = 0; for (;;) { const s = await status(sha); if (s.state === 'green') { say(`candidate ${sha.slice(0, 10)} is green (run ${s.record.run}${s.record.reusedFrom ? `, CI and acceptance of ${s.record.reusedFrom.sha.slice(0, 10)}` : ''})`); return s; } if (s.state === 'red') fail(`commit ${sha.slice(0, 10)} is red: ${s.failed.join(', ')} failed in ${s.run}. Fix it and push; nothing is released from a red commit.`); - if (s.state === 'none' && Date.now() - t0 > graceMin * 60_000) fail(`commit ${sha.slice(0, 10)} has no ci run on ${BASE}/${REPO}/actions: push it to main (ci.yml makes the candidate)`); + if (s.state === 'none' && Date.now() - t0 > graceMin * 60_000) fail(`commit ${sha.slice(0, 10)} has no ci run on ${BASE}/${REPO}/actions after ${graceMin} min: push it to main (ci.yml makes the candidate), or free the runners (a queued run gets no task until one is free)`); if (Date.now() > end) fail(`timeout after ${timeoutMin} min waiting for the candidate of ${sha.slice(0, 10)}${s.state === 'pending' ? `: ${s.run} still has ${s.running.join(', ') || 'the record upload'}` : ''}`); - if (Date.now() - last >= HEARTBEAT_MS) { last = Date.now(); say(`waiting for candidate ${sha.slice(0, 10)}: ${s.state === 'pending' ? `${s.run}: ${s.running.join(', ') || 'record upload'}` : 'no run yet'}`); } + if (Date.now() - last >= HEARTBEAT_MS) { last = Date.now(); say(`waiting for candidate ${sha.slice(0, 10)}: ${s.state === 'pending' ? `${s.run}: ${s.running.join(', ') || 'record upload'}` : 'no run yet (queued runs get no task until a runner is free)'}`); } await Bun.sleep(10_000); } } @@ -202,6 +203,7 @@ async function gate(sha: string, waitMin: number): Promise { say(`version-only commit: ${from} -> ${to} (every copy exactly re-written, plus changelog / acceptance files); the parent ${parent.slice(0, 10)}'s CI and acceptance matrix count if it is green`); const first = await status(parent); if (first.state === 'none') { say(`the parent has no ci run (pushed together with this commit?): everything runs`); output({ reuse: 'false', from: '' }); return; } + if (first.state === 'red' && first.cancelledOnly) { say(`the parent's run was cancelled (${first.run}), not failed: everything runs`); output({ reuse: 'false', from: '' }); return; } const s = await waitGreen(parent, waitMin); if (s.record.cliKey !== cliKey(ROOT)) { say(`the parent's CLI key ${s.record.cliKey} is not this commit's: everything runs`); output({ reuse: 'false', from: '' }); return; } output({ reuse: 'true', from: parent }); diff --git a/tools/release.ts b/tools/release.ts index 491d204..1207b4d 100644 --- a/tools/release.ts +++ b/tools/release.ts @@ -7,6 +7,8 @@ // rehearsal/: a prerelease on git.crt.fyi and the rehearsal // channel of storebound.dev (no app reads it), never npm and never // /Applications; everything it made is deleted at the end +// bun tools/release.ts --resume --yes after a stop past the push: HEAD is origin/main, tagged v, +// with that version; skips the bump and continues from step 4 // bun tools/release.ts mac-acceptance the macOS acceptance runs for this checkout's CLI key (cached) // // Without --yes: the checks and the plan only. With --yes, in order: @@ -224,12 +226,15 @@ async function main(): Promise { const yes = has('--yes'); if (argv[0] === 'mac-acceptance') { const key = cliKey(ROOT); console.log(await macAcceptance(key, join(RELEASES, 'logs', `acceptance-${key}`))); return; } const rehearsal = has('--rehearsal'); + const resume = has('--resume'); + if (resume && rehearsal) fail('--resume is for a real version'); const positional = argv.filter(a => !a.startsWith('--')); - const current = COPIES[0]!.read(readFileSync(join(ROOT, 'package.json'), 'utf8'))!; + const pkgVersion = COPIES[0]!.read(readFileSync(join(ROOT, 'package.json'), 'utf8'))!; + const current = pkgVersion; const n = rehearsal ? Number(positional[0] ?? Math.floor(Date.now() / 1000)) : 0; const version = rehearsal ? `0.0.0-rehearsal.${n}` : positional[0] ?? fail('usage: bun tools/release.ts [--yes] | --rehearsal [] [--yes]'); if (!SEMVER.test(version)) fail(`${version} is not a version`); - if (!rehearsal && !newer(version, current)) fail(`${version} is not newer than ${current}`); + if (!rehearsal && !resume && !newer(version, current)) fail(`${version} is not newer than ${current}`); const tag = `v${version}`; const branch = rehearsal ? `rehearsal/${n}` : 'main'; const out = join(RELEASES, 'desktop', version); @@ -240,14 +245,19 @@ async function main(): Promise { const remote = remoteSessionReason(process.env, sh(['launchctl', 'managername'], { allowFail: true }).trim()); if (remote) fail(`${remote}: signing and notarization need the owner's logged-in macOS session`); if (!existsSync(join(KEYS, 'updater.key'))) fail(`no updater key in ${KEYS.replace(HOME, '~')}`); - if (sh(['git', 'status', '--porcelain']).trim()) fail('the checkout has uncommitted changes'); + // --resume may run with a fixed release tool (tools/release*.ts), which is not part of what ships + const dirty = sh(['git', 'status', '--porcelain']).split('\n').filter(l => l.trim() && !(resume && /^ M tools\/release(-candidate)?\.ts$/.test(l))); + if (dirty.length) fail('the checkout has uncommitted changes'); sh(['git', 'fetch', '--quiet', 'origin', 'main', '--tags']); const head = sh(['git', 'rev-parse', 'HEAD']).trim(); if (head !== sh(['git', 'rev-parse', 'origin/main']).trim()) fail(`HEAD ${head.slice(0, 10)} is not origin/main: release from main as it is on git.crt.fyi (pull / push first), so the bump's parent is a candidate`); - if (!rehearsal && sh(['git', 'rev-parse', '-q', '--verify', `refs/tags/${tag}`], { allowFail: true }).trim()) fail(`tag ${tag} exists already`); - const parent = await status(head); - if (parent.state === 'red') fail(`origin/main ${head.slice(0, 10)} is red (${parent.failed.join(', ')}): ${parent.run}`); - if (parent.state === 'none') fail(`origin/main ${head.slice(0, 10)} has no ci run: its candidate is needed (push to main runs ci.yml)`); + const tagged = sh(['git', 'rev-parse', '-q', '--verify', `refs/tags/${tag}^{commit}`], { allowFail: true }).trim(); + if (resume) { + if (tagged !== head || pkgVersion !== version) fail(`--resume needs HEAD = origin/main tagged ${tag} with version ${version} (HEAD ${head.slice(0, 10)}, ${tag} ${tagged.slice(0, 10) || 'missing'}, package.json ${pkgVersion})`); + } else if (!rehearsal && tagged) fail(`tag ${tag} exists already (--resume continues a release that stopped after its push)`); + const parent = await status(resume ? sh(['git', 'rev-parse', 'HEAD~1']).trim() : head); + if (parent.state === 'red' && !resume) fail(`origin/main ${head.slice(0, 10)} is red (${parent.failed.join(', ')}): ${parent.run}`); + if (parent.state === 'none' && !resume) fail(`origin/main ${head.slice(0, 10)} has no ci run: its candidate is needed (push to main runs ci.yml)`); say(`storebound ${current} -> ${version}${rehearsal ? ' (rehearsal: prerelease, rehearsal channel, deleted at the end)' : ''}; parent ${head.slice(0, 10)} is ${parent.state}${parent.state === 'pending' ? ` (${parent.run})` : ''}`); // the release commit's author is the one of the commits already on main (the project's identity) const identity = sh(['git', 'log', '-1', '--format=%an <%ae>', 'origin/main']).trim(); @@ -265,8 +275,8 @@ async function main(): Promise { // ---------------------------------------------------------------- 2. + 3. bump, gate, commit, push const startBranch = sh(['git', 'rev-parse', '--abbrev-ref', 'HEAD']).trim(); - let sha = ''; - await phase('bump + fast gate + commit + push', async () => { + let sha = resume ? head : ''; + if (!resume) await phase('bump + fast gate + commit + push', async () => { if (rehearsal) sh(['git', 'checkout', '-q', '-b', branch]); setVersion(version); if (!rehearsal) writeFileSync(join(ROOT, 'CHANGELOG.md'), cutChangelog(readFileSync(join(ROOT, 'CHANGELOG.md'), 'utf8'), version, new Date().toISOString().slice(0, 10)));