Render a bill of materials from the module set a binary carries - #208
Merged
Merged
Conversation
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>
This was referenced Aug 27, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.goholdsBuildOf, which reads the module table the toolchain wrote into a binary. It was a private function insidecmd/noticesand is now the one reader both commands call. It also carriesvcs.modified, which the toolchain records and which no reader here took before.internal/bomrenders CycloneDX 1.6 JSON from that table: sorted by module path, no timestamp, no serial number. Ten cases undertestdata/bom/, in the shapeinternal/noticesandinternal/contextsare already proved with.cmd/bomtakes 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-versionprevents a document published beside an artefact answering "which version am I running" with a commit timestamp.build-is-from-a-modified-treeprevents 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-versionprevents 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
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.modifiedbuild 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.
The suite:
Ten cases, five of them refusing and each named against a neighbour that passes:
Each guard proved by deleting it.
if build.Modified {replaced byif false {reddens exactly the two cases that declare it and leaves the other eight alone:if !isReleaseVersion(build.Main.Version) {replaced byif false {reddens the three that declare that one and leaves the modified-tree case green: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:The last line of that block is a bound rather than a result:
cmd/bomstays 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:
The property that holds across it is one render and its cases:
internal/bomis 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:
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
buildOffunction insidecmd/notices/main.go, which moves tointernal/noticesunder 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.