From 080268fb43c784991e67cacc96acf60691744f67 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 18:55:12 +0000 Subject: [PATCH] ci-audit: the weekly issue is the workflow bot's own (creator filter, newest first) findIssue read the first 100 open issues oldest first and matched any issue with the title and marker, so an outsider's copy won the match and a repo with more than 100 open issues got a new audit issue every week. It now lists only github-actions[bot] issues, newest first, and requires that author. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/ci-audit.md | 5 +++-- lib/ci-audit/publish.ts | 11 ++++++++--- test/ci-audit.test.ts | 16 +++++++++++++++- 3 files changed, 26 insertions(+), 6 deletions(-) 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', () => {