Skip to content

Software Bill of Materials ​

What is inside each published Hegemony image, generated from committed files and reproducible byte for byte. The documents live in sbom/ and are regenerated with task sbom:generate; task sbom:verify fails if they are out of date.

A software bill of materials (SBOM) is a machine-readable list of everything a piece of software is made of: each dependency, its version, where it came from, and, where known, its licence. Vulnerability scanners read one instead of guessing, and auditors read one instead of asking.

What you get ​

Six CycloneDX 1.6 JSON documents. Five describe the published container images; one describes the platform as a whole.

FileDescribes
sbom/hegemony-api.cdx.jsonThe api image: base dependency set plus the api extra
sbom/hegemony-worker.cdx.jsonThe worker image: worker extra, plus two Go programs built from source
sbom/hegemony-scheduler.cdx.jsonThe scheduler image: scheduler extra
sbom/hegemony-ui.cdx.jsonThe ui image: nginx plus the npm trees the bundle was built from
sbom/hegemony-egress-nflog.cdx.jsonThe egress-nflog sidecar: two Debian packages on Debian
sbom/hegemony-platform.cdx.jsonAll of the above, the services a deployment runs beside them, and the development and test closure

Each document is self-contained, so a scanner can be pointed at one file without following references. In the platform document, each of Hegemony's own images also carries an externalReferences entry of type bom pointing at the document that details it, so a reader holding only the platform document can find the rest.

Where the facts come from ​

Four surfaces, four files, no network and no installed environment:

SourceContributes
uv.lockEvery Python distribution, per image, with hashes
apps/ui/package-lock.jsonEvery npm package, with integrity hashes
deploy/compose/Dockerfile.*Base images, OS packages, and programs compiled from upstream source
deploy/.images-allowed.txt, deploy/.os-packages-allowed.txtThe reviewed licence and tier for each image and OS package
pyproject.tomlHegemony's own version and licence

Every document records its own inputs under metadata.properties, so a reader who disagrees with the inventory knows which file to change.

Why not a container scanner ​

trivy image --format cyclonedx produces a good SBOM and answers a different question. It needs the built image, so it needs a registry, a network and a Docker daemon; it reports what one build happened to contain; and its output is neither reviewable in a pull request nor reproducible from a checkout.

This generator answers what the committed sources declare, which makes it reviewable and reproducible. The two are complementary and both are worth running: a scanner finds what a build added that nobody declared, and this finds what was declared. task security:trivy:images is the scanner half.

Determinism ​

The output is a pure function of the inputs. Same repository, same bytes -- on any machine, any Python 3.11 or later, any locale, with no virtual environment, node_modules, sibling checkout or network access.

That is what makes committing the documents worthwhile: a dependency change and the change to what the platform contains land in one commit, and --check proves they cannot come apart. The rules, all enforced by tests in tests/deploy/test_sbom.py:

  • No clock. metadata.timestamp is omitted. Pass --timestamp to add one for a release artefact. It is deliberately not read from the environment -- not even SOURCE_DATE_EPOCH -- because an ambient variable in one developer's shell would fail the gate for them alone.
  • No randomness. serialNumber is a UUID version 5 over the document's own content, not a version 4. Identical inputs give an identical serial number; any change to the inventory gives a new one.
  • Total ordering. Every list is sorted by an explicit key, ties included, so nothing depends on the order things were discovered or on PYTHONHASHSEED.
  • Canonical JSON. Sorted keys, two-space indent, UTF-8, LF, one trailing newline.
  • A tool version that does not churn. The recorded generator version is the output format version, bumped only when the shape of a document changes.
  • No locale, no platform. Every file is read and written as UTF-8 explicitly: without that, deploy/.images-allowed.txt fails to decode under LC_ALL=C and mojibakes under cp1252, because its SPDX header carries a non-ASCII name. Paths are emitted POSIX-style so a Windows checkout writes the same bytes, and the output holds no floats, whose rendering is a CPython implementation detail. The test matrix runs the generator under LC_ALL=C and asserts the bytes match the committed files, and adds a Turkish locale — the case-folding trap — only where the host has generated one, since exporting LC_ALL to a locale that does not exist leaves the interpreter in C and proves nothing. GitHub's runners generate only C and C.UTF-8, so that leg does not run in CI.

What the documents do not know ​

An SBOM that overclaims is worse than one with stated gaps. Each gap below is recorded in the output rather than filled in with a guess, and CycloneDX has a construct for saying so -- an omitted field for "unknown", and a composition for "not enumerated".

Licences of PyPI packages were the largest gap and are now filled. uv.lock has no licence field — the key is absent from its schema, not merely empty — so every PyPI component once carried no licenses at all, while the npm half of the same documents sat near complete because package-lock.json does record one. That asymmetry was a property of the two lockfile formats, not a decision.

Reading the licences out of installed packages, as pip-licenses does, would have cost the property that makes these documents checkable: scripts/generate-sbom.py reads committed files only, so an auditor can reproduce them from a bare clone and sbom:verify can run with no install. THIRD-PARTY-LICENSES.json keeps both. It is generated by task licenses:python from the exact pinned version's PyPI metadata — immutable for a released version, so asking for it is reproducible — committed, and read as one more committed input.

Each entry records how the licence was decided, so a reader can tell an assertion from an inference: reviewed (a person read the licence files the distribution ships and recorded what they say), expression (PEP 639 license_expression, already SPDX), declared (the legacy license field), classifier (derived from a trove classifier, and only when exactly one maps), or unknown. An unknown emits no licence rather than a guess.

reviewed wins over everything, including PyPI: it is a hand-written table keyed name==version in _REVIEWED in scripts/generate-python-licenses.py, and it is where a deprecated or ambiguous upstream value gets resolved -- paramiko declaring LGPL-2.1, which does not say whether "or later" applies, is the case it was written for. Keying on the exact version is what makes it safe: a version bump stops the override matching and the gate fails until the new release is re-read.

Two gates keep it from rotting back: task licenses:python:verify (offline, in the static-checks job) fails when a dependency bump leaves the map behind, and test_every_python_licence_is_traceable_to_the_committed_map fails if a licence ever appears that the map does not state.

Licences of the first-party plugin wheels. The hegemony-* distributions resolve to sibling checkouts. They are flagged hegemony:first-party -- derivable from [tool.uv.sources] -- but no licence is asserted, because each sibling's licence is a fact about another repository. They carry no package URL either, for the same reason: a pkg:pypi/... would name a registry release that may not be these bytes. Those licences are checked where they are stated, by task licenses:plugins, which also runs each plugin repository's own image licence gate at its pin; the images a plugin repository publishes (today ghcr.io/hegemony-sh/ansible-runner and ghcr.io/hegemony-sh/opentofu-runner) are outside these bills of materials and carry their own notices (see LICENSING.md, "First-party plugin repositories").

Versions of OS packages. No apt-get install in this repository pins one, so the version is whatever the base image's package index resolved at build time. The package URL is emitted without a version and the component says hegemony:os:pinned: false.

Contents of container images. Nothing here enumerates what is inside postgres:15-alpine, or even python:3.12-slim. Every container component is covered by a composition with aggregate: incomplete, and their internal dependencies by one with aggregate: unknown.

What a base image does state is its licences. deploy/.images-allowed.txt names the ones known to apply -- python is Python-2.0 AND GPL-2.0-or-later AND GPL-3.0-or-later AND LGPL-2.1-or-later -- and the incomplete composition above says the enumeration is not exhaustive. Read together they mean "at least these, and there is more in there", which is the true statement and one a policy engine can act on.

That field used to hold a marker, OS-AGGREGATE, on the grounds that no single identifier is truthful for a whole distribution userland. The reasoning was right and the encoding was the opposite of honest: CycloneDX has no place for a marker, so it rendered as license.name, and a scanner then recorded these images as carrying a licence called OS-AGGREGATE. A rule looking for components with no licence did not fire, and neither did a rule looking for GPL-2.0 -- on five images whose userlands contain it, and which the allowlist itself said so in prose two fields away. test_no_licence_is_emitted_as_free_text now fails if anything reaches license.name again, from either half of the inventory.

Which artefact a hash describes. A component carries at most one digest per algorithm, and it is the wheel's -- but only where a release ships a single py3-none-any wheel, the file every platform installs. Where a release ships several, no single hash is true of the component and none is given; the source release's digest is recorded as hegemony:pypi:sdist-sha256 instead, where its meaning is explicit. Emitting both as bare SHA-256 entries would be two contradictory claims with nothing to tell them apart.

