From 9059b2b32afe661b126c1950bb76e90696a6172f Mon Sep 17 00:00:00 2001 From: CRT Date: Fri, 16 Oct 2026 07:17:19 +0800 Subject: [PATCH] background tests: ci.yml runs the CI and the acceptance matrix on every push again but nothing waits for them (the gate no longer waits for the parent's run, which a release's own bump push cancels; the record job needs only the installers); tools/ci-status.ts reports main's newest test run and release.ts prints a red one as a warning --- .forgejo/workflows/ci.yml | 16 +++++++-------- tests/ci-status.test.ts | 12 +++++++++++ tools/ci-status.ts | 41 ++++++++++++++++++++++++++++++++++++++ tools/release-candidate.ts | 4 ++-- tools/release.ts | 6 ++++++ 5 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 tests/ci-status.test.ts create mode 100644 tools/ci-status.ts diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index ddf4ff2..09f0171 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -50,15 +50,15 @@ jobs: EVENT: ${{ github.event_name }} SHA: ${{ github.sha }} run: | - if [ "$EVENT" = pull_request ]; then echo "reuse=false" >> "$GITHUB_OUTPUT"; echo "pull request: everything runs"; exit 0; fi - curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null - export PATH="$HOME/.bun/bin:$PATH" - bun tools/release-candidate.ts gate --sha "$SHA" --wait 40 + # tests never gate a release (the owner's decision 2026-10-16): no waiting on the parent's run, which the + # release's own bump push cancels; every commit builds its installers and runs the tests in the background + echo "reuse=false" >> "$GITHUB_OUTPUT"; echo "from=" >> "$GITHUB_OUTPUT"; echo "tests run in the background; installers build for this commit" - # off by the owner's decision (2026-10-15: no CI, no acceptance runs); the jobs stay for a manual comeback + # background tests (the owner's decision 2026-10-16): they run on every push and report, but the record job (the + # release candidate) never waits for them; `bun tools/release.ts` and `bun tools/ci-status.ts` print a red result ci: needs: gate - if: false + if: github.event_name != 'pull_request' || true # runner label: ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04 runs-on: ubuntu-22.04 timeout-minutes: 60 @@ -78,7 +78,7 @@ jobs: # (tools/release.ts, cached per CLI key). acceptance: needs: gate - if: false + if: github.event_name != 'pull_request' || true strategy: fail-fast: false matrix: @@ -288,7 +288,7 @@ jobs: retention-days: 7 record: - needs: [gate, ci, acceptance, linux, windows] + needs: [gate, linux, windows] if: ${{ always() && github.event_name != 'pull_request' && needs.gate.result == 'success' && needs.linux.result == 'success' && needs.windows.result == 'success' }} runs-on: ubuntu-22.04 timeout-minutes: 30 diff --git a/tests/ci-status.test.ts b/tests/ci-status.test.ts new file mode 100644 index 0000000..4810e42 --- /dev/null +++ b/tests/ci-status.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from 'bun:test'; +import { testState } from '../tools/ci-status'; + +const t = (name: string, status: string, run: number, sha: string) => ({ id: 1, name, run_number: run, workflow_id: 'ci.yml', head_sha: sha, status }); +const url = (n: number) => `run/${n}`; + +test('background test state: newest commit with test jobs; a cancelled run falls back to the older commit', () => { + expect(testState([t('ci', 'success', 2, 'b'), t('acceptance (bun)', 'failure', 2, 'b'), t('linux', 'success', 2, 'b')], ['b'], url)).toEqual({ sha: 'b', run: 'run/2', state: 'red', failed: ['acceptance (bun)'], running: [] }); + expect(testState([t('ci', 'cancelled', 3, 'c'), t('ci', 'success', 2, 'b')], ['c', 'b'], url).state).toBe('green'); + expect(testState([t('ci', 'running', 3, 'c')], ['c'], url)).toMatchObject({ state: 'running', running: ['ci'] }); + expect(testState([t('linux', 'success', 3, 'c')], ['c'], url).state).toBe('none'); +}); diff --git a/tools/ci-status.ts b/tools/ci-status.ts new file mode 100644 index 0000000..a287720 --- /dev/null +++ b/tools/ci-status.ts @@ -0,0 +1,41 @@ +#!/usr/bin/env bun +// The background tests of main (ci.yml's ci and acceptance jobs): they never gate a release (the owner's decision, +// 2026-10-16), so this reports them. Check it at the start of a session and fix a red result in the next patch. +// bun tools/ci-status.ts the newest main run that has test jobs: green, red (which jobs) or running +// bun tools/ci-status.ts --json +import { spawnSync } from 'node:child_process'; +import { runUrl, tasks, type Task } from './release-candidate'; + +const TEST = /^(ci|acceptance\b)/; + +export interface TestState { sha: string; run: string; state: 'green' | 'red' | 'running' | 'none'; failed: string[]; running: string[] } + +/** The state of the newest run (of the given commits, newest first) that has test jobs. Pure. */ +export function testState(all: Task[], shas: string[], url: (n: number) => string): TestState { + for (const sha of shas) { + const mine = all.filter(t => t.head_sha === sha && t.workflow_id === 'ci.yml' && TEST.test(t.name)); + if (!mine.length) continue; + const latest = Math.max(...mine.map(t => t.run_number)); + const jobs = mine.filter(t => t.run_number === latest); + // a run cancelled by a newer push proves nothing: look at the older commit + if (jobs.every(t => t.status === 'cancelled')) continue; + const failed = jobs.filter(t => t.status === 'failure').map(t => t.name); + const running = jobs.filter(t => !['success', 'skipped', 'failure', 'cancelled'].includes(t.status)).map(t => t.name); + return { sha, run: url(latest), state: failed.length ? 'red' : running.length ? 'running' : 'green', failed, running }; + } + return { sha: shas[0] ?? '', run: '', state: 'none', failed: [], running: [] }; +} + +export async function mainTests(): Promise { + spawnSync('git', ['fetch', '-q', 'origin', 'main']); + const shas = (spawnSync('git', ['log', '-20', '--format=%H', 'origin/main'], { encoding: 'utf8' }).stdout ?? '').split('\n').filter(Boolean); + return testState(await tasks(), shas, runUrl); +} + +if (import.meta.main) { + const s = await mainTests(); + if (process.argv.includes('--json')) console.log(JSON.stringify(s, null, 2)); + else console.log(s.state === 'none' ? 'no test run on the last 20 commits of main' + : `${s.state}: ${s.sha.slice(0, 10)} ${s.run}${s.failed.length ? ` (failed: ${s.failed.join(', ')})` : ''}${s.running.length ? ` (running: ${s.running.join(', ')})` : ''}`); + process.exit(s.state === 'red' ? 1 : 0); +} diff --git a/tools/release-candidate.ts b/tools/release-candidate.ts index a1cdea8..414c181 100644 --- a/tools/release-candidate.ts +++ b/tools/release-candidate.ts @@ -117,7 +117,7 @@ function fail(msg: string): never { console.error(`release-candidate: ${msg}`); const t0 = Date.now(); const say = (m: string) => console.log(`[${String(Math.round((Date.now() - t0) / 1000)).padStart(4)}s] ${m}`); const sha256 = (f: string) => createHash('sha256').update(readFileSync(f)).digest('hex'); -const runUrl = (n: number) => `${BASE}/${REPO}/actions/runs/${n}`; +export const runUrl = (n: number) => `${BASE}/${REPO}/actions/runs/${n}`; function git(args: string[], allowFail = false): string { const r = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 << 20 }); @@ -135,7 +135,7 @@ export function changesBetween(base: string, head: string): Change[] { }); } -async function tasks(): Promise { +export async function tasks(): Promise { const all: Task[] = []; for (let page = 1; page <= 3; page++) { const r = await api<{ workflow_runs?: Task[] }>('GET', `/actions/tasks?limit=50&page=${page}`); diff --git a/tools/release.ts b/tools/release.ts index 2e82dbf..de981a5 100644 --- a/tools/release.ts +++ b/tools/release.ts @@ -275,6 +275,12 @@ async function main(): Promise { push ${branch}, push ${tag}; then in parallel: Mac build + notarize -> ${out.replace(HOME, '~')}; macOS acceptance (${accCached ? 'cached' : 'runs now'}, key ${key.slice(0, 12)}); Forgejo ci (candidate of the bump commit) + release.yml (promotion); add, verify, publish to ${rehearsal ? 'the rehearsal channel' : 'stable'} via ${host}; ${rehearsal ? 'app copied to a scratch folder; then the tag, release, candidates, branch and storebound.dev files of the rehearsal are deleted' : 'update /Applications/storebound.app; print npm publish + npm deprecate'}`); + // background tests never gate a release; a red result is printed so the next patch fixes it + try { + const { mainTests } = await import('./ci-status'); + const t = await mainTests(); + if (t.state === 'red') say(`WARNING: background tests are red on ${t.sha.slice(0, 10)} (${t.failed.join(', ')}): ${t.run}; releasing anyway, fix it in the next patch`); + } catch (e) { say(`background tests: not read (${(e as Error).message.slice(0, 120)})`); } // the dry run catches what the bump would refuse if (!rehearsal && !resume) { try { cutChangelog(readFileSync(join(ROOT, 'CHANGELOG.md'), 'utf8'), version, '2000-01-01'); } catch (e) { fail((e as Error).message); } } if (!yes) { console.log('\ndry run: nothing changed (--yes to release)'); return; }