Repository navigation
ci(release): attach a CycloneDX SBOM and state the dependency policy - #81
Merged
Merged
Conversation
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.
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.
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.
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.jsonto its GitHub release. The plan was to runnpm sbom --omit dev --workspace @speclynx/api-languageserviceon 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 themyaml,prettier, anddebugandmson 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 consumerscripts/check-package-contract.shalready creates, which is the tree a scanner evaluates. WhenSBOM_TOnames a file, the check runsnpm sbom --sbom-format cyclonedxthere 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
gitHeadbefore it packs, so a rebuild of the tag is a different archive with a different hash. WithPUBLISHED_VERSIONset, the check fetches that version's archive from the registry withnpm 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.mjsturns 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
filecomponents, each withpedigree.ancestorsnaming the npm package and version it came from:ucs2lengthfromajv@8.20.0,equalfromfast-deep-equal@3.1.3, and the better-errors port from@stoplight/better-ajv-errors@1.0.3. They are listed inpackages/api-languageservice/config/vendored-code.jsonwith 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 bumpingajvorfast-deep-equalcannot leave the record of a copy behind;CLAUDE.mdsays so next to the existing note about comparing the copies.The browser bundle
dist/api-languageservice.browser.min.jsis afilecomponent holding the packages it embeds, at the versions it was built with, read from thedist/THIRD-PARTY-NOTICES.txtshipped 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, andunraw@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 thev<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 holdingRELEASE_TOKENand theid-tokenpermission npm publishing uses. A third job, withcontents: writeand nothing else, downloads the file and attaches it to the release withgh 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.mjsrather than repeating it, and shows how to verify provenance withnpm 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.8anddelayed-stream@1.0.0throughform-data@4.0.6, andagent-base@6.0.2andms@2.1.3throughhttps-proxy-agent@5.0.1. It also namesmime-db@1.52.0,mime-types@2.1.35andhttps-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 ists-toolbelt@9.6.0, which comes through@types/ramdaand leaves with #76.Testing
SBOM_TO=… ./scripts/check-package-contract.shpasses and writes an SBOM with the package, its 82 installed dependencies, which match the consumer'snpm ls --all, the three copied files and the 50 packages of the bundle; its 137bom-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.shfetches and installs that version and fails with the original scan's findings,ajvandrequire-from-string,@stoplight/better-ajv-errorswithjsonpointerandleven, andjson-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'sdist.integrityfor 2.14.0.The script fails as intended with the
ajvversion 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.