Which bytes an unpinned tag named. Every base image the five published Dockerfiles convey is now pinned by digest, so where a digest exists it is the component's version and the tag is kept as a package-URL qualifier. Every base the five Dockerfiles use is pinned, conveyed or not — including the node:24-alpine builder stage in Dockerfile.ui.prod, which ships nothing but compiles the bundle that does, so a moving toolchain is a supply-chain path to a conveyed artefact. The same base stays floating in the dev Dockerfile.ui, where Node is the runtime rather than the compiler and wants security rebuilds.

What remains hegemony:image:pinned: tag-only is the external services in the Compose overlays (PostgreSQL, Temporal, Keycloak, OpenBao, Caddy, zot and the rest), and that is deliberate. Hegemony conveys none of their bytes; the operator pulls them. Pinning those digests here would freeze an operator's supply chain from this repository, and with no automated digest-bumping they would rot silently. The document records the tag and says the version is a tag, not a digest, which is the honest claim.

Which tag an operator will deploy. The compose files name Hegemony's own images as ghcr.io/.../api:${HEGEMONY_IMAGE_TAG:?...}, and the operator's Docker resolves that. Those components carry no version at all and say hegemony:image:pinned: unresolved-tag, with the deciding variable recorded as hegemony:image:tag-source. Copying the shell text into a version field would put braces, a default and an error message into a package URL -- worse than admitting the version is unknown, because it looks like data and a tool would parse it.

Whether a conditional requirement is installed. Some dependency edges in uv.lock carry a PEP 508 environment marker. Evaluating one would make the output depend on the machine that generated it, so marked edges are followed unconditionally and the marker is recorded as hegemony:pypi:marker. The documents therefore over-report slightly -- Windows-only colorama is listed for a Linux image -- which is the safe direction: a listed package that is absent is a false positive a reader can dismiss, and an installed package that is unlisted is the failure an SBOM exists to prevent.

A package required unconditionally by any requirement carries no marker, even where another requirement for it is conditional. greenlet is the case: marked under sqlalchemy, unmarked under sqlalchemy[asyncio], and installed everywhere because all three Python images take the extra. Where several markers are recorded, the package is installed when any of them holds -- they are alternatives, not a conjunction.

Reading the documents ​

Beyond the standard CycloneDX fields:

PropertyMeaning
hegemony:sbom:inputA file this document was derived from
hegemony:npm:integrityabsent where the lockfile records no integrity hash
hegemony:first-partyA distribution built from a sibling checkout, not a registry
hegemony:pypi:markerThe environment marker under which a requirement applies
hegemony:pypi:wheelsHow many per-platform wheels exist, when no single hash is truthful
hegemony:pypi:sdist-sha256The source release's digest, named rather than left unlabelled in hashes
hegemony:pypi:treedevelopment for the test and lint closure, source-copied for a first-party tree a Dockerfile copies in without installing (platform document only)
hegemony:npm:treeproduction or development
hegemony:npm:optionalnpm reached it only through an optional edge, so it may be absent from a given install
hegemony:image:roleruntime-base, copied-in, build-stage, or service
hegemony:image:tierFrom the image allowlist: first-party, base-image, external-service
hegemony:image:pinnedtag-only, no-tag, or unresolved-tag when the operator supplies the tag
hegemony:image:tag-sourceThe shell variable that decides an unresolved tag
hegemony:os:tierFrom the OS-package allowlist: exec, linked, or build-only
hegemony:source-build:revisionThe commit a program compiled from a Git tag was verified against
hegemony:source-build:packageThe go install path, where it is a command inside the module
hegemony:npm:bundlesPackages whose code is physically inside this tarball, and which are also listed separately

scope carries real information. required means the component is part of the artefact, or of what it was built out of. optional means it is in the artefact but not needed by it -- build tooling left behind in the image, such as the build-only gcc, or a service only a named overlay starts. excluded means it was present in the build and is not in the deployed artefact -- a discarded Go toolchain stage, the npm development tree -- and only those can be left out of a vulnerability assessment of the deployed image. An optional component is in the image and belongs in that assessment.

Two cases worth knowing about ​

The ui image contains no npm package. Dockerfile.ui.prod runs npm ci and npm run build in a node:24-alpine stage, then starts again from nginx:alpine and copies in only the built dist/. So "drop the devDependencies" would be wrong in both directions: it would drop packages whose code a bundler inlines into the bundle, and it would imply that everything remaining is present in the runtime image, which none of it is. Both trees are listed, distinguished by scope.

The worker image ships two Go programs. It compiles shoutrrr and the Docker CLI from upstream source in discarded golang: stages, so that both link a patched Go stdlib. Neither is a PyPI or npm distribution, neither is installed by apt or apk, and neither is part of the base image -- no other inventory in this repository can see them. One of them runs against a mounted host Docker socket, which makes it the most privileged binary in the deployment and the worst one to leave unlisted.

