Skip to content

Govern a shared schema across repos

When you run manni meta across dozens of repositories, you do not want a copy of the schema in each one that nobody maintains. Copies drift: one repo tightens a field, another never gets the update, and the standard you thought you were enforcing fractures.

There are two ways to hold one canonical schema, and they fail differently.

Concern Vendored (recommended) Referenced by URL
What the repo contains The schema file, committed, plus a hash pin One line naming a URL
What CI depends on Its own checkout The schema host answering within 10 seconds, on every job
Taking an update Re-run one command; review the diff Automatic, including when you did not want it
An upstream change you did not expect Shows up as a pull request Shows up as a build that started failing
Setup per repo One command One line

Vendoring is the default recommendation because it moves the schema into the consuming repository’s own history. It is reviewable in a pull request, diffable when it changes, and immune to the host being down. Start there, and keep the URL form for the cases described in When the URL form is enough.

  1. Publish the schema at a stable, versioned URL. Put the version in the path so each release has its own immutable URL. For example, https://schemas.example.com/house/2.1.json, then .../house/2.2.json. This is still worth doing when consumers vendor: the URL is what they vendor from, and what a re-vendor points at.

  2. In each consuming repo, run schemas vendor.

    Terminal window
    npx -y @hawkeyexl/manni meta schemas vendor https://schemas.example.com/house/2.1.json

    manni meta downloads the schema, writes it to ./schema/2.1.json, and records both where it came from and what it hashes to:

    Vendored https://schemas.example.com/house/2.1.json
    file schema/2.1.json (266 bytes)
    integrity sha256-c12b1cfe…7341
    config manni.config.yaml (created)

    The config now points at the local copy:

    manni.config.yaml
    meta:
    schemas:
    - ref: ./schema/2.1.json
    source: https://schemas.example.com/house/2.1.json
    integrity: sha256-c12b1cfe…7341
  3. Commit both. The downloaded file and the config change belong in the same commit. This is the step the whole approach rests on. See the caution below.

  4. Validate as usual. Nothing else changes:

    Terminal window
    npx -y @hawkeyexl/manni meta validate "docs/**/*.md"

    The schema host can now be down, moved, or retired, and this repo’s gate keeps working. What it validates against is in the checkout.

  5. Take an update deliberately. When the standard moves, re-run the command with the new URL:

    Terminal window
    npx -y @hawkeyexl/manni meta schemas vendor https://schemas.example.com/house/2.2.json

    The existing entry is replaced rather than duplicated. The result is a pull request whose diff shows exactly what changed in the contract. That is the same ergonomics as a lockfile.

Migrating a repo that already references the URL

Section titled “Migrating a repo that already references the URL”

If a repo already carries the URL form, vendoring converts it in place. Run the command with the same URL it already references:

Terminal window
npx -y @hawkeyexl/manni meta schemas vendor https://schemas.example.com/house/2.1.json

The bare-URL entry is replaced, not appended to, so the URL survives as provenance (source:) and stops being a live dependency. The rest of the config, comments included, is untouched.

integrity is recorded because vendoring means “I want these exact bytes”. On every run the file’s bytes are hashed and compared before the schema is parsed. A mismatch stops the run with exit 2 and no fallback:

manni: Schema "./schema/2.1.json" does not match its recorded integrity.
expected sha256-c12b1cfe…7341
found sha256-9091a816…46cb
The file's contents have changed since it was vendored. Re-download it with
`manni meta schemas vendor https://schemas.example.com/house/2.1.json`, or update
the recorded integrity if the change was intended.

That is the cost, stated plainly. An edit to the vendored file that does not go through schemas vendor, a well-meant local tweak included, breaks the build until the pin is updated. In exchange, no copy of the shared standard can drift without a reviewer seeing it. If you want the local file without the pin, delete the integrity: line; ref and source keep working on their own.

A plain URL reference is one line and needs no command. For some setups that is the right trade.

  • An internal repo on the same infrastructure as the schema host, where the host being down already means nobody is working.

  • A prototype or spike where a broken build costs nothing.

  • Checking a candidate schema for one run, with -s:

    Terminal window
    npx -y @hawkeyexl/manni meta validate "docs/**/*.md" -s https://schemas.example.com/house/3.0.json

State the tradeoff rather than hiding it. With a URL reference, every job in every consuming repo depends on that host answering within 10 seconds. They all fail at the same moment when it does not. A URL that is mutable (.../house/latest.json) adds a second failure: the meaning of your build can change overnight without a commit anywhere in your repository.

