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
3 changes: 2 additions & 1 deletion .github/scripts/qeps.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
60 changes: 48 additions & 12 deletions .github/scripts/stamp.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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}`);
}
}

Expand Down
22 changes: 17 additions & 5 deletions .github/workflows/stamp-version.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ on:
branches: [main]
paths:
- "qeps/**"
- ".github/scripts/**"
workflow_dispatch:
Comment thread
mmcky marked this conversation as resolved.

# 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).
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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 }}
Loading