diff --git a/.github/rfc-stakeholders b/.github/rfc-stakeholders new file mode 100644 index 0000000..e89aac8 --- /dev/null +++ b/.github/rfc-stakeholders @@ -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 diff --git a/.github/scripts/rfc.js b/.github/scripts/rfc.js new file mode 100644 index 0000000..82bb374 --- /dev/null +++ b/.github/scripts/rfc.js @@ -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, '@')} | ${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 }; diff --git a/.github/scripts/rfc.test.js b/.github/scripts/rfc.test.js new file mode 100644 index 0000000..c3b6f50 --- /dev/null +++ b/.github/scripts/rfc.test.js @@ -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, /@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']]); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0365f54 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/rfc.yml b/.github/workflows/rfc.yml new file mode 100644 index 0000000..7b70bbe --- /dev/null +++ b/.github/workflows/rfc.yml @@ -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 }); diff --git a/RFC.md b/RFC.md new file mode 100644 index 0000000..56b410c --- /dev/null +++ b/RFC.md @@ -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. diff --git a/RFC.md.meta b/RFC.md.meta new file mode 100644 index 0000000..978adf4 --- /dev/null +++ b/RFC.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: d4e353987b434e8ab73e3aa6f0c737ab +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: