Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/rfc-stakeholders
Original file line number Diff line number Diff line change
@@ -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.

125 changes: 125 additions & 0 deletions .github/scripts/rfc.cjs
Original file line number Diff line number Diff line change
@@ -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 };
104 changes: 104 additions & 0 deletions .github/scripts/rfc.test.cjs
Original file line number Diff line number Diff line change
@@ -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 \|/);
});
14 changes: 14 additions & 0 deletions .github/workflows/rfc-script.yml
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions .github/workflows/rfc.yml
Original file line number Diff line number Diff line change
@@ -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 });
23 changes: 23 additions & 0 deletions RFC.md
Original file line number Diff line number Diff line change
@@ -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.
Loading