Yocto: make the SBOM
Yocto’s create-spdx class writes an SBOM for each image. You switch it on, build, and upload what it wrote as it is.
Uploading from CI or the API is part of the Maker plan and the ones above it. On the Free plan the Settings page shows no key form. Without a key you can still run the free SBOM check and upload a release by hand in the app.
Sign in, open your product, then Settings → API keys. Create a key there — it is shown once. That same page shows these steps again with your product id already filled in, so once the key exists you may not need this page at all.
Switch on create-spdx
Add this line to conf/local.conf (or your distro configuration).shINHERIT += "create-spdx"Build your image
Run bitbake on the image you ship.shsource poky/oe-init-build-env build bitbake <your image>Find the image’s SBOM document
From Yocto 5.1 on, create-spdx writes one SPDX 3 document per image, named after your image and machine, in the deploy directory for your machine. There is no archive on this release line: upload the one file as it is. It counts as one file against the 25 MB per-file upload limit.shbuild/tmp/deploy/images/<machine>/<image>.rootfs.spdx.json
What this file does not list
Source: the upstream documentation at https://docs.yoctoproject.org/dev-manual/sbom.html, read 2026-10-10. Each point says whether the upstream page states it or whether it is our reading.
- The class creates SBOM documents from image and SDK contents; how much it writes is set by SPDX_INCLUDE_SOURCES and the other SPDX_ variables. (stated upstream)
- It describes what bitbake built from recipes. Files copied into the image by hand, or added after the build, have no recipe and so no entry. (our reading)
- A prebuilt binary wrapped in a recipe is listed by the recipe’s own name, version and licence fields, not by what is inside the binary. (our reading)
- Firmware that runs on another chip on the board, and is not in this image, is outside it. (our reading)
What to do: add the missing parts to the SBOM by hand, or ask the vendor of each part for their own SBOM. Declarable flags a component as a binary blob only when it is in your SBOM and matches one it already knows; a part the file never mentions is not found for you.
GitHub Actions example
An example to copy and adapt, not your actual workflow file. It ends with the Declarable Action, whose two files you download from the files page.
Each upload under a new version becomes the product’s current release, and what Declarable then shows and sends (open vulnerabilities, gaps, alerts) is about that release. So upload the builds you ship. The examples below build on every push but upload only for a tag or the default branch. To change that, edit the if: line on the GitHub upload step, or put your own rules: in the GitLab job that extends the template.
on: push
jobs:
sbom:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Your own layers, local.conf (with INHERIT += "create-spdx") and cache go here.
- run: |
source poky/oe-init-build-env build
bitbake <your image>
shell: bash
# The Action is not on the Marketplace yet. Download action.yml and upload.mjs from
# https://declarable.io/docs/ci/files/action.yml and https://declarable.io/docs/ci/files/upload.mjs
# into .github/actions/declarable/ in your repository and commit them: a vendored copy is code
# you can read before it runs with your API key.
- uses: ./.github/actions/declarable
# A release is recorded for a tag and for the default branch only. Edit this line to change that.
if: github.ref_type == 'tag' || github.ref_name == github.event.repository.default_branch
with:
api-key: ${{ secrets.DECLARABLE_API_KEY }}
product-id: <your product id>
sbom-path: build/tmp/deploy/images/*/*.rootfs.spdx.json
GitLab CI example
An example to copy and adapt, not your actual .gitlab-ci.yml. It ends with the Declarable job.
The job needs declarable.yml and a copy of upload.mjs in your repository, both on the files page. Put declarable.yml at the path the include: at the top of this example names, and upload.mjs at the path DECLARABLE_UPLOAD_SCRIPT names:
The upload job runs for a tag or the default branch only. A pipeline on another branch shows no declarable_upload job, and that is the template’s rule, not a fault.
include:
- local: integrations/gitlab-ci/declarable.yml
build:
script:
- source poky/oe-init-build-env build
- bitbake <your image>
artifacts:
paths:
- build/tmp/deploy/images/*/*.rootfs.spdx.json
declarable_upload:
extends: .declarable_upload
needs:
- job: build
artifacts: true
variables:
# The path of the upload.mjs you copied into your repository.
DECLARABLE_UPLOAD_SCRIPT: "ci/declarable/upload.mjs"
DECLARABLE_PRODUCT_ID: "<your product id>"
DECLARABLE_SBOM_PATH: "build/tmp/deploy/images/*/*.rootfs.spdx.json"
Any other CI
A release script or another CI can run the same upload from a shell.
Read upload.sh before you run it, then point it at build/tmp/deploy/images/*/*.rootfs.spdx.json:
export DECLARABLE_API_KEY=... # from your secret store, never typed into a script
export DECLARABLE_PRODUCT_ID="<your product id>"
export DECLARABLE_SBOM_PATH="build/tmp/deploy/images/*/*.rootfs.spdx.json"
sh integrations/curl/upload.sh
Nothing limits a script: run it from the job that builds a release.
Left unset, the version is the commit the build ran from; set DECLARABLE_VERSION to the tag for a tag build.
Inputs and exit codes
GitHub calls them inputs; GitLab and the shell script read the same settings as variables. The key scopes are in the API reference.
| GitHub input | Variable | Default | What it does |
|---|---|---|---|
api-key | DECLARABLE_API_KEY | none, required | Your API key (scopes releases:write and products:read). Keep it in your CI secret store, never in the file. |
product-id | DECLARABLE_PRODUCT_ID | none, required | Which product the release belongs to. It is in the product page’s address. |
sbom-path | DECLARABLE_SBOM_PATH | none, required | One or more SBOM files or globs, separated by newlines or spaces. Every match is sent in one release. A pattern that matches nothing stops the job. |
| none | DECLARABLE_UPLOAD_SCRIPT | none, required (GitLab only) | The path of the upload.mjs you copied into your repository. The GitLab job stops with exit 2 if it is empty. |
keep-paths | DECLARABLE_KEEP_PATHS | "false" | Needed only by a reader that takes the environment from the file’s path (PlatformIO today). "true" sends each file under its path inside the project instead of its base name; without it every file uploads under its base name. |
version | DECLARABLE_VERSION | the commit SHA | The release’s label. Re-running with a version that already exists records nothing new and checks the findings. |
api-base-url | DECLARABLE_API_BASE_URL | https://declarable.io | Where Declarable runs. Change it only for a self-hosted or staging copy. |
replace | DECLARABLE_REPLACE | "false" | "true" replaces the release already under this version when the files differ; otherwise different files are left alone. |
released-at | DECLARABLE_RELEASED_AT | none (no date is sent) | Optional. The day the firmware shipped, as YYYY-MM-DD, read as a UTC date. Refused if it is in the future. Ignored when the same files were already uploaded under this version. |
commit | DECLARABLE_COMMIT | the commit that triggered the build | The commit the firmware was built from, stored with the release. "none" sends no commit. At most 200 characters. |
build-id | DECLARABLE_BUILD_ID | none (nothing is sent) | Optional. Your own label for the build, stored with the release. At most 200 characters. |
image-sha256 | DECLARABLE_IMAGE_SHA256 | none (nothing is sent) | Optional. The SHA-256 of the firmware image, 64 hex characters. Stored as given; nothing checks it against a real image. |
supported-until | DECLARABLE_SUPPORTED_UNTIL | none (nothing is sent) | Optional. The last day you will support this release, as YYYY-MM-DD. A value Declarable would refuse stops the job with exit 2. The four build fields are ignored when the same files were already uploaded under this version. |
fail-on-gap | DECLARABLE_FAIL_ON_GAP | blocker | blocker, major, minor or none. Fails the job when the current release has a gap at that severity or worse; none never fails it. |
fail-on-exploited | DECLARABLE_FAIL_ON_EXPLOITED | "true" | "true" fails the job when an open vulnerability is known to be exploited; "false" only reports it. |
GitHub Action outputs (give the step an id): release-id is the id of the release the step uploaded, or of the one already under this version; release-url is that release’s address in Declarable; component-count is how many components the release holds (empty if the version exists with different files); recorded is "true" when a new or replaced release was recorded, "false" when the version already existed.
0: uploaded and inside your thresholds (or uploaded with the findings not checked, which the last line says).1: a threshold was crossed.2: the upload failed.
A worked example: build fields and outputs
One pipeline that hashes the image it built, sends the hash and the run id with the upload, and reads back the release’s address. Both fields are stored as sent; nothing checks them against the files you uploaded.
- name: Hash the firmware image
id: image
run: echo "sha256=$(sha256sum build/firmware.bin | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
- uses: ./.github/actions/declarable
id: declarable
with:
api-key: ${{ secrets.DECLARABLE_API_KEY }}
product-id: <your product id>
sbom-path: build/*.spdx
build-id: ${{ github.run_id }}
image-sha256: ${{ steps.image.outputs.sha256 }}
- run: echo "Recorded at ${{ steps.declarable.outputs.release-url }}"
The same two fields on GitLab, computed in before_script so the export reaches the job’s own script::
declarable_upload:
extends: .declarable_upload
needs:
- job: build
artifacts: true
variables:
DECLARABLE_UPLOAD_SCRIPT: "ci/declarable/upload.mjs"
DECLARABLE_PRODUCT_ID: "<your product id>"
DECLARABLE_SBOM_PATH: "build/spdx/*.spdx"
DECLARABLE_BUILD_ID: $CI_PIPELINE_ID
before_script:
- export DECLARABLE_IMAGE_SHA256=$(sha256sum build/firmware.bin | cut -d' ' -f1)
And from a shell:
export DECLARABLE_API_KEY=...
export DECLARABLE_PRODUCT_ID="<your product id>"
export DECLARABLE_SBOM_PATH="build/spdx/*.spdx"
export DECLARABLE_BUILD_ID="$CI_JOB_ID" # or any run id your own CI exposes
export DECLARABLE_IMAGE_SHA256=$(sha256sum build/firmware.bin | cut -d' ' -f1)
sh integrations/curl/upload.sh
Declarable prepares documentation for you to review. It is not legal advice and not certification. You sign your own declaration of conformity.
Yocto 5.0 and earlier: the archive case
Before 5.1, create-spdx packs the image document and one document per recipe into an archive instead of writing the single document above.
Find the image’s SBOM archive
build/tmp/deploy/images/<machine>/<image>.rootfs.spdx.tar.zstEvery Yocto release checked (back to 4.0, kirkstone) publishes this archive as zstd‑compressed (.tar.zst); none has been found to publish a gzip one (.tar.gz). The check was made against the real build artefacts of each release. Declarable opens a .tar.gz too, in case your own build or a vendor repack produces one.
GitHub Actions example (5.0 and earlier)
An example to copy and adapt, not your actual workflow file.
on: push
jobs:
sbom:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Your own layers, local.conf (with INHERIT += "create-spdx") and cache go here.
- run: |
source poky/oe-init-build-env build
bitbake <your image>
shell: bash
# The Action is not on the Marketplace yet. Download action.yml and upload.mjs from
# https://declarable.io/docs/ci/files/action.yml and https://declarable.io/docs/ci/files/upload.mjs
# into .github/actions/declarable/ in your repository and commit them: a vendored copy is code
# you can read before it runs with your API key.
- uses: ./.github/actions/declarable
# A release is recorded for a tag and for the default branch only. Edit this line to change that.
if: github.ref_type == 'tag' || github.ref_name == github.event.repository.default_branch
with:
api-key: ${{ secrets.DECLARABLE_API_KEY }}
product-id: <your product id>
sbom-path: build/tmp/deploy/images/*/*.rootfs.spdx.tar.zst
GitLab CI example (5.0 and earlier)
An example to copy and adapt, not your actual .gitlab-ci.yml.
include:
- local: integrations/gitlab-ci/declarable.yml
build:
script:
- source poky/oe-init-build-env build
- bitbake <your image>
artifacts:
paths:
- build/tmp/deploy/images/*/*.rootfs.spdx.tar.zst
declarable_upload:
extends: .declarable_upload
needs:
- job: build
artifacts: true
variables:
# The path of the upload.mjs you copied into your repository.
DECLARABLE_UPLOAD_SCRIPT: "ci/declarable/upload.mjs"
DECLARABLE_PRODUCT_ID: "<your product id>"
DECLARABLE_SBOM_PATH: "build/tmp/deploy/images/*/*.rootfs.spdx.tar.zst"