Skip to content

ADR-061: versions.json Is a Public Consumption Contract

Accepted — extends ADR-059

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:

  1. The docs drift gate (ADR-059): the documentation repository’s update-versions.ts and drift:check CI fetch this file and fail the docs build when the site’s stack claims diverge from it.
  2. 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 template field was hand-maintained and drifted: it reported v0.2.0 while package.json and the latest git tag were 0.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/latest API endpoint and no changelog surface to link; “latest version” had to be inferred from the tag list.
  • 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:check already runs in quality: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.

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/latest becomes 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 template field 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

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.
  • template is stamped, never hand-edited. pnpm run version:fix writes it from package.json "version"; pnpm run version:check fails quality:ci on 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’s CHANGELOG.md section (extracted by the unit-tested scripts/src/extract-changelog.ts). Consumers use releases/latest; a tag without a non-empty changelog section fails the workflow.
  • Dependency-derived values are exact pins. (Amended 2026-08-13.) version:check deliberately skips any value containing x, so loose ranges were exactly where drift accumulated undetected (the 2026-08 audit found astro-mdx: 5.x against an installed 7.0.5, plus stale sharp/vite ranges). Every key that maps to a package.json dependency now holds the exact installed version and is guarded by the extended check-version-consistency.ts mapping; pnpm run version:fix maintains the pins. Only ecosystem facts with no dependency to check against (node-lts, node-current) may stay ranges. The lighthouse key was added (additive, non-breaking) so lighthouse-ci can pin the @lhci/cli version its name actually refers to.
// 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 fails
  • External consumers can rely on key stability and a stamped template version
  • releases/latest gives machine consumers and humans one canonical “current version” surface
  • The entire release chain (bump → tag → Release with notes) is automated and CI-guarded
  • 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)
  • versions.yml had 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
  • Metric 1: version:check fails CI on any template drift (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.

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.

  • Testable consequences:
    • TC-1: versions.json is consistent with package.json (stamped template field; no drift).
  • Checks:
    • TC-1 → version:check in quality:ci (status: block, pre-existing gate)
  • 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