-
Notifications
You must be signed in to change notification settings - Fork 0
QEP-1 v1: version-defined living standards (amend in place, not supersede) #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
2668f25
QEP-1 v1: introduce in-place versioning of QEPs
mmcky 96ad24a
QEP-1 v1: anchor revision via a version-hash key, not a YAML comment
mmcky 4486c4b
QEP-1 v1: edit prose to describe the process, not its own history
mmcky 791af8c
QEP-1 v1: harden CI checks and stamping; make rendering claims contin…
mmcky a9a3bdc
Untrack pr-5-review.md working notes (committed by mistake)
mmcky 516a29c
QEP-1 v1: enforce presence of the README Status column in check.mjs
mmcky File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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])); | ||
| 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.'); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(' | ')} |`; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.