diff --git a/.claude/commands/audit-security.md b/.claude/commands/audit-security.md
index 5d2cc7700..433ac50ac 100644
--- a/.claude/commands/audit-security.md
+++ b/.claude/commands/audit-security.md
@@ -96,10 +96,11 @@ grep -rlE '/Users/|/home/[a-z]|/tmp/|ghp_|npm_[A-Za-z0-9]|BEGIN [A-Z ]*PRIVATE K
/tmp/sbom-audit/ && echo "LEAK" || echo "clean"
```
-## 5. Determinism
+## 5. Core determinism
-Regeneration at the same commit must be byte-identical, or the reproducibility
-claim in `security/SBOM.md` is false.
+The dependency inventory must be byte-identical for the same release commit,
+generator commit, and resolver input. Do not pass `--with-licenses` here: live
+registry license and supplier metadata is point-in-time enrichment.
```bash
node scripts/generate-sbom.mjs google --output-dir /tmp/sbom-audit-2
diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml
index f7ed2e69d..d05bcaaa0 100644
--- a/.github/workflows/sbom.yml
+++ b/.github/workflows/sbom.yml
@@ -15,7 +15,7 @@ on:
workflow_dispatch:
inputs:
tag:
- description: "Release tag to (re)generate an SBOM for"
+ description: "Release tag to generate an SBOM for"
required: true
type: string
@@ -55,6 +55,22 @@ jobs:
fetch-depth: 0
persist-credentials: false
+ - name: Take the generator from the default branch
+ id: generator
+ # Keep release manifests at the tag while using the current generator.
+ env:
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ run: |
+ git fetch --no-tags --depth=1 origin "$DEFAULT_BRANCH"
+ GENERATOR_COMMIT=$(git rev-parse FETCH_HEAD)
+ git checkout "$GENERATOR_COMMIT" -- \
+ scripts/generate-sbom.mjs \
+ scripts/sbom-dependencies.mjs \
+ scripts/release-branch-policy.mjs \
+ scripts/assert-release-tag.mjs
+ echo "commit=$GENERATOR_COMMIT" >> "$GITHUB_OUTPUT"
+ echo "Generator taken from $DEFAULT_BRANCH at $GENERATOR_COMMIT"
+
- name: Setup Node
uses: actions/setup-node@v7
with:
@@ -69,9 +85,7 @@ jobs:
RELEASE_TAG: ${{ steps.tag.outputs.tag }}
run: node scripts/generate-sbom.mjs resolve-tag "$RELEASE_TAG"
- - name: Verify SBOM generator behaviour
- if: ${{ steps.component.outputs.matched == 'true' }}
- run: node --test scripts/generate-sbom.test.mjs
+ # CI tests the generator and its historical fixtures in their owning tree.
- name: Generate CycloneDX SBOM
id: generate
@@ -80,11 +94,13 @@ jobs:
# own registry. A registry outage degrades to a missing license field
# rather than failing the release.
env:
- COMPONENT: ${{ steps.component.outputs.component }}
+ GENERATOR_COMMIT: ${{ steps.generator.outputs.commit }}
+ RELEASE_TAG: ${{ steps.tag.outputs.tag }}
run: |
- node scripts/generate-sbom.mjs "$COMPONENT" \
+ node scripts/generate-sbom.mjs --tag "$RELEASE_TAG" \
--output-dir sbom \
--commit "$(git rev-parse HEAD)" \
+ --generator-commit "$GENERATOR_COMMIT" \
--with-licenses
- name: Verify the SBOM describes this release
@@ -92,6 +108,7 @@ jobs:
env:
SBOM_FILE: ${{ steps.generate.outputs.sbom-file }}
EXPECTED_VERSION: ${{ steps.component.outputs.version }}
+ EXPECTED_GENERATOR_COMMIT: ${{ steps.generator.outputs.commit }}
RELEASE_TAG: ${{ steps.tag.outputs.tag }}
run: |
# A version mismatch means the tag and the manifest disagree, which
@@ -105,6 +122,12 @@ jobs:
.metadata.component.properties[]
| select(.name == "openiap:release:commit") | .value
' "$SBOM_FILE")
+ ACTUAL_GENERATOR_COMMIT=$(jq -r '
+ .metadata.tools.components[]
+ | select(.name == "openiap-sbom-generator")
+ | .properties[]
+ | select(.name == "openiap:generator:commit") | .value
+ ' "$SBOM_FILE")
if [ "$ACTUAL_VERSION" != "$EXPECTED_VERSION" ]; then
echo "::error::SBOM version $ACTUAL_VERSION does not match tag version $EXPECTED_VERSION"
@@ -118,6 +141,10 @@ jobs:
echo "::error::SBOM commit $ACTUAL_COMMIT does not match the released commit"
exit 1
fi
+ if [ "$ACTUAL_GENERATOR_COMMIT" != "$EXPECTED_GENERATOR_COMMIT" ]; then
+ echo "::error::SBOM generator $ACTUAL_GENERATOR_COMMIT does not match $EXPECTED_GENERATOR_COMMIT"
+ exit 1
+ fi
# Fail closed if a local path ever reaches a published document.
if grep -qE '/Users/|/home/[a-z]|/tmp/' "$SBOM_FILE"; then
@@ -139,7 +166,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ steps.tag.outputs.tag }}
SBOM_FILE: ${{ steps.generate.outputs.sbom-file }}
- run: gh release upload "$RELEASE_TAG" "$SBOM_FILE" --clobber
+ run: gh release upload "$RELEASE_TAG" "$SBOM_FILE"
- name: Report a skipped tag
if: ${{ steps.component.outputs.matched != 'true' }}
diff --git a/packages/docs/src/pages/docs/security/sbom.tsx b/packages/docs/src/pages/docs/security/sbom.tsx
index fa7ae4332..cb7d334c8 100644
--- a/packages/docs/src/pages/docs/security/sbom.tsx
+++ b/packages/docs/src/pages/docs/security/sbom.tsx
@@ -132,7 +132,7 @@ const LIMITS: Limit[] = [
{
title: 'Licenses and suppliers',
detail:
- 'resolve for every direct dependency except two structural cases: pub.dev packages, whose metadata exposes neither field, and NuGet packages whose nuspec gives only a non-SPDX license URL.',
+ 'come from live registries. Unavailable metadata is omitted, and pub.dev and some NuGet packages do not expose a standard value.',
},
];
@@ -143,7 +143,7 @@ function SecuritySbom() {
@@ -223,12 +223,10 @@ flutter_inapp_purchase-10.3.0.cdx.json`}
{ header: 'License', cell: (row) => row.license },
]}
/>
-
- Generation is deterministic. The document timestamp is the release
- commit's timestamp, and the serial number is derived from the
- release identity rather than randomly generated — so regenerating at
- the released commit produces a byte-identical file. You can rebuild it
- yourself and compare.
+
+ The SBOM records both the release commit and the exact generator
+ commit, so you can reproduce its dependency inventory. Licenses and
+ suppliers come from live registries and may differ on a later run.
@@ -245,6 +243,7 @@ flutter_inapp_purchase-10.3.0.cdx.json`}
The repository URL and the exact commit the release was built from
+ The exact generator commit used to create the SBOM
The release tag, so an artifact and its inventory cannot be
mismatched
diff --git a/scripts/fixtures/historical-releases/google-v1.3.0/openiap-versions.json b/scripts/fixtures/historical-releases/google-v1.3.0/openiap-versions.json
new file mode 100644
index 000000000..86f948773
--- /dev/null
+++ b/scripts/fixtures/historical-releases/google-v1.3.0/openiap-versions.json
@@ -0,0 +1,6 @@
+{
+ "gql": "1.2.2",
+ "docs": "1.2.2",
+ "google": "1.3.0",
+ "apple": "1.2.24"
+}
diff --git a/scripts/fixtures/historical-releases/google-v1.3.0/packages/google/gradle.properties b/scripts/fixtures/historical-releases/google-v1.3.0/packages/google/gradle.properties
new file mode 100644
index 000000000..aa0628ab1
--- /dev/null
+++ b/scripts/fixtures/historical-releases/google-v1.3.0/packages/google/gradle.properties
@@ -0,0 +1 @@
+COMPOSE_UI_VERSION=1.6.8
diff --git a/scripts/fixtures/historical-releases/google-v1.3.0/packages/google/openiap/build.gradle.kts b/scripts/fixtures/historical-releases/google-v1.3.0/packages/google/openiap/build.gradle.kts
new file mode 100644
index 000000000..23032c677
--- /dev/null
+++ b/scripts/fixtures/historical-releases/google-v1.3.0/packages/google/openiap/build.gradle.kts
@@ -0,0 +1,21 @@
+dependencies {
+ implementation("androidx.core:core-ktx:1.12.0")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
+ compileOnly("com.android.billingclient:billing-ktx:8.0.0")
+ add("playApi", "com.android.billingclient:billing-ktx:8.0.0")
+ add("autoApi", "com.android.billingclient:billing-ktx:8.0.0")
+ add("autoApi", "com.meta.horizon.billingclient.api:horizon-billing-compatibility:1.1.1")
+ add("horizonApi", "com.meta.horizon.billingclient.api:horizon-billing-compatibility:1.1.1")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
+ implementation("com.google.code.gson:gson:2.10.1")
+
+ val composeUiVersion = (project.findProperty("COMPOSE_UI_VERSION") as String?) ?: "1.6.8"
+ implementation("androidx.compose.runtime:runtime:$composeUiVersion")
+ implementation("androidx.compose.ui:ui:$composeUiVersion")
+
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
+ androidTestImplementation("androidx.test.ext:junit:1.1.5")
+}
diff --git a/scripts/generate-sbom.mjs b/scripts/generate-sbom.mjs
index cd6cee6af..5915cde79 100644
--- a/scripts/generate-sbom.mjs
+++ b/scripts/generate-sbom.mjs
@@ -9,15 +9,17 @@
* being describable here, and a version can never disagree with the release
* that produced it.
*
- * Output is deterministic for a given (component, version, commit): the
- * document timestamp comes from the commit, and the serial number is derived
- * from the release identity rather than randomly generated. Re-running this on
- * the same commit reproduces the same bytes.
+ * The core inventory is deterministic for a given release tag, release commit,
+ * generator commit, and resolver input. Registry enrichment requested with
+ * `--with-licenses` is point-in-time metadata and can vary between runs.
*
* Usage:
* node scripts/generate-sbom.mjs [--output-dir DIR]
* [--commit SHA]
+ * [--generator-commit SHA]
* [--resolved FILE]
+ * [--tag TAG]
+ * [--with-licenses]
* [--stdout]
*/
@@ -376,6 +378,8 @@ export function buildSbom({
componentId,
version,
commit,
+ generatorCommit,
+ releaseTag,
timestamp,
dependencies,
vulnerabilities = [],
@@ -386,7 +390,17 @@ export function buildSbom({
}
const purl = definition.purl(version);
- const tag = releaseTagFor(componentId, version);
+ const tag = releaseTag ?? releaseTagFor(componentId, version);
+ const resolvedTag = componentFromTag(tag);
+ if (
+ resolvedTag?.componentId !== componentId ||
+ resolvedTag.version !== version
+ ) {
+ throw new Error(
+ `Release tag '${tag}' does not match ${componentId} ${version}`,
+ );
+ }
+ const resolvedGeneratorCommit = generatorCommit ?? commit;
const componentRef = purl;
// A VEX statement that points at a bom-ref this SBOM does not contain says
@@ -426,6 +440,12 @@ export function buildSbom({
type: "application",
name: GENERATOR_NAME,
version: GENERATOR_VERSION,
+ properties: [
+ {
+ name: "openiap:generator:commit",
+ value: resolvedGeneratorCommit,
+ },
+ ],
},
],
},
@@ -486,8 +506,8 @@ async function fetchText(url) {
* not part of the security inventory, and a registry outage must not block a
* release.
*
- * (name, version) pairs are immutable in every registry used here, so this
- * stays reproducible in practice.
+ * Registry availability and metadata can change, so enriched output is not
+ * byte-identical across runs.
*/
async function lookupComponentMetadata(entry) {
try {
@@ -655,6 +675,8 @@ export async function generateSbom(
{
root = repoRoot,
commit,
+ generatorCommit,
+ releaseTag,
resolvedFile,
withLicenses = false,
runGit = defaultRunGit,
@@ -669,7 +691,9 @@ export async function generateSbom(
const version = readComponentVersion(componentId, root);
const resolvedCommit = commit || runGit(["rev-parse", "HEAD"]);
- // Commit time, not wall-clock time, keeps regeneration byte-identical.
+ const resolvedGeneratorCommit =
+ generatorCommit || runGit(["rev-parse", "HEAD"]);
+ // Commit time keeps the core inventory deterministic.
const timestamp = new Date(
runGit(["show", "-s", "--format=%cI", resolvedCommit]),
).toISOString();
@@ -688,6 +712,8 @@ export async function generateSbom(
componentId,
version,
commit: resolvedCommit,
+ generatorCommit: resolvedGeneratorCommit,
+ releaseTag,
timestamp,
dependencies,
vulnerabilities,
@@ -713,6 +739,8 @@ function parseArguments(argv) {
options.outputDir = argv[++index];
} else if (argument === "--commit") {
options.commit = argv[++index];
+ } else if (argument === "--generator-commit") {
+ options.generatorCommit = argv[++index];
} else if (argument === "--resolved") {
options.resolvedFile = argv[++index];
} else if (argument === "--tag") {
@@ -730,19 +758,24 @@ function parseArguments(argv) {
}
}
- if (options.tag && !options.componentId) {
+ if (options.tag) {
const resolved = componentFromTag(options.tag);
if (!resolved) {
throw new Error(
`Release tag '${options.tag}' does not belong to a known SBOM component`,
);
}
+ if (options.componentId && options.componentId !== resolved.componentId) {
+ throw new Error(
+ `Release tag '${options.tag}' belongs to ${resolved.componentId}, not ${options.componentId}`,
+ );
+ }
options.componentId = resolved.componentId;
}
if (!options.componentId) {
throw new Error(
- `Usage: generate-sbom.mjs <${listComponentIds().join("|")}|--tag TAG> [--output-dir DIR] [--commit SHA] [--resolved FILE] [--with-licenses] [--stdout]`,
+ `Usage: generate-sbom.mjs <${listComponentIds().join("|")}|--tag TAG> [--output-dir DIR] [--commit SHA] [--generator-commit SHA] [--resolved FILE] [--with-licenses] [--stdout]`,
);
}
return options;
@@ -769,6 +802,8 @@ async function main() {
const options = parseArguments(process.argv.slice(2));
const result = await generateSbom(options.componentId, {
commit: options.commit,
+ generatorCommit: options.generatorCommit,
+ releaseTag: options.tag,
resolvedFile: options.resolvedFile,
withLicenses: options.withLicenses,
});
diff --git a/scripts/generate-sbom.test.mjs b/scripts/generate-sbom.test.mjs
index 5f295d75c..a3857b47f 100644
--- a/scripts/generate-sbom.test.mjs
+++ b/scripts/generate-sbom.test.mjs
@@ -31,6 +31,10 @@ import {
import { versionSources } from "./release-branch-policy.mjs";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const historicalGoogleRoot = resolve(
+ repoRoot,
+ "scripts/fixtures/historical-releases/google-v1.3.0",
+);
const { COMPONENTS } = generatorTesting;
const {
expandGradleForLoops,
@@ -111,6 +115,76 @@ test("every release tag pattern resolves back to its own component", () => {
assert.equal(componentFromTag(""), null);
});
+test("generated SBOMs preserve accepted release tag aliases", () => {
+ for (const [componentId, config] of Object.entries(PACKAGE_CONFIG)) {
+ for (const tag of config.tags("9.9.9")) {
+ const document = buildSbom({
+ componentId,
+ version: "9.9.9",
+ commit: stubCommit,
+ generatorCommit: stubCommit,
+ releaseTag: tag,
+ timestamp: "2026-01-01T00:00:00.000Z",
+ dependencies: [],
+ });
+ const properties = Object.fromEntries(
+ document.metadata.component.properties.map((property) => [
+ property.name,
+ property.value,
+ ]),
+ );
+ assert.equal(properties["openiap:release:tag"], tag, tag);
+ }
+ }
+});
+
+test("current generator supports the google-v1.3.0 release tree", async () => {
+ const releaseCommit = "768dc142634a3f34e6a97b9eda4cdd9574d9c2ed";
+ const generatorCommit = "f".repeat(40);
+ const { document, directCount } = await generateSbom("google", {
+ root: historicalGoogleRoot,
+ commit: releaseCommit,
+ generatorCommit,
+ releaseTag: "google-v1.3.0",
+ runGit: stubGit,
+ });
+
+ assert.equal(document.metadata.component.version, "1.3.0");
+ assert.equal(directCount, 10);
+ assert.deepEqual(
+ document.components.map((component) => component.name),
+ [
+ "androidx.compose.runtime:runtime",
+ "androidx.compose.ui:ui",
+ "androidx.core:core-ktx",
+ "androidx.lifecycle:lifecycle-runtime-ktx",
+ "androidx.lifecycle:lifecycle-viewmodel-ktx",
+ "com.android.billingclient:billing-ktx",
+ "com.google.code.gson:gson",
+ "com.meta.horizon.billingclient.api:horizon-billing-compatibility",
+ "org.jetbrains.kotlinx:kotlinx-coroutines-android",
+ "org.jetbrains.kotlinx:kotlinx-coroutines-core",
+ ],
+ );
+
+ const releaseProperties = Object.fromEntries(
+ document.metadata.component.properties.map((property) => [
+ property.name,
+ property.value,
+ ]),
+ );
+ assert.equal(releaseProperties["openiap:release:tag"], "google-v1.3.0");
+ assert.equal(releaseProperties["openiap:release:commit"], releaseCommit);
+
+ const toolProperties = Object.fromEntries(
+ document.metadata.tools.components[0].properties.map((property) => [
+ property.name,
+ property.value,
+ ]),
+ );
+ assert.equal(toolProperties["openiap:generator:commit"], generatorCommit);
+});
+
test("serial number is derived from release identity, not randomness", () => {
const identity = {
componentId: "expo",
@@ -165,6 +239,13 @@ test("SBOM carries the metadata a release must be traceable by", () => {
);
assert.equal(properties["openiap:release:commit"], stubCommit);
assert.equal(properties["openiap:release:tag"], "react-native-iap-16.3.0");
+ const toolProperties = Object.fromEntries(
+ document.metadata.tools.components[0].properties.map((property) => [
+ property.name,
+ property.value,
+ ]),
+ );
+ assert.equal(toolProperties["openiap:generator:commit"], stubCommit);
});
test("generated SBOM version always matches the shipped manifest", async () => {
diff --git a/security/CRA.md b/security/CRA.md
index c47991c1a..b115c732f 100644
--- a/security/CRA.md
+++ b/security/CRA.md
@@ -102,8 +102,9 @@ product contains.
**How OpenIAP does this:** a CycloneDX 1.6 SBOM is generated for every
published release of every releasable component and attached to its GitHub
-Release. Generation is automated, reads the same manifests the build reads, and
-is reproducible from the released commit.
+Release. Generation records the release and generator commits, and the core
+dependency inventory is reproducible from those inputs. Registry-sourced
+license and supplier metadata is point-in-time enrichment.
See [SBOM.md](SBOM.md). Practical constraint: transitive closure is complete
only where an ecosystem resolver export is supplied; direct runtime
@@ -143,6 +144,7 @@ through the workflows in `.github/workflows/`, and each produces a new SBOM.
| Which SBOM version corresponds to it? | SBOM filename and `metadata.component.version`; the workflow refuses to upload on a mismatch |
| Which workflow generated it? | The provenance attestation on the SBOM, verifiable with `gh attestation verify` |
| Which commit was it built from? | `openiap:release:commit` property inside the SBOM, and the attestation subject |
+| Which generator revision was used? | `openiap:generator:commit` property on the SBOM generator component |
| Was the npm artifact itself built by us? | npm provenance (`npm publish --provenance`), checked at release time by `scripts/verify-npm-release-provenance.mjs` |
## Deliberate boundaries
diff --git a/security/SBOM.md b/security/SBOM.md
index 72b96c137..a8281990a 100644
--- a/security/SBOM.md
+++ b/security/SBOM.md
@@ -114,6 +114,7 @@ table.
bun run sbom # writes ./sbom/-.cdx.json
bun run sbom --with-licenses # also resolve licenses from registries
bun run sbom --stdout # print instead of writing
+bun run sbom --tag # preserve the published tag identity
bun run sbom resolve-tag # which component does this tag belong to?
```
@@ -246,9 +247,9 @@ release workflow → GitHub Release published
attest provenance upload as release asset
```
-Before upload, the workflow asserts that the SBOM's version, release tag, and
-commit all match the release being processed, and that no local filesystem
-path leaked into the document. Any mismatch fails the run.
+Before upload, the workflow asserts that the SBOM's version, release tag,
+release commit, and recorded generator commit match its inputs, and that no
+local filesystem path leaked into the document. Any mismatch fails the run.
Tags that do not belong to a component are skipped with a notice rather than
failing.
@@ -298,15 +299,45 @@ OpenIAP runs none of these in CI — see [README.md](README.md#scanning-posture)
for why — but each accepts a CycloneDX 1.6 document directly, so a consumer can
point their own scanner at a release asset on their own schedule.
-Maintainers can additionally reproduce it. Generation is deterministic for a
-given commit — the document timestamp is the commit timestamp and the serial
-number is derived from the release identity, not randomly generated — so
-regenerating at the released commit yields a byte-identical file:
+Maintainers can reproduce the core dependency inventory from the published tag
+and the generator commit recorded under `openiap:generator:commit`. The release
+workflow uses live registries to enrich dependencies with licenses and
+suppliers, so those fields are point-in-time metadata and are not guaranteed to
+be byte-identical later.
```bash
-git checkout react-native-iap-16.3.0
-bun run sbom react-native --output-dir /tmp/verify
-diff /tmp/verify/react-native-iap-16.3.0.cdx.json ./react-native-iap-16.3.0.cdx.json
+RELEASE_TAG=react-native-iap-16.3.0
+PUBLISHED_SBOM=/absolute/path/react-native-iap-16.3.0.cdx.json
+GENERATOR_COMMIT=$(jq -r '
+ .metadata.tools.components[]
+ | select(.name == "openiap-sbom-generator")
+ | .properties[]
+ | select(.name == "openiap:generator:commit") | .value
+' "$PUBLISHED_SBOM")
+
+git fetch origin main --tags
+SBOM_REPRO_DIR=$(mktemp -d)
+git worktree add --detach "$SBOM_REPRO_DIR" "$RELEASE_TAG"
+git -C "$SBOM_REPRO_DIR" checkout "$GENERATOR_COMMIT" -- \
+ scripts/generate-sbom.mjs \
+ scripts/sbom-dependencies.mjs \
+ scripts/release-branch-policy.mjs \
+ scripts/assert-release-tag.mjs
+(
+ cd "$SBOM_REPRO_DIR"
+ node scripts/generate-sbom.mjs --tag "$RELEASE_TAG" \
+ --commit "$(git rev-parse HEAD)" \
+ --generator-commit "$GENERATOR_COMMIT" \
+ --output-dir sbom
+)
+
+jq '(.components[]? |= del(.licenses, .supplier))' \
+ "$PUBLISHED_SBOM" > /tmp/published-core.json
+jq '(.components[]? |= del(.licenses, .supplier))' \
+ "$SBOM_REPRO_DIR/sbom/react-native-iap-16.3.0.cdx.json" \
+ > /tmp/generated-core.json
+diff /tmp/published-core.json /tmp/generated-core.json
+git worktree remove --force "$SBOM_REPRO_DIR"
```
## Update policy
@@ -315,13 +346,13 @@ diff /tmp/verify/react-native-iap-16.3.0.cdx.json ./react-native-iap-16.3.0.cdx.
- SBOMs are **immutable once published**, exactly like the release tag they
belong to. A dependency change ships as a new release with a new SBOM; a
published SBOM is never edited in place.
-- If a release predates this system, its SBOM can be generated retroactively
- with `workflow_dispatch` against that tag. The result describes that tag's
- commit, not today's `main`.
+- A release that predates this system and has no SBOM asset can be described
+ with `workflow_dispatch`. The workflow reads manifests from the release tag,
+ records the exact default-branch generator commit, and refuses to overwrite
+ an existing asset.
- Changes to the generator are covered by `scripts/generate-sbom.test.mjs`,
- which runs in CI on every pull request. The test asserting that every
- releasable component has SBOM metadata fails if a new component is added
- without one.
+ including a historical release-tree fixture and every accepted tag alias.
+ CI also fails if a releasable component has no SBOM metadata.
## Relationship to vulnerability management