diff --git a/docs/ci-audit.md b/docs/ci-audit.md index 16c38f31e..88ad9c301 100644 --- a/docs/ci-audit.md +++ b/docs/ci-audit.md @@ -82,7 +82,8 @@ Turn it off with `gstack-config set ci_audit_ship off`. `lib/ci-audit/templates/ci-audit-weekly.yml` runs the full report every Saturday at 15:00 UTC and keeps one open "Weekly CI audit" issue current. A complete audit creates or replaces the issue, or closes it when nothing is left to fix. A partial or failed audit comments -on the issue and keeps the last complete findings. The issue is matched by its title and -a marker line, so an issue a person opened with the same title is left alone. It reads +on the issue and keeps the last complete findings. The issue is matched by its author +(`github-actions[bot]`, the workflow's own token), its title and a marker line, so an +issue anyone else opened, even with the same title and marker, is left alone. It reads with the workflow's `GITHUB_TOKEN` (1,000 calls an hour), so it runs with `--max-calls 400`. diff --git a/lib/ci-audit/publish.ts b/lib/ci-audit/publish.ts index 035dacd69..21fd912b2 100644 --- a/lib/ci-audit/publish.ts +++ b/lib/ci-audit/publish.ts @@ -45,11 +45,16 @@ export function issueBody(r: AuditReport, runUrl?: string): string { return head + (md.length > MAX_BODY ? md.slice(0, MAX_BODY) + '\n\n(truncated; the full report is the run artifact)' : md); } -/** The open issue this audit owns: its title and marker, so a person's issue with the same title is left alone. */ +export const ISSUE_AUTHOR = 'github-actions[bot]'; + +/** + * The open issue this audit owns: opened by the workflow's own token, with its title and marker. + * Anyone can copy the title and marker, so an issue another account opened is never touched. + */ export async function findIssue(client: ApiClient, repo: string): Promise { - const res = await client.get(`/repos/${repo}/issues?state=open&per_page=100&sort=created&direction=asc`); + const res = await client.get(`/repos/${repo}/issues?state=open&creator=${encodeURIComponent(ISSUE_AUTHOR)}&per_page=100&sort=created&direction=desc`); if (res.status !== 200) throw new AuditError('ci_audit_api_failed', `issues list returned HTTP ${res.status}`, 'now'); - const hit = ((res.json as any[]) ?? []).find(i => !i.pull_request && i.title === ISSUE_TITLE && String(i.body ?? '').includes(ISSUE_MARKER)); + const hit = ((res.json as any[]) ?? []).find(i => !i.pull_request && i.user?.login === ISSUE_AUTHOR && i.title === ISSUE_TITLE && String(i.body ?? '').includes(ISSUE_MARKER)); return hit ? { number: Number(hit.number), body: String(hit.body) } : null; } diff --git a/test/ci-audit.test.ts b/test/ci-audit.test.ts index 296e4df07..1fbb319ff 100644 --- a/test/ci-audit.test.ts +++ b/test/ci-audit.test.ts @@ -7,7 +7,7 @@ import type { Run } from '../lib/ci-audit/collect'; import { concurrencyFlag, lessonRunners } from '../lib/ci-audit/lessons'; import { cohort, measure, phases } from '../lib/ci-audit/measure'; import { priceRunner } from '../lib/ci-audit/prices'; -import { ISSUE_MARKER, planIssue } from '../lib/ci-audit/publish'; +import { ISSUE_MARKER, ISSUE_TITLE, findIssue, planIssue } from '../lib/ci-audit/publish'; import type { AuditReport } from '../lib/ci-audit/report'; import { CI_SHAPING, MIN_COHORT, comparable, judgeHead, staticChecks } from '../lib/ci-audit/ship-check'; import { parseWorkflow, readStatic } from '../lib/ci-audit/static'; @@ -263,6 +263,20 @@ describe('weekly issue', () => { expect(planIssue({ exit: 3, report: null, error: 'CI_AUDIT: unavailable x (ci_audit_deadline)' }, null).action).toBe('none'); expect(planIssue({ exit: 4, report: { ...withFixes, partial: ['x'] } as AuditReport }, null).action).toBe('create'); }); + + test('finds only the workflow bot\'s own issue, past 100 open issues and an outsider\'s copy', async () => { + const issue = (number: number, login: string, title = 'other', body = '') => ({ number, title, body, user: { login } }); + const open = [issue(1, 'outsider', ISSUE_TITLE, `${ISSUE_MARKER}\nfake findings`), + ...Array.from({ length: 150 }, (_, i) => issue(i + 2, 'person')), + issue(152, 'github-actions[bot]', ISSUE_TITLE, `${ISSUE_MARKER}\nreal findings`)]; + const client = { text: async () => null, get: async (apiPath: string) => { + const q = new URL(apiPath, 'https://api.github.com').searchParams; + const rows = open.filter(i => !q.get('creator') || i.user.login === q.get('creator')); + if (q.get('direction') !== 'asc') rows.reverse(); + return { status: 200, json: rows.slice(0, Number(q.get('per_page') ?? 30)), headers: {} }; + } }; + expect(await findIssue(client as never, 'o/n')).toEqual({ number: 152, body: `${ISSUE_MARKER}\nreal findings` }); + }); }); describe('Saturday workflow template', () => {