From 9186d8791695536848556c7570bbc68b07bfd50a Mon Sep 17 00:00:00 2001
From: Pragyan Poudyal <pragyanpoudyal41999@gmail.com>
Date: Thu, 14 May 2026 21:13:52 +0530
Subject: [PATCH 1/8] dockerfile/uki: Rework to remove kernel + initrd

Now that we can pass kernel and initrd paths to `bootc ukify`, rework
our UKI Dockerfile to remove kernel + initrd from the final layer
and only keep the UKI

This still will not *remove* the kernel + initrd from the tarball but
have whiteout instead

See https://github.com/bootc-dev/bootc/issues/2027#issuecomment-4244181869

Signed-off-by: Pragyan Poudyal <pragyanpoudyal41999@gmail.com>
---
 Dockerfile                                    | 44 ++++++++--
 contrib/packaging/finalize-uki                | 15 +---
 contrib/packaging/seal-uki                    | 86 +++++++++++++------
 crates/tests-integration/src/container.rs     |  2 +
 tmt/tests/Dockerfile.upgrade                  | 34 ++++++--
 tmt/tests/booted/tap.nu                       | 28 +++++-
 .../test-install-to-filesystem-var-mount.sh   | 55 ++++++++----
 7 files changed, 192 insertions(+), 72 deletions(-)

diff --git a/Dockerfile b/Dockerfile
index 40f84b967..fd1199303 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -108,7 +108,7 @@ RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
     # Install systemd-ukify and systemd-boot for UKIs
     # This also installs systemd-boot for the grub UKI case which is not ideal...
     if [[ "${boot_type}" == "uki" ]]; then
