How this site is built
A static site, in one repository, deployed by GitHub Pages from the
dev branch. There is no framework, no build step you have to install, and
no server. What there is is a release pipeline that refuses to publish a site
that fails its own checks — and that pipeline is the first thing this site shipped,
before it had a single role page to publish.
Everything below runs identically on a laptop and in CI. That is the whole design: the commands in the release checklist are the commands the workflow runs, so a release that passes locally passes in CI, and a release that fails in CI fails the same way in front of you.
The pipeline: three jobs, each gating the next
One workflow, .github/workflows/deploy-pages.yml, on every push to
dev and main, on every pull request against them, and on
manual dispatch.
# push to dev validate ──▶ tag-release ──▶ deploy │ │ │ │ │ └─ upload-pages-artifact, deploy-pages │ └─ git push origin refs/tags/vX.Y.Z └─ gen_*.py --check ; node admin/build/validate.js # pull request → validate only (no tag, no deploy) # push to main → validate, deploy; tag-release skips # dispatch → validate, deploy; tag-release skips
| Job | Runs when | What it does | If it fails |
|---|---|---|---|
| validate | always | Re-runs every generator in
--check mode, then node admin/build/validate.js |
Nothing is tagged and nothing is deployed. The live site is untouched. |
| tag-release | push to dev only | Works out this release's tag, verifies it against the version file and the commit subject, pushes it | No deploy. The release is wrong, not the site — fix the version and push again. |
| deploy | any push or dispatch, never a PR | Assembles
the tree (minus .git, .github) and publishes it to
GitHub Pages | The previously deployed version stays live. |
main is deploy-only, on purpose. tag-release is
gated on refs/heads/dev, so a push to main validates and
publishes without writing a tag. That makes main usable as a deploy test or
a fallback without polluting the tag history.
The release gate
admin/build/validate.js is plain Node with no dependencies, so it runs
anywhere Node exists. Six checks, in order, and any failure exits non-zero:
| # | Check | Why it exists |
|---|---|---|
| 1 | Version agreement — admin/build/version.txt
against every page's version badge, the row in the
versions table, llms.txt, llms-full.txt and
index.md, with no version listed twice | A version that means different things in different files means nothing. |
| 2 | Internal links — every relative href and
src resolves to a file that exists, and every
#fragment resolves to an id on the page it
names | This site cross-links between roster entries, the topologies page and the role-format page by section — a claim, an exclusion list, a learning — and a renamed heading turns every one of those into a link that lands at the top of the right page and says nothing. |
| 3 | Canonical host — every page declares a
rel="canonical", and every canonical and og:url is on
the host named in CNAME | A page that claims a canonical URL on the wrong host is worse than one that claims none. |
| 4 | The no-verbatim gate — every
<blockquote> declares whose words it carries, and a third
party's are capped at 40 words | Most quotes on this site are the product
team's own words (data-quote="founder"), which are theirs to
publish. The cap still guards any future page quoting a third party — Wardley,
Team Topologies, Cynefin. |
| 5 | The roster is the data — every role in
data/roster.json has a page, every roster page is in the data, and
every tally written into a page is recomputed from the data and must
agree | 06__ of the commissioning pack requires role counts to be generated, not claimed. See below. |
| 6 | The leak tripwire — nothing in the tree may look like a vault key, an AWS access key id, a GitHub token, an API secret, a private key block, a Slack token or an AWS account id | 07__ of the pack requires this to be a build step, not a review step, precisely because this pack's own source material is a live product team's operational documents — the 8 April 2026 commit in the evolution page exists because this is a live risk, not a hypothetical one. |
The roster is the data, in detail
Every count on the front page, the roster index and llms.txt is
computed from data/roster.json — a page writes an empty slot,
admin/build/chrome.py fills it after the generators run, and
validate.js recomputes the same counts independently from the same file
and fails on disagreement. Both halves have to be wrong in the same direction to ship a
wrong number.
<!-- in the page source --> <span class="tally" data-k="portable-core"></span> roles form the portable core # chrome.py fills it, from data/roster.json # validate.js recomputes the same count from the same file and fails on disagreement
How CI decides the tag
Every push to dev is a minor release. The version is owned by one file
and stated twice, and CI refuses to tag unless both agree:
# 1. the file that owns it admin/build/version.txt v0.1.0 # 2. the release commit's subject git commit -m "site v0.1.0: the pipeline, before the roster" # CI then checks, in this order: newest release commit's version == version.txt # or: error, the two disagree tag vX.Y.Z sits on THAT commit # or: error, version was not bumped vX.Y.Z is the next minor after the latest tag # or a deliberate major to .0 git push origin refs/tags/vX.Y.Z # the load-bearing push
Three details worth knowing, each a bug that was already fixed once on a sibling site:
- The release commit is not always HEAD. When a pull request lands as a merge commit, HEAD is the merge and the release commit is its parent. Every check anchors to the newest commit whose subject carries a version, not to HEAD.
- Historical tags are backfilled. On its first run the job reads the whole history and tags any commit whose subject carries a version it cannot find a tag for. It is idempotent, so it is a no-op on every run afterwards.
- A backfilled tag can be legitimately rejected.
GITHUB_TOKENcannot push a ref onto a commit whose tree carries a different.github/workflowsblob. Those are reported as warnings and the job still succeeds.
What is generated, and how drift is caught
Anything that restates something else is generated from it. A hand-maintained twin of a file is a stale artefact with a longer fuse.
| File | Owns | Generated from |
|---|---|---|
admin/build/version.txt | The version — single source of truth | hand-edited, once per release |
data/roster.json | The roster. Every role, its claim, its claim form, its exclusions, its teams and its commit count | the commissioning pack's teams__roster.json,
unchanged |
admin/build/chrome.py | The nav and footer of every page, the version badge, and the roster tallies | one definition in the
script, plus data/roster.json |
admin/build/pagelib.py | The shared page shell, the write-or-check writer, and the tiny markdown this site's prose is authored in | — |
admin/build/gen_roster.py | roster/ —
every role, grouped by function, with its markdown twin |
data/roster.json |
admin/build/gen_documents.py | documents/ |
data/documents.json + briefs/* |
admin/build/gen_llms_full.py | llms-full.txt |
llms.txt, index.md, roster/*/index.md,
briefs/*.md |
admin/build/gen_sitemap.py | sitemap.xml |
the tree, dated from the versions table |
admin/build/validate.js | The release gate | — |
Every generator has a --check mode, and CI runs all of them before
the gate. A generated page that has drifted from its source is a build failure
here, not a warning.
Why the markdown twins are generated too
Every roster entry publishes an HTML page and a markdown twin at the same URL stem,
and the twin is not a second copy. The role's prose comes straight from
data/roster.json and is rendered twice — once as HTML, once as markdown —
through the same tiny inline-markdown renderer, so the two cannot drift out of sync
with each other; either can still drift from the JSON, which is what
--check catches.
The release checklist
These are the commands the workflow runs, in this order, for the reason each note gives:
# 1. bump the version — exactly once per release — and add a row to admin/versions.html # 2. regenerate the pages that come from data python3 admin/build/gen_roster.py python3 admin/build/gen_documents.py # 3. chrome AFTER the generators: it propagates the version badge, the nav and the # tallies into the pages they have just produced python3 admin/build/chrome.py # 4. the files that read the tree and the stamped twins — AFTER chrome, or they # assemble a stale version line python3 admin/build/gen_llms_full.py python3 admin/build/gen_sitemap.py # 5. validate exactly what CI validates python3 admin/build/gen_roster.py --check python3 admin/build/gen_documents.py --check python3 admin/build/gen_llms_full.py --check python3 admin/build/gen_sitemap.py --check node admin/build/validate.js # 6. ship it git commit -am "site vX.Y.Z: ..." && git push -u origin dev
One inherited trap, pre-empted
The Python .gitignore this repository starts from carries
build/, which silently swallows admin/build/ — a sibling site
shipped a first release whose validate job died on a missing file before it could check
anything. The checks appeared to run and did not. The !admin/build/
negation is in this repository's first commit that adds it.