ADR-061: versions.json Is a Public Consumption Contract
Status
Section titled “Status”Accepted — extends ADR-059
Context
Section titled “Context”versions.json began as an internal manifest of the template’s stack pins. It now has external,
machine consumers that read it by key name over raw GitHub URLs:
- The docs drift gate (ADR-059): the documentation repository’s
update-versions.tsanddrift:checkCI fetch this file and fail the docs build when the site’s stack claims diverge from it. - The clownware.org product-facts layer: the marketing site’s sync script fetches this file
at refresh time and renders version fact strips on the product page. Sibling templates
(e.g.
go-performance-starter) adopt the same file name and shape, so consumers treat the contract as uniform across products.
Two defects motivated formalising the contract:
- The manifest’s own
templatefield was hand-maintained and drifted: it reportedv0.2.0whilepackage.jsonand the latest git tag were0.9.0. Nothing stamped it during releases, so the one field external consumers use as “which template version are these facts for” was wrong for months. - The repository publishes git tags but no GitHub Releases, so consumers have no stable
releases/latestAPI endpoint and no changelog surface to link; “latest version” had to be inferred from the tag list.
Decision Drivers
Section titled “Decision Drivers”- Consumer stability: two external systems break silently if keys are renamed or removed.
- No hand-maintained facts: hand-edited version fields have drifted every time they existed
(README footer, dependency pins, and now
template— all shipped drifted at least once). - Existing enforcement pattern:
version:checkalready runs inquality:ci; extending it costs less than a new mechanism and keeps one guard per drift class. - Adopter cleanliness: any release automation must stay generic — cloners inherit workflows.
Considered Options
Section titled “Considered Options”Option 1: Formal contract + stamped template field + Releases on tag push
Section titled “Option 1: Formal contract + stamped template field + Releases on tag push”Description: Declare versions.json a public contract (additive keys fine; renames/removals
breaking). Stamp template from package.json "version" in version:fix; fail
version:check (and therefore quality:ci) on drift. Publish a GitHub Release on v* tag push
with notes extracted from CHANGELOG.md.
Pros:
- Reuses the existing guard-script pattern and CI gate; no new infrastructure
releases/latestbecomes a stable machine endpoint; the Release body links the changelog- Zero adopter pollution — the release workflow is fully generic
Cons:
- Key names are now frozen commitments; renaming requires coordinating consumers
- One more workflow to maintain
Option 2: Versioned API endpoint (publish facts to a hosted JSON endpoint)
Section titled “Option 2: Versioned API endpoint (publish facts to a hosted JSON endpoint)”Description: Publish product facts to a hosted location (e.g. Pages artifact or a worker) with schema versioning, decoupled from the repository layout.
Pros:
- Contract decoupled from file layout; schema can evolve behind a version
- Could aggregate multiple products server-side
Cons:
- New infrastructure and deploy surface for what raw GitHub URLs already provide
- Breaks the sibling-template symmetry (each template would need the same infra)
- Overkill for two consumers reading a dozen keys
Option 3: Status quo (informal file, hand-maintained template field)
Section titled “Option 3: Status quo (informal file, hand-maintained template field)”Description: Keep versions.json informal; consumers read it at their own risk.
Pros:
- No work
Cons:
- The
templatefield already shipped drifted (v0.2.0 vs 0.9.0) — proven failure mode - Renames break the docs drift gate and marketing site silently
- No stable release endpoint for consumers
Decision
Section titled “Decision”We will go with Option 1 because both defects are instances of drift classes this repository already guards against in CI, and the consumers already exist — the contract is being formalised, not invented.
The contract:
- Additive keys are non-breaking. New stack pins may be added freely.
- Renaming or removing a key is a breaking change. It requires checking both consumers (the docs repository’s drift gate; the clownware.org sync script) and coordinating the change.
templateis stamped, never hand-edited.pnpm run version:fixwrites it frompackage.json"version";pnpm run version:checkfailsquality:cion any mismatch.- GitHub Releases are the version endpoint. Pushing a
v*tag triggers.github/workflows/release.yml, which publishes a Release whose body is that version’sCHANGELOG.mdsection (extracted by the unit-testedscripts/src/extract-changelog.ts). Consumers usereleases/latest; a tag without a non-empty changelog section fails the workflow. - Dependency-derived values are exact pins. (Amended 2026-08-13.)
version:checkdeliberately skips any value containingx, so loose ranges were exactly where drift accumulated undetected (the 2026-08 audit foundastro-mdx: 5.xagainst an installed 7.0.5, plus stalesharp/viteranges). Every key that maps to apackage.jsondependency now holds the exact installed version and is guarded by the extendedcheck-version-consistency.tsmapping;pnpm run version:fixmaintains the pins. Only ecosystem facts with no dependency to check against (node-lts,node-current) may stay ranges. Thelighthousekey was added (additive, non-breaking) solighthouse-cican pin the@lhci/cliversion its name actually refers to.
Implementation Details
Section titled “Implementation Details”// scripts/src/check-version-consistency.ts — the template field joins the// existing drift guards (README footer, dependency pins):export function findTemplateMismatch( pkgVersion: string, versions: Record<string, string>,): string | null; // null = consistent; message = drift, quality:ci failsConsequences
Section titled “Consequences”Positive
Section titled “Positive”- External consumers can rely on key stability and a stamped
templateversion releases/latestgives machine consumers and humans one canonical “current version” surface- The entire release chain (bump → tag → Release with notes) is automated and CI-guarded
Negative
Section titled “Negative”- Key names are commitments; refactoring the manifest now has a coordination cost
- Cutting a release requires a CHANGELOG section for the tagged version (enforced by failure)
Neutral
Section titled “Neutral”versions.ymlhad no programmatic consumers and was legacy; it was removed by this ADR’s implementing change (#305) (amended 2026-08-02: originally said the file was “untouched by this ADR” and a candidate for removal under its own change; the implementing commit deleted it — recorded in ADR-035’s 2026-07-12 amendment)- Existing tags (v0.9.0 and earlier) predate the workflow; backfilling their Releases is a manual one-time action
Validation
Section titled “Validation”- Metric 1:
version:checkfails CI on anytemplatedrift (guard is exercised by unit tests) - Metric 2: the next
v*tag produces a GitHub Release with the correct CHANGELOG body - Metric 3: docs drift gate and clownware.org sync run without key-mapping changes across template releases
Amendment (2026-09-02) — Dependabot bumps sync versions.json automatically
Section titled “Amendment (2026-09-02) — Dependabot bumps sync versions.json automatically”version:check fails quality:ci on any drift between versions.json and
package.json, and Dependabot cannot run version:fix, so every Dependabot
bump of a tracked dependency arrived red and needed a hand commit before it
could merge — the drift class this ADR exists to prevent was being reintroduced
by the tool meant to keep dependencies current.
.github/workflows/versions-sync.yml closes the gap. On a Dependabot PR that
touches package.json it runs the guard script’s --fix mode, commits only
versions.json back to the Dependabot branch, and re-dispatches CI for the new
head (workflow_dispatch is the one event a GITHUB_TOKEN push may trigger;
ci.yml gained that trigger for this purpose). The job runs under
pull_request_target with a write token, so it executes nothing from the PR
head: no install, and the script is taken from the base commit. The
version:check gate is unchanged and still blocks — a failed sync leaves the
PR red rather than letting drift through.
Cost accepted: after the job has pushed, Dependabot refuses @dependabot rebase; @dependabot recreate (or the Update branch button) is the path, and
the job runs again on the new head. Alternatives rejected: loosening the pins
to .x (the drift gate in the docs site consumes exact values); a post-merge
sync on master (branch protection requires a review, so a bot cannot push
there); a PAT-backed sync (works, but adds a secret every adopter must mint).
Amendment (2026-09-23) — the sync push does start runs; they wait for approval
Section titled “Amendment (2026-09-23) — the sync push does start runs; they wait for approval”The 2026-09-02 amendment says workflow_dispatch is “the one event a
GITHUB_TOKEN push may trigger”. That is no longer what GitHub does. The sync
commit’s push creates pull_request runs for both ci.yml and
lighthouse.yml, held at action_required because their actor is
github-actions[bot] (observed on #421 and #431). Once a maintainer approves
them they run normally and attach to the PR, so the checks tab is complete.
The re-dispatch stays: it is what gives build-test, the only required check,
a result on the new head without a human click, so an unattended Dependabot PR
still shows a real pass or fail. What changes is the review step. Lighthouse has
no dispatch path, so on a synced PR it runs only if the waiting runs are
approved, and branch protection does not require it. Approving the waiting runs
is now part of reviewing a synced Dependabot PR; versions-sync.yml carries the
one-line command. This resolves #398 as its option 1 (document, add no moving
parts) with the corrected facts. Making lighthouse a required check was not
taken: its mobile leg is known to flake and would then block unrelated merges.
References
Section titled “References”- ADR-059: Docs Drift Gate Replaces Push-Sync
- ADR-045: Cross-Tool Agents Spine — the no-drift CI pattern
scripts/src/check-version-consistency.ts,scripts/src/extract-changelog.ts.github/workflows/release.yml
The same contract shape is being adopted by go-performance-starter (its own versions.json
stamped by its release flow), so cross-product consumers can treat every Clownware template
identically. Consumers deliberately read raw GitHub URLs rather than a hosted endpoint — see
Option 2 for why.
Enforcement
Section titled “Enforcement”- Testable consequences:
- TC-1:
versions.jsonis consistent withpackage.json(stampedtemplatefield; no drift).
- TC-1:
- Checks:
- TC-1 →
version:checkinquality:ci(status: block, pre-existing gate)
- TC-1 →
- Not machine-checkable: whether a key rename/removal is justified — a contract break requires a superseding or amending ADR, which is a governance act.
- Graduation log: (empty at creation; entries added when a check changes status)
Date: 2026-07-12
Participants: Template maintainers
Outcome: Accepted