-        pkgs_to_install+=(systemd-ukify)
+        pkgs_to_install+=(systemd-ukify binutils)
     fi
 
     if [[ ${#pkgs_to_install[@]} -gt 0 ]]; then
@@ -178,7 +178,10 @@ ARG pkgversion
 ARG SOURCE_DATE_EPOCH
 ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}
 # Build RPM directly from source, using cached target directory
-RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp --mount=type=cache,target=/src/target --mount=type=cache,target=/var/roothome RPM_VERSION="${pkgversion}" /src/contrib/packaging/build-rpm
+RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
+    --mount=type=cache,target=/src/target \
+    --mount=type=cache,target=/var/roothome \
+    RPM_VERSION="${pkgversion}" /src/contrib/packaging/build-rpm
 
 # Build a systemd-sysext containing just the bootc binary.
 # Skips RPM machinery entirely for fast incremental rebuilds.
@@ -261,7 +264,7 @@ COPY --from=update-generated-from-code /src/docs/src/*.schema.json /docs/src/
 # ----
 
 # Perform all filesystem transformations except generating the sealed UKI (if configured)
-FROM base as base-penultimate
+FROM base as base-penultimate-source
 ARG variant
 ARG bootloader
 ARG boot_type
@@ -290,6 +293,10 @@ rm -rf /var/cache
 rm -rf /run/rhsm
 
 EORUN
+
+FROM base-penultimate-source as base-penultimate
+ARG boot_type
+
 # Configure the rootfs
 ARG rootfs=""
 RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
@@ -309,9 +316,19 @@ RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp
     install -D -m 0644 -t /usr/lib/bootc/kargs.d /run/usr-extras/lib/bootc/kargs.d/*.toml
 # Clean up package manager caches
 RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
-    --mount=type=bind,from=packaging,src=/,target=/run/packaging \
+    --mount=type=bind,from=base-penultimate-source,src=/,target=/run/base-penultimate-src \
+    --mount=type=bind,from=packaging,src=/,target=/run/packaging <<EORUN
     /run/packaging/cleanup
 
+    # Remove kernel + initrd if UKI
+    if [[ "${boot_type}" == "uki" ]]; then
+        kver=$(bootc container inspect --rootfs /run/base-penultimate-src --json | jq -r '.kernel.version')
+
+        rm -v "/usr/lib/modules/$kver/vmlinuz"
+        rm -v "/usr/lib/modules/$kver/initramfs.img"
+    fi
+EORUN
+
 # Generate the sealed UKI in a separate stage
 # This computes the composefs digest from base-penultimate and creates a signed UKI
 # We need our newly-built bootc for the compute-composefs-digest command
@@ -327,18 +344,27 @@ RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp
 RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
     --mount=type=secret,id=secureboot_key \
     --mount=type=secret,id=secureboot_cert \
+    --mount=type=bind,from=base-penultimate-source,src=/,target=/run/base-penultimate-src \
     --mount=type=bind,from=packaging,src=/,target=/run/packaging \
     --mount=type=bind,from=base-penultimate,src=/,target=/run/target <<EORUN
 set -xeuo pipefail
 
-allow_missing_verity=false
+allow_missing_verity=()
 
 if [[ $filesystem == "xfs" ]]; then
-    allow_missing_verity=true
+    allow_missing_verity=(--allow-missing-verity)
 fi
 
 if test "${boot_type}" = "uki"; then
-  /run/packaging/seal-uki /run/target /out /run/secrets $allow_missing_verity $seal_state
+  kver=$(bootc container inspect --rootfs /run/base-penultimate-src --json | jq -r '.kernel.version')
+
+  /run/packaging/seal-uki \
+      --target /run/target \
+      --output /out \
+      --secrets /run/secrets \
+      "${allow_missing_verity[@]}" \
+      --kernel-dir "/run/base-penultimate-src/usr/lib/modules/$kver" \
+      --seal-state $seal_state
 fi
 EORUN
 
@@ -348,11 +374,13 @@ ARG variant
 ARG boot_type
 # Copy the sealed UKI and finalize the image (remove raw kernel, create symlinks)
 RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
+    --mount=type=bind,from=base-penultimate-source,src=/,target=/run/base-penultimate-src \
     --mount=type=bind,from=packaging,src=/,target=/run/packaging \
     --mount=type=bind,from=sealed-uki,src=/,target=/run/sealed-uki <<EORUN
 set -xeuo pipefail
 if test "${boot_type}" = "uki"; then
-  /run/packaging/finalize-uki /run/sealed-uki/out
+  kver=$(bootc container inspect --rootfs /run/base-penultimate-src --json | jq -r '.kernel.version')
+  /run/packaging/finalize-uki /run/sealed-uki/out "$kver"
 fi
 EORUN
 # And finally, test our linting
diff --git a/contrib/packaging/finalize-uki b/contrib/packaging/finalize-uki
index c382eccf6..9221cb79b 100755
--- a/contrib/packaging/finalize-uki
+++ b/contrib/packaging/finalize-uki
@@ -20,13 +20,8 @@ set -xeuo pipefail
 # Path to directory containing the generated UKI
 uki_src=$1
 shift
-
-# Find the kernel version from the current system
-kver=$(bootc container inspect --json | jq -r '.kernel.version')
-if [ -z "$kver" ] || [ "$kver" = "null" ]; then
-  echo "Error: No kernel found" >&2
-  exit 1
-fi
+kver=$1
+shift
 
 # Create the EFI directory structure
 mkdir -p /boot/EFI/Linux
@@ -36,12 +31,6 @@ mkdir -p /boot/EFI/Linux
 target=/boot/EFI/Linux/${kver}.efi
 cp "${uki_src}/${kver}.efi" "${target}"
 
-# Remove the raw kernel and initramfs since we're using a UKI now.
-# NOTE: We intentionally keep these for now until bcvk is updated to extract
-# kernel/initramfs from UKIs in subdirectories. Once bcvk PR #144 is fixed
-# to look for .efi files in /usr/lib/modules/<kver>/, we can uncomment this.
-# rm -v "/usr/lib/modules/${kver}/vmlinuz" "/usr/lib/modules/${kver}/initramfs.img"
-
 # NOTE: We used to create a symlink from /usr/lib/modules/${kver}/${kver}.efi to the UKI
 # for tooling compatibility. However, composefs-boot's find_uki_components() doesn't
 # handle symlinks correctly and fails with "is not a regular file". The UKI is already
diff --git a/contrib/packaging/seal-uki b/contrib/packaging/seal-uki
index 66de92ffd..bfc7c4bec 100755
--- a/contrib/packaging/seal-uki
+++ b/contrib/packaging/seal-uki
@@ -2,32 +2,70 @@
 # Generate a sealed UKI with embedded composefs digest
 set -xeuo pipefail
 
-# Path to the desired root filesystem
-target=$1
-shift
-# Write to this directory
-output=$1
-shift
-# Path to secrets directory
-secrets=$1
-shift
-allow_missing_verity=$1
-shift
-seal_state=$1
-shift
-
-if [[ $seal_state == "sealed" && $allow_missing_verity == "true" ]]; then
+missing_verity=()
+
+while [ ! -z "${1:-}" ]; do
+    case "$1" in
+        # Path to the desired root filesystem
+        "--target")
+            target="$2"
+            shift
+            shift
+        ;;
+
+        # Write to this directory
+        "--output")
+            output="$2"
+            shift
+            shift
+        ;;
+
+        # Path to secrets directory
+        "--secrets")
+            secrets="$2"
+            shift
+            shift
+        ;;
+
+        "--allow-missing-verity")
+            missing_verity=(--allow-missing-verity)
+            shift
+        ;;
+
+        "--seal-state")
+            seal_state="$2"
+            shift
+            shift
+        ;;
+
+        # Path to the directory containing kernel and initramfs
+        "--kernel-dir")
+            kernel_dir="$2"
+            shift
+            shift
+        ;;
+
+        * )
+            echo "Argument $1 not understood"
+            exit 1
+        ;;
+    esac
+done
+
+if [[ $seal_state == "sealed" && ${#missing_verity[@]} -gt 0 ]]; then
     echo "Cannot have missing verity with sealed UKI" >&2
     exit 1
 fi
 
-# Find the kernel version (needed for output filename)
-kver=$(bootc container inspect --rootfs "${target}" --json | jq -r '.kernel.version')
-if [ -z "$kver" ] || [ "$kver" = "null" ]; then
-  echo "Error: No kernel found" >&2
-  exit 1
+if [[ -z $kernel_dir ]]; then
+    echo "kernel dir is required" >&2
+    exit 1
 fi
 
+kernel_params=(--kernel-dir "$kernel_dir")
+
+kver=$(basename "$kernel_dir")
+
 mkdir -p "${output}"
 
 # Baseline ukify options
@@ -45,12 +83,6 @@ fi
 # Baseline container ukify options
 containerukifyargs=(--rootfs "${target}")
 
-missing_verity=()
-
-if [[ $allow_missing_verity == "true" ]]; then
-    missing_verity+=(--allow-missing-verity)
-fi
-
 # Build the UKI using bootc container ukify
 # This computes the composefs digest, reads kargs from kargs.d, and invokes ukify
-bootc container ukify "${containerukifyargs[@]}" "${missing_verity[@]}" -- "${ukifyargs[@]}"
+bootc container ukify "${containerukifyargs[@]}" "${kernel_params[@]}" "${missing_verity[@]}" -- "${ukifyargs[@]}"
diff --git a/crates/tests-integration/src/container.rs b/crates/tests-integration/src/container.rs
index a7d8e89d6..b03a69f58 100644
--- a/crates/tests-integration/src/container.rs
+++ b/crates/tests-integration/src/container.rs
@@ -51,6 +51,8 @@ pub(crate) fn test_bootc_container_inspect() -> Result<()> {
         .as_bool()
         .expect("kernel.unified should be a boolean");
 
+    println!("kernel: {kernel:#?}");
+
     let is_uki = std::env::var("BOOTC_boot_type").is_ok_and(|var| var == "uki");
 
     if let Some(variant) = std::env::var("BOOTC_variant").ok() {
diff --git a/tmt/tests/Dockerfile.upgrade b/tmt/tests/Dockerfile.upgrade
index 561e2e0a7..3b1ab73c3 100644
--- a/tmt/tests/Dockerfile.upgrade
+++ b/tmt/tests/Dockerfile.upgrade
@@ -13,6 +13,18 @@ ARG filesystem=ext4
 FROM scratch AS packaging
 COPY contrib/packaging /
 
+# Get kernel + initrd from the UKI
+FROM localhost/bootc as kernel
+ARG boot_type
+RUN <<-EOF
+    if test "${boot_type}" = "uki"; then
+        kver=$(bootc container inspect --rootfs / --json | jq -r '.kernel.version')
+        mkdir -p "/boot/$kver"
+        objcopy -O binary --only-section=.initrd "/boot/EFI/Linux/$kver.efi" "/boot/$kver/initramfs.img"
+        objcopy -O binary --only-section=.linux "/boot/EFI/Linux/$kver.efi" "/boot/$kver/vmlinuz"
+    fi
+EOF
+
 # Create the upgrade content (a simple marker file).
 # For UKI builds, we also remove the existing UKI so that seal-uki can
 # regenerate it with the correct composefs digest for this derived image.
@@ -36,16 +48,26 @@ RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp
     --mount=type=secret,id=secureboot_key \
     --mount=type=secret,id=secureboot_cert \
     --mount=type=bind,from=packaging,src=/,target=/run/packaging \
+    --mount=type=bind,from=kernel,src=/,target=/run/kernel \
     --mount=type=bind,from=upgrade-base,src=/,target=/run/target <<EORUN
 set -xeuo pipefail
 
-allow_missing_verity=false
-if [ "${filesystem}" = "xfs" ]; then
-    allow_missing_verity=true
+allow_missing_verity=()
+
+if [[ $filesystem == "xfs" ]]; then
+    allow_missing_verity=(--allow-missing-verity)
 fi
 
 if test "${boot_type}" = "uki"; then
-  /run/packaging/seal-uki /run/target /out /run/secrets "${allow_missing_verity}" "${seal_state}"
+  kver=$(bootc container inspect --rootfs /run/kernel --json | jq -r '.kernel.version')
+
+  /run/packaging/seal-uki \
+      --target /run/target \
+      --output /out \
+      --secrets /run/secrets \
+      "${allow_missing_verity[@]}" \
+      --kernel-dir "/run/kernel/boot/$kver" \
+      --seal-state $seal_state
 fi
 EORUN
 
@@ -54,9 +76,11 @@ FROM upgrade-base
 ARG boot_type
 RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
     --mount=type=bind,from=packaging,src=/,target=/run/packaging \
+    --mount=type=bind,from=kernel,src=/,target=/run/kernel \
     --mount=type=bind,from=sealed-upgrade-uki,src=/,target=/run/sealed-uki <<EORUN
 set -xeuo pipefail
 if test "${boot_type}" = "uki"; then
-  /run/packaging/finalize-uki /run/sealed-uki/out
+  kver=$(bootc container inspect --rootfs /run/kernel --json | jq -r '.kernel.version')
+  /run/packaging/finalize-uki /run/sealed-uki/out $kver
 fi
 EORUN
diff --git a/tmt/tests/booted/tap.nu b/tmt/tests/booted/tap.nu
index b8b0b3f7e..f571bfff4 100644
--- a/tmt/tests/booted/tap.nu
+++ b/tmt/tests/booted/tap.nu
@@ -89,26 +89,46 @@ export def make_uki_containerfile [containerfile: string] {
         return $containerfile
     }
 
-    let allow_missing_verity = $st.status.booted.composefs.missingVerityAllowed
+    let allow_missing_verity = if $st.status.booted.composefs.missingVerityAllowed {
+        "--allow-missing-verity"
+    } else {
+        ""
+    }
+
     # TODO: Handle sealed UKI
     let seal_state = "unsealed"
 
     let uki_stuff = $"
+        FROM base as kernel
+        RUN <<-EOF
+            kver=$\(bootc container inspect --rootfs / --json | jq -r '.kernel.version'\)
+            mkdir -p /boot/$kver
+            objcopy -O binary --only-section=.initrd /boot/EFI/Linux/$kver.efi /boot/$kver/initramfs.img
+            objcopy -O binary --only-section=.linux /boot/EFI/Linux/$kver.efi /boot/$kver/vmlinuz
+        EOF
+
         FROM base as base-final
         RUN rm -rf /boot/EFI/Linux/*.efi
 
         FROM base as sealed-uki
         RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \\
             --mount=type=bind,from=base-final,src=/,target=/run/target \\
-            /usr/bin/seal-uki /run/target /out /run/secrets ($allow_missing_verity) ($seal_state)
+            --mount=type=bind,from=kernel,src=/,target=/run/kernel \\
+              /usr/bin/seal-uki \\
+                  --target /run/target \\
+                  --output /out \\
+                  --secrets /run/secrets ($allow_missing_verity) \\
+                  --kernel-dir /run/kernel/boot/$\(bootc container inspect --rootfs /run/kernel --json | jq -r '.kernel.version'\) \\
+                  --seal-state ($seal_state)
 
         FROM base-final
 
         # Copy the sealed UKI and finalize the image remove raw kernel, create symlinks
         RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \\
             --mount=type=bind,from=sealed-uki,src=/,target=/run/sealed-uki \\
-            /usr/bin/finalize-uki /run/sealed-uki/out
-    "
+            --mount=type=bind,from=kernel,src=/,target=/run/kernel \\
+            /usr/bin/finalize-uki /run/sealed-uki/out $\(bootc container inspect --rootfs /run/kernel --json | jq -r '.kernel.version'\)
+    " | lines | each { str trim } | str join "\n"
 
     return $"($containerfile)\n($uki_stuff)"
 }
diff --git a/tmt/tests/booted/test-install-to-filesystem-var-mount.sh b/tmt/tests/booted/test-install-to-filesystem-var-mount.sh
index 8d588dd48..c81bef0b4 100644
--- a/tmt/tests/booted/test-install-to-filesystem-var-mount.sh
+++ b/tmt/tests/booted/test-install-to-filesystem-var-mount.sh
@@ -34,24 +34,49 @@ is_composefs=$(bootc status --json | jq '.status.booted.composefs')
 boot_type=$(bootc status --json | jq -r '.status.booted.composefs.bootType' | tr '[:upper:]' '[:lower:]')
 
 if [[ $is_composefs != "null" && $boot_type == "uki" ]]; then
-    allow_missing_verity=$(bootc status --json | jq -r '.status.booted.composefs.missingVerityAllowed')
-    seal_state="unsealed"
-
-    cat >> /tmp/Containerfile.drop-lbis <<-EOF
-    FROM base as base-final
-    RUN rm -rf /boot/EFI/Linux/*.efi
+    allow_missing_verity=()
 
-    FROM base as sealed-uki
-    RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
-        --mount=type=bind,from=base-final,src=/,target=/run/target \
-        /usr/bin/seal-uki /run/target /out /run/secrets $allow_missing_verity $seal_state
+    if [[ "$(bootc status --json | jq -r '.status.booted.composefs.missingVerityAllowed')" == "true" ]]; then
+        allow_missing_verity=("--allow-missing-verity")
+    fi
 
-    FROM base-final
+    seal_state="unsealed"
 
-    # Copy the sealed UKI and finalize the image remove raw kernel, create symlinks
-    RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
-        --mount=type=bind,from=sealed-uki,src=/,target=/run/sealed-uki \
-        /usr/bin/finalize-uki /run/sealed-uki/out
+    cat >> /tmp/Containerfile.drop-lbis <<-EOF
+FROM base as kernel
+RUN <<-RUNEOF
+    kver=\$(bootc container inspect --rootfs / --json | jq -r '.kernel.version')
+    mkdir -p "/boot/\$kver"
+    objcopy -O binary --only-section=.initrd "/boot/EFI/Linux/\$kver.efi" "/boot/\$kver/initramfs.img"
+    objcopy -O binary --only-section=.linux "/boot/EFI/Linux/\$kver.efi" "/boot/\$kver/vmlinuz"
+RUNEOF
+
+FROM base as base-final
+RUN rm -rf /boot/EFI/Linux/*.efi
+
+FROM base as sealed-uki
+RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
+    --mount=type=bind,from=kernel,src=/,target=/run/kernel \
+    --mount=type=bind,from=base-final,src=/,target=/run/target <<RUNEOF
+
+    kver=\$(bootc container inspect --rootfs /run/kernel --json | jq -r '.kernel.version')
+
+    /usr/bin/seal-uki \
+      --target /run/target \
+      --output /out \
+      --secrets /run/secrets \
+      --kernel-dir /run/kernel/boot/\$kver \
+      --seal-state $seal_state \
+      "${allow_missing_verity[@]}"
+
+RUNEOF
+
+FROM base-final
+
+RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
+    --mount=type=bind,from=sealed-uki,src=/,target=/run/sealed-uki \
+    --mount=type=bind,from=kernel,src=/,target=/run/kernel \
+    /usr/bin/finalize-uki /run/sealed-uki/out \$(bootc container inspect --rootfs /run/kernel --json | jq -r '.kernel.version')
 EOF
 fi
 

From bef390c3c52f0bf79e312e71584e098718fd8b8f Mon Sep 17 00:00:00 2001
From: Pragyan Poudyal <pragyanpoudyal41999@gmail.com>
Date: Fri, 15 May 2026 12:51:30 +0530
Subject: [PATCH 2/8] test/integration: Test vmlinuz non-existence with UKI

vmlinuz and intrd should not be present in UKI images; add test for the
same

Signed-off-by: Pragyan Poudyal <pragyanpoudyal41999@gmail.com>
---
 crates/tests-integration/src/container.rs | 27 +++++++++++++++++++----
 1 file changed, 23 insertions(+), 4 deletions(-)

diff --git a/crates/tests-integration/src/container.rs b/crates/tests-integration/src/container.rs
index b03a69f58..14396107b 100644
--- a/crates/tests-integration/src/container.rs
+++ b/crates/tests-integration/src/container.rs
@@ -1,8 +1,10 @@
+use cap_std_ext::cap_std::ambient_authority;
+use cap_std_ext::cap_std::fs::Dir;
 use indoc::indoc;
 use scopeguard::defer;
 use serde::Deserialize;
-use std::fs;
 use std::process::Command;
+use std::{fs, path::Path};
 
 use anyhow::{Context, Result};
 use camino::Utf8Path;
@@ -51,8 +53,6 @@ pub(crate) fn test_bootc_container_inspect() -> Result<()> {
         .as_bool()
         .expect("kernel.unified should be a boolean");
 
-    println!("kernel: {kernel:#?}");
-
     let is_uki = std::env::var("BOOTC_boot_type").is_ok_and(|var| var == "uki");
 
     if let Some(variant) = std::env::var("BOOTC_variant").ok() {
@@ -74,6 +74,24 @@ pub(crate) fn test_bootc_container_inspect() -> Result<()> {
                 );
                 // Version should be non-empty after stripping extension
                 assert!(!version.is_empty(), "version should not be empty for UKI");
+
+                let target_root = Dir::open_ambient_dir(TARGET, ambient_authority())
+                    .with_context(|| format!("{TARGET} not found"))?;
+
+                // For UKI make sure vmlinuz + initrd don't exist
+                let usr_lib_mod = Path::new("usr/lib/modules").join(version);
+                assert!(
+                    target_root.exists(&usr_lib_mod),
+                    "'{usr_lib_mod:?}' does not exist"
+                );
+                assert!(
+                    !target_root.exists(usr_lib_mod.join("vmlinuz")),
+                    "vmlinuz should not exist for UKI"
+                );
+                assert!(
+                    !target_root.exists(usr_lib_mod.join("initramfs.img")),
+                    "initramfs should not exist for UKI"
+                );
             }
             o => eprintln!("notice: Unhandled variant for kernel check: {o:?}"),
         }
@@ -190,6 +208,8 @@ fn test_variant_base_crosscheck() -> Result<()> {
     Ok(())
 }
 
+const TARGET: &str = "/run/target";
+
 /// Verify exported tar has correct size/mode/content vs source.
 /// Checks all critical paths (kernel, boot) plus ~10% random sample.
 pub(crate) fn test_container_export_tar() -> Result<()> {
@@ -197,7 +217,6 @@ pub(crate) fn test_container_export_tar() -> Result<()> {
     use std::io::Read;
     use std::os::unix::fs::MetadataExt;
 
-    const TARGET: &str = "/run/target";
     const CRITICAL: &[&str] = &["usr/lib/modules/", "usr/lib/ostree-boot/", "boot/"];
 
     anyhow::ensure!(

From df0ae0369a50896c6cebe1155c733dbe867eeb31 Mon Sep 17 00:00:00 2001
From: Pragyan Poudyal <pragyanpoudyal41999@gmail.com>
Date: Tue, 2 Jun 2026 13:57:28 +0530
Subject: [PATCH 3/8] cli: Add cmd `bootc internals uki-extract`

So we can just use bootc to extract the `.linux` and `.initrd` sections
from the UKI and not have to use objcopy

Signed-off-by: Pragyan Poudyal <pragyanpoudyal41999@gmail.com>
---
 Dockerfile                                    |  2 +-
 crates/lib/src/cli.rs                         | 53 ++++++++++++++++++-
 tmt/tests/Dockerfile.upgrade                  |  4 +-
 tmt/tests/booted/tap.nu                       |  4 +-
 .../test-install-to-filesystem-var-mount.sh   |  4 +-
 5 files changed, 56 insertions(+), 11 deletions(-)

diff --git a/Dockerfile b/Dockerfile
index fd1199303..1f487f864 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -108,7 +108,7 @@ RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
     # Install systemd-ukify and systemd-boot for UKIs
     # This also installs systemd-boot for the grub UKI case which is not ideal...
     if [[ "${boot_type}" == "uki" ]]; then
-        pkgs_to_install+=(systemd-ukify binutils)
+        pkgs_to_install+=(systemd-ukify)
     fi
 
     if [[ ${#pkgs_to_install[@]} -gt 0 ]]; then
diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs
index 26a4ba8f3..9770891b4 100644
--- a/crates/lib/src/cli.rs
+++ b/crates/lib/src/cli.rs
@@ -4,7 +4,7 @@
 
 use std::ffi::{CString, OsStr, OsString};
 use std::fs::File;
-use std::io::{BufWriter, Seek};
+use std::io::{BufWriter, Seek, SeekFrom};
 use std::os::fd::AsFd;
 use std::os::unix::process::CommandExt;
 use std::process::Command;
@@ -27,6 +27,7 @@ use composefs_boot::BootOps as _;
 use etc_merge::{compute_diff, print_diff};
 use fn_error_context::context;
 use indoc::indoc;
+use ocidir::cap_std::ambient_authority;
 use ostree::gio;
 use ostree_container::store::PrepareResult;
 use ostree_ext::container as ostree_container;
@@ -627,6 +628,19 @@ pub(crate) enum FsverityOpts {
     },
 }
 
+#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
+pub(crate) enum UkiSubcommands {
+    /// Extract kernel + initrd from a UKI
+    /// The output (vmlinuz + initramfs.img) is placed in a directory named
+    /// after the kernel version found in the UKI
+    Extract {
+        /// The path to the UKI PE
+        path: Utf8PathBuf,
+        /// The output path
+        output_path: Utf8PathBuf,
+    },
+}
+
 /// Hidden, internal only options
 #[derive(Debug, clap::Subcommand, PartialEq, Eq)]
 pub(crate) enum InternalsOpts {
@@ -743,6 +757,9 @@ pub(crate) enum InternalsOpts {
     /// Block device inspection tools.
     #[clap(subcommand)]
     Blockdev(BlockdevOpts),
+    /// For various UKI operations
+    #[clap(subcommand)]
+    Uki(UkiSubcommands),
 }
 
 /// Subcommands for `bootc internals blockdev`.
@@ -2323,6 +2340,40 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
                 println!();
                 Ok(())
             }
+            InternalsOpts::Uki(uki_opts) => match uki_opts {
+                UkiSubcommands::Extract { path, output_path } => {
+                    let mut uki_file =
+                        std::fs::File::open(&path).with_context(|| format!("Opening {path}"))?;
+
+                    let uname =
+                        composefs_boot::uki::get_text_section_buffered(&mut uki_file, ".uname")
+                            .context("Getting uname")?;
+
+                    std::fs::create_dir_all(&output_path).context("Creating output directory")?;
+
+                    let output_dir = Dir::open_ambient_dir(&output_path, ambient_authority())
+                        .context("Opening output dir")?;
+                    output_dir.create_dir(&uname)?;
+
+                    let output_dir = output_dir.open_dir(&uname)?;
+
+                    for (section_name, file_name) in
+                        [(".linux", "vmlinuz"), (".initrd", "initramfs.img")]
+                    {
+                        uki_file
+                            .seek(SeekFrom::Start(0))
+                            .context("Seeking to start")?;
+                        let section =
+                            composefs_boot::uki::get_section_buffered(&mut uki_file, section_name)
+                                .with_context(|| format!("Getting {section_name} section"))?;
+                        output_dir
+                            .write(file_name, section)
+                            .with_context(|| format!("Writing {file_name}"))?;
+                    }
+
+                    Ok(())
+                }
+            },
         },
         Opt::State(opts) => match opts {
             StateOpts::WipeOstree => {
diff --git a/tmt/tests/Dockerfile.upgrade b/tmt/tests/Dockerfile.upgrade
index 3b1ab73c3..b66393114 100644
--- a/tmt/tests/Dockerfile.upgrade
+++ b/tmt/tests/Dockerfile.upgrade
@@ -19,9 +19,7 @@ ARG boot_type
 RUN <<-EOF
     if test "${boot_type}" = "uki"; then
         kver=$(bootc container inspect --rootfs / --json | jq -r '.kernel.version')
-        mkdir -p "/boot/$kver"
-        objcopy -O binary --only-section=.initrd "/boot/EFI/Linux/$kver.efi" "/boot/$kver/initramfs.img"
-        objcopy -O binary --only-section=.linux "/boot/EFI/Linux/$kver.efi" "/boot/$kver/vmlinuz"
+        bootc internals uki extract /boot/EFI/Linux/$kver.efi /boot
     fi
 EOF
 
diff --git a/tmt/tests/booted/tap.nu b/tmt/tests/booted/tap.nu
index f571bfff4..c25a89479 100644
--- a/tmt/tests/booted/tap.nu
+++ b/tmt/tests/booted/tap.nu
@@ -102,9 +102,7 @@ export def make_uki_containerfile [containerfile: string] {
         FROM base as kernel
         RUN <<-EOF
             kver=$\(bootc container inspect --rootfs / --json | jq -r '.kernel.version'\)
-            mkdir -p /boot/$kver
-            objcopy -O binary --only-section=.initrd /boot/EFI/Linux/$kver.efi /boot/$kver/initramfs.img
-            objcopy -O binary --only-section=.linux /boot/EFI/Linux/$kver.efi /boot/$kver/vmlinuz
+            bootc internals uki extract /boot/EFI/Linux/$kver.efi /boot
         EOF
 
         FROM base as base-final
diff --git a/tmt/tests/booted/test-install-to-filesystem-var-mount.sh b/tmt/tests/booted/test-install-to-filesystem-var-mount.sh
index c81bef0b4..94cdd4d3f 100644
--- a/tmt/tests/booted/test-install-to-filesystem-var-mount.sh
+++ b/tmt/tests/booted/test-install-to-filesystem-var-mount.sh
@@ -46,9 +46,7 @@ if [[ $is_composefs != "null" && $boot_type == "uki" ]]; then
 FROM base as kernel
 RUN <<-RUNEOF
     kver=\$(bootc container inspect --rootfs / --json | jq -r '.kernel.version')
-    mkdir -p "/boot/\$kver"
-    objcopy -O binary --only-section=.initrd "/boot/EFI/Linux/\$kver.efi" "/boot/\$kver/initramfs.img"
-    objcopy -O binary --only-section=.linux "/boot/EFI/Linux/\$kver.efi" "/boot/\$kver/vmlinuz"
+    bootc internals uki extract /boot/EFI/Linux/\$kver.efi /boot
 RUNEOF
 
 FROM base as base-final

From ece22d8e5092a150a7d7e59ecaf60bf5faf851ac Mon Sep 17 00:00:00 2001
From: Pragyan Poudyal <pragyanpoudyal41999@gmail.com>
Date: Tue, 16 Jun 2026 10:10:14 +0530
Subject: [PATCH 4/8] cli: Add cmd `bootc container split-kernel-and-rootfs`

This command is equivalent to
`mv /target-root/usr/lib/modules/$kver/{vmlinuz,initramfs.img} /out/$kver`

We could just use `mv`, but having an actual bootc cmd is cleaner

Signed-off-by: Pragyan Poudyal <pragyanpoudyal41999@gmail.com>
---
 crates/lib/src/cli.rs                         | 52 +++++++++++++++
 ...otc-container-split-kernel-and-rootfs.8.md | 65 +++++++++++++++++++
 docs/src/man/bootc-container.8.md             |  1 +
 3 files changed, 118 insertions(+)
 create mode 100644 docs/src/man/bootc-container-split-kernel-and-rootfs.8.md

diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs
index 9770891b4..b70eb44ce 100644
--- a/crates/lib/src/cli.rs
+++ b/crates/lib/src/cli.rs
@@ -417,6 +417,23 @@ pub(crate) enum ContainerOpts {
         /// Identifier for image; if not provided, the running image will be used.
         image: Option<String>,
     },
+    /// Split kernel and rootfs from a container image
+    ///
+    /// This command extracts the kernel (vmlinuz and initramfs.img) from the
+    /// container rootfs and moves them to a separate output directory, organized
+    /// by kernel version
+    ///
+    /// Example:
+    ///   bootc container split-kernel-rootfs --rootfs /target-rootfs --output /out
+    SplitKernelAndRootfs {
+        /// Operate on the provided rootfs
+        #[clap(long, default_value = "/")]
+        rootfs: Utf8PathBuf,
+
+        /// Output directory for the extracted kernel files
+        #[clap(long)]
+        output: Utf8PathBuf,
+    },
     /// Build a Unified Kernel Image (UKI) using ukify.
     ///
     /// This command computes the necessary arguments from the container image
@@ -1858,6 +1875,41 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
                 )?;
                 Ok(())
             }
+            ContainerOpts::SplitKernelAndRootfs { rootfs, output } => {
+                use crate::kernel::{KernelType, find_kernel};
+
+                let root = Dir::open_ambient_dir(&rootfs, ambient_authority())?;
+
+                let kernel_internal = find_kernel(&root)?
+                    .ok_or_else(|| anyhow::anyhow!("No kernel found in rootfs"))?;
+
+                if kernel_internal.kernel.unified {
+                    anyhow::bail!("UKIs are not supported");
+                }
+
+                match &kernel_internal.k_type {
+                    KernelType::Vmlinuz { path, initramfs } => {
+                        let kver = &kernel_internal.kernel.version;
+                        let kernel_output_dir = output.join(kver);
+                        std::fs::create_dir_all(&kernel_output_dir)?;
+
+                        let vmlinuz_src = rootfs.join(path);
+                        let initramfs_src = rootfs.join(initramfs);
+                        let vmlinuz_dst = kernel_output_dir.join("vmlinuz");
+                        let initramfs_dst = kernel_output_dir.join("initramfs.img");
+
+                        std::fs::rename(&vmlinuz_src, &vmlinuz_dst).context("Moving vmlinuz")?;
+                        std::fs::rename(&initramfs_src, &initramfs_dst)
+                            .context("Moving initramfs")?;
+                    }
+
+                    KernelType::Uki { .. } => {
+                        anyhow::bail!("UKIs are not supported");
+                    }
+                }
+
+                Ok(())
+            }
             ContainerOpts::ComputeComposefsDigest {
                 path,
                 write_dumpfile_to,
diff --git a/docs/src/man/bootc-container-split-kernel-and-rootfs.8.md b/docs/src/man/bootc-container-split-kernel-and-rootfs.8.md
new file mode 100644
index 000000000..8e5bb669f
--- /dev/null
+++ b/docs/src/man/bootc-container-split-kernel-and-rootfs.8.md
@@ -0,0 +1,65 @@
+# NAME
+
+bootc-container-split-kernel-and-rootfs - Split kernel and rootfs from a container image
+
+# SYNOPSIS
+
+bootc container split-kernel-and-rootfs
+
+# DESCRIPTION
+
+Split kernel and rootfs from a container image
+
+# OPTIONS
+
+<!-- BEGIN GENERATED OPTIONS -->
+**--rootfs**=*ROOTFS*
+
+    Operate on the provided rootfs
+
+    Default: /
+
+**--output**=*OUTPUT*
+
+    Output directory for the extracted kernel files
+
+<!-- END GENERATED OPTIONS -->
+
+# EXAMPLES
+
+**Extract kernel files from the current root filesystem to `/kernel`:**
+
+```
+bootc container split-kernel-and-rootfs --output /kernel
+```
+
+This extracts the kernel and initramfs from the current root filesystem (/) and places them in `/kernel/<kernel-version>/` with filenames `vmlinuz` and `initramfs.img`.
+
+**Extract kernel files from a mounted container rootfs:**
+
+```
+bootc container split-kernel-and-rootfs --rootfs /mnt/container-rootfs --output /output/kernels
+```
+
+This extracts kernel files from a container filesystem mounted at `/mnt/container-rootfs` and places them in the output directory.
+
+**Example output structure:**
+
+After running the command, the output directory will contain:
+
+```
+/output/kernels/
+└── 6.5.0-15-generic/
+    ├── vmlinuz
+    └── initramfs.img
+```
+
+where `6.5.0-15-generic` is the detected kernel version from the source rootfs.
+
+# SEE ALSO
+
+**bootc**(8)
+
+# VERSION
+
+<!-- VERSION PLACEHOLDER -->
diff --git a/docs/src/man/bootc-container.8.md b/docs/src/man/bootc-container.8.md
index b8b540972..1b1fce62f 100644
--- a/docs/src/man/bootc-container.8.md
+++ b/docs/src/man/bootc-container.8.md
@@ -21,6 +21,7 @@ Operations which can be executed as part of a container build
 |---------|-------------|
 | **bootc container inspect** | Output information about the container image |
 | **bootc container lint** | Perform relatively inexpensive static analysis checks as part of a container build |
+| **bootc container split-kernel-and-rootfs** | Split kernel and rootfs from a container image |
 | **bootc container ukify** | Build a Unified Kernel Image (UKI) using ukify |
 
 <!-- END GENERATED SUBCOMMANDS -->

From 0fa605e8360119974908b871dbebe3bfcb1825db Mon Sep 17 00:00:00 2001
From: Pragyan Poudyal <pragyanpoudyal41999@gmail.com>
Date: Tue, 16 Jun 2026 10:14:01 +0530
Subject: [PATCH 5/8] dockerfile: Changes for UKI variant

Since we do not want kernel + initrd in the final UKI dockerfile, we now
build the initrd inside the `target-rootfs` generated by
`bootc-base-imagectl`.

Instead of rebuilding the initrd ourselves, now we add `/var/usr`
directory to the target-rootfs with `--add-dir` cli option to
bootc-base-imagectl. This directory contains our rpms and other configs
required for building the initramfs

After that's done we split the rootfs and vmlinuz + initrd into
/target-rootfs and /kernel/$kver respectively

Signed-off-by: Pragyan Poudyal <pragyanpoudyal41999@gmail.com>
---
 Dockerfile                              | 93 ++++++++++++++-----------
 contrib/packaging/configure-rootfs      |  9 ++-
 contrib/packaging/inject-baseconfig     | 14 ++--
 contrib/packaging/install-rpm-and-setup | 14 ++--
 hack/downgrade-kernel.sh                | 34 +++++++++
 hack/provision-fetch.sh                 | 27 -------
 6 files changed, 105 insertions(+), 86 deletions(-)
 create mode 100755 hack/downgrade-kernel.sh

diff --git a/Dockerfile b/Dockerfile
index 1f487f864..e64c3ca41 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -46,12 +46,55 @@ RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp --mount=type=c
 #       local sources. We'll override it later.
 # NOTE: All your base belong to me.
 FROM $base as target-base
+
+# SKIP_CONFIGS=1 skips LBIs, test kargs, and install configs (for FCOS testing)
+ARG boot_type
+ARG seal_state
+ARG variant
+ARG baseconfigs=""
+ARG rootfs=""
+
 # Handle version skew between base image and mirrors for CentOS Stream
 # xref https://gitlab.com/redhat/centos-stream/containers/bootc/-/issues/1174
 RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
     --mount=type=bind,from=packaging,src=/,target=/run/packaging \
     /run/packaging/enable-compose-repos
-RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp /usr/libexec/bootc-base-imagectl build-rootfs --manifest=standard /target-rootfs
+
+RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
+    --mount=type=bind,from=packages,src=/,target=/run/packages \
+    --mount=type=bind,from=packaging,src=/,target=/run/packaging \
+    --mount=type=bind,from=packaging,src=/usr-extras,target=/run/usr-extras <<EOF
+    set -eux
+
+    mkdir -p /var/add-dir/usr
+
+    # Install our rpms in /var/add-dir
+    /run/packaging/install-rpm-and-setup /run/packages /var/add-dir
+
+    # Configure the rootfs
+    /run/packaging/configure-rootfs "${variant}" "/var/add-dir/usr" "${rootfs}"
+
+    # Inject base configuration (e.g. transient-etc, transient-root) before dracut runs
+    /run/packaging/inject-baseconfig "/var/add-dir/usr" "${variant}" "${baseconfigs}"
+
+    install -D -m 0644 -t /var/add-dir/usr/lib/bootc/kargs.d /run/usr-extras/lib/bootc/kargs.d/*.toml
+
+    /usr/libexec/bootc-base-imagectl build-rootfs --add-dir /var/add-dir/usr --manifest=standard /target-rootfs
+EOF
+
+# Inject some other configuration
+COPY --from=packaging /usr-extras/ /target-rootfs/usr/
+
+RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp <<EOF 
+    set -eux
+
+    # create this regardless else bind mounts down the line fail for non-uki builds
+    mkdir -p /kernel
+
+    if [[ "${boot_type}" == "uki" ]]; then
+        /target-rootfs/usr/bin/bootc container split-kernel-and-rootfs --rootfs /target-rootfs --output /kernel
+    fi
+EOF
 
 # Get the latest GrubCC binary
 FROM quay.io/fedora/eln:latest AS grub-cc-download
@@ -264,7 +307,7 @@ COPY --from=update-generated-from-code /src/docs/src/*.schema.json /docs/src/
 # ----
 
 # Perform all filesystem transformations except generating the sealed UKI (if configured)
-FROM base as base-penultimate-source
+FROM base as base-penultimate
 ARG variant
 ARG bootloader
 ARG boot_type
@@ -294,41 +337,6 @@ rm -rf /run/rhsm
 
 EORUN
 
-FROM base-penultimate-source as base-penultimate
-ARG boot_type
-
-# Configure the rootfs
-ARG rootfs=""
-RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
-    --mount=type=bind,from=packaging,src=/,target=/run/packaging \
-    /run/packaging/configure-rootfs "${variant}" "${rootfs}"
-# Inject base configuration (e.g. transient-etc, transient-root) before dracut runs
-RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
-    --mount=type=bind,from=packaging,src=/,target=/run/packaging \
-    /run/packaging/inject-baseconfig "${variant}" "${baseconfigs}"
-# Override with our built package
-RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
-    --mount=type=bind,from=packaging,src=/,target=/run/packaging \
-    --mount=type=bind,from=packages,src=/,target=/run/packages \
-    /run/packaging/install-rpm-and-setup /run/packages
-RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
-    --mount=type=bind,from=packaging,src=/usr-extras,target=/run/usr-extras \
-    install -D -m 0644 -t /usr/lib/bootc/kargs.d /run/usr-extras/lib/bootc/kargs.d/*.toml
-# Clean up package manager caches
-RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
-    --mount=type=bind,from=base-penultimate-source,src=/,target=/run/base-penultimate-src \
-    --mount=type=bind,from=packaging,src=/,target=/run/packaging <<EORUN
-    /run/packaging/cleanup
-
-    # Remove kernel + initrd if UKI
-    if [[ "${boot_type}" == "uki" ]]; then
-        kver=$(bootc container inspect --rootfs /run/base-penultimate-src --json | jq -r '.kernel.version')
-
-        rm -v "/usr/lib/modules/$kver/vmlinuz"
-        rm -v "/usr/lib/modules/$kver/initramfs.img"
-    fi
-EORUN
-
 # Generate the sealed UKI in a separate stage
 # This computes the composefs digest from base-penultimate and creates a signed UKI
 # We need our newly-built bootc for the compute-composefs-digest command
@@ -344,7 +352,7 @@ RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp
 RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
     --mount=type=secret,id=secureboot_key \
     --mount=type=secret,id=secureboot_cert \
-    --mount=type=bind,from=base-penultimate-source,src=/,target=/run/base-penultimate-src \
+    --mount=type=bind,from=target-base,src=/kernel,target=/run/kernel \
     --mount=type=bind,from=packaging,src=/,target=/run/packaging \
     --mount=type=bind,from=base-penultimate,src=/,target=/run/target <<EORUN
 set -xeuo pipefail
@@ -356,14 +364,14 @@ if [[ $filesystem == "xfs" ]]; then
 fi
 
 if test "${boot_type}" = "uki"; then
-  kver=$(bootc container inspect --rootfs /run/base-penultimate-src --json | jq -r '.kernel.version')
+  kver=$(ls /run/kernel)
 
   /run/packaging/seal-uki \
       --target /run/target \
       --output /out \
       --secrets /run/secrets \
       "${allow_missing_verity[@]}" \
-      --kernel-dir "/run/base-penultimate-src/usr/lib/modules/$kver" \
+      --kernel-dir "/run/kernel/$kver" \
       --seal-state $seal_state
 fi
 EORUN
@@ -374,12 +382,13 @@ ARG variant
 ARG boot_type
 # Copy the sealed UKI and finalize the image (remove raw kernel, create symlinks)
 RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
-    --mount=type=bind,from=base-penultimate-source,src=/,target=/run/base-penultimate-src \
     --mount=type=bind,from=packaging,src=/,target=/run/packaging \
+    --mount=type=bind,from=target-base,src=/kernel,target=/run/kernel \
     --mount=type=bind,from=sealed-uki,src=/,target=/run/sealed-uki <<EORUN
 set -xeuo pipefail
+
 if test "${boot_type}" = "uki"; then
-  kver=$(bootc container inspect --rootfs /run/base-penultimate-src --json | jq -r '.kernel.version')
+  kver=$(ls /run/kernel)
   /run/packaging/finalize-uki /run/sealed-uki/out "$kver"
 fi
 EORUN
diff --git a/contrib/packaging/configure-rootfs b/contrib/packaging/configure-rootfs
index 1af76346c..94fda6e8d 100755
--- a/contrib/packaging/configure-rootfs
+++ b/contrib/packaging/configure-rootfs
@@ -3,10 +3,15 @@
 set -xeuo pipefail
 
 VARIANT="${1:-}"
-ROOTFS="${2:-}"
+
+# Where to write the config
+# Defaults to /
+BASE_DIR="${2:-/}"
+
+ROOTFS="${3:-}"
 
 # Support overriding the rootfs at build time
-CONFIG_DIR="/usr/lib/bootc/install"
+CONFIG_DIR="${BASE_DIR}/usr/lib/bootc/install"
 mkdir -p "${CONFIG_DIR}"
 
 kver=$(bootc container inspect --json | jq -re .kernel.version)
diff --git a/contrib/packaging/inject-baseconfig b/contrib/packaging/inject-baseconfig
index 041810a8b..4d1b2cf6f 100755
--- a/contrib/packaging/inject-baseconfig
+++ b/contrib/packaging/inject-baseconfig
@@ -3,8 +3,12 @@
 # Arguments: $1=variant, $2=baseconfigs (comma-separated, may be empty)
 set -xeuo pipefail
 
-VARIANT="${1:-}"
-BASECONFIGS="${2:-}"
+# Where to write the config
+# Defaults to /
+BASE_DIR="${1:-/}"
+
+VARIANT="${2:-}"
+BASECONFIGS="${3:-}"
 
 # No-op if no baseconfigs specified
 if [ -z "${BASECONFIGS}" ]; then
@@ -15,7 +19,7 @@ fi
 # which has a different (INI) format and different option names.
 case "${VARIANT}" in
   composefs*)
-    TARGET="/usr/lib/composefs/setup-root-conf.toml"
+    TARGET="${BASE_DIR}/usr/lib/composefs/setup-root-conf.toml"
     ;;
   *)
     echo "inject-baseconfig: baseconfigs not supported for variant '${VARIANT}'" >&2
@@ -49,9 +53,9 @@ for raw_token in "${TOKENS[@]}"; do
       # tmpfs there at local-fs.target.  Using a plain tmpfs avoids the
       # overlayfs-on-overlayfs restriction that breaks tools like podman which
       # use overlayfs under /var/lib/containers.
-      mkdir -p /usr/lib/bootc/kargs.d
+      mkdir -p "${BASE_DIR}/usr/lib/bootc/kargs.d"
       printf 'kargs = ["systemd.volatile=state"]\n' \
-        > /usr/lib/bootc/kargs.d/50-var-volatile.toml
+        > "${BASE_DIR}/usr/lib/bootc/kargs.d/50-var-volatile.toml"
       ;;
     *)
       echo "Unknown baseconfig: ${token}" >&2
diff --git a/contrib/packaging/install-rpm-and-setup b/contrib/packaging/install-rpm-and-setup
index c21ab70ce..80e9f630d 100755
--- a/contrib/packaging/install-rpm-and-setup
+++ b/contrib/packaging/install-rpm-and-setup
@@ -3,30 +3,24 @@
 set -xeuo pipefail
 
 RPM_DIR="${1:-/tmp}"
+INSTALL_ROOT="${2:-/}"
 
 # Install the RPM package
 # Use rpm -Uvh with --oldpackage to allow replacing with dev version
-rpm -Uvh --replacepkgs --oldpackage --nosignature "${RPM_DIR}"/*.rpm
+rpm -Uvh --root "${INSTALL_ROOT}" --nodeps --replacepkgs --oldpackage --nosignature "${RPM_DIR}"/*.rpm
 # Note: we don't need to clean up the source directory since it's a bind mount
 
-# Regenerate initramfs if we have initramfs-setup
-kver=$(cd /usr/lib/modules && echo *)
-# DRACUT_NO_XATTR=1 is the default in newer base images, and
-# we have --add bootc here until the change to add the module in base
-# images lands.
-env DRACUT_NO_XATTR=1 dracut --add bootc -vf /usr/lib/modules/$kver/initramfs.img $kver
-
 # Only in this containerfile, inject a file which signifies
 # this comes from this development image. This can be used in
 # tests to know we're doing upstream CI.
-touch /usr/lib/.bootc-dev-stamp
+touch "${INSTALL_ROOT}/usr/lib/.bootc-dev-stamp"
 
 # Fedora 43+ ships a GRUB with the BLI module, so enable DPS
 # auto-discovery for root.  This must run after our RPM is installed
 # since older bootc doesn't recognize the discoverable-partitions key.
 . /usr/lib/os-release
 if [ "${ID}" = "fedora" ] && [ "${VERSION_ID}" -ge 43 ] 2>/dev/null; then
-  cat > /usr/lib/bootc/install/20-discoverable-partitions.toml <<'EOF'
+  cat > "${INSTALL_ROOT}/usr/lib/bootc/install/20-discoverable-partitions.toml" <<'EOF'
 [install]
 discoverable-partitions = true
 EOF
diff --git a/hack/downgrade-kernel.sh b/hack/downgrade-kernel.sh
new file mode 100755
index 000000000..5267f8b50
--- /dev/null
+++ b/hack/downgrade-kernel.sh
@@ -0,0 +1,34 @@
+set -eux
+
+rootfs=${1:-/}
+
+# Temporary: downgrade kernel to last 6.x when 7.0 or 7.1 is present.
+# Kernel 7.x broke composefs ("has no fs-verity digest"), fixed in 7.2.
+# xref https://github.com/bootc-dev/bootc/issues/2174
+# TODO: Remove once all base images ship kernel >= 7.2
+kernel_ver=$(rpm --root "$rootfs" -q --qf '%{VERSION}' kernel 2>/dev/null || true)
+case "${kernel_ver}" in
+    7.0.*|7.1.*)
+        arch=$(uname -m)
+        koji_kver="6.19.10"
+        koji_krel="300.fc44"
+        koji_base="https://kojipkgs.fedoraproject.org/packages/kernel/${koji_kver}/${koji_krel}/${arch}"
+        kernel_td=$(mktemp -d)
+        trap 'rm -rf "${kernel_td}"' EXIT
+        for pkg in kernel kernel-core kernel-modules kernel-modules-core; do
+            curl --retry 5 --retry-delay 5 --retry-all-errors -fL \
+                "${koji_base}/${pkg}-${koji_kver}-${koji_krel}.${arch}.rpm" \
+                -o "${kernel_td}/${pkg}.rpm"
+        done
+        # TMPDIR=/var/tmp: works around an rpm-ostree bug
+        TMPDIR=/var/tmp dnf --installroot="$rootfs" -y downgrade "${kernel_td}"/*.rpm
+        # Note: we should also fix the Fedora kernel packaging to not copy symvers into /boot
+        rm -rf "${rootfs}"/boot/*
+        rm -rf "${kernel_td}"
+        trap - EXIT
+        ;;
+esac
+
+dnf clean all
+# Clean logs and caches
+rm "$rootfs"/var/log/* "$rootfs"/var/cache "$rootfs"/var/lib/{dnf,rpm-state,rhsm} -rf
diff --git a/hack/provision-fetch.sh b/hack/provision-fetch.sh
index 4eae43b47..ef2ee9e73 100755
--- a/hack/provision-fetch.sh
+++ b/hack/provision-fetch.sh
@@ -86,33 +86,6 @@ if ! rpm -q ostree 2>/dev/null | grep -q "2026\." ; then
     esac
 fi
 
-# Temporary: downgrade kernel to last 6.x when 7.0 or 7.1 is present.
-# Kernel 7.x broke composefs ("has no fs-verity digest"), fixed in 7.2.
-# xref https://github.com/bootc-dev/bootc/issues/2174
-# TODO: Remove once all base images ship kernel >= 7.2
-kernel_ver=$(rpm -q --qf '%{VERSION}' kernel 2>/dev/null || true)
-case "${kernel_ver}" in
-    7.0.*|7.1.*)
-        arch=$(uname -m)
-        koji_kver="6.19.10"
-        koji_krel="300.fc44"
-        koji_base="https://kojipkgs.fedoraproject.org/packages/kernel/${koji_kver}/${koji_krel}/${arch}"
-        kernel_td=$(mktemp -d)
-        trap 'rm -rf "${kernel_td}"' EXIT
-        for pkg in kernel kernel-core kernel-modules kernel-modules-core; do
-            curl --retry 5 --retry-delay 5 --retry-all-errors -fL \
-                "${koji_base}/${pkg}-${koji_kver}-${koji_krel}.${arch}.rpm" \
-                -o "${kernel_td}/${pkg}.rpm"
-        done
-        # TMPDIR=/var/tmp: works around an rpm-ostree bug
-        TMPDIR=/var/tmp dnf -y downgrade "${kernel_td}"/*.rpm
-        # Note: we should also fix the Fedora kernel packaging to not copy symvers into /boot
-        rm -rf /boot/*
-        rm -rf "${kernel_td}"
-        trap - EXIT
-        ;;
-esac
-
 dnf clean all
 # Clean logs and caches
 rm /var/log/* /var/cache /var/lib/{dnf,rpm-state,rhsm} -rf

From c7edd255d036c69f4892aff9e7dadc897a9f3c9a Mon Sep 17 00:00:00 2001
From: Pragyan Poudyal <pragyanpoudyal41999@gmail.com>
Date: Tue, 16 Jun 2026 10:58:06 +0530
Subject: [PATCH 6/8] justfile: Build packages as deps for stage fetch

Now since we need to build our initramfs before the `fetch` build stage,
we need packages built first as we need `bootc` and
`bootc-initramfs-setup` binaries

Signed-off-by: Pragyan Poudyal <pragyanpoudyal41999@gmail.com>
---
 Justfile | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/Justfile b/Justfile
index 6ba80d0c7..3c16ea1aa 100644
--- a/Justfile
+++ b/Justfile
@@ -102,7 +102,7 @@ build: package _keygen && _pull-lbi-images
 # The retry parameters can be overridden via environment variables:
 #   BOOTC_CI_RETRIES=10 BOOTC_CI_DELAY=60 just build-fetch
 [group('core')]
-build-fetch: _keygen
+build-fetch: _keygen package
     #!/bin/bash
     set -euo pipefail
     retries=${BOOTC_CI_RETRIES:-3}
@@ -131,12 +131,15 @@ build-fetch: _keygen
     for img in {{lbi_images}}; do
         retry podman pull -q "$img"
     done
+
+    pkg_path=$(realpath target/packages)
+
     # Build the network-heavy fetch stage of the main image.  If this
     # succeeds, `just build` will get a cache hit on the fetch layer and
     # run entirely offline.
     # Note: buildargs (not base_buildargs) is needed here because the
     # target-base stage requires --cap-add/--security-opt for bwrap.
-    retry podman build {{_nocache_arg}} --target=fetch {{buildargs}} .
+    retry podman build {{_nocache_arg}} --build-context "packages=${pkg_path}" --target=fetch {{buildargs}} .
     # Same for the upgrade-source image used by test-upgrade.
     retry podman build {{_nocache_arg}} --build-arg=base={{base}} \
         --target=fetch -f tmt/tests/Dockerfile.upgrade-source .

From fdc718c8898f8530644607e0d41747e23eef6840 Mon Sep 17 00:00:00 2001
From: Pragyan Poudyal <pragyanpoudyal41999@gmail.com>
Date: Mon, 29 Jun 2026 14:56:49 +0530
Subject: [PATCH 7/8] dockerfile/rpm: Setup new dnf repo with our built content

We do not want to use `--nodeps` for installing our built rpms in the
target-rootfs. So, we create a new dnf repo in `/tmp/localrepo`, copy
our rpms in there and set it's priority as highest.

We pass `--install bootc` to bootc-base-imagectl which will install from
our newly created repo ensuring we have the latest binaries in the final
image

Signed-off-by: Pragyan Poudyal <pragyanpoudyal41999@gmail.com>
---
 Dockerfile                              | 10 ++++++----
 contrib/packaging/install-rpm-and-setup | 24 ++++++++++++++++++++----
 2 files changed, 26 insertions(+), 8 deletions(-)

diff --git a/Dockerfile b/Dockerfile
index e64c3ca41..4de0c2e8f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -79,12 +79,14 @@ RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
 
     install -D -m 0644 -t /var/add-dir/usr/lib/bootc/kargs.d /run/usr-extras/lib/bootc/kargs.d/*.toml
 
-    /usr/libexec/bootc-base-imagectl build-rootfs --add-dir /var/add-dir/usr --manifest=standard /target-rootfs
+    /usr/libexec/bootc-base-imagectl build-rootfs \
+        --install bootc \
+        --install bootc-tests \
+        --install system-reinstall-bootc \
+        --add-dir /var/add-dir/usr \
+        --manifest=standard /target-rootfs
 EOF
 
-# Inject some other configuration
-COPY --from=packaging /usr-extras/ /target-rootfs/usr/
-
 RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp <<EOF 
     set -eux
 
diff --git a/contrib/packaging/install-rpm-and-setup b/contrib/packaging/install-rpm-and-setup
index 80e9f630d..696a3c15c 100755
--- a/contrib/packaging/install-rpm-and-setup
+++ b/contrib/packaging/install-rpm-and-setup
@@ -5,10 +5,26 @@ set -xeuo pipefail
 RPM_DIR="${1:-/tmp}"
 INSTALL_ROOT="${2:-/}"
 
-# Install the RPM package
-# Use rpm -Uvh with --oldpackage to allow replacing with dev version
-rpm -Uvh --root "${INSTALL_ROOT}" --nodeps --replacepkgs --oldpackage --nosignature "${RPM_DIR}"/*.rpm
-# Note: we don't need to clean up the source directory since it's a bind mount
+# We'll create a new dnf repo to install our newer stuff
+dnf install -y createrepo_c
+
+mkdir /tmp/localrepo
+cp "${RPM_DIR}"/*.rpm /tmp/localrepo/
+
+createrepo_c /tmp/localrepo
+
+# Create a .repo file with higher priority so that our newer
+# stuff is installed inside the target-rootfs
+cat <<EOF > /etc/yum.repos.d/local.repo
+[local]
+name=Local CI repo
+baseurl=file:///tmp/localrepo
+enabled=1
+gpgcheck=0
+priority=1
+EOF
+
+mkdir -p "${INSTALL_ROOT}/usr/lib/bootc/install"
 
 # Only in this containerfile, inject a file which signifies
 # this comes from this development image. This can be used in

From eecbb1194ab2884b704a152932415954f4b5cc76 Mon Sep 17 00:00:00 2001
From: Pragyan Poudyal <pragyanpoudyal41999@gmail.com>
Date: Wed, 1 Jul 2026 13:05:51 +0530
Subject: [PATCH 8/8] tmt: Fix stale loop partition state in multi-device ESP
 test
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The three-devices-partial-ESP scenario fails with "No device found for
/dev/loop1p1" because the dual-ESP test's cleanup leaves stale kernel
partition nodes and LVM device registrations.

The prior cleanup called `pvremove -f $loop` on the loop device itself
(e.g. /dev/loop0) instead of on the partitions where PVs actually live
(e.g. /dev/loop0p2), making it a no-op.  After `losetup -d`, the
kernel retained busy partition nodes.  When the next test reused the
loop device with a different partition layout, `partx -u` tried to
atomically reconcile old and new partitions — but could not remove the
busy stale partition #2, so it aborted entirely without adding the new
partition #1.

Fix both cleanup and setup:

Cleanup:
- pvremove on actual partition devices (p1/p2/p3), not the loop device
- wipefs -a on each partition to clear signatures holding references
- udevadm settle to let udev finish processing
- partx -d to remove kernel partition entries before losetup -d
- Remove /etc/lvm/devices/system.devices to clear stale device IDs

Setup (setup_disk_with_partitions, setup_disk_with_root):
- Replace `partx -u` with `partx -d` (ignore errors) then `partx -a`,
  so a stuck stale partition does not block adding new ones
- Replace `sleep 1sec` with `udevadm settle` for reliable synchronization

Co-Authored-By: Claude Opus 4.6
Signed-off-by: Pragyan Poudyal <pragyanpoudyal41999@gmail.com>
---
 tmt/tests/booted/test-multi-device-esp.nu | 33 ++++++++++++++++-------
 1 file changed, 23 insertions(+), 10 deletions(-)

diff --git a/tmt/tests/booted/test-multi-device-esp.nu b/tmt/tests/booted/test-multi-device-esp.nu
index 40fd4acb9..526a54afc 100644
--- a/tmt/tests/booted/test-multi-device-esp.nu
+++ b/tmt/tests/booted/test-multi-device-esp.nu
@@ -45,13 +45,23 @@ def cleanup [vg_name: string, loops: list<string>, mountpoint: string] {
     do { vgchange -an $vg_name } | complete | ignore
     do { vgremove -f $vg_name } | complete | ignore
 
-    # Remove PVs and detach loop devices
+    # Remove PVs from partitions and detach loop devices
     for loop in $loops {
         if ($loop | path exists) {
-            do { pvremove -f $loop } | complete | ignore
+            for i in [1, 2, 3] {
+                let part = $"($loop)p($i)"
+                if ($part | path exists) {
+                    do { pvremove -f $part } | complete | ignore
+                    do { wipefs -a $part } | complete | ignore
+                }
+            }
+            do { udevadm settle } | complete | ignore
+            do { partx -d $loop } | complete | ignore
             do { losetup -d $loop } | complete | ignore
         }
     }
+
+    rm -f /etc/lvm/devices/system.devices
 }
 
 # Create a disk with GPT, optional ESP, and LVM partition
@@ -72,9 +82,11 @@ def setup_disk_with_partitions [
         # GPT with ESP (512MB) + LVM partition
         $"label: gpt\nsize=512M, type=($ESP_TYPE)\ntype=($LVM_TYPE)\n" | sfdisk $loop
 
-        # Reload partition table (partx is part of util-linux)
-        partx -u $loop
-        sleep 1sec
+        # Remove stale partition entries then add new ones; -d may fail
+        # for busy partitions from a prior test, but -a still succeeds
+        do { partx -d $loop } | complete | ignore
+        partx -av $loop
+        udevadm settle
 
         # Format ESP
         mkfs.vfat -F 32 $"($loop)p1"
@@ -82,9 +94,9 @@ def setup_disk_with_partitions [
         # GPT with only LVM partition (full disk)
         $"label: gpt\ntype=($LVM_TYPE)\n" | sfdisk $loop
 
-        # Reload partition table (partx is part of util-linux)
-        partx -u $loop
-        sleep 1sec
+        do { partx -d $loop } | complete | ignore
+        partx -av $loop
+        udevadm settle
     }
 
     $loop
@@ -101,8 +113,9 @@ def setup_disk_with_root [
 
     # GPT with ESP (512MB) + root partition
     $"label: gpt\nsize=512M, type=($ESP_TYPE)\ntype=($ROOT_TYPE)\n" | sfdisk $loop
-    partx -u $loop
-    sleep 1sec
+    do { partx -d $loop } | complete | ignore
+    partx -av $loop
+    udevadm settle
 
     mkfs.vfat -F 32 $"($loop)p1"
     mkfs.ext4 -q $"($loop)p2"
