Skip to content

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, and ai-world-view.github.io were all still pinned to v1.26.0 — a full minor behind, with no signal that a newer version existed.
  • it-journey and lifehacker.dev were unpinned, tracking main, 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 and audit-consumer quietly 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.yml regenerates 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.