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
110 changes: 110 additions & 0 deletions .github/scripts/check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Pull-request checks:
// 1. `version` moves legally: a new QEP starts unversioned (v0); once a QEP is
// versioned it stays versioned; a first version is 1; otherwise it stays equal
// (editorial) or increments by one (substantive) relative to the base branch.
// 2. `type` and `status` are known values.
// 3. The README Type/Status/Version columns match each QEP's frontmatter.
// Run by .github/workflows/qep-checks.yml. Exits non-zero on any failure.
import { execSync } from 'node:child_process';
import { FRONTMATTER, parseQep, qepFiles, readIndex, versionCell } from './qeps.mjs';

const base = process.env.BASE_REF || 'main';
const errors = [];

const TYPES = new Set(['standard', 'process', 'informational']);
const STATUSES = new Set(['Draft', 'Accepted', 'Rejected', 'Withdrawn', 'Superseded']);

// State of a QEP file on the base branch: whether it existed, and its `version`
// (read from the frontmatter only, so a body YAML example is ignored).
function baseState(path) {
let text;
try {
text = execSync(`git show "origin/${base}:${path}"`, {
stdio: ['pipe', 'pipe', 'ignore'],
}).toString();
} catch {
return { existed: false, version: undefined };
}
const fm = text.match(FRONTMATTER);
const block = fm ? fm[1] : text;
const m = block.match(/^version:[ \t]*(\d+)/m);
return { existed: true, version: m ? Number(m[1]) : undefined };
}

// 1. version moves legally relative to the base branch.
for (const path of qepFiles()) {
const { version } = parseQep(path);
const { existed, version: prev } = baseState(path);

if (!existed) {
// A brand-new QEP must start unversioned (implicitly v0).
if (version !== undefined) {
errors.push(`${path}: a new QEP must start unversioned (v0) — remove the version field`);
}
continue;
}
if (version === undefined) {
// Dropping `version` is only legal if the QEP was never versioned.
if (prev !== undefined) {
errors.push(`${path}: version ${prev} was removed; once versioned, a QEP stays versioned`);
}
continue;
}
if (prev === undefined) {
if (version !== 1) {
errors.push(`${path}: introduces version ${version}; a first version must be 1`);
}
} else if (version !== prev && version !== prev + 1) {
errors.push(
`${path}: version ${prev} -> ${version} must stay equal (editorial) or increment by one (substantive)`,
);
}
}

// 2/3. type/status enums, and README Type/Status/Version parity with frontmatter.
const idx = readIndex();
if (idx.cols.type === -1) errors.push(`${'README.md'}: index table is missing a Type column`);
if (idx.cols.status === -1) errors.push(`${'README.md'}: index table is missing a Status column`);
if (idx.cols.version === -1) errors.push(`${'README.md'}: index table is missing a Version column`);
const rows = new Map(idx.rows.map((r) => [r.qep, r]));
Comment thread
mmcky marked this conversation as resolved.
for (const path of qepFiles()) {
const q = parseQep(path);
if (q.qep === undefined) continue;

if (q.type !== undefined && !TYPES.has(q.type)) {
errors.push(`${path}: unknown type "${q.type}" (expected one of ${[...TYPES].join(', ')})`);
}
if (q.status !== undefined && !STATUSES.has(q.status)) {
errors.push(`${path}: unknown status "${q.status}" (expected one of ${[...STATUSES].join(', ')})`);
}

const row = rows.get(q.qep);
if (!row) {
errors.push(`README index has no row for QEP-${q.qep} (${path})`);
continue;
}
const expect = (label, colIdx, want) => {
if (colIdx === -1) return;
const got = row.cells[colIdx];
if (got !== want) errors.push(`QEP-${q.qep}: README ${label} "${got}" != frontmatter "${want}"`);
};
expect('Type', idx.cols.type, q.type);
expect('Status', idx.cols.status, q.status);

// Version parity, tolerating a hand-typed ASCII "-" or empty cell for a v0 QEP:
// stamp.mjs normalises it to the en dash post-merge, so don't block the PR on it.
if (idx.cols.version !== -1) {
const got = row.cells[idx.cols.version];
const want = versionCell(q.version); // en dash for v0
const v0ok = q.version === undefined && (got === '-' || got === '');
if (got !== want && !v0ok) {
errors.push(`QEP-${q.qep}: README Version "${got}" != frontmatter "${want}"`);
}
}
}

if (errors.length) {
console.error('QEP checks failed:\n' + errors.map((e) => ` - ${e}`).join('\n'));
process.exit(1);
}
console.log('QEP checks passed.');
110 changes: 110 additions & 0 deletions .github/scripts/qeps.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Shared helpers for QEP CI: parse QEP frontmatter and the README index table.
// Used by stamp.mjs (post-merge stamp + README sync) and check.mjs (PR checks).
import { readFileSync, readdirSync } from 'node:fs';

