Skip to content

Render a bill of materials from the module set a binary carries - #208

Merged
iderex merged 3 commits into
mainfrom
release/bill-of-materials
Aug 27, 2026
Merged

iderex merged 3 commits into
mainfrom
release/bill-of-materials

Conversation

@iderex

@iderex iderex commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Refs #37

What this changes

The bill-of-materials half of #37. The notices half landed in #134 and #136; this is the generator beside it, and it lands ahead of the release workflow rather than inside it, because the workflow attaches artefacts and this produces one.

Three pieces.

internal/notices/build.go holds BuildOf, which reads the module table the toolchain wrote into a binary. It was a private function inside cmd/notices and is now the one reader both commands call. It also carries vcs.modified, which the toolchain records and which no reader here took before.

internal/bom renders CycloneDX 1.6 JSON from that table: sorted by module path, no timestamp, no serial number. Ten cases under testdata/bom/, in the shape internal/notices and internal/contexts are already proved with.

cmd/bom takes one argument, the binary to describe, and writes the document to standard output. No module cache, because this document reproduces no licence text. No connection, no environment variable.

Which of #37 and #41 owns the generator

That question is open in the notes on both issues, and the body of #37 answers it: "the release workflow that attaches both is blocked on it". Generating belongs here and attaching belongs to #41, so the generator lands under this issue and the workflow that runs it lands under that one.

The means

Go, standard library only, in the module that is already here. The document is rendered from debug/buildinfo, which the toolchain ships, so no dependency is added to a tree whose whole dependency count is zero and no second language arrives for one file. A generator written as a shell step in a workflow would be in a language this repository has no suite and no ledger for, and the three rules would have nothing to hold it to. The alternative considered was an external SBOM tool run as a build step, which would put the document's contents outside anything this repository can refuse and would make the release build depend on fetching it.

What failure it prevents

A hand-maintained dependency list, which is correct on the day it is written and wrong shortly afterwards, in the direction that matters: a dependency present in the binary and absent from the list. Nothing here is maintained; the input is what the binary records.

Three properties, and each one is a failure somebody meets rather than a rule for symmetry.

main-component-has-no-release-version prevents a document published beside an artefact answering "which version am I running" with a commit timestamp.

build-is-from-a-modified-tree prevents a release built from a working tree carrying changes version control does not hold, where the revision written beside the components names a commit that is not what was compiled.

dependency-has-no-version prevents a row a scanner has to either ignore or guess at.

A fourth failure the render is written against rather than refusing: a clock. CycloneDX offers a timestamp and a serial number, both optional, and either would move between two runs of one build. #41 asks for two runs from one tag to produce identical checksums, and this file is published under one of them.

What was wrong and how it was found

The version rule was written as a pattern anchored at the commit hash. This repository built itself and the run came back clean over

v0.0.0-20260827021325-db0a15471e88+dirty

because the suffix a modified tree adds comes after the hash. It was found by the case that reads a real binary rather than by reading the pattern: that case prints the version it saw, and the exit code beside it was 0.

The repair is in two parts, which is the part worth reading rather than the fix. The pattern now carries the trailing build metadata, so it is right about what a version derived from a commit looks like. The modified tree is refused under its own property, read from the vcs.modified build setting, because the setting is the answer and the suffix is a spelling of it, and a rule that reads a spelling is one rewording away from silence.

What was run

At the head of this branch.

$ go build ./cmd/... ./internal/...
$ go vet ./cmd/... ./internal/...
$ gofmt -l cmd internal
$ go run ./cmd/lab check .
examined .
1 experiment directory walked, 1 record read
26 decision records read
0 refused

The suite:

$ go test -count=1 -v ./cmd/... ./internal/...
ok  	github.com/Flowfin/lab/cmd/bom	13.061s
ok  	github.com/Flowfin/lab/cmd/contexts	0.989s
ok  	github.com/Flowfin/lab/cmd/lab	8.162s
ok  	github.com/Flowfin/lab/cmd/notices	13.951s
ok  	github.com/Flowfin/lab/cmd/pullrequest	1.225s
ok  	github.com/Flowfin/lab/internal/bom	0.996s
ok  	github.com/Flowfin/lab/internal/check	1.253s
ok  	github.com/Flowfin/lab/internal/contexts	0.995s
ok  	github.com/Flowfin/lab/internal/hardware	1.227s
ok  	github.com/Flowfin/lab/internal/invariants	1.562s
ok  	github.com/Flowfin/lab/internal/notices	1.232s
ok  	github.com/Flowfin/lab/internal/prose	1.006s
ok  	github.com/Flowfin/lab/internal/pullrequest	0.998s

Ten cases, five of them refusing and each named against a neighbour that passes:

    bom_test.go:88: 10 case(s) read from ../../testdata/bom
    bom_test.go:125: 5 refusing case(s) proved against a neighbour that passes

Each guard proved by deleting it.

if build.Modified { replaced by if false { reddens exactly the two cases that declare it and leaves the other eight alone:

--- FAIL: TestEveryCaseIsRenderedAsItsFilesDeclare/a-build-from-a-modified-tree
--- FAIL: TestEveryCaseIsRenderedAsItsFilesDeclare/a-dirty-build-that-is-not-at-a-tag
--- PASS: TestEveryCaseIsRenderedAsItsFilesDeclare/a-build-that-is-not-at-a-tag
--- PASS: TestEveryCaseIsRenderedAsItsFilesDeclare/a-release-with-no-dependencies

if !isReleaseVersion(build.Main.Version) { replaced by if false { reddens the three that declare that one and leaves the modified-tree case green:

--- FAIL: TestEveryCaseIsRenderedAsItsFilesDeclare/a-build-that-is-not-at-a-tag
--- FAIL: TestEveryCaseIsRenderedAsItsFilesDeclare/a-build-the-toolchain-knew-nothing-about
--- FAIL: TestEveryCaseIsRenderedAsItsFilesDeclare/a-dirty-build-that-is-not-at-a-tag
--- PASS: TestEveryCaseIsRenderedAsItsFilesDeclare/a-build-from-a-modified-tree

build.Deps = append(build.Deps, module) in the shared reader replaced by a discard reddens one test in the whole tree, which is the collector proof #154 put back and which now covers both commands because it covers the one function they share:

--- FAIL: TestATreeWithADependencyAddedProducesADocumentThatListsIt (6.48s)
FAIL	github.com/Flowfin/lab/cmd/notices	21.733s
ok  	github.com/Flowfin/lab/cmd/bom	17.760s

The last line of that block is a bound rather than a result: cmd/bom stays green under that deletion, because the binary it reads has no third-party module, so its own suite could not have caught it.

The size

This moves more lines than one reading is, and the run says so as a note rather than a refusal. What the lines are, counted at this head:

$ git diff --numstat origin/main...HEAD
fixtures 262, suites 570, source 598, total 1430

The property that holds across it is one render and its cases: internal/bom is the renderer, most of its length is the argument for each field, and every fixture is four short files. Splitting it would produce a renderer with no cases and cases with no renderer, and neither half is reviewable alone.

What this does not do

It does not finish #37. Two legs of that issue's done-when ask for both artefacts to be generated by the release build and attached to the release, and there is no release workflow in this tree and no release:

$ gh api repos/Flowfin/lab/releases --jq 'length'
0

That workflow is #41 and it is where those two legs are met.

It does not validate the document against the published CycloneDX schema. Nothing here reads that schema, so what the suite holds is the fields this renderer writes and the shape it writes them in. A reader who needs conformance has to check it.

It does not identify a licence, and the package URL it writes is not validated against the package-url specification. The module path in that reference keeps the case the toolchain recorded rather than being folded, which is deliberate: two module paths differing only in case are different modules.

It removes no path from the tree. The only deletion is the private buildOf function inside cmd/notices/main.go, which moves to internal/notices under a name both commands call.

This board has no second reader tonight. What stands in place of one is the evidence above: every claim carries the command that produced it, and every guard carries the deletion that reddens it.

iderex added 3 commits August 27, 2026 08:08
Refs #37.

The release build owes two documents about one binary: the third-party
notices, which already exist, and a bill of materials, which does not yet. Both
are statements about what is inside the same artefact, and both are rendered
from the module table the toolchain wrote into it.

A second reader of that table would let the two disagree about it. An operator
holding a notices file and a bill of materials that name different module sets
has no way to tell which of them is wrong, and that is the one failure neither
document is allowed to have. So the reader moves out of cmd/notices, where it
was a private function, into internal/notices as BuildOf, and cmd/notices calls
it rather than carrying its own copy.

It also carries one fact it did not carry before. The toolchain records whether
the tree a binary was built from held changes version control does not have,
and that is a fact about the binary in exactly the way the module set is. It is
read from the build setting rather than derived from the version string,
because the version says it by appending a suffix and the setting says it
outright.

Nothing about the notices document changes. The proof that a dependency added
to a tree reaches a document is cmd/notices/tree_test.go, and it now covers
both commands because it covers the one function they share:

    go test -count=1 ./cmd/... ./internal/...
    ok  	github.com/Flowfin/lab/cmd/notices	13.951s

Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com>
Refs #37.

The issue asks for a bill of materials generated at build time, from the build
rather than from a list somebody maintains, because a hand-written list is
correct on the day it is written and wrong shortly afterwards, in the direction
that matters: a dependency in the binary and absent from the list.

internal/bom renders CycloneDX 1.6 JSON from the module table, sorted by module
path, with no timestamp and no serial number. Both of those fields are optional
and either would move between two runs of one build, which would destroy the
claim the release milestone rests on: that two runs from one tag produce
identical checksums. A clock is the easy thing to add to a generated file and
the hard thing to notice afterwards, because a document with yesterday's date
in it looks correct.

Three properties, each with its own case and each proved by deleting it.

main-component-has-no-release-version refuses a build whose own version names
no release, so a document published beside an artefact cannot answer the
question of which version an operator is running with a commit timestamp.

build-is-from-a-modified-tree refuses a build made from a tree carrying changes
version control does not hold. It is a separate property because the repair is
separate: one is repaired by tagging and the other by committing.

dependency-has-no-version refuses a component nothing can match against an
advisory, which is the operational reason the document exists at all.

What was wrong and how it was found. The version rule was written as a pattern
anchored at the commit hash, and this repository built itself and returned a
clean run over v0.0.0-20260827021325-db0a15471e88+dirty, because the suffix a
modified tree adds comes after the hash. The case that reads a real binary
printed the string. The pattern now carries the suffix, and the modified tree
is refused under its own property rather than through a spelling.

What the document does not claim. It declares a specVersion and nothing here
validates it against the published CycloneDX schema, so what the suite holds is
the fields this renderer writes. It identifies no licence, for the reason
internal/notices already gives. The package URL is not case-folded, because two
module paths differing only in case are different modules.

Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com>
Refs #37.

cmd/bom takes one argument, the binary to describe, and writes the document to
standard output. It needs no module cache, because a bill of materials names
components and reproduces no licence text, so nothing outside the binary is
read at all. It opens no connection, so the document can be produced from an
archive of a build with the network unplugged.

The exit codes are record 0011's contract, the same three the runner returns. A
refusal still writes the document, because a document incomplete by named
entries is more useful than none, and the entries are named on standard error
rather than inside it: a field this repository invented would be dropped in
silence by anything reading the document as CycloneDX.

Where the file lands is the release build's business rather than this command's,
which is the shape cmd/notices already has.

    go test -count=1 -v ./cmd/bom
    ok  	github.com/Flowfin/lab/cmd/bom	13.061s

Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com>
@iderex iderex self-assigned this Aug 27, 2026
@iderex
iderex merged commit 024251c into main Aug 27, 2026
25 checks passed
@iderex
iderex deleted the release/bill-of-materials branch August 27, 2026 06:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant