Theme propagation¶
How a zer0-mistakes release reaches the sites built on it.
The problem¶
Releasing used to end at RubyGems. Nothing connected a published version to the five sites that render with it, so every pin sat wherever a human last edited it. When v1.27.0 shipped:
bashconsultants,year-of-ai.github.io, andai-world-view.github.iowere all still pinned to v1.26.0 — a full minor behind, with no signal that a newer version existed.it-journeyandlifehacker.devwere unpinned, trackingmain, so every push to this repo reached production immediately with no gate and no rollback._data/theme-manifest.yml— the contract the consumer audit reads — had frozen at 1.14.0 while the theme shipped 13 minors to 1.27.0, leaving every checksum in it wrong andaudit-consumerquietly useless.
Bumping meant editing five repositories from memory, up to four files in one of them.
The pieces¶
| Piece | Where | Role |
|---|---|---|
_data/theme-manifest.yml |
this repo | Version + per-file SHA-256 of every themable file. The contract consumers audit against. |
_data/consumers.yml |
this repo | Registry: every consumer, how it consumes the theme, and which files hold its pin. |
scripts/propagate.rb |
this repo | Reads the registry; reports pin drift or dispatches theme-release. |
.github/workflows/propagate-theme.yml |
this repo | Dispatches on release; weekly drift report. |
.github/workflows/manifest-sync.yml |
this repo | Regenerates the manifest inside the release PR. |
templates/consumer/ |
copied downstream | The consumer's half: pin-rewrite script, bump workflow, override declaration. |
The flow¶
release published
│
▼
propagate-theme.yml ──reads──> _data/consumers.yml
│
└──repository_dispatch: theme-release──> consumer repo
│
▼
theme-bump.yml (consumer)
│
bump-theme-pins.sh rewrites its pins
│
▼
PR, gated by the CONSUMER's own CI
Two properties are deliberate:
This repo never writes to a consumer. It dispatches an event; the consumer opens its own PR. Every consumer gates differently — it-journey runs make build-ci + make content-audit, bashconsultants builds in Docker and runs an editorial lint, the org hubs re-roll member configs with provision-org-sites.rb. Pushing a commit from here would bypass all of it.
Dispatch is not the only path. theme-bump.yml also runs weekly. Dispatch can be missed — an unreachable repo, an expired PAT, a consumer that hasn't adopted the workflow yet — and gem-mode consumers, which nothing dispatches to, depend on the schedule entirely.
Keeping the manifest honest¶
The manifest is a release snapshot, not a working-tree mirror. That distinction drives two rules:
- CI gates only that
manifest.version == version.rb(ci.yml→ "Version ↔ theme manifest consistency"). Diffing 500 generated lines on every PR would churn and conflict for no benefit. manifest-sync.ymlregenerates it in full on the release PR, so the tagged release always ships a manifest that describes it.
./scripts/bin/manifest --check verifies the whole file on demand, ignoring generated_at (which moves every run).
This is what rotted before: the manifest header claimed it was regenerated by scripts/bin/release, but that became the manual fallback when release-please took over, and nothing in the new path knew about the file.
Consumption modes¶
| Mode | Consumers | Bumped by |
|---|---|---|
remote_theme_pinned |
year-of-ai, ai-world-view hubs | Dispatch + schedule |
remote_theme_floating |
it-journey, lifehacker.dev | Nothing — no version to bump |
gem |
bashconsultants (Azure stack) | Schedule only — no remote_theme means nothing is picked up automatically, ever |
path |
bashconsultants (local dev) | Never |
The org hubs are a special case: their _data/hub.yml theme_repo is the value provision-org-sites.rb stamps into every member repo's _config.yml, so one bump there re-rolls the whole org (12 sites for year-of-ai). The hub's own _config.yml and hub.yml must move together — different code paths read them, and they drift silently.
Floating consumers¶
it-journey and lifehacker.dev track main with no tag. Pinning them is recommended: it makes the bump PR meaningful, and it puts a gate between a theme merge and two production sites. bump-theme-pins.sh --pin converts a floating ref, deliberately behind an explicit flag — bumping and pinning are different decisions.
Commands¶
ruby scripts/propagate.rb # who's behind?
ruby scripts/propagate.rb --format json # machine-readable
ruby scripts/propagate.rb --strict # exit 1 if any consumer is behind
ruby scripts/propagate.rb --dispatch --dry-run
./scripts/bin/manifest # regenerate the manifest
./scripts/bin/manifest --check # is it current?
./scripts/bin/audit-consumer --consumer-path /path/to/site # what have they forked?
./scripts/bin/audit-consumer --consumer-path /path/to/site --fix # delete IDENTICAL copies
./scripts/bin/sync-plugins --consumer-path /path/to/site # vendor required plugins
--dispatch needs LIFECYCLE_PAT (the existing fleet secret): GITHUB_TOKEN is scoped to this repository and cannot dispatch to another.
Migrating already-seeded navigation data¶
_data is not part of Jekyll's theme payload — a remote_theme ships _layouts, _includes, _sass and assets, and nothing else. So site.data.navigation on a consumer is always that consumer's own file, and updating the theme can never fix it.
Until the fix for #332, install.sh copied the theme's entire _data/ into every consumer, which meant each one's "own" navigation was a verbatim copy of the theme's — carrying the theme's page taxonomy. Measured on a real install: 106 of 129 seeded navigation URLs pointed at pages the installer does not create, ~47 per doc page from docs.yml alone. A consumer could not remove a single one from their content, and a link-integrity gate would block every docs PR on it.
New installs now seed all seven files from templates/data/navigation-*.yml.template, whose links resolve against the pages the installer actually creates (0 of 26 unresolvable).
This does not retroactively clean the sites already in _data/consumers.yml. A theme release cannot reach into a consumer's _data/. Each existing consumer has to do one of these once:
| your situation | what to do |
|---|---|
| You never customised the sidebar | Delete _data/navigation/docs.yml and re-run the installer; it re-seeds the starter. Same for any other _data/navigation/*.yml you never touched. |
| You want a sidebar with no data file to maintain | Set sidebar: { nav: pages } in your docs front matter. It builds the tree from your page URLs, so it cannot go stale. |
| You did customise it | Keep your file. Delete only the entries pointing at pages you do not have — the theme's are the ones under /docs/liquid/, /docs/docker/, /docs/ruby/, /docs/jekyll/, /docs/deployment/, /docs/customization/, /docs/features/, plus /news/*, /authors/ and /about/stats/. |
To find them: grep -n 'url:' _data/navigation/*.yml and check each against your own routes.
The renderer was never at fault. sidebar: { nav: tree } resolves through auto → page.collection → site.data.navigation[collection], which is correct; it was being handed the wrong data.
The reverse channel¶
The weekly drift report answers "who is behind". The audit answers the more valuable question: what are consumers forking that the theme should just provide? Three consumers shadowing the same include is an upstream candidate — that observation is what produced the consumer-adoption work in #338, and running audit-consumer across the registry turns it from a manual audit into a standing report.
.theme-overrides.yml is what makes that signal legible: it separates deliberate forks from ones someone forgot about. Without it every shadowed file reads as unjustified drift, which is why an audit of it-journey currently reports ~55 problems and gets ignored.