export const QEP_DIR = 'qeps';
export const README = 'README.md';

// Every qeps/qep-XXXX-slug.md file, sorted by name (so by QEP number).
export function qepFiles() {
return readdirSync(QEP_DIR)
.filter((f) => /^qep-\d{4}-.*\.md$/.test(f))
.sort()
.map((f) => `${QEP_DIR}/${f}`);
}

// The leading YAML frontmatter block. Exported so the stamp/check scripts can
// scope their version parsing to it — a QEP *body* may contain literal
// `version:`/`version-hash:` lines inside a YAML example, which must be ignored.
export const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---/;

function field(block, name) {
const m = block.match(new RegExp(`^${name}:[ \\t]*(.*?)[ \\t]*$`, 'm'));
return m ? m[1] : undefined;
}

function stripQuotes(s) {
return s === undefined ? undefined : s.replace(/^["']|["']$/g, '');
}

// Parse the fields we care about from a QEP file's YAML frontmatter.
// `version` is a plain number (undefined = implicitly v0); `hash` is the stamped
// short SHA from the sibling `version-hash` field (undefined when not yet stamped).
export function parseQep(path) {
const text = readFileSync(path, 'utf8');
const fm = text.match(FRONTMATTER);
if (!fm) throw new Error(`${path}: missing YAML frontmatter`);
const block = fm[1];

const rawVersion = field(block, 'version');
let version;
if (rawVersion !== undefined && rawVersion !== '') {
const v = rawVersion.match(/^(\d+)$/);
if (!v) throw new Error(`${path}: malformed "version: ${rawVersion}"`);
version = Number(v[1]);
}

// `version-hash` may carry a trailing "# stamped by CI" signpost comment.
const rawHash = field(block, 'version-hash');
let hash;
if (rawHash !== undefined && rawHash !== '') {
const h = rawHash.match(/^([0-9a-fA-F]+)(?:[ \t]*#.*)?$/);
if (!h) throw new Error(`${path}: malformed "version-hash: ${rawHash}"`);
hash = h[1];
}

const qep = field(block, 'qep');
return {
path,
qep: qep === undefined ? undefined : Number(qep),
title: stripQuotes(field(block, 'title')),
status: stripQuotes(field(block, 'status')),
type: stripQuotes(field(block, 'type')),
version,
hash,
};
}

// How `version` is displayed in the README Version column.
export function versionCell(version) {
return version === undefined ? '–' : `v${version}`;
}

// --- README index table ---------------------------------------------------
// The Markdown table under the "## Index" heading: its first column links each
// QEP file. Column positions are read from the header row, so the column order
// can change without touching this code.

function splitRow(line) {
// "| a | b |" -> ["a", "b"]
return line
.replace(/^\s*\|/, '')
.replace(/\|\s*$/, '')
.split('|')
.map((c) => c.trim());
}

export function readIndex() {
const text = readFileSync(README, 'utf8');
const lines = text.split('\n');
const start = lines.findIndex((l) => /^\|\s*QEP\s*\|/i.test(l));
if (start === -1) throw new Error(`${README}: no index table header (| QEP | ...) found`);
const header = splitRow(lines[start]);
const col = (name) => header.findIndex((c) => c.toLowerCase() === name);
const cols = { type: col('type'), status: col('status'), version: col('version') };

const rows = [];
for (let i = start + 2; i < lines.length; i++) {
const line = lines[i];
if (!line.trimStart().startsWith('|')) break; // table ended
const m = line.match(/qep-(\d+)-/);
if (!m) continue;
rows.push({ index: i, cells: splitRow(line), qep: Number(m[1]) });
}
return { lines, cols, rows };
}

// Rebuild a single-spaced Markdown row from its trimmed cells.
export function formatRow(cells) {
return `| ${cells.join(' | ')} |`;
}
94 changes: 94 additions & 0 deletions .github/scripts/stamp.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Post-merge: stamp the merged short hash into each changed QEP's `version-hash`
// field, and sync the README Type/Version columns from each QEP's frontmatter.
// Run by .github/workflows/stamp-version.yml on push to main. Idempotent: writes
// nothing when everything is already current.
import { execSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import {
FRONTMATTER,
README,
formatRow,
parseQep,
qepFiles,
readIndex,
versionCell,
} from './qeps.mjs';

const sha = execSync('git rev-parse --short HEAD').toString().trim();

// Files changed by the push tip. QEP PRs should be squash-merged (one parent),
// but `git diff-tree -r HEAD` prints nothing for a merge commit — so if a PR is
// merged as a merge commit, stamping would silently no-op. Diffing against the
// first parent works for both squash and merge commits.
const parents = execSync('git rev-list --parents -n 1 HEAD')
.toString()
.trim()
.split(/\s+/)
.slice(1);
if (parents.length > 1) {
console.log(`note: HEAD has ${parents.length} parents (merge commit); diffing against the first parent`);
}
const changed = new Set(
execSync(
parents.length
? 'git diff --name-only HEAD^1 HEAD'
: 'git diff-tree --no-commit-id --name-only -r HEAD', // root commit has no parent
)
.toString()
.split('\n')
.map((s) => s.trim())
.filter(Boolean),
);

let dirty = false;

// 1. Stamp the hash into any changed v1+ QEP whose `version-hash` isn't this SHA.
const SIGNPOST = '# stamped by CI; do not edit';
for (const path of qepFiles()) {
if (!changed.has(path)) continue;
const { version, hash } = parseQep(path);
if (version === undefined) continue; // v0 — no version to stamp
if (hash === sha) continue; // already current
const text = readFileSync(path, 'utf8');
const line = `version-hash: ${sha} ${SIGNPOST}`;
// Edit only the frontmatter block (anchored at the file start) — the body may
// carry a literal version:/version-hash: YAML example that must not be touched.
const head = text.match(FRONTMATTER)[0];
const newHead = /^version-hash:.*$/m.test(head)
? head.replace(/^version-hash:.*$/m, line) // re-stamp: replace existing
: head.replace(/^(version:[ \t]*\d+)[ \t]*$/m, `$1\n${line}`); // first stamp: insert
const stamped = newHead + text.slice(head.length);
if (stamped !== text) {
writeFileSync(path, stamped);
dirty = true;
console.log(`stamped ${path} -> ${sha}`);
}
}

// 2. Sync the README Type/Version columns from frontmatter.
const meta = new Map(
qepFiles()
.map((p) => parseQep(p))
.filter((q) => q.qep !== undefined)
.map((q) => [q.qep, q]),
);
const idx = readIndex();
const out = [...idx.lines];
for (const row of idx.rows) {
const q = meta.get(row.qep);
if (!q) continue;
const cells = [...row.cells];
if (idx.cols.type !== -1 && q.type !== undefined) cells[idx.cols.type] = q.type;
if (idx.cols.version !== -1) cells[idx.cols.version] = versionCell(q.version);
if (cells.join('|') !== row.cells.join('|')) {
out[row.index] = formatRow(cells);
console.log(`README: synced QEP-${row.qep} row`);
}
}
const readme = out.join('\n');
if (readme !== readFileSync(README, 'utf8')) {
writeFileSync(README, readme);
dirty = true;
}

console.log(dirty ? 'changes written' : 'nothing to stamp or sync');
29 changes: 29 additions & 0 deletions .github/workflows/qep-checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: QEP checks
on:
pull_request:
paths:
- "qeps/**"
- "README.md"
- ".github/scripts/**"
- ".github/workflows/qep-checks.yml"

# Read-only: this job only validates the PR.
permissions:
contents: read

jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Fetch base branch
run: git fetch --no-tags --depth=1 origin "${{ github.base_ref }}"
- name: Run QEP checks
run: node .github/scripts/check.mjs
env:
BASE_REF: ${{ github.base_ref }}
50 changes: 50 additions & 0 deletions .github/workflows/stamp-version.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Stamp QEP version
on:
push:
branches: [main]
paths:
- "qeps/**"

# Needs write access to push the stamp/sync commit back to main.
permissions:
contents: write

# Serialise stamp runs so two QEP merges in quick succession don't race on the
# push to main (the later push would otherwise be rejected non-fast-forward).
concurrency:
group: stamp-main
cancel-in-progress: false

jobs:
stamp:
# The bot's own commit carries [skip-stamp]; pushes made with GITHUB_TOKEN
# don't retrigger workflows anyway, so this guard is belt-and-suspenders.
# That same no-retrigger behaviour means this push does not redeploy the
# site — which is fine: everything visible (the type/version pills and the
# README columns) is already correct from the merge commit, and only the
# invisible `version-hash` field waits for the next push to main to republish.
if: ${{ !contains(github.event.head_commit.message, '[skip-stamp]') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Stamp hash and sync README
run: node .github/scripts/stamp.mjs
- name: Commit and push if changed
run: |
if git diff --quiet; then
echo "Nothing to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
git commit -m "Stamp QEP version hash and sync README [skip-stamp]"
# Rebase onto anything that landed on main while we were stamping,
# then push, so a near-simultaneous merge can't drop this stamp.
git pull --rebase origin main
git push origin HEAD:main
Loading
Loading