With vendoring, that same mutable-URL case degrades to “your committed copy is stale”, which is visible, safe, and fixed by a re-vendor. It does not become “your build changed meaning while you slept.”

A URL reference can be supplied the same three ways any reference can.

A document names its own schema with a $schema key in its frontmatter.

docs/concepts/overview.md
---
$schema: https://schemas.example.com/house/2.1.json
type: concept
---

Whichever source supplies it, the schema set for each file is resolved by the same precedence chain. That is --schema → file $schema → config overrides → config schemas → built-in default. Vendoring does not change that chain; it changes what one reference in it points at. For the full chain, see the schema resolution reference.

Each fetch is bounded by a 10-second timeout. It is retried once after roughly 500 ms on a network error or a 5xx. A 4xx is never retried, because it will not heal. The result is cached in memory for the rest of the run, so two thousand files sharing one URL make one request. Successful fetches are also written to a cross-run disk cache. That speeds up your local edit loop. It does not help CI, because a hosted runner starts with an empty workspace on every job.

A URL schema that cannot be turned into a usable schema fails the run. The error is operational and exits 2, distinct from a validation failure (exit 1). A gate can tell “a document is non-conformant” from “the schema host is unreachable”. For the full contract, see Exit codes & PR annotations.

Failure What manni meta reports
The request exceeds the 10-second timeout Failed to fetch schema "<url>": timed out after 10000ms.
The server responds with a non-2xx status Failed to fetch schema "<url>": HTTP <status>.
The host is unreachable or the request errors Failed to fetch schema "<url>": <reason>.
The response body is not valid JSON Schema "<url>" did not return valid JSON: <reason>.
The body parses but is not a schema Schema "<url>" does not look like a JSON Schema: …

That last row matters more than it looks. An API gateway or a misconfigured bucket commonly answers a bad path with 200 OK and a JSON error envelope. That would otherwise compile as a schema with no constraints and pass every document, which is a green gate that has stopped working. On the vendoring path as well, manni meta refuses it, so an envelope can never be committed as your contract.

When your repo takes outside pull requests

Section titled “When your repo takes outside pull requests”

Everything above governs where your canonical schema comes from. One more thing decides whether a repository is actually judged by it.

A document’s own $schema sits above config in the precedence chain. That is the feature that lets a page carry its own contract. In a repo taking pull requests from people you have not met, it is also a way to opt out of yours. One line of frontmatter, and that file is judged by a schema the contributor chose:

contributed.md
---
title: Contributed page
$schema: https://example.invalid/permissive.json
---

Serve {"type": "object"} from that URL and the file passes everything. Worse, the run reads as a partial success, with the contributed file green and the honest files still red against your real schema. Nothing about the output looks like a bypass:

✓ contributed.md
✗ honest.md
(root) must have required property 'type' (line 1) [google:okf:0.1]

Note what this walks past. schemas:, the vendored copy, and the integrity pin all sit below $schema in the chain. None of the durability work above applies to that file.

One key closes it:

manni.config.yaml
meta:
schemas:
- ref: ./schema/2.1.json
source: https://schemas.example.com/house/2.1.json
integrity: sha256-c12b1cfe…7341
schemaTrust:
documentRefs: local

Now the contributed file fails with the reason on the file itself, and every other document is validated exactly as before:

✗ contributed.md
Refusing the "$schema" URL "https://example.invalid/permissive.json":
schemaTrust.documentRefs is "local" …

It is a failing file and exit 1, rather than a stopped run. So with --format github the annotation appears on that line of the pull request. The contributor can act on it.

What local still allows is worth stating, because it is why turning it on is cheap. A built-in id ($schema: google:okf:0.1) and a schema file committed in the repository both keep working. Only a URL, and a path reaching outside the repository, are refused. And -s/--schema is untouched in every mode, so you can still check a candidate schema for one run.

documentRefs: none goes one step further. The document’s key is ignored entirely and the config decides for every file. One line on stderr names each file whose reference was dropped.

The json output reports the resolved schema set per file. A normal run on a known-good file shows which reference a repo is actually wired to:

Terminal window
npx -y @hawkeyexl/manni meta validate docs/concepts/overview.md --format json

In the JSON, the file’s schemas array names either your vendored path or your URL. A clean run exits 0.

One check is worth doing once per repo, to prove vendoring took. With the schema file committed, temporarily block the schema host, or just note that nothing in the run touches it. Then confirm the gate still passes.