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
14 changes: 14 additions & 0 deletions .github/rfc-stakeholders
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# 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.

# Bandwidth keeps engine direction and design.
* @CloudMracek

# The splashpack writer produces what psxsplash reads at runtime.
Runtime/PSXSceneWriter.cs @CloudMracek
Runtime/PSXLoaderPackWriter.cs @CloudMracek
Runtime/IPSXBinaryWritable.cs @CloudMracek
147 changes: 147 additions & 0 deletions .github/scripts/rfc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
'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, '\\|').replace(/@/g, '&#64;')} | ${r.notBefore ? stamp(r.notBefore) : 'unknown'} |`).join('\n') + '\n';
}

// Pull requests can share a head commit, and a commit has one status per
// context, so a failing verdict for any of them wins.
function worst(a, b) {
return !a || (b.state === 'failure' && a.state !== 'failure') ? b : a;
}

async function evaluate(github, owner, repo, prs, now) {
const rows = [];
const bySha = new Map();
for (const pr of prs) {
const labels = pr.labels.map((l) => l.name);
const isRfc = labels.includes(LABEL);
const events = isRfc
? await github.paginate(github.rest.issues.listEvents, { owner, repo, issue_number: pr.number, per_page: 100 })
: [];
const v = verdict(labels, events, now);
if (isRfc) rows.push({ number: pr.number, title: pr.title, notBefore: v.notBefore });
bySha.set(pr.head.sha, worst(bySha.get(pr.head.sha), v));
}
return { rows, bySha };
}

async function publishStatuses(github, core, owner, repo, bySha, target) {
for (const [sha, v] of bySha) {
const { data: current } = await github.rest.repos.listCommitStatusesForRef({ owner, repo, ref: 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, state: v.state, context: CONTEXT, description: v.description, target_url: target,
});
core.info(`${sha.slice(0, 7)}: ${v.state}, ${v.description}`);
}
}

async function announce(github, owner, repo, pr, rows, target, stakeholdersText) {
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 when = row && row.notBefore ? stamp(row.notBefore) : 'the window closes';
const body = `This is now an RFC: it cannot merge before ${when}, ` +
'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 });
}

async function updateIndex(github, core, owner, repo, rows) {
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 });
}
}

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, bySha } = await evaluate(github, owner, repo, prs, now);
await publishStatuses(github, core, owner, repo, bySha, target);
const payload = context.payload;
if (context.eventName === 'pull_request_target' && payload.action === 'labeled' && payload.label.name === LABEL) {
await announce(github, owner, repo, payload.pull_request, rows, target, stakeholdersText);
}
await updateIndex(github, core, owner, repo, rows);
}

module.exports = { run, verdict, labeledAt, parseStakeholders, readStakeholders, stakeholders, indexBody, WINDOW_MS };
132 changes: 132 additions & 0 deletions .github/scripts/rfc.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
'use strict';

// node --test .github/scripts/rfc.test.js ; run by the rfc-script job in build.yml.

const test = require('node:test');
const assert = require('node:assert');
const rfc = require('./rfc.js');

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 \|/);
});

test('index escapes mentions in titles', () => {
const body = rfc.indexBody('o', 'r', [{ number: 1, title: 'ping @someone | x', notBefore: 0 }]);
assert.ok(!body.includes('@someone'));
assert.match(body, /&#64;someone \\\| x/);
});

test('a shared head commit gets the failing verdict', async () => {
const statuses = [];
const prs = [
{ number: 1, title: 'a', labels: [{ name: 'rfc' }], head: { sha: 'same' }, user: { login: 'x' } },
{ number: 2, title: 'b', labels: [], head: { sha: 'same' }, user: { login: 'x' } },
];
const github = {
paginate: async (fn, args) => fn(args),
rest: {
pulls: { list: () => prs },
issues: { listEvents: () => [on(t0)], listForRepo: () => [] },
repos: {
listCommitStatusesForRef: async () => ({ data: [] }),
createCommitStatus: async (a) => statuses.push([a.sha, a.state]),
},
},
};
const context = { repo: { owner: 'o', repo: 'r' }, eventName: 'schedule', payload: {} };
await rfc.run({ github, context, core: { info() {}, warning() {} }, now: t0 + DAY });
assert.deepStrictEqual(statuses, [['same', 'failure']]);
});
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: CI

on:
push:
branches: [ main ]
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
rfc-script:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: node --test .github/scripts/rfc.test.js
38 changes: 38 additions & 0 deletions .github/workflows/rfc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
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

# A labeled run posts the announcement, so it gets its own group: a pending
# run in a shared group is replaced by the next one and would be lost.
concurrency:
group: ${{ github.event.action == 'labeled' && format('rfc-labeled-{0}', github.run_id) || '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.js').run({ github, context, core });
37 changes: 37 additions & 0 deletions RFC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# RFCs

Some changes commit this package to something its users build on, and fixing them later
means breaking those users. A pull request making such a change gets the `rfc` label and
cannot merge for seven days, so the people who depend on it can comment first.

## What needs one

A change to anything a project or the runtime depends on:

- the components and assets users put in their scenes (`Runtime/PSX*.cs`): adding one, or
renaming, removing or changing the meaning of a serialized field, which silently drops data
from scenes that already use it;
- the splashpack files the exporter writes and psxsplash reads (`Runtime/PSXSceneWriter.cs`,
`Runtime/PSXLoaderPackWriter.cs` and the `IPSXBinaryWritable` implementations). A format change
also needs its psxsplash counterpart.

A bugfix that makes the code do what its contract already promises does not need one, and neither
do editor-only changes, tests or documentation. Who opens the pull request makes no difference. If
you are not sure, ask on the pull request or add the label.

## 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. If the contract changes during the window, remove
and re-apply the label and say what changed in a comment; the seven days start over. Changes
that leave the contract alone do not restart it.

Open RFCs and the time each can merge are listed in the pinned
[Open RFCs](https://github.com/psxsplash/splashedit/issues/41) issue. When the label goes on,
the people whose code depends on the touched paths are mentioned on the pull request; the list is
[.github/rfc-stakeholders](.github/rfc-stakeholders).

## Commenting

Comment on the pull request. An objection helps most with a use case attached: what you do
today that the change would break, or what it would stop you from doing.
7 changes: 7 additions & 0 deletions RFC.md.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading