Publishing schema snapshots¶
Publishing is a maintainer task performed by schepherd-publisher, a separate
binary that is not part of the client distribution. Only the publisher
contacts upstream schema sources. For the public catalog the whole flow runs
every week without a human (see Catalog automation); this
page describes the tool and the files it works with.
upstream catalog at one commit
→ prepare (keep what did not change; fetch the rest at that commit, resolve $ref closures, bundle,
verify, license gate; IDs, reuse, holds and exclusions come from catalog/state.json)
→ diff (compare with catalog/state.json; no registry)
→ publish (compare with catalog/state.json, pack what changed, push schemas, then the catalog,
then write the new state)
→ latest (once the new state is recorded: point catalog-latest at the catalog it records)
go run ./tools/install-jsonschema # pinned bundler into .tools/bin (checksum verified)
schepherd-publisher prepare --source sources/schemastore.toml --policy sources/licenses.toml \
--ids sources/ids.json --state catalog/state.json --out ./prepared --json
schepherd-publisher diff --prepared ./prepared --state catalog/state.json --json
schepherd-publisher publish --prepared ./prepared --repository registry.example/org/schemas \
--state catalog/state.json --state-out new-state.json --json
# after new-state.json is committed as catalog/state.json, tagged and released:
schepherd-publisher latest --repository registry.example/org/schemas --state catalog/state.json --json
| Command | Flags |
|---|---|
prepare |
--source (required), --out (required; missing or empty), --policy (required for SchemaStore), --ids, --state, --snapshot-dir, --jsonschema, --jobs, --refresh <id> (repeatable), --refresh-all, --json |
diff |
--prepared, --state (both required), --json |
publish |
--prepared, --repository (both required), --state, --state-out, --registry-config, --update-latest, --now YYYYMMDD.HHMM (tests and replays), --json |
latest |
--repository, --state (both required; the file must exist), --check, --registry-config, --json |
--quiet prints only errors. A missing --state file means that nothing has
been published yet.
Sources¶
A source file describes where schemas come from. Two kinds exist.
kind = "upstream": SchemaStore¶
sources/schemastore.toml
imports the complete SchemaStore catalog from
one Git commit of its repository. commit is the last imported commit; the
weekly update moves it in the same bot commit as catalog/state.json, and
only when the published catalog changes. Maintainers do not move it by hand.
kind = "upstream"
commit = "05b037b7a2b68ead6893c857db2594bcad9951d4"
tarball_base_url = "https://codeload.github.com/SchemaStore/schemastore/tar.gz"
max_tarball_bytes = 67108864
[dependencies] # per schema, never per run
max_depth = 8
max_per_schema = 64
max_document_bytes = 16777216
max_total_bytes = 268435456
The publisher downloads the repository tarball for that commit once
(--snapshot-dir keeps it for later runs), checks the archive strictly (no
absolute paths, .., links or devices; size and entry limits; the gzip
trailer), and reads the catalog, the schemas, the test instances, LICENSE
and NOTICE from it. The catalog is SchemaStore's format, so only the
members Schepherd reads are checked ($schema, version and schemas; in
each entry name, description, url, fileMatch and versions: exact
key case, no nulls, absolute http(s) URLs without credentials), and any other
member, at the top level or in an entry, is ignored; duplicate keys and the
size and depth limits apply to the whole document. A catalog document that
breaks these rules stops the run; a single entry that breaks them fails with
reason invalid-metadata.
Every spelling of a SchemaStore URL (www.schemastore.org,
json.schemastore.org, with or without .json, under /schemas/json/, and
the repository's raw URLs) maps to the file of that snapshot, so no live or
mutable URL is consulted for them.
kind = "local": an explicit list¶
Used for private sets and for the end-to-end fixtures. Relative paths resolve against the source file:
kind = "local"
name = "company"
policy = "licenses.toml"
[[entries]]
id = "company-config" # optional; derived from url otherwise
name = "company.json"
description = "Company configuration"
url = "https://schemas.example/company.json" # provenance; not fetched when file is set
file = "schemas/company.json"
file_match = ["config/company.json"]
license = "MIT" # SPDX expression; see Licenses
instances = ["tests/company/valid.json"] # optional JSON instances for the behaviour check
[[documents]] # serve a dependency URI from a local file
uri = "https://schemas.example/common.json"
file = "schemas/common.json"
license = "MIT"
# Optional: ids = "ids.json" (ID overrides), [dependencies] with the same keys
# as the upstream kind, and [fetch] to relax the fetcher for private or test
# setups: allow_http = true, allow_private_hosts = ["host:port"].
Every entry of a local source that was never published is required: if one
is not prepared, prepare exits with code 5. A published one is held like
any other published schema (see Published schemas).
Preparation¶
For every upstream record the publisher decides one of:
| Status | Meaning |
|---|---|
included |
Prepared, verified and part of the snapshot. |
excluded |
An explicit, reviewed rule in the license policy excludes it. |
pending-review |
No license rule allows the source or one of its dependencies. |
failed |
A technical failure with a stable reason code (see below). |
Records that share one schema URL are merged into one catalog entry whose
fileMatch is the union of theirs. SchemaStore versions maps (older schema
variants) are not published; report.json keeps them per record.
Output of prepare --out <dir>:
| File | Content |
|---|---|
prepared.json |
The upstream commit and tarball digest, the recipe (with the bundler version), entries sorted by ID with metadata, content digests, provenance (source and dependency digests) and license decision (allowing rules, pinned detections and redirects), plus the held and excluded published schemas (schema) |
schemas/<id>.json |
Prepared schema bytes (none for a reused entry) |
notices/<sha256>.txt |
License notices (none for a reused entry) |
report.json |
Every record with its status, reason, license, license detections, verification and, for SchemaStore, its file in the snapshot (snapshotPath), the held and excluded published schemas, and totals; the only file with a timestamp (generatedAt) |
report.json accounts for every upstream record. Its held ([{id,
reason}]) and excluded ([{id, rule}]) repeat those of prepared.json,
and totals counts the records by status, the prepared entries (and how many
are reused), the held and dropped published schemas, the dropped
patterns, unpublished version URLs, the verification methods and the
license detection results. prepare --json prints {outDir, source, totals,
regressions, collisions, held, excluded}; the text summary prints the counts
and one held: <id> (<reason>) or excluded: <id> (rule <rule>) line per
published schema.
Two runs over the same inputs (the state and the answers of license
detection included) produce byte-identical files except the timestamp in
report.json.
Self-contained schemas¶
A consumer receives one file, so each artifact must be self-contained:
- A schema without external references is only compacted (insignificant whitespace removed); its data, number literals and member order included, is unchanged.
- A schema with external references is bundled. The publisher discovers the
reference closure with the Sourcemeta
jsonschemaCLI (inspect), fetches each dependency once (SchemaStore URLs from the snapshot, others over HTTPS, only when the license policy allows the dependency and the final URL of any redirect), and embeds every dependency under its own$idwithjsonschema bundle(definitionsfor draft-04/06/07,$defsfor 2019-09 and later). References keep their original URIs and resolve to the embedded resources. A document without$idfirst gets one (idfor draft-04) equal to the URI it was retrieved from. - The bundler re-serializes bundled schemas: members are reordered and
numbers normalized (
2.50becomes2.5,1e2becomes1e+2,-0becomes0). Values, annotations and unknown keywords are preserved. - Fragment roots. When an upstream URL points into a document
(
…/doc.json#/definitions/xor…/doc.json#anchor), the entry is that subschema: the root is{"$schema": <document dialect>, "allOf": [{"$ref": "<document $id>#<fragment>"}]}and the whole document is embedded as a resource, so every reference inside it resolves as in the original. The derived ID appends the fragment's last segment (ansible.json#/$defs/tasks→ansible-tasks). Upstream tests are instances of the whole document, so fragment entries arestructural-only. License rules match the document URL; ruleurlsmust not contain a fragment. - Top-level
$refin draft-04/06/07. Those dialects ignore every sibling of a top-level$ref, so a document (root or dependency) that must be bundled is rewritten first:"$ref": Xbecomes"allOf": [{"$ref": X}]in place. This keeps the meaning only when every sibling is inert ($schema,$id/id,$comment,definitions, annotations, unknown keywords); a sibling that constrains instances (type,properties,required, …) fails withtop-level-ref-draft7, because validators ignore it while editors apply it. When a rewritten root declares an$idother than its retrieval URL (SchemaStore files declare theirjson.schemastore.orgalias), that identifier becomes effective: the publisher fetches it (only when the license policy allows it) and requires byte-identical content (id-mismatchotherwise). - The official JSON Schema meta-schemas are never bundled.
Every bundle is verified before it is accepted:
- it carries exactly the
$ref,$dynamicRef,$recursiveRef,$anchor,$dynamicAnchorand$recursiveAnchormembers of the documents it was made from; jsonschema inspectreports no external references;- it compiles offline with an independent implementation
(
santhosh-tekuri/jsonschema) and a loader that refuses every URL; - for every upstream JSON test instance (positive and negative), the original schema and the bundle give the same verdict.
report.json records the verification of each prepared entry as
{method, valid, invalid, skippedNonJSON}:
| Method | Meaning |
|---|---|
compact-only |
Not bundled; the upstream bytes without insignificant whitespace. |
behaviour-compared |
The bundle agreed with the original on at least one JSON test instance. |
structural-only |
Checks 1–3 only, because no JSON test instance exists (SchemaStore tests are often YAML or TOML, counted in skippedNonJSON, and fragment entries have none). |
prepare warns about bundled entries that only structural checks verified
and names them.
Failure reasons¶
| Reason | Meaning |
|---|---|
fragment-root |
The upstream URL's fragment is neither a JSON Pointer nor a plain-name anchor, or the document is not a JSON object. |
undeclared-dialect |
The schema has external references, or the URL names a fragment of it, but it has no $schema. |
unsupported-dialect |
$schema is not draft-04/06/07, 2019-09 or 2020-12. |
top-level-ref-draft7 |
A draft-04/06/07 document that must be bundled has a top-level $ref next to keywords that constrain instances (named in the detail). |
id-mismatch |
A dependency declares a different $id than the URL it was fetched from, or a rewritten draft-04/06/07 root declares an $id that does not serve the same bytes. |
unresolved-ref |
A reference cannot be resolved. |
dependency-limit |
Depth, count or size limits of the closure were exceeded. |
fetch-failed |
A source or dependency could not be fetched. |
invalid-json |
Not well-formed JSON. |
duplicate-keys |
An object has duplicate member names. |
invalid-schema |
The verifying implementation rejects the schema. |
invalid-instance |
An upstream test instance is not strict JSON or too large. |
invalid-metadata |
Name, description, dialect, patterns, provenance or license decision violate the catalog or state format, or the notice is not UTF-8 text. |
too-large |
The prepared schema or its notice is larger than clients accept by default (64 MiB and 1 MiB). |
bundler-error |
The bundler failed. |
not-self-contained |
The bundle still references external resources. |
behaviour-mismatch |
Bundle and original disagree on a test instance. |
reference-mismatch |
The bundle does not carry exactly the reference and anchor members of its source documents. |
Unsupported fileMatch patterns (extended globs such as !(config).yml) are
dropped from an otherwise included entry and reported as
pattern-unsupported. A missing schema is never replaced by a permissive {}
or true, and upstream schemas are never "fixed".
Published schemas¶
prepare --state catalog/state.json reads the state of the last publication.
The state keeps IDs stable, lets unchanged schemas keep their artifact and
names every published schema; the prepared set accounts for each of them as
an entry, as held or as excluded.
A published schema is never dropped automatically and never fails the
run. When the run cannot refresh it, it is held: the next catalog keeps
its last published entry and artifact, prepare logs a warning, and
prepared.json, the result and report.json list it under held with one
of these reasons:
| Reason | Why the published schema was not refreshed |
|---|---|
removed-upstream |
No upstream record maps to its ID any more: upstream removed the record, or an ID override moved its source to another ID. |
fetch-failed |
Its source or a dependency could not be downloaded. |
license-detection-failed |
Automatic license detection could not answer: a failed request or rate limit (fetch-failed), or a URL it cannot pin (unsupported-host). |
license-refused |
Automatic license detection found no usable license, one that asserts nothing, or one the [auto] allow list does not permit (no-license, not-asserted, not-permissive). |
license-review |
The license policy holds it for review: a review rule, no rule at all, a refused redirect, notices over 768 KiB, or a license declaration of a local source that asserts nothing. |
prepare-failed |
Any other failure: a malformed upstream catalog entry, or bundling, compiling, verification, the catalog's rules or the client's size limits rejected the new content. |
A later run that refreshes the schema ends the hold.
Only an explicit exclude rule removes a published schema from the
catalog, a human decision in sources/licenses.toml (for example after a
takedown request). The rule is matched against the source as upstream lists
it now and against the source, dependencies and redirect targets the state
records, so it also removes a schema upstream no longer lists. prepare
lists it under excluded with the rule's ID, publish drops it from the
catalog, and the state keeps its record with excludedRevision, so the ID
stays reserved. When the rule goes away, the schema returns as added and
keeps its recorded artifact when its content is unchanged.
prepare exits 5 (after writing its output) only when an entry of a local
source that was never published was not prepared, even over an exclude rule.
SchemaStore records never cause that: new records that fail stay out of the
catalog and appear in the report.
IDs of the state stay reserved, excluded ones included, and so does an old ID
left behind when an ID override renamed a published schema: it is held as
removed-upstream, and no other source can take it. Its stand-in source
urn:schepherd:reserved-id:<id> may appear in the collisions of
report.json.
Reusing unchanged schemas¶
A published schema whose inputs did not change is not prepared again. For an
upstream record whose source the state lists under the same ID, prepare
first decides the license again from the state alone: the current policy
rules applied to the source, the recorded dependencies and redirect targets,
and the detection findings the state recorded (licenseDecision). No
detection service is contacted.
- An
excluderule that now matches excludes the schema; areviewrule holds it aslicense-review; a recorded detected license the[auto]allow list no longer permits holds it aslicense-refused. - When the policy still allows it on the same rules, findings and license
expression,
preparefetches the source and every recorded dependency. When each has its recorded digest and is served as recorded (directly, or through the redirect recorded inlicenseDecision.redirects), the entry is reused: it keeps the recorded artifact, digests, dialect, provenance and license decision, and takes its name, description andfileMatchfresh from upstream. Nothing is bundled or verified. A change of that metadata alone is published asmetadataChangedwith the same artifact. - A source or dependency that cannot be fetched holds the schema as
fetch-failed. Anything else (a changed digest, a new, changed or dropped redirect, another decision) prepares the schema in full, which asks license detection again.
A week without upstream changes therefore costs the published schemas no detection request and no bundling. A source that was never published has no recorded decision and is detected again every run.
--refresh <id> (repeatable) and --refresh-all prepare published schemas
in full although their inputs did not change: that is how a recipe, bundler,
notice or policy change reaches them. Each --refresh ID must be a schema of
the state (exit code 2 otherwise). A refreshed schema whose bytes and notice
come out the same keeps its artifact.
The prepared set¶
prepared.json lists the entries of the next catalog, sorted by ID, and the
published schemas it does not carry: held ([{id, reason}]) and excluded
([{id, rule}]). A record the state already marks excluded appears only when
it returns. A prepared entry names its files (schema, and notice when it
has one); a reused entry has reusedArtifact (the recorded schema manifest
descriptor) and noticeDigest instead. licenseDecision records the
allowing rules and detections and the redirects that served its documents
(url, target, digest, sorted by url).
{
"formatVersion": 1,
"recipe": "schepherd-prepare/2+sourcemeta-jsonschema-16.12.0",
"source": {
"kind": "schemastore",
"commit": "05b037b7a2b68ead6893c857db2594bcad9951d4",
"tarballDigest": "sha256:…"
},
"entries": [
{
"id": "package",
"name": "package.json",
"description": "NPM configuration file",
"dialect": "http://json-schema.org/draft-07/schema#",
"fileMatch": ["package.json"],
"schema": "schemas/package.json",
"contentDigest": "sha256:…",
"notice": "notices/<sha256 hex>.txt",
"provenance": {
"source": "https://www.schemastore.org/package.json",
"sourceDigest": "sha256:…",
"license": "Apache-2.0"
},
"licenseDecision": { "rules": ["schemastore"] }
},
{
"id": "tool",
"name": "Tool",
"fileMatch": ["tool.json"],
"contentDigest": "sha256:…",
"noticeDigest": "sha256:…",
"reusedArtifact": {
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:…",
"size": 512
},
"provenance": {
"source": "https://github.com/owner/repo/raw/main/tool.json",
"sourceDigest": "sha256:…",
"license": "MIT"
},
"licenseDecision": {
"detections": [
{
"url": "https://raw.githubusercontent.com/owner/repo/refs/heads/main/tool.json",
"source": "github:owner/repo@<commit>",
"license": "MIT",
"licenseFile": "LICENSE",
"licenseDigest": "sha256:…",
"verdict": "allow"
}
],
"redirects": [
{
"url": "https://github.com/owner/repo/raw/main/tool.json",
"target": "https://raw.githubusercontent.com/owner/repo/refs/heads/main/tool.json",
"digest": "sha256:…"
}
]
}
}
],
"held": [{ "id": "delta", "reason": "license-detection-failed" }],
"excluded": [{ "id": "taken-down", "rule": "takedown" }]
}
publish and diff accept a prepared set only together with the state it
was prepared with (see Comparing with the state).
The bundler and the recipe¶
The bundler is the Sourcemeta jsonschema CLI, pinned in
tools/pins/jsonschema.json (version and SHA-256 of every platform asset) and
installed with go run ./tools/install-jsonschema. It is AGPL-3.0 licensed,
runs only as a separate process on the maintainer side, without network
access, and is never distributed with Schepherd.
prepared.json records the recipe
schepherd-prepare/2+sourcemeta-jsonschema-<pinned version>, also pinned as
a constant in api/prepared.schema.json. publish refuses a prepared set
made with another recipe (exit code 2). A bundler upgrade reaches published
schemas only through --refresh-all; each whose bytes change is then
published as changed. Golden files in testdata/publisher/bundle/*.golden
pin the bundler's exact output: after an upgrade, review their diff and
regenerate them with
go test ./internal/publisher/bundle -run TestPinnedBundlerOutput -update.
Fetch safety¶
Per schema: at most 8 levels and 64 dependency documents, 16 MiB per document
and 256 MiB in total. By default the fetcher accepts HTTPS only, refuses
private, loopback and link-local addresses (checked on the dialed IP),
re-validates redirects, refuses downgrades and limits response sizes. A local
source can relax the first two for private or test setups with [fetch].
Stable IDs¶
IDs never come from display names:
- an explicit override in
sources/ids.json({"<source URL>": "<id>"}; any spelling of a SchemaStore URL works as the key, and so does a fragment URL); - otherwise the ID the same source has in the state (
catalog/state.json); - otherwise a slug of the URL's file name (
…/github-workflow.json→github-workflow).
When two sources would get the same ID, the one that already owned it keeps
it and the others get a deterministic suffix; the report lists every such
collision. Renames are done with an override; the old ID stays in the catalog
with its last artifact, held as removed-upstream, and stays reserved.
Licenses¶
A publicly reachable URL is not permission to redistribute. The policy file
(sources/licenses.toml) holds explicit rules:
[[rules]]
id = "schemastore"
decision = "allow" # allow | exclude | review
hosts = ["www.schemastore.org", "json.schemastore.org"]
path_prefix = "/" # optional
license = "Apache-2.0"
notice_file = "notices/schemastore.txt"
reason = "why this decision was made"
- A rule matches by exact
urls, or byhosts(optionally narrowed bypath_prefix).excludeandreviewrules may also listfile_names, which match every spelling of a file on the rule's hosts under its prefix (with or without.json, case-insensitive). Inlinenoticetext can replacenotice_file. - For one URL the most specific rule decides: exact
urls, thenfile_namesrules, then host rules by the longestpath_prefix. Across a source and its dependencies,excludewins overreview, which wins overallow. A schema is included only if its own source and every bundled dependency are allowed; anything without a matching rule ispending-reviewunless automatic detection allows it. - On
github.comandraw.githubusercontent.comthe owner and repository segments match in any letter case, as GitHub resolves them, so a rule for/SchemaStore/schemastore/also covers/schemastore/SchemaStore/. - A license value that asserts nothing never allows publication. An
allowrule'slicensemust be an SPDX license expression that does not useNOASSERTION,NONE,OTHER,UNKNOWNorUNLICENSED(any letter case) as an identifier;LicenseRef-…is fine, npm'sSEE LICENSE IN <file>and free text such asApache 2.0are not. A policy with such an allow rule is refused (exit code 2);excludeandreviewrules may record such a value for information. A local source entry or[[documents]]declaration with such a license is held for review even when an allow rule covers its URL, and aprovenance.licenseinprepared.jsonthat fails the check makes the prepared set invalid forpublish(exit code 5).
The shipped policy allows files of the SchemaStore repository under its
repository license (Apache-2.0), holds back four files whose comments mention
third-party content (under every spelling of their URLs), and enables
automatic detection for everything else. The notice layer of a SchemaStore
file carries the rule's notice (sources/notices/schemastore.txt) followed
by the repository's LICENSE (whose section 4(a) requires copies to carry
it) and NOTICE files from the snapshot.
THIRD_PARTY_NOTICES.md lists the schemas of the recorded catalog grouped by
the source of their license. It is generated by
go run ./tools/release notices generate, and the weekly job regenerates and
commits it with every catalog revision it records.
Automatic license detection¶
Sources outside the SchemaStore repository that no rule matches get their license detected at a pinned point, and a permissive license on the allow list publishes them:
[auto]
enabled = true # default false when the section or the key is absent
allow = ["MIT", "MIT-0", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", "0BSD", "CC0-1.0", "Unlicense", "BlueOak-1.0.0"]
hosts = ["api.github.com", "registry.npmjs.org", "unpkg.com", "cdn.jsdelivr.net"]
allowlists SPDX license identifiers, compared case-insensitively; the default is the list above. An empty list, an expression, a value that asserts nothing, aLicenseRef-…orDocumentRef-…identifier (whose meaning differs per source) or a duplicate is a usage error (exit code 2).hostslists the services detection may contact, a subset of the four above (default: all). Unknown keys and keys in the wrong letter case are refused.
Explicit rules come first, then the license declarations of a local source, then detection: only URLs that no rule matches are detected.
| Source URL | How the license is found |
|---|---|
raw.githubusercontent.com/<owner>/<repo>/<ref>/<path>, github.com/<owner>/<repo>/raw/<ref>/<path> or /blob/<ref>/<path> |
The ref (a branch, tag or commit, or refs/heads/<name> or refs/tags/<name>; a branch containing / is read as its first segment) is resolved to a commit through the GitHub REST API; the license comes from GET /repos/{owner}/{repo}/license?ref=<commit>, and for Apache-2.0 the NOTICE, NOTICE.txt or NOTICE.md file at that commit. A renamed branch is resolved through GET /repos/{owner}/{repo}/branches/{ref}. |
unpkg.com/<package>@<version>/<path>, cdn.jsdelivr.net/npm/<package>@<version>/<path> |
The version must be exact (no range, tag or build metadata). The license comes from the npm registry metadata of that version; the LICENSE or LICENCE file (and for Apache-2.0 the NOTICE file) from the same CDN at that version. |
Anything else, including URLs with a query, is unsupported-host. Each URL
is requested once and each repository ref or package version is detected
once per run. A detected expression allows the source only when it asserts a
license and the allow list permits it: an OR needs one permitted operand,
an AND all of them (SPDX precedence), and WITH exceptions and
LicenseRef-… identifiers are never auto-allowed. Everything else stays
pending-review with a reason (a published schema is held instead, see
Published schemas):
| Reason | Meaning |
|---|---|
no-license |
no license file, or its text cannot travel with the schema (over 64 KiB, not UTF-8, control or bidirectional characters) |
not-permissive |
the detected license is not permitted by allow |
not-asserted |
the detected value names no license (NOASSERTION, …) or is not an SPDX expression |
unsupported-host |
the URL is not a pinnable GitHub or npm CDN location, or detection may not contact the service it needs |
fetch-failed |
a request failed, or a service's rate limit stopped detection |
An auto-allowed schema carries the license text, and for Apache-2.0 the
NOTICE text, in its notice layer:
License of the GitHub repository <owner>/<repo> at <ref> (SPDX: <expression>), file <path>:
<license text>
NOTICE of the GitHub repository <owner>/<repo> at <ref>, file <name>:
<NOTICE text, only for Apache-2.0 when the repository has one>
For npm sources the headers name the npm package <name> version <version>.
The header names the ref as the URL does, not the resolved commit, so every
schema of one source shares one notice blob and the text changes only when
the license text does. A schema whose combined notices exceed 768 KiB is
held for review.
GitHub token and rate limits. The publisher sends GITHUB_TOKEN, when
set, to api.github.com only. A value with whitespace or control characters
inside, or a token GitHub rejects (HTTP 401), is a usage error (exit code 2),
so a broken token cannot silently keep new schemas out. A full SchemaStore
import needs roughly 900 GitHub requests, far above the 60 anonymous
requests per hour; only the first import, --refresh-all and new or changed
sources need detection. When a service refuses a request for its rate limit
and names when the limit resets (X-RateLimit-Reset or Retry-After),
detection pauses that service and retries: up to 65 minutes with a GitHub
token, up to 2 minutes without one, at most four times per run. A later
reset, no reset time or a fifth limit stops all requests to that service for
the rest of the run; the sources that still need it stay pending-review
(fetch-failed).
Detection results are recorded where they cannot change the catalog on their
own: report.json lists the findings for every document of every decided
record (licenseDetections) and counts them in totals.licenseDetection;
prepared.json and the state record the decision each entry was published
with (licenseDecision), which pins the allowing detections (source is
github:<owner>/<repo>@<commit> or npm:<package>@<version>). The catalog
carries only the combined expression in provenance.license.
Comparing with the state¶
schepherd-publisher diff --prepared <dir> --state <file> [--json] compares a
prepared set with the state exactly as publish would, without contacting a
registry, and exits 0 whether or not anything changed. The prepared set must
have been prepared with that state: every schema of the recorded catalog must
be an entry, held or excluded, and a reused entry must carry the recorded
artifact. Otherwise diff, like publish, exits with code 2 and the
prepared set was not prepared with this publisher state: …. A prepared set
compared with the state its own publication wrote shows no changes.
{
"hasChanges": false,
"added": [],
"changed": [],
"metadataChanged": [],
"held": [{ "id": "tool", "reason": "fetch-failed" }],
"excluded": [],
"unchanged": 646
}
| Member | Meaning |
|---|---|
hasChanges |
Whether publish would make a new revision: added, changed, metadataChanged or excluded is not empty. A hold, or its end, never sets it alone. |
added |
IDs new to the catalog: never published, or returning after an exclusion. |
changed |
IDs whose artifact changes: new schema bytes or a new notice. |
metadataChanged |
IDs that keep their artifact while their entry changes (name, description, fileMatch, dialect or provenance). |
held |
Every schema the new catalog keeps with its last entry and artifact, with its reason. Held entries count in unchanged. |
excluded |
IDs an explicit exclude rule removes now. Records the state already marks excluded are not listed again. |
unchanged |
Entries of the new catalog that equal the recorded ones. |
Every array is present and sorted by ID, and the new catalog has added +
changed + metadataChanged + unchanged entries. Without a state everything is
added.
Publishing¶
publish needs no previous catalog from the registry: the state decides what
is new.
- Compare the prepared set with the state, as
diffdoes. When the catalog would not change, the publication is anoop: no registry request, no revision, and the state written to--state-outis byte-identical to--state. A hold alone therefore never makes a revision. With--update-latesta noop only movescatalog-latestto the state's catalog, after checking thatcatalog-<revision>names exactly that catalog index (exit code 5 otherwise). Without--statethere is never a noop: identical content resumes the newest revision instead. - Pack new and changed content (recipe
schepherd-pack/1, see OCI format). Identical content produces identical digests, independent of the date, the host and the repository name. - Choose the revision. When the newest
catalog-<revision>in the repository is newer than the state's revision and its catalog has the same content, that revision is resumed: nothing is uploaded again, only missing tags are created, and the same state is written. That is what a run interrupted before the state was written leaves behind. Otherwise the revision is the UTC minute of the publication. It must be newer than every revision in the repository and in the state: the same minute with other content, or a clock behind, is refused with exit code 2 (… publish again in a later minute). A newest tag in a newer Schepherd wire format stopspublishwith exit code 2 before anything is written. - Push schemas. Missing blobs and manifests are uploaded, without tags
of their own, and every entry is verified to resolve in the repository. A recorded artifact missing from the repository is pushed
again when packing reproduces it byte for byte; otherwise
publishstops with exit code 5 before any catalog exists. Reused and held entries carry no schema bytes, so publish a set prepared against a state only into the registry that state describes, or into a mirror of its catalog. - Push the catalog. The revision tag is checked first, then
catalog.json, the catalog metadata manifest and finally the catalog index, which references the metadata manifest and every schema artifact (see OCI format), are pushed and the index is taggedcatalog-<revision>. A minute that another catalog took in the meantime gets the same exit-2 refusal. An index larger than 4 MiB, which clients and registries refuse, stopspublishwith exit code 5 before anything is pushed. Published tags never move, and these are the only tags besidescatalog-latest: the index keeps the schema artifacts alive. - Write the new state to
--state-out(which needs--stateand may be the same file; its path is checked before any work starts). When writing fails, the catalog exists butpublishexits non-zero; the next run resumes the revision and writes the state. - Only with
--update-latest, and only after all of the above succeeded, movecatalog-latest.
Registry settings (plain HTTP, extra CA bundle, credentials file) come from
--registry-config, a TOML file with the same [registries."<key>"] tables
as the client configuration (see
Registries); relative paths in
it resolve against its directory.
Artifact reuse¶
An unchanged schema is published once and keeps its artifact digest. Reused
and held entries keep the recorded artifact, and so does a schema prepared in
full whose contentDigest and noticeDigest equal the recorded ones, even if
packing would produce other bytes today (a new packing recipe or Go
toolchain, for example); such entries are listed in keptArtifacts with a
warning. A new notice for unchanged schema bytes is new content: the schema
is uploaded again and reported as changed, so an artifact always carries
the notice of the license its entry names.
Result¶
publish --json prints:
{
"status": "published",
"repository": "registry.example/org/schemas",
"revision": "20260928.0300",
"catalogDigest": "sha256:…",
"added": ["new-tool"],
"changed": ["package"],
"metadataChanged": ["renovate"],
"removedUpstream": ["gone"],
"held": [
{ "id": "gone", "reason": "removed-upstream" },
{ "id": "tool", "reason": "fetch-failed" }
],
"excluded": [],
"keptArtifacts": [],
"tags": { "created": ["catalog-20260928.0300", "…"], "existing": ["…"] },
"catalogSize": 512,
"unchanged": 646,
"uploadedSchemas": 2,
"reusedSchemas": 647
}
| Member | Meaning |
|---|---|
status |
noop, published or resumed |
revision |
the new revision; for a noop the state's |
catalogDigest |
digest of the catalog index, which clients pin (catalogSize is its size) |
added, changed, metadataChanged, held, excluded, unchanged |
as in diff; a noop lists the current holds |
removedUpstream |
the IDs of held whose reason is removed-upstream |
keptArtifacts |
IDs prepared in full that would pack to another artifact today and kept their recorded one |
tags |
tags created (or moved, for catalog-latest) and tags that already pointed right |
uploadedSchemas |
distinct schema artifacts this run pushed |
reusedSchemas |
entries that kept the artifact the state records, such as unchanged, held and metadata-only changed ones |
Moving catalog-latest¶
schepherd-publisher latest --repository <repo> --state <file> [--check]
points catalog-latest at the catalog the state records, without a prepared
set. It first checks that catalog-<revision> names exactly the recorded
catalog index and that this catalog holds the recorded revision and entries;
otherwise it exits with code 5 and moves nothing. --check only verifies. A
missing state file is a usage error (exit code 2). --json prints
{repository, revision, catalogDigest, latest, catalogSize}, where latest
is moved, current or checked.
State¶
catalog/state.json is the record in Git of what the registry holds. The
publisher decides from it alone whether anything changed, which artifacts and
license decisions to reuse and which IDs stay reserved. Its JSON Schema is
api/state.schema.json.
{
"formatVersion": 1,
"source": {
"kind": "schemastore",
"repository": "https://github.com/SchemaStore/schemastore",
"commit": "05b037b7a2b68ead6893c857db2594bcad9951d4",
"tarballDigest": "sha256:…"
},
"recipe": "schepherd-prepare/2+sourcemeta-jsonschema-16.12.0",
"catalog": { "revision": "20261012.0300", "digest": "sha256:…", "size": 512 },
"schemas": [
{
"id": "tool",
"contentDigest": "sha256:…",
"noticeDigest": "sha256:…",
"firstRevision": "20260928.0300",
"artifactRevision": "20260928.0300",
"lastChangedRevision": "20261005.0300",
"heldSinceRevision": "20261012.0300",
"heldReason": "fetch-failed",
"licenseDecision": { "rules": ["tool-vendor"] },
"entry": { "id": "tool", "artifact": { "…": "…" }, "…": "…" }
}
]
}
| Member | Meaning |
|---|---|
source |
the upstream input of the current catalog: kind schemastore with repository, commit and tarball digest, or kind local with only a name |
recipe |
the prepare recipe of the set the current catalog was published from; reused and held entries may keep artifacts of earlier recipes |
catalog |
revision, digest and size of the current catalog index |
schemas[].contentDigest |
SHA-256 of the prepared schema bytes; while it and noticeDigest stay the same, the artifact in entry is reused |
schemas[].noticeDigest |
SHA-256 of the notice text; absent when the artifact has none |
schemas[].firstRevision |
the revision that added the schema, again after an exclusion |
schemas[].artifactRevision |
present only when the last change was metadata-only: the revision that gave the entry its current artifact |
schemas[].lastChangedRevision |
the revision that last changed the entry or its artifact; a hold or an exclusion does not move it |
schemas[].heldSinceRevision |
present while the schema is held: the first published revision that kept it without refreshing it |
schemas[].heldReason |
present exactly when heldSinceRevision is: the reason of the latest hold |
schemas[].excludedRevision |
present while an exclude rule keeps the schema out: the revision that dropped it; entry is the entry it last had |
schemas[].licenseDecision |
the decision the entry was published with: allowing rule IDs (rules), allowing detections sorted by URL (detections) and redirects that served its documents sorted by url (redirects); absent when all three are empty |
schemas[].entry |
the catalog entry exactly as in catalog.json |
Rules the publisher enforces beyond the JSON Schema:
- Schemas are sorted by ID, excluded ones included, and
idequalsentry.id. The entries that are not excluded form a valid catalog with provenance. - Revisions are real UTC minutes, ordered as follows (a missing
artifactRevisiondrops out of the first chain): firstRevision <= artifactRevision < lastChangedRevision <= catalog.revisionlastChangedRevision < heldSinceRevision <= catalog.revisionlastChangedRevision < excludedRevision <= catalog.revision- A record is never held and excluded at once.
- Redirects are unique and sorted by
url, atargetdiffers from itsurl, and both arehttporhttpsURLs of printable ASCII without credentials or fragment.
A formatVersion other than 1 is a usage error (exit code 2); any other
defect is an integrity error (exit code 5).
What a publication of revision R writes, which is also how the release notes classify each schema:
| The schema is | The state records | Release notes |
|---|---|---|
| added | firstRevision and lastChangedRevision R; no artifactRevision or excludedRevision |
Added |
| changed | lastChangedRevision R; no artifactRevision |
Changed |
| metadata-only changed | artifactRevision the revision of its current artifact, lastChangedRevision R |
Metadata updated |
| held | heldSinceRevision R unless it was already held, and the current heldReason |
Held (unchanged) |
| refreshed after a hold | no heldSinceRevision or heldReason |
by what changed |
| excluded | excludedRevision R and no hold; entry stays as last published |
Excluded |
The encoding is canonical (fixed member order, two-space indentation, a final
newline) and has no timestamps, so the file changes only when the published
content changes. Only schepherd-publisher publish --state-out writes it,
and in this repository only the weekly bot commits it; never edit it by hand.
Coverage of the SchemaStore import¶
Measured at the imported commit 05b037b with the shipped configuration (the
rules of sources/licenses.toml and automatic license detection with a
read-only GitHub token):
- 1,474 upstream records, 990 catalog entries (991 included records, one merged into another entry that shares its URL);
- 451
pending-review: 447 whose license detection allowed nothing (267unsupported-host, 66no-license, 56not-asserted, 46not-permissive, 12fetch-failed) and 4 held by the policy's review rules; - 0 excluded;
- 32 failed: 7
id-mismatch, 5unsupported-dialect, 5top-level-ref-draft7, 5invalid-json, 3undeclared-dialect, 3fetch-failed, 2unresolved-ref, 1invalid-schemaand 1duplicate-keys; - 80 bundled (32 behaviour-compared, 48 structural-only), 910 compacted only, 2 extended-glob patterns dropped, 1,102 version URLs not published.
The published snapshot is therefore not "all of SchemaStore"; the report
lists the fate of every record. The numbers move with upstream: the job
summary of every weekly run that prepares upstream shows the current
coverage. To measure it yourself, run prepare without --state (see
below) and read jq .totals <out>/report.json.
Running it locally¶
A maintainer can replay the registry side of a weekly run against a local
registry. The Git side (catalogbot commit and release) is covered by
tools/catalogbot tests against a fake GitHub API; never run it by hand
against the real repository. Set GITHUB_TOKEN to a read-only token for
prepare, or GitHub's anonymous rate limit leaves sources that need
detection out or held.
pnpm build # dist/bin/schepherd and dist/bin/schepherd-publisher
go run ./tools/install-jsonschema
mkdir -p dist/update
upstream="$(git ls-remote https://github.com/SchemaStore/schemastore refs/heads/master | cut -f1)"
go run ./tools/catalogbot set-commit --source sources/schemastore.toml --commit "$upstream" \
--out dist/update/schemastore.toml
dist/bin/schepherd-publisher prepare --source dist/update/schemastore.toml --policy sources/licenses.toml \
--ids sources/ids.json --state catalog/state.json --snapshot-dir dist/update/snapshot \
--out dist/update/prepared --json > dist/update/prepare.json
dist/bin/schepherd-publisher diff --prepared dist/update/prepared --state catalog/state.json # what the weekly job would publish
docker run --detach --name schepherd-local-registry --publish 127.0.0.1:5000:5000 \
distribution/distribution:3.1.2@sha256:d106962e6fe3fa69c178cec77eeeee5edb626b8df6535b7bd6b95e658a0a5ca9
printf '[registries."localhost:5000"]\nplain_http = true\n' > dist/update/registries.toml
printf 'config_version = 1\n[registries."localhost:5000"]\nplain_http = true\n' > dist/update/client.toml
base=dist/update/none.json # does not exist: no publication yet
if [ -f catalog/state.json ]; then # reused and held entries need the recorded artifacts
dist/bin/schepherd --config dist/update/client.toml mirror \
"ghcr.io/ovineko/schepherd-schemas@$(jq -r .catalog.digest catalog/state.json)" localhost:5000/local/schemas
base=catalog/state.json
fi
pub=(dist/bin/schepherd-publisher publish --prepared dist/update/prepared --repository localhost:5000/local/schemas
--registry-config dist/update/registries.toml --json)
"${pub[@]}" --state "$base" --state-out dist/update/state.json --update-latest > dist/update/publish.json
"${pub[@]}" --state dist/update/state.json # status "noop", no registry request
go run ./tools/catalogbot notes --result dist/update/publish.json --state dist/update/state.json \
--repository localhost:5000/local/schemas
A set prepared against catalog/state.json can only be published against
that state, and its reused and held entries point at artifacts that only the
recorded catalog holds, which is why the local registry first receives a
mirror of it (the weekly test job does the same). To measure coverage from
scratch, drop --state from prepare and publish against the missing
dist/update/none.json.