diff --git a/.github/scripts/qeps.mjs b/.github/scripts/qeps.mjs index 06a57c1..dfebc97 100644 --- a/.github/scripts/qeps.mjs +++ b/.github/scripts/qeps.mjs @@ -28,7 +28,8 @@ function stripQuotes(s) { } // 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 +// `version` is a plain number (undefined = not yet stamped; a merged QEP past Draft +// is stamped v0 by stamp.mjs); `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'); diff --git a/.github/scripts/stamp.mjs b/.github/scripts/stamp.mjs index c002c6a..435952e 100644 --- a/.github/scripts/stamp.mjs +++ b/.github/scripts/stamp.mjs @@ -1,5 +1,7 @@ -// 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. +// Post-merge: stamp `version: 0` and an anchor hash into any QEP that has left +// Draft and carries no `version` (QEP-1 v3 — stamp from v0), stamp the merged +// short hash into each changed QEP's `version-hash` field, and sync the README +// index 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'; @@ -39,28 +41,62 @@ const changed = new Set( .filter(Boolean), ); +// A manual run (workflow_dispatch) may start from the bot's own stamp commit; +// its files are not a merge's, so nothing is re-stamped to that SHA. +if (/\[skip-stamp\]/.test(execSync('git log -1 --format=%s HEAD').toString())) { + console.log('note: HEAD is a stamp commit; only backfilling unversioned QEPs'); + changed.clear(); +} + let dirty = false; -// 1. Stamp the hash into any changed v1+ QEP whose `version-hash` isn't this SHA. +// The most recent commit that touched a file, ignoring the bot's own stamp +// commits: the anchor for a QEP that left Draft before stamping from v0 existed. +// For a QEP merged by this push that is HEAD itself. +function lastTouch(path) { + return execSync(`git log -1 --format=%h --invert-grep --grep='\\[skip-stamp\\]' -- "${path}"`) + .toString() + .trim(); +} + +// 1. Stamp `version`/`version-hash`. Two cases: +// - a QEP past Draft with no `version` gets `version: 0` and the hash of the +// last commit that touched it (this push's SHA when it merged just now, a +// historical anchor when it is being backfilled) — QEP-1 v3, stamp from v0; +// - a changed, versioned QEP whose `version-hash` isn't this SHA is re-stamped. +// A Draft is never stamped: this script writes `version` on the push to main +// that records the outcome, and a Draft on main is a state the lifecycle does +// not admit. 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 { status, version, hash } = parseQep(path); + if (status === 'Draft') continue; 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 + let newHead; + if (version === undefined) { + const anchor = changed.has(path) ? sha : lastTouch(path); + const lines = `version: 0\nversion-hash: ${anchor} ${SIGNPOST}`; + // Insert after `type:` (the field order QEP-1 uses), else after `status:`. + newHead = /^type:.*$/m.test(head) + ? head.replace(/^(type:.*)$/m, `$1\n${lines}`) + : head.replace(/^(status:.*)$/m, `$1\n${lines}`); + } else { + if (!changed.has(path)) continue; + if (hash === sha) continue; // already current + const line = `version-hash: ${sha} ${SIGNPOST}`; + 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}`); + const { version: v, hash: h } = parseQep(path); + console.log(`stamped ${path} -> v${v} @ ${h}`); } } diff --git a/.github/workflows/stamp-version.yml b/.github/workflows/stamp-version.yml index 4acaad8..69a111d 100644 --- a/.github/workflows/stamp-version.yml +++ b/.github/workflows/stamp-version.yml @@ -4,10 +4,14 @@ on: branches: [main] paths: - "qeps/**" + - ".github/scripts/**" + workflow_dispatch: -# Needs write access to push the stamp/sync commit back to main. +# Needs write access to push the stamp/sync commit back to main, and to +# dispatch the site deploy afterwards. permissions: contents: write + actions: 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). @@ -19,10 +23,11 @@ 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. + # That same no-retrigger behaviour means the stamp push does not redeploy + # the site on its own — and since v0 stamping, the stamp commit is where + # `version: 0` and the README's `v0` cell first appear, so the last step + # dispatches deploy.yml explicitly (a workflow_dispatch call from + # GITHUB_TOKEN does run; only events *caused by* its pushes are dropped). if: ${{ !contains(github.event.head_commit.message, '[skip-stamp]') }} runs-on: ubuntu-latest steps: @@ -35,11 +40,13 @@ jobs: - name: Stamp hash and sync README run: node .github/scripts/stamp.mjs - name: Commit and push if changed + id: push run: | if git diff --quiet; then echo "Nothing to commit." exit 0 fi + echo "pushed=true" >> "$GITHUB_OUTPUT" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add -A @@ -48,3 +55,8 @@ jobs: # then push, so a near-simultaneous merge can't drop this stamp. git pull --rebase origin main git push origin HEAD:main + - name: Redeploy the site with the stamped metadata + if: steps.push.outputs.pushed == 'true' + run: gh workflow run deploy.yml --ref main + env: + GH_TOKEN: ${{ github.token }}