Skip to content

ci(release): attach a CycloneDX SBOM and state the dependency policy - #81

Merged
frantuma merged 3 commits into
mainfrom
frantuma/release-sbom
Oct 20, 2026
Merged

frantuma merged 3 commits into
mainfrom
frantuma/release-sbom

Conversation

@frantuma

@frantuma frantuma commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

This is the second half of the last step of the plan to remove Ajv and the components flagged by the dependency policy scan from the published dependency tree. #80 made CI keep those components out; this PR adds a CycloneDX SBOM to every release, with the code the package contains rather than installs listed inside it, and states the dependency policy in the README, as the scan's report recommends for regulated users.

The SBOM

Every release attaches speclynx-api-languageservice-<version>.cdx.json to its GitHub release. The plan was to run npm sbom --omit dev --workspace @speclynx/api-languageservice on the workspace, but that SBOM is wrong: it lists 65 components and leaves out 54 of the 83 packages a consumer of the packed package installs, among them yaml, prettier, and debug and ms on the Axios path, and it names the lockfile's ApiDOM 6.0.6 where a consumer resolves 6.1.0. The SBOM is therefore read from the scratch consumer scripts/check-package-contract.sh already creates, which is the tree a scanner evaluates. When SBOM_TO names a file, the check runs npm sbom --sbom-format cyclonedx there once every other check has passed.

For a release, that consumer installs what was published rather than a fresh pack. Lerna writes the release commit into the manifest as gitHead before it packs, so a rebuild of the tag is a different archive with a different hash. With PUBLISHED_VERSION set, the check fetches that version's archive from the registry with npm pack <spec>, runs every check on it, and installs it from the registry, which npm verifies against the same integrity. The SBOM's subject therefore carries the registry's tarball URL and the SHA-512 of the published archive, where a pull request's SBOM has the fresh pack's.

scripts/complete-sbom.mjs turns that consumer's SBOM into the package's. The package becomes the subject, and the script fails unless the subject's hash is that of the archive the check verified, and unless the checkout it reads the copied files from has the same version. Two kinds of code the package contains become components nested in it.

The files copied or ported into it are file components, each with pedigree.ancestors naming the npm package and version it came from: ucs2length from ajv@8.20.0, equal from fast-deep-equal@3.1.3, and the better-errors port from @stoplight/better-ajv-errors@1.0.3. They are listed in packages/api-languageservice/config/vendored-code.json with the file each came from and how it was changed, and the licence and copyright are read from each file's SPDX header. The script fails when one of those versions differs from the exact devDependency pin, so bumping ajv or fast-deep-equal cannot leave the record of a copy behind; CLAUDE.md says so next to the existing note about comparing the copies.

The browser bundle dist/api-languageservice.browser.min.js is a file component holding the packages it embeds, at the versions it was built with, read from the dist/THIRD-PARTY-NOTICES.txt shipped beside it. Those come from the workspace lockfile and can differ from what a consumer installs: today the bundle has the 32 ApiDOM packages at 6.0.6 where the consumer has 6.1.0, and unraw@3.0.0, which the consumer's tree no longer contains. The origins of the copied files are credited in the notices too, and are left to their pedigree. They are only devDependencies, so the package could not import them without failing the export checks in the consumer. The script fails when the notices lose the layout it reads.

The output validates against the CycloneDX 1.5 JSON schema.

The build workflow sets SBOM_TO, so every pull request generates the SBOM and a mistake in it fails before a release. The release workflow generates it after publishing, in a job of its own that checks out the v<version> tag and runs the contract check on the published version, with a read-only token, no secrets and no persisted credentials. The scratch consumer resolves the dependency ranges afresh and runs code no lockfile pins, install scripts and the probe's imports included, and that must not happen in the job holding RELEASE_TOKEN and the id-token permission npm publishing uses. A third job, with contents: write and nothing else, downloads the file and attaches it to the release with gh release upload. A failure in either job, including a registry that does not serve the new version yet, leaves the release published without its SBOM; re-running the failed jobs generates and attaches it.

The policy

The root README's SBOM section describes the release SBOM and keeps the dependency graph export for the whole repository. A new "Dependency policy and supply chain" section states the policy: no runtime dependency on end-of-life or unmaintained components, no optional dependencies, so an integration not every consumer needs would be an optional peer, copied code credited in NOTICE, the third-party notices and the SBOM, and policy findings triaged for every release. It points to the deny-list in scripts/package-contract-probe.mjs rather than repeating it, and shows how to verify provenance with npm audit signatures.

