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

This commit is contained in:
CRTkafa 2026-10-16 07:17:19 +08:00
parent b84538e4cb
commit 9059b2b32a
5 changed files with 69 additions and 10 deletions

View file

@ -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

12
tests/ci-status.test.ts Normal file
View file

@ -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');
});

41
tools/ci-status.ts Normal file
View file

@ -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<TestState> {
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);
}

View file

@ -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<Task[]> {
export async function tasks(): Promise<Task[]> {
const all: Task[] = [];
for (let page = 1; page <= 3; page++) {
const r = await api<{ workflow_runs?: Task[] }>('GET', `/actions/tasks?limit=50&page=${page}`);

View file

@ -275,6 +275,12 @@ async function main(): Promise<void> {
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; }