diff --git a/.github/rfc-stakeholders b/.github/rfc-stakeholders new file mode 100644 index 0000000..c72a815 --- /dev/null +++ b/.github/rfc-stakeholders @@ -0,0 +1,7 @@ +# Who is mentioned when the rfc label goes on a pull request, by the paths it +# touches. One pattern per line, then the GitHub handles to mention. +# * every change +# dir/ anything under dir +# path that exact file +# The pull request's author is never mentioned. + diff --git a/.github/scripts/rfc.cjs b/.github/scripts/rfc.cjs new file mode 100644 index 0000000..83b6162 --- /dev/null +++ b/.github/scripts/rfc.cjs @@ -0,0 +1,125 @@ +'use strict'; + +// Sets the rfc-moratorium commit status on open pull requests and rewrites +// the pinned "Open RFCs" issue. Run by .github/workflows/rfc.yml; see RFC.md. + +const fs = require('fs'); +const path = require('path'); + +const LABEL = 'rfc'; +const CONTEXT = 'rfc-moratorium'; +const WINDOW_MS = 7 * 24 * 60 * 60 * 1000; +const INDEX_TITLE = 'Open RFCs'; + +function stamp(ms) { + return new Date(ms).toISOString().slice(0, 16).replace('T', ' ') + ' UTC'; +} + +// When the rfc label was last applied, from the issue events, or null. +function labeledAt(events) { + let at = null; + for (const e of events) { + if (e.event !== 'labeled' || !e.label || e.label.name !== LABEL) continue; + const t = Date.parse(e.created_at); + if (at === null || t > at) at = t; + } + return at; +} + +function verdict(labels, events, now) { + if (!labels.includes(LABEL)) return { state: 'success', description: 'Not an RFC' }; + const at = labeledAt(events); + if (at === null) return { state: 'failure', description: 'Labeled rfc, but no labeled event found' }; + const notBefore = at + WINDOW_MS; + if (now < notBefore) { + return { state: 'failure', description: `RFC window open, merge not before ${stamp(notBefore)}`, notBefore }; + } + return { state: 'success', description: `RFC window closed ${stamp(notBefore)}`, notBefore }; +} + +function parseStakeholders(text) { + const rules = []; + for (const raw of text.split('\n')) { + const line = raw.replace(/#.*/, '').trim(); + if (!line) continue; + const [pattern, ...handles] = line.split(/\s+/); + rules.push({ pattern, handles: handles.map((h) => h.replace(/^@/, '')) }); + } + return rules; +} + +function stakeholders(rules, files, author) { + const out = new Set(); + for (const { pattern, handles } of rules) { + const hit = files.some((f) => + pattern === '*' || (pattern.endsWith('/') ? f.startsWith(pattern) : f === pattern)); + if (hit) handles.forEach((h) => out.add(h)); + } + out.delete(author); + return [...out]; +} + +function readStakeholders() { + return fs.readFileSync(path.join(__dirname, '..', 'rfc-stakeholders'), 'utf8'); +} + +function indexBody(owner, repo, rows) { + const head = 'Pull requests carrying the `rfc` label and the earliest time each can merge. ' + + `The RFC workflow rewrites this issue, see [RFC.md](https://github.com/${owner}/${repo}/blob/main/RFC.md). ` + + 'Comment on the pull request itself.\n\n'; + if (rows.length === 0) return head + 'No open RFCs.\n'; + rows.sort((a, b) => a.notBefore - b.notBefore); + return head + '| Pull request | Merge not before |\n|---|---|\n' + + rows.map((r) => `| #${r.number} ${r.title.replace(/\|/g, '\\|')} | ${r.notBefore ? stamp(r.notBefore) : 'unknown'} |`).join('\n') + '\n'; +} + +async function run({ github, context, core, now = Date.now(), stakeholdersText }) { + const { owner, repo } = context.repo; + const target = `https://github.com/${owner}/${repo}/blob/main/RFC.md`; + const prs = await github.paginate(github.rest.pulls.list, { owner, repo, state: 'open', per_page: 100 }); + const rows = []; + + for (const pr of prs) { + const labels = pr.labels.map((l) => l.name); + const events = labels.includes(LABEL) + ? await github.paginate(github.rest.issues.listEvents, { owner, repo, issue_number: pr.number, per_page: 100 }) + : []; + const v = verdict(labels, events, now); + if (labels.includes(LABEL)) rows.push({ number: pr.number, title: pr.title, notBefore: v.notBefore }); + + const { data: current } = await github.rest.repos.listCommitStatusesForRef({ + owner, repo, ref: pr.head.sha, per_page: 100, + }); + const last = current.find((s) => s.context === CONTEXT); + if (last && last.state === v.state && last.description === v.description) continue; + await github.rest.repos.createCommitStatus({ + owner, repo, sha: pr.head.sha, state: v.state, context: CONTEXT, description: v.description, target_url: target, + }); + core.info(`#${pr.number}: ${v.state}, ${v.description}`); + } + + const payload = context.payload; + if (context.eventName === 'pull_request_target' && payload.action === 'labeled' && payload.label.name === LABEL) { + const pr = payload.pull_request; + const files = (await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: pr.number, per_page: 100 })) + .map((f) => f.filename); + const text = stakeholdersText !== undefined ? stakeholdersText : readStakeholders(); + const who = stakeholders(parseStakeholders(text), files, pr.user.login); + const row = rows.find((r) => r.number === pr.number); + const body = `This is now an RFC: it cannot merge before ${row && row.notBefore ? stamp(row.notBefore) : 'the window closes'}, ` + + 'so anyone who depends on what it changes has a week to comment. See [RFC.md](' + target + ').' + + (who.length ? '\n\n' + who.map((h) => '@' + h).join(' ') + ', this touches code you depend on.' : ''); + await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body }); + } + + const issues = await github.paginate(github.rest.issues.listForRepo, { owner, repo, state: 'open', per_page: 100 }); + const index = issues.find((i) => !i.pull_request && i.title === INDEX_TITLE); + const body = indexBody(owner, repo, rows); + if (!index) { + core.warning(`No open issue titled "${INDEX_TITLE}", so no index to update.`); + } else if (index.body !== body) { + await github.rest.issues.update({ owner, repo, issue_number: index.number, body }); + } +} + +module.exports = { run, verdict, labeledAt, parseStakeholders, readStakeholders, stakeholders, indexBody, WINDOW_MS }; diff --git a/.github/scripts/rfc.test.cjs b/.github/scripts/rfc.test.cjs new file mode 100644 index 0000000..f3dbc65 --- /dev/null +++ b/.github/scripts/rfc.test.cjs @@ -0,0 +1,104 @@ +'use strict'; + +// node --test .github/scripts/rfc.test.cjs ; run by .github/workflows/rfc-script.yml. + +const test = require('node:test'); +const assert = require('node:assert'); +const rfc = require('./rfc.cjs'); + +const DAY = 24 * 60 * 60 * 1000; +const t0 = Date.parse('2026-09-01T12:00:00Z'); +const on = (at) => ({ event: 'labeled', label: { name: 'rfc' }, created_at: new Date(at).toISOString() }); +const off = (at) => ({ event: 'unlabeled', label: { name: 'rfc' }, created_at: new Date(at).toISOString() }); + +test('no label passes', () => { + assert.strictEqual(rfc.verdict(['bug'], [on(t0)], t0).state, 'success'); +}); + +test('fails inside the window, passes after it', () => { + assert.strictEqual(rfc.verdict(['rfc'], [on(t0)], t0 + 7 * DAY - 1).state, 'failure'); + assert.strictEqual(rfc.verdict(['rfc'], [on(t0)], t0 + 7 * DAY).state, 'success'); +}); + +test('re-applying the label restarts the clock', () => { + const events = [on(t0), off(t0 + 6 * DAY), on(t0 + 6 * DAY + 1)]; + const v = rfc.verdict(['rfc'], events, t0 + 8 * DAY); + assert.strictEqual(v.state, 'failure'); + assert.strictEqual(v.notBefore, t0 + 13 * DAY + 1); +}); + +test('other labels do not start the clock', () => { + const other = { event: 'labeled', label: { name: 'bug' }, created_at: new Date(t0 + 5 * DAY).toISOString() }; + assert.strictEqual(rfc.verdict(['rfc'], [on(t0), other], t0 + 7 * DAY).state, 'success'); +}); + +test('label with no event fails', () => { + assert.strictEqual(rfc.verdict(['rfc'], [], t0).state, 'failure'); +}); + +test('description fits a commit status', () => { + assert.ok(rfc.verdict(['rfc'], [on(t0)], t0).description.length <= 140); +}); + +test('stakeholders by path, author left out', () => { + const rules = rfc.parseStakeholders('# c\n* @a\nmonitor/ @b @a\nopenbios/x.s @c\n'); + assert.deepStrictEqual(rfc.stakeholders(rules, ['psyqo/x.h'], 'z'), ['a']); + assert.deepStrictEqual(rfc.stakeholders(rules, ['monitor/monitor.c'], 'a'), ['b']); + assert.deepStrictEqual(rfc.stakeholders(rules, ['openbios/x.s'], 'z').sort(), ['a', 'c']); + assert.deepStrictEqual(rfc.stakeholders(rules, ['openbios/x.s.bak'], 'z'), ['a']); +}); + +test('the real stakeholders file parses', () => { + for (const { pattern, handles } of rfc.parseStakeholders(rfc.readStakeholders())) { + assert.ok(pattern.length > 0); + assert.ok(handles.length > 0, `${pattern} names nobody`); + handles.forEach((h) => assert.match(h, /^[A-Za-z0-9-]+$/)); + } +}); + +test('run sets statuses, comments on labeling, rewrites the index', async () => { + const calls = []; + const prs = [ + { number: 37, title: 'monitor: protocol v3', labels: [{ name: 'rfc' }], head: { sha: 'aaa' }, user: { login: 'rixnobis' } }, + { number: 38, title: 'fix', labels: [], head: { sha: 'bbb' }, user: { login: 'x' } }, + ]; + const lists = { + pulls: prs, + events: [on(t0)], + files: [{ filename: 'monitor/monitor.c' }], + issues: [{ number: 47, title: 'Open RFCs', body: 'old' }], + }; + const github = { + paginate: async (fn, args) => fn(args), + rest: { + pulls: { list: () => lists.pulls, listFiles: () => lists.files }, + issues: { + listEvents: () => lists.events, + listForRepo: () => lists.issues, + createComment: async (a) => calls.push(['comment', a]), + update: async (a) => calls.push(['update', a]), + create: async (a) => calls.push(['create', a]), + }, + repos: { + listCommitStatusesForRef: async () => ({ data: [] }), + createCommitStatus: async (a) => calls.push(['status', a]), + }, + }, + }; + const context = { + repo: { owner: 'o', repo: 'r' }, + eventName: 'pull_request_target', + payload: { action: 'labeled', label: { name: 'rfc' }, pull_request: prs[0] }, + }; + const core = { info() {}, warning() {} }; + await rfc.run({ github, context, core, now: t0 + DAY, stakeholdersText: '* @nicolasnoble\nmonitor/ @spicyjpeg\n' }); + + const statuses = calls.filter((c) => c[0] === 'status').map((c) => [c[1].sha, c[1].state]); + assert.deepStrictEqual(statuses, [['aaa', 'failure'], ['bbb', 'success']]); + const comment = calls.find((c) => c[0] === 'comment')[1]; + assert.match(comment.body, /2026-09-08 12:00 UTC/); + assert.match(comment.body, /@nicolasnoble @spicyjpeg/); + const update = calls.find((c) => c[0] === 'update')[1]; + assert.strictEqual(update.issue_number, 47); + assert.match(update.body, /\| #37 monitor: protocol v3 \| 2026-09-08 12:00 UTC \|/); +}); diff --git a/.github/workflows/rfc-script.yml b/.github/workflows/rfc-script.yml new file mode 100644 index 0000000..2dae33e --- /dev/null +++ b/.github/workflows/rfc-script.yml @@ -0,0 +1,14 @@ +name: RFC script + +on: + pull_request: + paths: + - '.github/scripts/rfc*' + - '.github/rfc-stakeholders' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: node --test .github/scripts/rfc.test.cjs diff --git a/.github/workflows/rfc.yml b/.github/workflows/rfc.yml new file mode 100644 index 0000000..6937042 --- /dev/null +++ b/.github/workflows/rfc.yml @@ -0,0 +1,36 @@ +name: RFC + +# Keeps the rfc-moratorium status and the pinned Open RFCs issue current. See +# RFC.md. The hourly run is what turns the status green once a window closes. + +on: + pull_request_target: + types: [opened, reopened, synchronize, labeled, unlabeled, closed] + schedule: + - cron: '17 * * * *' + workflow_dispatch: + +permissions: + contents: read + issues: write + pull-requests: write + statuses: write + +concurrency: + group: rfc + cancel-in-progress: false + +jobs: + moratorium: + runs-on: ubuntu-latest + steps: + # This checks out the base branch. The job holds a write token, so it + # must never check out or run anything from the pull request. + - uses: actions/checkout@v4 + with: + sparse-checkout: .github + + - uses: actions/github-script@v7 + with: + script: | + await require('./.github/scripts/rfc.cjs').run({ github, context, core }); diff --git a/RFC.md b/RFC.md new file mode 100644 index 0000000..e59afca --- /dev/null +++ b/RFC.md @@ -0,0 +1,23 @@ +# RFCs + +People put this tool in their build and asset pipelines, so some changes break them. A pull +request that changes the TIM files it writes, the image files it accepts, or removes a feature, gets the `rfc` label and cannot merge for +seven days, so the people who depend on it can comment first. + +Bugfixes that make the output match what the tool already promises, UI changes that leave the +output alone, and new features do not need one. Who opens the pull request makes no difference. + +## The window + +While the label is on, the `rfc-moratorium` check fails until seven days after the label was +last applied, and `main` requires that check. The check's description gives the time the +window closes. If the proposal changes during the window, remove and re-apply the label and say +what changed in a comment; the seven days start over. + +Open RFCs are the +[open pull requests with the label](https://github.com/ps1dev/timweb/pulls?q=is%3Apr+is%3Aopen+label%3Arfc). + +## Commenting + +Comment on the pull request. An objection helps most with a use case attached: what you do +today that the change would break.