diff --git a/.github/rfc-stakeholders b/.github/rfc-stakeholders deleted file mode 100644 index e89aac8..0000000 --- a/.github/rfc-stakeholders +++ /dev/null @@ -1,14 +0,0 @@ -# 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 deleted file mode 100644 index 82bb374..0000000 --- a/.github/scripts/rfc.js +++ /dev/null @@ -1,147 +0,0 @@ -'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 deleted file mode 100644 index c3b6f50..0000000 --- a/.github/scripts/rfc.test.js +++ /dev/null @@ -1,132 +0,0 @@ -'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 deleted file mode 100644 index 0365f54..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 7b70bbe..0000000 --- a/.github/workflows/rfc.yml +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 56b410c..0000000 --- a/RFC.md +++ /dev/null @@ -1,37 +0,0 @@ -# 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.