An SBOM is only useful if it describes the directory or image that the release process is about to publish. Generating one after an artifact has moved to another job, registry, or environment can leave a gap: the report may be well formed, but it may no longer identify the bytes that deployment will use.
This guide uses Syft 1.51.0 to scan a local build directory, write both CycloneDX JSON and SPDX JSON, and check the resulting inventory before a later CI step uploads the artifact. The completed result is a small fail-closed shell boundary: it verifies the downloaded Syft archive, generates two machine-readable SBOM formats from a named directory, and stops if the expected package is absent. The example scans a disposable directory on the runner. It does not contact a registry, Docker daemon, cluster, or production system.
The commands were exercised on Linux amd64 with Syft 1.51.0. The release archive matched its entry in the release checksum manifest before extraction. A directory containing requirements.txt with requests==2.32.3 produced an SPDX inventory containing requests@2.32.3 and the directory’s root package. After that manifest was removed, a second scan retained only the root package; requests was absent. This is the useful operational observation: a directory scan reports what is present in the directory at scan time, not what a build was expected to contain. Treat an empty or unexpectedly small inventory as a release failure, not as a report to upload and explain later.
Syft 1.51.0 was released on 10 August 2026. Its release notes include native Go FIPS 140 mode detection and expanded standard-library scope and module patterns for symbol capture, along with cataloger fixes. Those changes are a reason to record the exact scanner version with an SBOM. They are not a reason to assume that a new scanner version found every package relevant to a particular build. Keep a small, representative inventory assertion in CI when changing Syft versions or build layouts.
Put SBOM generation beside the build output
Choose the narrowest source that represents the release input. For a source or build directory, use the explicit dir: scheme. It prevents a path from being interpreted as an image reference and makes the intended scan boundary obvious in logs. For a container image, use the immutable image digest produced by the build instead of a mutable tag; that is a separate workflow from this local-directory example.
The directory should be complete before the scan begins. If a dependency-install step writes site-packages, node_modules, a vendor tree, or a package manifest after Syft runs, the SBOM will not describe that later state. Similarly, do not scan the entire CI workspace just because it is convenient. It may include test fixtures, cached tools, credentials accidentally written by another step, or outputs from a different job.
Here is a minimal disposable fixture. The requirements.txt file is intentionally simple so that the expected result is unambiguous:
set -eu
workdir=$(mktemp -d)
mkdir -p "$workdir/release"
printf '%s\n' 'requests==2.32.3' > "$workdir/release/requirements.txt"
In a real job, replace the fixture with the completed staging directory, such as $CI_PROJECT_DIR/dist or another path that contains only the files that will form the release. Do not put a private registry address, customer-specific package name, token, or internal path into a published SBOM example.
Verify the scanner before running it
A scanner is part of the release trust boundary. Downloading a binary named syft and immediately making it executable gives the scanner more trust than the artifact it is meant to describe. Syft releases include a checksum manifest and platform archives. Fetch both from the same fixed release and validate the archive against the manifest before extraction.
The following commands select the Linux amd64 archive used for the validation run. Adjust both the version and platform deliberately when upgrading. Run this in a temporary tools directory rather than writing an unreviewed binary into a source repository.
set -eu
version=1.51.0
base="https://github.com/anchore/syft/releases/download/v${version}"
tools=$(mktemp -d)
cd "$tools"
curl --fail --location --output "syft_${version}_checksums.txt" \
"$base/syft_${version}_checksums.txt"
curl --fail --location --output "syft_${version}_linux_amd64.tar.gz" \
"$base/syft_${version}_linux_amd64.tar.gz"
grep " syft_${version}_linux_amd64.tar.gz$" \
"syft_${version}_checksums.txt" | sha256sum -c -
tar -xzf "syft_${version}_linux_amd64.tar.gz" syft
chmod 0755 syft
./syft version
The checksum command must print OK. Stop if it does not find exactly one archive entry or if sha256sum returns a nonzero status. Do not work around a failed comparison by copying a hash from a log. The tested binary reported Version: 1.51.0, Platform: linux/amd64, and SchemaVersion: 16.1.10.
The release also provides a signature and certificate for its checksum manifest. Teams that already maintain a verified release-key or Sigstore policy should verify those assets as well. The archive checksum check above establishes that the downloaded archive matches the release manifest; it does not independently establish who signed that manifest.
Generate two formats from the same directory
CycloneDX and SPDX can carry similar inventory data but are consumed by different tools. Generate them from the same source in the same command so that downstream systems do not compare reports built from different directory states. Syft accepts repeated -o flags, including a format and output path.
release_dir="<completed-release-directory>"
mkdir -p sbom
"$tools/syft" scan "dir:$release_dir" \
--source-name release-directory \
--source-version "<release-version>" \
-o "cyclonedx-json=sbom/release.cdx.json" \
-o "spdx-json=sbom/release.spdx.json"
--source-name and --source-version give the root component a stable identity. Without them, Syft derives an identity from the directory path and warns that no explicit name and version were supplied. A temporary runner path is not a useful release identity, and it makes otherwise identical reports harder to compare.
Use a name and version that refer to the release unit, not the scanner. For example, a service build may use --source-name api and the version already assigned by the release process. Keep the value non-secret: SBOM metadata is often attached to a release, submitted to a dependency-tracking system, or made available to customers.
The two outputs should be treated as release artifacts. Store them with the artifact they describe and, when possible, sign or attest them using the same reviewed release policy. Do not edit an SBOM after it is generated. If an inventory needs to change, rebuild the release directory and generate a new report.
Check the inventory before publishing it
A valid JSON document is not evidence that Syft found the expected application dependencies. Add a small check that is specific to the build. The test fixture contained one pinned Python requirement, so the decisive check is that the SPDX package list includes requests at version 2.32.3.
python3 - sbom/release.spdx.json <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
document = json.load(handle)
packages = {
(package.get("name"), package.get("versionInfo"))
for package in document.get("packages", [])
}
expected = ("requests", "2.32.3")
if expected not in packages:
raise SystemExit(f"expected package missing: {expected[0]}@{expected[1]}")
print(f"found expected package: {expected[0]}@{expected[1]}")
PY
The assertion should match a dependency that is expected to be in every release of this component. Do not check only that packages is nonempty: a scanner can successfully identify an operating-system package or document root while missing the application dependency that prompted the SBOM requirement. For a multi-language build, use several assertions or compare a reviewed normalized inventory against an expected baseline.
In the local test, Syft’s SPDX JSON declared SPDX-2.3 and contained requests@2.32.3 plus the directory root package when the requirements file was present. Removing that file and scanning again removed requests while leaving the directory root package. That before-and-after check is deliberately basic, but it catches the failure mode that matters in CI: generating a polished SBOM that omits an application dependency because the scan ran before the package manifest or installed dependencies reached the release directory.
Make absence and scanner errors fail the job
Syft returns a nonzero status when it cannot resolve a requested source. It is important not to mask that result with || true, an unconditional report-upload step, or a shell pipeline that keeps only the final command’s exit code. Keep set -e enabled and upload SBOM files only after generation and inventory checks succeed.
A narrow CI sequence looks like this:
set -eu
./build-release-directory.sh
./generate-sbom.sh
./check-sbom-inventory.sh
./publish-release-artifacts.sh
If the build creates dependencies outside the directory being scanned, decide whether the release boundary is wrong or whether the build needs a packaging step that gathers those files first. Do not solve that problem by scanning the runner root or a shared cache. The resulting report becomes harder to review and may disclose packages unrelated to the product.
A Syft report is an inventory, not a vulnerability verdict or an approval decision. Feed it to the organization’s dependency and vulnerability process, retain the Syft version and checksum evidence, and review unexpected additions or removals as build changes. The local 1.51.0 validation showed the essential property for this gate: the package list changed when the only input manifest changed, and the empty result was observable before any publication step.