Regulatory framing ​

The seven NTIA minimum elements, honestly:

ElementStatus
Author of SBOM dataPresent — metadata.tools names the generator, metadata.supplier names Hegemony
Component namePresent for every component
Version of the componentPartial — absent for the OS packages, which are installed unpinned, and for the image references whose tag an operator supplies, for the reasons under "What the documents do not know"
Other unique identifiersPartial — a bom-ref on every component; a package URL on every component except the first-party hegemony-* distributions, which have none to name (see "Licences of the first-party plugin wheels")
Dependency relationshipPresent for the lockfile-derived components; declared unknown, via a composition, for images, OS packages and the Go builds
Supplier nameNot present per component. The only supplier in the documents is metadata.supplier, which is who produced the SBOM, not who supplies react or PostgreSQL. Deriving a per-component supplier would mean querying registries, which this generator does not do
TimestampSupplied at publication with --timestamp; absent from the committed files, which cannot carry one and stay reproducible

Licence data is present for PyPI packages, npm packages, images, OS packages and the source-built Go programs. It is absent in two cases, and the summary says which one applies to each such component: either a first-party hegemony-* distribution, whose licence is asserted by its sibling repository rather than by this one, or an upstream package that declares no licence at all. Only the second is a gap worth chasing. The NTIA minimum elements do not require licence data at all; it is here because the question this repository is most often asked is a licensing one.

Only CycloneDX is emitted. A second format would double the generated bytes committed here, and double what a dependency bump rewrites, in exchange for a translation of the same facts. If SPDX is ever required, convert rather than regenerate: cyclonedx-cli convert --input-file sbom/hegemony-platform.cdx.json --output-format spdxjson.

Reading the documents against the licensing model ​

Hegemony is licensed under AGPL-3.0-or-later (see LICENSING.md), and an SBOM is read by exactly the people who need to know what that means for a deployment. Three things in the documents carry that weight.

Everything linked is AGPL-compatible, and the documents show it. LICENSING.md's load-bearing rule is that a library linked into a Hegemony process forms one work with Hegemony's own code, so its licence must be compatible with AGPL-3.0-or-later. Every OS package carries its tier, so the rule is checkable over the generated inventory — and it is checked, by test_no_agpl_incompatible_licence_on_a_linked_component. The check fails closed: a licence counts as compatible only when it is on the test's list of licences known to be, so one nobody has reviewed fails. The only linked package is libpq-dev (PostgreSQL licence), and every copyleft OS package is exec (git, procps, ulogd2), build-only (gcc), an external service, or Hegemony's own AGPL image.

The check covers the Python closure too, which is where the linkage actually happens. An OS package states how it is used through its tier, and only linked reaches Hegemony's code -- git is executed over a process boundary, which is mere aggregation. A PyPI distribution carries no tier because there is nothing to distinguish: a required wheel is imported into the process, so it is the linked case by construction. test_the_compatibility_check_actually_reaches_the_python_closure pins that population, because the earlier version of this check read the tier alone and therefore silently skipped every wheel in every document.

Four copyleft PyPI distributions ship inside published images, and all four are judged rather than skipped. paramiko and scp (LGPL-2.1-or-later, reached through netmiko) pass because LGPL-2.1 is AGPL-compatible -- it lets a recipient take the code under GPL-2.0 or any later version, section 6 preserves the relinking right, and they install as ordinary, replaceable modules. certifi (MPL-2.0) passes because MPL-2.0 is compatible with the GPL family and is file-level copyleft over files Hegemony does not modify. asyncssh (EPL-2.0 OR GPL-2.0-or-later, worker only) passes on its GPL arm: OR is a choice the distributor makes, so a dual-licensed component is incompatible only when every arm is.

Which arm is elected, and the wording of that election, remain a human judgement recorded in THIRD-PARTY-NOTICES.md. The gate asserts the weaker, general fact -- that a compatible arm exists -- which is what makes dual-licensed dependencies usable at all.

The exec/linked tier travels in type and description, not only in a property. It is the load-bearing claim in the licensing position — a copyleft program that is executed is mere aggregation, while the same licence on a library that is linked forms one work with Hegemony's own code — and a claim only an SBOM viewer's reader can check is worth nothing if the viewer cannot show it. Every OS package used to be type: library with no description, so a viewer rendered git (GPL-2.0-only, executed, harmless) and libpq-dev (linked) identically, when those two are the licensing opposites of each other. Now library is reserved for the linked tier, everything executed or build-only is an application, and the reason is spelled out in description. The hegemony:os:tier property stays as the machine-readable form.

scope distinguishes "contains" from "can contain". A service declared only in an opt-in Compose overlay is optional and names its overlay in hegemony:image:overlay. This matters to anyone assessing a deployment: LICENSING.md's position is that no default dev or prod deployment starts a third-party strong-copyleft or source-available service, and a document listing every overlay's services as required would contradict that in front of a procurement reviewer. (The case that drew the line, the demo's Vault (BUSL-1.1), has since left this repository altogether: its vault-external overlay lives in the demo-data repository now, so these documents no longer name it at all.) optional is also what a build-only OS package gets — present in the image, not needed by it, and not to be read as linked.

An overlay every launcher applies is not optional. docker-compose.dind.yml is an overlay in layout only: dc.sh and the compose tasks apply it to every stack, SERVICES=core included, because the Docker-in-Docker sandbox is the only daemon flow containers may run on. Its services — the docker:28-dind sandbox, the docker:28-cli one-shot that prepares it, and Hegemony's own egress-nflog image — are therefore required and name no overlay, exactly like a base file's. optional there would be the mirror of the error above: it would tell a reader the sandbox can be left out, and egress-nflog runs the GPL-2.0-only ulogd2 in every deployment.

Each subject cites the licensing model, not just an SPDX identifier. An identifier says which licence applies, not what it asks of a distributor or how this repository applies it to linked and executed components, and CycloneDX has no field for that. So the document's subject carries an externalReferences entry of type license pointing at LICENSING.md. The link is pinned to the release tag — blob/v3.1.0/LICENSING.md, built from the version in pyproject.toml — because terms a reader is pointed at must not move: an old document has to resolve to the terms its artefact shipped under, not to whatever the default branch says today.

Hegemony's own distributions are marked hegemony:first-party, because LICENSING.md's sentence that Hegemony's licence covers Hegemony's own code and not third-party software is only actionable if a reader can tell which is which.

None of this is a licence review, and the documents do not replace THIRD-PARTY-NOTICES.md, which carries the notices and the written source offer that the licences themselves require.

The licence summary ​

task sbom:generate also writes sbom/README.md: a reading guide to the six documents, for whoever reviews the licence position. The documents are the machine-readable answer and are unusable by that reader — the platform document alone is 920 components of JSON — so this states the same facts as tables: every licence with a component count and a link to its text at SPDX, the components that carry an obligation with how each one is used, the components that assert no licence and why, and how far each claim can be trusted.

It is named README.md so browsing sbom/ on a forge renders it above the files it describes. Handing counsel one directory link is the point.

It states no legal conclusion, and says so. Grouping licences into families (permissive, weak/strong/network copyleft, source-available) is an aid to reading a long list, not a claim about what any obligation requires. test_the_summary_states_that_it_is_not_legal_advice asserts the disclaimer is present, because the grouping reads like a legal reading without it.

Three properties are worth knowing:

  • It is emitted by generate-sbom.py, not by its own script. It lands in the same rendered map as the JSON, so --check compares it byte for byte and the pre-commit hook and the CI step that already watch sbom/ cover it. No new gate, and no way for it to drift from what it summarises.
  • Counts are of distinct components. The platform document is a superset of the five image documents, so summing per-document counts triple-counts most packages — MIT read 1342 for a set holding 670. A reviewer reads that column as "how many notices do I owe".
  • An unrecognised licence fails a test rather than being filed as permissive.classify returns unclassified for anything it does not name, and test_every_licence_in_the_documents_is_classified fails on it, so a licence arriving with a dependency bump is read by a person.

It also carries an obligation checklist: one row per licence, one column per thing the licence text asks of a distributor — reproduce notices, offer source, state changes, whether copyleft reaches the file or the combined work, whether it extends to network users. Each column says what sets it off, because that is what decides whether it applies: an obligation triggered by distribution does not attach to a component marked excluded.

That table summarises what the licences say. It is not a view on what they mean here, and asserts nothing about compliance. Two properties keep it honest: _OBLIGATIONS is keyed per identifier rather than per family, because the families disagree exactly where it matters (GPL-3.0 adds a patent clause GPL-2.0 has not; LGPL adds a relinking right; BSD-3-Clause adds a non-endorsement term BSD-2-Clause has not), and a licence with no entry fails test_every_licence_has_an_obligation_entry rather than rendering an empty row. An empty set is a valid answer — 0BSD and CC0-1.0 have one — but it has to be written down.

For a dual-licensed component the checklist shows the union of both arms. Narrowing it to the elected arm would hide the choice; the union is what lets a reader see what electing the other arm would cost. Which arm is elected is a decision, recorded in THIRD-PARTY-NOTICES.md.

Unlike sbom/*.cdx.json it is not marked linguist-generated: a new copyleft component or a licence that changed family is exactly what a reviewer should see in a pull request diff.

Changing the generator ​

The code is under scripts/sbom/, with scripts/generate-sbom.py as a thin command-line entry point. It is stdlib-only on purpose: an auditor should be able to clone this repository and reproduce the inventory with nothing but Python, and needing to install the dependency set first would make the artefact depend on what it describes.

  • model.py -- the format-neutral inventory, and package-URL construction

  • spdx_licenses.py -- the vendored SPDX identifier list, refreshed with task sbom:refresh-spdx. license.id is a reference to the SPDX enumeration, so an npm licence field outside it makes the document invalid; membership is the test, not the shape of the string, because BSD looks exactly like MIT and is not an identifier.

    Refresh against the cyclonedx-python-lib version uv.lock resolves, which is what task sbom:refresh-spdx uses. The authority for license.id is the schema that validates the document, not the newest list SPDX has published — generating against a newer library put 33 identifiers in the list that the validating schema rejects, which is the defect the list exists to prevent. The test (test_no_vendored_spdx_id_is_rejected_by_the_schema) asserts a subset in that one direction: an identifier here but not in the schema is an invalid document, while one in the schema but not here only costs precision — the licence is written as license.name, which is always valid — so a library bump does not fail the build over identifiers nothing here uses.

  • python_lock.py -- uv.lock: the closure walk, extras, markers, hashes

  • npm_lock.py -- package-lock.json: names, trees, integrity, dependency edges

  • deployment.py -- images, OS packages and source builds, via scripts/image_inventory.py

  • artifacts.py -- the catalogue: which image carries which closure

  • cyclonedx.py -- serialization and the determinism contract

The parsing of deploy/ lives in scripts/image_inventory.py, shared with scripts/check-image-licenses.py. That sharing is deliberate: two independently written parsers would drift the first time a Dockerfile used a form only one of them understood, and the drift would be silent in the direction that matters -- an image the licence gate reviewed but the SBOM omitted, or the reverse.

The artefact catalogue in artifacts.py is hand-written, and every field of it is held against the file that independently knows the answer: slug and dockerfile against the publish workflow's matrix, image against the first-party rows of the image allowlist, and python, python_extras and npm against the Dockerfile's own uv sync and npm ci lines. A sixth published image, or a Dockerfile that starts installing another extra, fails the test suite until the catalogue is updated -- rather than silently producing a document that describes an image nobody built.

After changing anything the documents depend on, run task sbom:generate and commit the result. A pre-commit hook and the Static Checks CI job both run task sbom:verify, so a forgotten regeneration fails on the pull request that caused it. A plugin pin bump is such a change: task release:floors:sync rewrites uv.lock with the new first-party wheel versions, and every hegemony-* component in the documents carries that version, so the regeneration follows the relock in the same commit.

At release time ​

scripts/bump-version.sh regenerates the documents during a release and .releaserc.json commits them, because each document records the platform version it describes and the bump rewrites both pyproject.toml and uv.lock. Without that, a release would leave sbom/ describing the previous version and the failure would surface on the next pull request rather than on the release that caused it.

To publish a document with a timestamp -- the one NTIA minimum element a committed file cannot carry and stay reproducible -- generate it outside the working tree:

bash
python3 scripts/generate-sbom.py --output-dir dist/sbom \
  --timestamp "$(TZ=UTC git show -s --format=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ HEAD)"

Deriving the timestamp from the commit date rather than the wall clock keeps even that output reproducible from the tag. The value must be UTC and --timestamp enforces it, which is why the commit date is converted rather than reformatted: %cI renders the committer's own offset, so a commit made outside UTC yields +02:00 and is rejected -- and hand-editing that to Z would embed an instant two hours from the truth.

Released as open source under the AGPL-3.0-or-later license. Development is sponsored by Rexonix s.r.o.. Contact — [email protected].