It documents the one finding the project accepts. Axios gives ApiDOM's HTTP resolver the proxies and custom agents enterprise networks need, so it stays. The section names the components on its path that are older than five years, from their publish dates on the registry, checked on 2026-10-20: asynckit@0.4.0, combined-stream@1.0.8 and delayed-stream@1.0.0 through form-data@4.0.6, and agent-base@6.0.2 and ms@2.1.3 through https-proxy-agent@5.0.1. It also names mime-db@1.52.0, mime-types@2.1.35 and https-proxy-agent@5.0.1, which cross five years between February and April 2027. A waiver note has to use the component names of the scan report itself, so the section says so instead of quoting a report we do not have. The only other component older than five years in a consumer's tree is ts-toolbelt@9.6.0, which comes through @types/ramda and leaves with #76.

Testing

SBOM_TO=… ./scripts/check-package-contract.sh passes and writes an SBOM with the package, its 82 installed dependencies, which match the consumer's npm ls --all, the three copied files and the 50 packages of the bundle; its 137 bom-refs are unique. It validates against the CycloneDX 1.5 schema, and it contains none of the denied components.

The release path cannot run without a release, so it was exercised against the published 2.14.0. PUBLISHED_VERSION=2.14.0 ./scripts/check-package-contract.sh fetches and installs that version and fails with the original scan's findings, ajv and require-from-string, @stoplight/better-ajv-errors with jsonpointer and leven, and json-source-map. With the probe and the type check left out, the same run writes a valid SBOM whose subject has the registry tarball URL and a SHA-512 equal to the registry's dist.integrity for 2.14.0.

The script fails as intended with the ajv version in the manifest changed to 8.19.0, with the pack report of another archive, with a consumer installing another version than the checkout, and with a heading or a rule of the notices altered. actionlint reports nothing for either workflow; the upload needs a release and was not run. Build, lint (no new warnings), type-check, 570 tests, the identifier check and the validator drift check pass.

Every release attaches a CycloneDX SBOM of the package to its GitHub
release. It is read from the scratch consumer of the package contract
check, which installs the packed tarball, so it lists what installing
the release brings in. `npm sbom --omit dev` on the workspace lockfile
left out 54 of the 83 packages a consumer installs, and named the
lockfile's ApiDOM versions instead of the ones a consumer resolves.

`scripts/complete-sbom.mjs` makes the package the subject of the SBOM
npm writes for the consumer. It nests the files copied or ported into
the package, which `config/vendored-code.json` lists, as components
whose pedigree names the package and version each came from, with the
licence and copyright of the file's SPDX header. It fails when such a
version differs from the devDependency pin, so a bump cannot leave the
record of a copy behind.

The contract check writes the SBOM when `SBOM_TO` names a file. The
build workflow sets it, so every pull request generates one. The
release workflow generates it from the release tag after publishing,
in a job with a read-only token and no secrets, because the scratch
consumer resolves dependencies afresh and runs code no lockfile pins.
A third job, which only downloads the file, attaches it to the GitHub
release.
A README section states the dependency policy. The package depends at
runtime on no end-of-life or unmaintained component and has no
optional dependencies, copied code is credited in NOTICE, the
third-party notices and the SBOM, and policy findings are triaged for
every release. The section points to the deny-list the package
contract check enforces and shows how to verify provenance with
`npm audit signatures`.

It also documents the finding the project accepts. Axios gives the
ApiDOM HTTP resolver the proxies and custom agents enterprise networks
need, so it stays, and the components on its path that are older than
five years are named, with the dates when the next ones cross that
line.
@frantuma
frantuma requested a review from char0n as a code owner October 20, 2026 09:44
The release SBOM described an archive nobody receives. Its job rebuilt
and packed the tag, but Lerna writes `gitHead` into the manifest before
it publishes, so the subject's hash was not the published archive's,
and its distribution was a temporary `file:` path. With
`PUBLISHED_VERSION` set, the contract check now fetches that version's
archive from the registry, checks it, and installs it from the
registry, so npm records the registry URL and the integrity it
verified. The SBOM job sets it and no longer builds. `complete-sbom`
fails unless the subject's SHA-512 is that of the archive the check
verified, and unless the checkout it reads the copied files from is
the same version.

The browser bundle embeds its dependencies at the versions the
workspace lockfile resolved when it was built, which a consumer's
install need not have: ApiDOM 6.0.6 against 6.1.0 today, and `unraw`,
which is no longer in the tree at all. The packages the notices shipped
beside the bundle name become components of a `file` component for the
bundle, except the origins of the copied files, which their pedigree
already names. The script fails when the notices no longer have the
layout it reads.
@frantuma
frantuma merged commit 2273950 into main Oct 20, 2026
10 of 11 checks passed
@frantuma
frantuma deleted the frantuma/release-sbom branch October 20, 2026 10:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant