Skip to main content

TECH VEDA

Embedded Linux on Edge-AI 23rd Sept 2026 enrollingLinux kernel & Device drivers starts on 24th Oct 2026 enrollingCorporate on-site training - Submit proposal Pick your modulesSharpen your kernel skills: deep dives, drivers, Yocto, CVEs, careers — updated daily. Read the blog →Embedded Linux fast track starts 23rd sept 2026 enrollingEmbedded Linux Mastery track starts 23rd sept 2026 enrollingLinux systems engineering starts 23rd sept 2026 enrolling
Debug Stories

The SPDX Tasks That Refused to Die: A BitBake Parse-Order Mystery

An image recipe inherited nospdx, yet the SPDX tasks ran anyway and the build failed on a missing rootfs-packages.json. The culprit was bitbake's deferred inherit ordering. Here is how to read it and fix it.

The SPDX Tasks That Refused to Die: A BitBake Parse-Order Mystery

Most bugs live in code that is wrong. The interesting ones live in code that is right. The class at the centre of this story did exactly what its eight lines of source say — and still did nothing at all. A build generating SPDX 3 SBOMs on Scarthgap failed with a FileNotFoundError in do_create_rootfs_spdx on a custom boot-partition image. The obvious escape hatch — inherit nospdx, a class whose entire body is a list of deltask lines — was added to the recipe, and the tasks it deletes ran anyway. No warning, no parse error. The deleted tasks simply ran. It is worth walking through slowly, because the explanation is not where you would first look. This post follows the trail: the failing task, the false lead, and the point where the mystery actually starts.

The setting: why a Scarthgap build is generating SPDX 3 at all

A software bill of materials has quietly moved from nice-to-have to contractual. Regulators are pushing from one side — the EU Cyber Resilience Act and US federal procurement rules both lean on SBOMs as the way to answer “what exactly is in this device?” — and customers are pushing from the other, increasingly refusing deliveries that don’t come with one. The Yocto Project saw this coming: the build system inherits create-spdx by default and emits an SPDX document alongside every image, which is one of the stronger arguments for building products on it in the first place.

The version, though, is a moving target. Scarthgap — the LTS many products are pinned to until 2028 — ships SPDX 2.2, while the ecosystem’s tooling and the requests landing in your inbox increasingly say SPDX 3.0. That is why the backport series bringing SPDX 3.0 support to Scarthgap exists, and why real production builds run this combination: an LTS base with next-generation SBOM machinery grafted on.

The second ingredient is just as common in real products. Not every image in a build is a root filesystem. Devices carry boot partitions, recovery volumes, firmware containers — images assembled from deploy artifacts rather than installed packages. Yocto has a well-worn idiom for these, the “nopackages” image: inherit image for the deploy machinery, but replace do_rootfs with your own function that lays out the partition contents directly. Each ingredient is standard practice. Put all of them in one build, and you get the failure this story is about.

The symptom: a missing JSON file in an image that installs no packages

The setup, concretely: Scarthgap 5.0.19 with the SPDX 3.0 support backported, and an image recipe — call it bootfiles.bb — that produces a boot partition rather than a normal root filesystem. It follows the nopackages pattern: the recipe inherits image for the deploy machinery, but supplies its own do_rootfs that assembles the partition contents directly instead of installing packages. Trimmed to what matters, the recipe looks like this:

SUMMARY = "Boot partition content: kernel, DTBs and boot script"
LICENSE = "MIT"

inherit image

# Nothing is installed by the package manager
IMAGE_INSTALL = ""
PACKAGE_INSTALL = ""
IMAGE_FEATURES = ""
IMAGE_LINGUAS = ""

# The staged tree is packed here, then placed into the disk
# layout by the wic image that consumes it
IMAGE_FSTYPES = "tar.gz"

# Content comes straight from the deploy directory
do_rootfs[depends] += "virtual/kernel:do_deploy virtual/bootloader:do_deploy"

fakeroot python do_rootfs() {
    import os, shutil

    deploy = d.getVar('DEPLOY_DIR_IMAGE')
    rootfs = d.getVar('IMAGE_ROOTFS')

    os.makedirs(rootfs, exist_ok=True)
    for f in ("Image", "boot.scr"):
        shutil.copy2(os.path.join(deploy, f), rootfs)
    for dtb in (d.getVar('KERNEL_DEVICETREE') or "").split():
        shutil.copy2(os.path.join(deploy, os.path.basename(dtb)), rootfs)
}

The override on do_rootfs is the defining move: the standard package-install machinery is gone, replaced by a straight copy out of DEPLOY_DIR_IMAGE. Keep that in mind — it is the detail everything else hangs on.

The build stopped here:

ERROR: bootfiles-1.0-r0 do_create_rootfs_spdx: Error executing a python function in exec_func_python() autogenerated:
...
File: '.../meta/lib/oe/spdx30_tasks.py', lineno: 1081, function: create_rootfs_spdx
     1077:    image_basename = d.getVar("IMAGE_BASENAME")
     1078:    image_rootfs = d.getVar("IMAGE_ROOTFS")
     1079:    machine = d.getVar("MACHINE")
     1080:
 *** 1081:    with root_packages_file.open("r") as f:
     1082:        packages = json.load(f)
...
Exception: FileNotFoundError: [Errno 2] No such file or directory:
'.../tmp/work/mymachine-poky-linux/bootfiles/1.0/spdx/3.0.1/rootfs-packages.json'

do_create_rootfs_spdx wants a file called rootfs-packages.json and nobody wrote it.

Where rootfs-packages.json comes from

The producer lives in create-spdx-image-3.0.bbclass and is short enough to read in full:

SPDX_ROOTFS_PACKAGES = "${SPDXDIR}/rootfs-packages.json"

python spdx_collect_rootfs_packages() {
    import json
    from pathlib import Path
    from oe.rootfs import image_list_installed_packages

    root_packages_file = Path(d.getVar("SPDX_ROOTFS_PACKAGES"))

    packages = image_list_installed_packages(d)
    if not packages:
        packages = {}

    root_packages_file.parent.mkdir(parents=True, exist_ok=True)
    with root_packages_file.open("w") as f:
        json.dump(packages, f)
}
ROOTFS_POSTUNINSTALL_COMMAND =+ "spdx_collect_rootfs_packages"

The function is not a task. It is hooked into ROOTFS_POSTUNINSTALL_COMMAND, one of the command lists that the standard do_rootfs machinery in oe.rootfs executes as it installs and prunes packages. A nopackages image replaces do_rootfs with its own function, so the hook never fires and the JSON is never written. do_create_rootfs_spdx then runs — it is a real task, scheduled after do_rootfs before do_image — and falls over the missing file.

So far, unsurprising: the SPDX image class assumes every image goes through the normal rootfs code path, and a custom do_rootfs breaks that assumption. The clean response is to opt this one recipe out of SPDX generation, and oe-core ships a class for exactly that:

raghu@techveda.org:~/oe/meta/classes-recipe$ cat nospdx.bbclass
deltask do_create_recipe_spdx
deltask do_create_recipe_sbom
deltask do_create_spdx
deltask do_create_spdx_runtime
deltask do_create_package_spdx
deltask do_create_rootfs_spdx
deltask do_create_image_spdx
deltask do_create_image_sbom_spdx

Add inherit nospdx to bootfiles.bb, rebuild — and the build dies in do_create_rootfs_spdx again, exactly as before. Listing the tasks shows why: the image SPDX tasks the class deletes are still in the task graph:

raghu@techveda.org:~/build$ bitbake bootfiles -c listtasks | grep create_
do_create_image_sbom_spdx      Creates the final SBOM document for an image
do_create_image_spdx           Generates SPDX data for image files
do_create_rootfs_spdx          Generates SPDX data for rootfs packages

That explains the missing file — but not this. The missing JSON was only the setup. The real mystery of this debug story is that a class containing nothing but deltasks was inherited, and three of the tasks it deletes survived.

deltask is not a ban, it is an event

The mental model most of us carry is that deltask do_foo marks a task as forbidden. It does not. addtask and deltask are directives that execute in the order bitbake evaluates them during parsing. deltask removes the task from the list at the moment it runs; if an addtask for the same task is evaluated later, the task comes straight back. Deletion only sticks if it is the last word.

That reframes the question: what could possibly run after the recipe body? To answer it, it helps to know that there are three moments at which bitbake can add or remove tasks for a recipe:

  1. Configuration-level inherits (INHERIT/INHERIT_DISTRO), evaluated before the recipe body.
  2. The recipe body, top to bottom, including every plain inherit statement in it and in the classes it pulls in.
  3. Deferred inherits (inherit_defer), collected during parsing but evaluated at the end, after the entire recipe body has been processed.

Whoever speaks last wins — and stage 3 is where the late addtask comes from. inherit_defer exists so that expressions like ${IMAGE_CLASSES} are expanded as late as possible, and image.bbclass uses it for its helper classes:

IMGCLASSES = "rootfs_${IMAGE_PKGTYPE} image_types ${IMAGE_CLASSES}"
...
inherit_defer ${IMGCLASSES}

IMAGE_CLASSES is exactly how the SPDX image class arrives, because the globally inherited create-spdx-3.0.bbclass does:

IMAGE_CLASSES:append = " create-spdx-image-3.0"

Map bootfiles.bb onto the three stages and the bug is visible:

  1. Stage 1 inherits create-spdx-3.0 and adds the recipe-scope SPDX tasks (do_create_spdx, do_create_package_spdx, …).
  2. Stage 2 reaches inherit nospdx, whose eight deltasks run. At this instant, every SPDX task that exists is gone.
  3. Stage 3 inherits create-spdx-image-3.0 via IMAGE_CLASSES. Its addtask do_create_rootfs_spdx, addtask do_create_image_spdx and addtask do_create_image_sbom_spdx execute — and re-add the three image tasks that nospdx just deleted.

nospdx worked. Then the image class un-worked it. From bitbake’s point of view nothing is wrong — tasks were removed, tasks were added — so nothing is printed. Note that the class is fine for ordinary recipes: a library or application recipe has no deferred image inherit, so its deltasks are final. It fails specifically for image recipes — exactly where the nopackages pattern makes you need it.

The fix: don’t fight the addtask, prevent the inherit

Once the ordering is understood, the fix writes itself. Deleting the tasks before they are re-added is a losing race; the winning moves both act after — or instead of — stage 3.

Preferred fix: remove the image class

Stop create-spdx-image-3.0 from being inherited at all for this recipe. inherit_defer expands its arguments at deferred-processing time, which means a recipe-scope change to IMAGE_CLASSES is honoured. Keep the inherit nospdx line — it still removes the stage-1 recipe-scope tasks, which the remove below does not touch — and add one line to bootfiles.bb, so the opt-out becomes this pair:

inherit nospdx
IMAGE_CLASSES:remove = "create-spdx-image-3.0"

The image class never joins the recipe, so its addtasks never run — and the ROOTFS_POSTUNINSTALL_COMMAND hook and the SSTATETASKS entries it carries disappear with it. Between the two lines, nothing half-configured is left behind:

raghu@techveda.org:~/build$ bitbake bootfiles -c listtasks | grep create_
raghu@techveda.org:~/build$ bitbake bootfiles
...
NOTE: Tasks Summary: Attempted 412 tasks of which 397 didn't need to be rerun and all succeeded.

Fallback: delete the tasks in anonymous python

If you would rather keep everything in one opt-out — say, in a layer where many image recipes need it — do the deletion from an anonymous python function in the recipe (or in your own class), because anonymous python runs during recipe finalisation, after the deferred inherits have been processed:

python () {
    for t in ("do_create_rootfs_spdx",
              "do_create_image_spdx",
              "do_create_image_sbom_spdx"):
        bb.build.deltask(t, d)
}

Both work. What does not work, other than by brute force, is the workaround this failure usually pushes people into: prepending the task functions to turn them into no-ops. That leaves the tasks in the graph, keeps their sstate and dependency plumbing live, and — as the postscript shows — quietly breaks the one output you might still have wanted.

There is arguably an oe-core bug here: inherit nospdx should mean what it says for image recipes too, and the class could achieve that by carrying the IMAGE_CLASSES:remove itself. That discussion belongs on the openembedded-core list; the recipe-scope remove above is the fix you can ship today on Scarthgap, Styhead, or master.

Postscript: where the SBOM actually lands

One loose end remains, and it trips up almost everyone who enables SPDX 3: the manual promises IMAGE-MACHINE.spdx.json in tmp/deploy/images/MACHINE/, yet a look through the build tree finds a whole hierarchy under tmp/deploy/spdx/3.0.1/... instead. Doc bug? No — two different output directories are in play:

do_create_rootfs_spdx[sstate-outputdirs]     = "${DEPLOY_DIR_SPDX}"
do_create_image_sbom_spdx[sstate-outputdirs] = "${DEPLOY_DIR_IMAGE}"

DEPLOY_DIR_SPDX — the tmp/deploy/spdx/ tree — is the intermediate object store. The final document the docs describe is assembled from those intermediates by do_create_image_sbom_spdx, the only step that deploys into tmp/deploy/images/MACHINE/. That is also why the no-op workaround is worse than it looks: stub out do_create_image_sbom_spdx and the assembly never happens, so the intermediates are all you ever get — the promised SBOM never appears. On a healthy image that goes through the normal rootfs path, the .spdx.json sits next to the image artifacts, exactly as documented — though a sentence in the manual mentioning DEPLOY_DIR_SPDX would spare the next person an hour of staring at the intermediates.

The operational lesson is bigger than SPDX: when bitbake appears to ignore an instruction you can read on the screen, the question is almost never what was evaluated but when. Parse-order reasoning of this kind is a core part of what we teach in the Embedded Linux BSP Development course, because nearly every “bitbake is ignoring my change” mystery resolves to it.

Key takeaways

  • deltask is order-sensitive: it removes a task at the moment it is evaluated, and a later addtask re-adds the task without any warning. A deletion only sticks if nothing adds the task afterwards.
  • bitbake adds and removes tasks at three moments: configuration inherits, the recipe body, and deferred inherits — and image.bbclass pulls its helper classes (including create-spdx-image-3.0, via IMAGE_CLASSES) in the last one. A deltask in a recipe or plainly-inherited class therefore cannot remove tasks added by image classes.
  • To opt an image recipe out of SPDX entirely, pair inherit nospdx (which removes the recipe-scope tasks) with IMAGE_CLASSES:remove = "create-spdx-image-3.0" (which stops the image tasks from ever being added). An anonymous python function calling bb.build.deltask() also runs late enough.
  • do_create_rootfs_spdx depends on rootfs-packages.json, which is written by a ROOTFS_POSTUNINSTALL_COMMAND hook inside the standard do_rootfs machinery. Images that replace do_rootfs (the nopackages pattern) never produce it.
  • The final IMAGE-MACHINE.spdx.json in tmp/deploy/images/MACHINE/ is assembled by do_create_image_sbom_spdx; everything under tmp/deploy/spdx/ (DEPLOY_DIR_SPDX) is intermediate object storage, not the SBOM.

Further reading

Was this worth your time?
RB
Raghu Bharadwaj

Founder, TECH VEDA — 20+ years teaching the Linux kernel, device drivers and embedded systems.

Follow on LinkedIn

Get new posts by email

Kernel, embedded Linux and AI-era engineering — a few sharp reads a month. No spam.

We email occasionally and never share your address.