Skip to content

Publish Bluefin dakota #350

Publish Bluefin dakota

Publish Bluefin dakota #350

Workflow file for this run

name: Publish Bluefin dakota
on:
workflow_dispatch:
workflow_run:
workflows: ["Build Bluefin dakota"]
types: [completed]
branches:
- main
- 'gh-readonly-queue/main/**'
- next
- 'gh-readonly-queue/next/**'
- testing
- 'gh-readonly-queue/testing/**'
env:
IMAGE_NAME: dakota
IMAGE_REGISTRY: ghcr.io/${{ github.repository_owner }}
permissions:
contents: write
packages: write
id-token: write
attestations: write
actions: read
concurrency:
# Separate publish queues per branch so next and main don't collide.
group: publish-${{ github.event.workflow_run.head_branch || github.ref_name }}
cancel-in-progress: false
jobs:
# ── Resolve context ───────────────────────────────────────────────────────
# Single source of truth for SHA and trigger event, shared by all downstream jobs.
setup:
runs-on: ubuntu-24.04
outputs:
sha: ${{ steps.context.outputs.sha }}
event: ${{ steps.context.outputs.event }}
branch: ${{ steps.context.outputs.branch }}
testing_tag: ${{ steps.context.outputs.testing_tag }}
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event != 'pull_request' &&
(github.event.workflow_run.head_branch == 'main' ||
startsWith(github.event.workflow_run.head_branch, 'gh-readonly-queue/main/') ||
github.event.workflow_run.head_branch == 'next' ||
startsWith(github.event.workflow_run.head_branch, 'gh-readonly-queue/next/') ||
github.event.workflow_run.head_branch == 'testing' ||
startsWith(github.event.workflow_run.head_branch, 'gh-readonly-queue/testing/')))
steps:
- name: Resolve build SHA and trigger event
id: context
run: |
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
echo "sha=${{ github.event.workflow_run.head_sha }}" >> "$GITHUB_OUTPUT"
echo "event=${{ github.event.workflow_run.event }}" >> "$GITHUB_OUTPUT"
BRANCH="${{ github.event.workflow_run.head_branch }}"
else
echo "sha=${{ github.sha }}" >> "$GITHUB_OUTPUT"
echo "event=${{ github.event_name }}" >> "$GITHUB_OUTPUT"
BRANCH="${{ github.ref_name }}"
fi
echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT"
# Derive the :testing tag from the branch name.
# main → :testing (from main via promote job)
# testing → :testing (direct: every merge to testing publishes immediately)
# next → :next (rolling GNOME master / bleeding edge)
if [[ "$BRANCH" == "main" || "$BRANCH" == gh-readonly-queue/main/* ]]; then
echo "testing_tag=testing" >> "$GITHUB_OUTPUT"
elif [[ "$BRANCH" == "next" || "$BRANCH" == gh-readonly-queue/next/* ]]; then
echo "testing_tag=next" >> "$GITHUB_OUTPUT"
elif [[ "$BRANCH" == "testing" || "$BRANCH" == gh-readonly-queue/testing/* ]]; then
# testing branch builds publish :testing directly so every merge to
# testing produces a fresh image. Matches bluefin/bluefin-lts behaviour.
echo "testing_tag=testing" >> "$GITHUB_OUTPUT"
else
# Fallback for any future branches: strip merge-queue prefix
CLEAN="${BRANCH#gh-readonly-queue/}"
CLEAN="${CLEAN%%/*}"
echo "testing_tag=${CLEAN}-testing" >> "$GITHUB_OUTPUT"
fi
# ── Export, sign, attest ─────────────────────────────────────────────────
# Pulls artifact from remote CAS, pushes :$sha, signs and attests.
# SBOM generation is split to a separate job (publish-sbom) so it runs
# in parallel with promote rather than blocking it.
publish-image:
needs: setup
runs-on: ubuntu-24.04
timeout-minutes: 90
# Variant matrix: default and NVIDIA publish in parallel from the shared
# BST artifact cache. NVIDIA is `continue-on-error: true` so its failure
# does not block default publication.
strategy:
fail-fast: false
matrix:
include:
- variant: default
element: oci/bluefin.bst
image_suffix: ''
sbom_filename: dakota.spdx.json
continue: false
- variant: nvidia
element: oci/bluefin-nvidia.bst
image_suffix: '-nvidia'
sbom_filename: dakota-nvidia.spdx.json
continue: true
continue-on-error: ${{ matrix.continue }}
permissions:
contents: read
packages: write
id-token: write
attestations: write
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ needs.setup.outputs.sha }}
- name: Capture build timestamp
id: timestamp
run: echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
# update-podman: true — newer podman from Ubuntu resolute for bst2 + push.
# Note: setup-runner btrfs does not expose loopback-free; watch for disk
# pressure and upstream the option if needed.
- name: Setup runner
uses: projectbluefin/actions/bootc-build/setup-runner@99d867d662ab64a1f82b4f5f24f73b4051dbff01 # v1
with:
storage-backend: btrfs
update-podman: true
install-tools: '["just"]'
# Fetch-only BST config — pull artifact from remote CAS, no build/push
- name: Generate BST fetch config
env:
CASD_CLIENT_CERT: ${{ vars.CASD_CLIENT_CERT }}
CASD_CLIENT_KEY: ${{ secrets.CASD_CLIENT_KEY }}
uses: ./.github/actions/generate-bst-ci-config
with:
enable-remote-execution: 'false'
enable-push: 'false'
- name: Export OCI image from BuildStream
id: export
env:
BST_FLAGS: -o x86_64_v3 true --no-interactive --config /src/buildstream-ci.conf
BUILD_IMAGE_NAME: ${{ env.IMAGE_NAME }}
OCI_IMAGE_CREATED: ${{ steps.timestamp.outputs.created }}
OCI_IMAGE_REVISION: ${{ needs.setup.outputs.sha }}
OCI_IMAGE_VERSION: latest
run: just export ${{ matrix.variant }}
- name: Chunkify image layers
# BST images need physical xattr injection before chunkah runs — BST strips
# xattrs on OCI export so chunkah detects 0 components without it.
# xattr-manifest (fakecap-manifest.tsv) is pre-committed (Cargo.lock pattern)
# and regenerated by the update-filemap workflow when elements change.
# Merge order: projectbluefin/actions#<PR> must land before this workflow runs.
uses: projectbluefin/actions/bootc-build/chunka@99d867d662ab64a1f82b4f5f24f73b4051dbff01 # v1
with:
source-image: localhost/${{ env.IMAGE_NAME }}${{ matrix.image_suffix }}:latest
max-layers: "120"
xattr-manifest: files/fakecap-manifest.tsv
- name: Validate with bootc container lint
env:
BUILD_IMAGE_NAME: ${{ env.IMAGE_NAME }}${{ matrix.image_suffix }}
run: just lint
- name: Login to GHCR
env:
GH_ACTOR: ${{ github.actor }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "$GH_TOKEN" | sudo podman login ghcr.io --username "$GH_ACTOR" --password-stdin
mkdir -p ~/.docker
echo "$GH_TOKEN" | podman login ghcr.io --username "$GH_ACTOR" --password-stdin \
--compat-auth-file ~/.docker/config.json
- name: Push :${{ needs.setup.outputs.sha }} to GHCR
id: push
env:
BUILD_SHA: ${{ needs.setup.outputs.sha }}
run: |
LOCAL_IMAGE="${{ env.IMAGE_NAME }}${{ matrix.image_suffix }}"
IMAGE="${{ env.IMAGE_REGISTRY }}/${LOCAL_IMAGE}"
sudo podman tag "localhost/${LOCAL_IMAGE}:latest" "${IMAGE}:${BUILD_SHA}"
PUSH_OK=false
for attempt in 1 2 3; do
sudo podman push --compression-format=zstd \
--digestfile /tmp/digest.txt \
"${IMAGE}:${BUILD_SHA}" && { PUSH_OK=true; break; }
echo "Push attempt ${attempt} failed for :${BUILD_SHA}, retrying in 5s..."
[ "$attempt" -lt 3 ] && sleep 5
done
$PUSH_OK || { echo "ERROR: all push attempts failed for :${BUILD_SHA}"; exit 1; }
echo "digest=$(cat /tmp/digest.txt)" >> "$GITHUB_OUTPUT"
- name: Install oras
uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 # v2.0.0
- name: Sign image and create attestation
# generate-sbom: false — dakota uses just sbom (BST-native provenance via
# bst artifact list). Syft post-build scan is less accurate for BST builds.
# SBOM attachment and SBOM signing are handled in the publish-sbom job.
uses: projectbluefin/actions/bootc-build/sign-and-publish@99d867d662ab64a1f82b4f5f24f73b4051dbff01 # v1
with:
image: ${{ env.IMAGE_REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image_suffix }}
digest: ${{ steps.push.outputs.digest }}
generate-sbom: false
github-token: ${{ secrets.GITHUB_TOKEN }}
# ── Boot check (hard gate) ───────────────────────────────────────────────
# Inline QEMU boot check — gates :testing promotion. Target: <15 min.
# Checks: image installs via bootc, boots to multi-user.target, GDM is
# active, SSH is reachable. No AT-SPI, no behave, no timing sensitivity.
# See: projectbluefin/dakota#850
boot-check:
name: Boot check — gate
needs: [setup, publish-image]
if: needs.publish-image.result == 'success'
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ needs.setup.outputs.sha }}
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | \
sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules && sudo udevadm trigger --name-match=kvm
- name: Install QEMU
run: sudo apt-get install -y --no-install-recommends qemu-system-x86
- name: Login to GHCR
env:
GH_ACTOR: ${{ github.actor }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: echo "$GH_TOKEN" | sudo podman login ghcr.io --username "$GH_ACTOR" --password-stdin
- name: Install image to disk via bootc
env:
IMAGE: ${{ env.IMAGE_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.setup.outputs.sha }}
run: |
set -euo pipefail
fallocate -l 30G disk.raw
# Match the working testsuite raw-image flow: let the image's own
# bootc install config provide the xfs rootfs + systemd bootloader
# defaults, and only pass the raw file path via --via-loopback.
# Dakota ships those defaults in files/bootc-install/00-defaults.toml.
# Source: projectbluefin/testsuite/.github/actions/gnome-e2e/action.yml
# --ipc=host: bootc's loopback install uses IPC for udevd/mount
# coordination inside the privileged container; without it the
# loopback attach races and partitions may not be visible.
sudo podman run --rm --privileged --pid=host --ipc=host \
--security-opt label=type:unconfined_t \
-v /dev:/dev \
-v /var/lib/containers:/var/lib/containers \
-v "$(pwd):/data" \
"${IMAGE}" bootc install to-disk \
--via-loopback /data/disk.raw \
--filesystem ext4 \
--bootloader none \
--wipe \
|| { rc=$?; echo "bootc install exited ${rc} — checking whether the deployment was written"; }
sudo podman rmi "${IMAGE}" 2>/dev/null || true
# Attach with -P so the kernel scans the partition table and
# creates loop0p1/p2/p3 nodes before the next step mounts them.
LOOP=$(sudo losetup -f --show -P disk.raw)
echo "BOOT_CHECK_LOOP=${LOOP}" >> "$GITHUB_ENV"
sudo udevadm settle --timeout=30 2>/dev/null || true
# Verify root filesystem was created — fail fast with a clear error.
if ! sudo blkid "${LOOP}p3" &>/dev/null; then
echo "ERROR: bootc install did not create a filesystem on ${LOOP}p3"
sudo fdisk -l "${LOOP}" 2>&1 || true
exit 1
fi
- name: Extract kernel, initramfs, root UUID and ostree path
id: disk
run: |
set -euo pipefail
# Reuse the loop device attached by the install step.
# Disk layout from bootc (--bootloader none, x86-64):
# p1 = BIOS boot (1 MiB)
# p2 = EFI System (512 MiB, not populated — bootloader skipped)
# p3 = Linux root — ext4, ostree deployments live here
LOOP="${BOOT_CHECK_LOOP}"
if [ -z "${LOOP}" ] || ! sudo losetup "${LOOP}" &>/dev/null; then
# Fallback: re-attach if the install step detached it
LOOP=$(sudo losetup -f --show -P disk.raw)
sudo udevadm settle --timeout=10 2>/dev/null || true
fi
echo "Loop: ${LOOP}"
ROOT_PART="${LOOP}p3"
sudo mount "${ROOT_PART}" /mnt
ROOT_UUID=$(sudo blkid -s UUID -o value "${ROOT_PART}")
echo "root=${ROOT_PART} uuid=${ROOT_UUID}"
# Find the ostree deployment directory
DEPLOY=$(sudo ls /mnt/ostree/deploy/default/deploy/ 2>/dev/null | head -1)
[[ -z "${DEPLOY}" ]] && { echo "ERROR: ostree deployment missing"; exit 1; }
DEP="/mnt/ostree/deploy/default/deploy/${DEPLOY}"
VAR="/mnt/ostree/deploy/default/var"
echo "deploy=${DEPLOY}"
# --bootloader none skips writing BLS entries to the ESP, so we
# find OSTREE_PATH directly from the ostree boot.1 tree on the root
# filesystem. bootc writes /ostree/boot.1/default/TREEHASH/N
# regardless of bootloader mode. Fresh install always has exactly
# one deployment so sort | head -1 is deterministic.
# Note: boot.1/default/TREEHASH/N is a symlink — omit -type d so
# find matches symlinks as well as directories.
OSTREE_PATH=$(sudo find /mnt/ostree/boot.1/default -mindepth 2 -maxdepth 2 \
2>/dev/null | sort | head -1 | sed 's|^/mnt||')
if [[ -z "${OSTREE_PATH}" ]]; then
echo "DEBUG: boot.1 tree:"
sudo find /mnt/ostree/boot.1 -maxdepth 4 2>/dev/null || echo " (empty or missing)"
echo "ERROR: could not find OSTREE_PATH in /ostree/boot.1"; exit 1
fi
echo "ostree_path=${OSTREE_PATH}"
# Pick kernel version that has vmlinuz
KVER=""
for kv in $(sudo ls "${DEP}/usr/lib/modules/" 2>/dev/null); do
if [[ -f "${DEP}/usr/lib/modules/${kv}/vmlinuz" ]]; then
KVER="${kv}"; break
fi
done
[[ -z "${KVER}" ]] && { echo "ERROR: no vmlinuz found in deployment"; exit 1; }
echo "kver=${KVER}"
sudo cp "${DEP}/usr/lib/modules/${KVER}/vmlinuz" ./vmlinuz
sudo cp "${DEP}/usr/lib/modules/${KVER}/initramfs.img" ./initramfs.img
sudo chmod 644 vmlinuz initramfs.img
{
echo "ROOT_UUID=${ROOT_UUID}"
echo "OSTREE_PATH=${OSTREE_PATH}"
echo "KVER=${KVER}"
echo "DEP=${DEP}"
echo "VAR=${VAR}"
echo "LOOP=${LOOP}"
} >> "$GITHUB_OUTPUT"
- name: Inject SSH key into deployment
env:
ROOT_UUID: ${{ steps.disk.outputs.ROOT_UUID }}
OSTREE_PATH: ${{ steps.disk.outputs.OSTREE_PATH }}
DEP: ${{ steps.disk.outputs.DEP }}
VAR: ${{ steps.disk.outputs.VAR }}
run: |
set -euo pipefail
ssh-keygen -t ed25519 -f /tmp/vm_key -N "" -C "boot-check@gha"
PUBKEY=$(cat /tmp/vm_key.pub)
BFT_UID=1001
# Enable sshd in the deployment
sudo ln -sf /usr/lib/systemd/system/sshd.service \
"${DEP}/etc/systemd/system/multi-user.target.wants/sshd.service"
sudo ssh-keygen -A -f "${DEP}" 2>/dev/null || true
# Create bluefin-test user if absent
if ! sudo grep -q '^bluefin-test:' "${DEP}/etc/passwd" 2>/dev/null; then
printf 'bluefin-test:x:%d:%d:Bluefin Test:/var/home/bluefin-test:/bin/bash\n' \
"${BFT_UID}" "${BFT_UID}" | sudo tee -a "${DEP}/etc/passwd"
printf 'bluefin-test:*:19000:0:99999:7:::\n' | sudo tee -a "${DEP}/etc/shadow"
printf 'bluefin-test:x:%d:\n' "${BFT_UID}" | sudo tee -a "${DEP}/etc/group"
fi
# sshd drop-in: AuthorizedKeysCommand so key works without home dir
sudo mkdir -p "${DEP}/etc/ssh/sshd_config.d/"
printf 'PubkeyAuthentication yes\nPermitUserEnvironment yes\nAuthorizedKeysCommand /bin/cat /etc/ssh/ci-authorized-keys\nAuthorizedKeysCommandUser root\n' | \
sudo tee "${DEP}/etc/ssh/sshd_config.d/00-ci-auth.conf"
printf '%s\n' "${PUBKEY}" | sudo tee "${DEP}/etc/ssh/ci-authorized-keys"
sudo chmod 600 "${DEP}/etc/ssh/ci-authorized-keys"
# tmpfiles.d entry to create home dir at runtime
sudo mkdir -p "${DEP}/etc/tmpfiles.d/"
printf 'Z /var/home/bluefin-test 0700 %d %d -\nd /var/home/bluefin-test 0700 %d %d -\nd /var/home/bluefin-test/.ssh 0700 %d %d -\n' \
"${BFT_UID}" "${BFT_UID}" \
"${BFT_UID}" "${BFT_UID}" \
"${BFT_UID}" "${BFT_UID}" | \
sudo tee "${DEP}/etc/tmpfiles.d/ci-user.conf"
printf 'bluefin-test ALL=(ALL) NOPASSWD:ALL\n' | \
sudo tee "${DEP}/etc/sudoers.d/bluefin-test"
# Inject public key into var (runtime /var/home)
sudo mkdir -p "${VAR}/home/bluefin-test/.ssh"
printf '%s\n' "${PUBKEY}" | \
sudo tee "${VAR}/home/bluefin-test/.ssh/authorized_keys"
sudo chmod 700 "${VAR}/home/bluefin-test/.ssh"
sudo chmod 600 "${VAR}/home/bluefin-test/.ssh/authorized_keys"
sudo umount /mnt
sudo losetup -d "${{ steps.disk.outputs.LOOP }}" 2>/dev/null || true
- name: Boot VM
env:
ROOT_UUID: ${{ steps.disk.outputs.ROOT_UUID }}
OSTREE_PATH: ${{ steps.disk.outputs.OSTREE_PATH }}
KVER: ${{ steps.disk.outputs.KVER }}
run: |
set -euo pipefail
KERNEL_ARGS="root=UUID=${ROOT_UUID} rw ostree=${OSTREE_PATH}"
KERNEL_ARGS+=' systemd.firstboot=no selinux=0'
KERNEL_ARGS+=' console=ttyS0,115200 systemd.journald.forward_to_console=1'
KERNEL_ARGS+=' quiet'
# Mask slow/network-dependent services that are not needed for boot check
for svc in tailscaled brew-setup NetworkManager-wait-online firewalld \
wpa_supplicant flatpak-preinstall podman-auto-update \
podman-auto-update.timer malcontent-webd malcontent-webd-update \
malcontent-control speech-dispatcherd avahi-daemon avahi-daemon.socket \
cups cups.path cups.socket cups.browsed blueman-mechanism \
gnome-remote-desktop bazzite-hardware-setup \
greenboot-healthcheck greenboot-set-rollback-trigger \
systemd-udev-settle; do
KERNEL_ARGS+=" systemd.mask=${svc}.service"
done
touch vm-serial.log && chmod 666 vm-serial.log
sudo qemu-system-x86_64 \
-machine q35,accel=kvm \
-cpu host \
-m 4096 \
-smp 4 \
-kernel ./vmlinuz \
-initrd ./initramfs.img \
-append "${KERNEL_ARGS}" \
-object iothread,id=ioth0 \
-drive if=none,id=disk,file=disk.raw,format=raw,cache=unsafe,aio=threads,discard=unmap \
-device virtio-blk-pci,drive=disk,iothread=ioth0 \
-netdev user,id=net0,hostfwd=tcp::2222-:22 \
-device virtio-net-pci,netdev=net0 \
-display none \
-serial "file:$(pwd)/vm-serial.log" \
-daemonize \
-pidfile /tmp/qemu.pid
QEMU_PID=$(sudo cat /tmp/qemu.pid)
echo "VM booting (pid=${QEMU_PID})"
- name: Wait for SSH
run: |
set -euo pipefail
SSH_OPTS=(-i /tmp/vm_key -o StrictHostKeyChecking=no -o ConnectTimeout=5
-o UserKnownHostsFile=/dev/null -o BatchMode=yes)
echo "Waiting for SSH on port 2222..."
for attempt in $(seq 1 36); do
if ssh "${SSH_OPTS[@]}" -p 2222 bluefin-test@127.0.0.1 true 2>/dev/null; then
echo "SSH ready after ~$((attempt * 5))s"
exit 0
fi
sleep 5
done
echo "ERROR: SSH did not become reachable within 3 minutes"
echo "--- last 40 lines of serial log ---"
tail -40 vm-serial.log || true
exit 1
- name: Health checks
run: |
set -euo pipefail
SSH=(ssh -i /tmp/vm_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null
-o BatchMode=yes -p 2222 bluefin-test@127.0.0.1)
echo "==> systemd multi-user.target"
# SSH becomes reachable before systemd finishes activating multi-user.target.
# Poll for up to 60 s (6 × 10 s) before declaring failure.
for _i in $(seq 1 6); do
if "${SSH[@]}" sudo systemctl is-active multi-user.target 2>/dev/null; then
break
fi
[[ ${_i} -lt 6 ]] || { echo "ERROR: multi-user.target not active after 60s"; exit 1; }
echo " multi-user.target not yet active (attempt ${_i}/6), retrying in 10s…"
sleep 10
done
echo "==> gdm.service"
# In headless QEMU (-display none) GDM is 'inactive' — no display device,
# so the display-manager never starts. That is expected and OK.
# Fail only if GDM is in 'failed' state, which means it crashed — a real
# image regression. 'inactive' / 'activating' are both acceptable here.
if ! GDM_STATE=$("${SSH[@]}" sudo systemctl show gdm.service --property=ActiveState --value 2>/dev/null); then
echo "ERROR: unable to query gdm.service ActiveState"
exit 1
fi
if [[ -z "${GDM_STATE}" ]]; then
echo "ERROR: empty gdm.service ActiveState — unit may not exist"
exit 1
fi
echo "GDM state: ${GDM_STATE}"
if [[ "${GDM_STATE}" == "failed" ]]; then
echo "ERROR: gdm.service is in failed state — image regression"
exit 1
fi
echo "==> no failed critical units"
FAILED=$("${SSH[@]}" sudo systemctl list-units --state=failed --no-legend \
| grep -v 'boot-timeout\|greenboot\|bazzite\|malcontent\|avahi\|cups\|blueman' \
|| true)
if [[ -n "${FAILED}" ]]; then
echo "WARNING: failed units:"
echo "${FAILED}"
fi
echo "Boot check passed."
- name: Cleanup VM
if: always()
run: |
if [[ -f /tmp/qemu.pid ]]; then
sudo kill "$(sudo cat /tmp/qemu.pid)" 2>/dev/null || true
fi
- name: Upload serial log
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: boot-check-serial-${{ needs.setup.outputs.sha }}
path: vm-serial.log
retention-days: 3
if-no-files-found: ignore
# ── Generate and attach SBOM ──────────────────────────────────────────────
# Runs outside the critical path to :testing. Failures here must not block
# publish/promotion; stable release notes already regenerate SBOM inline in
# execute-release.yml. A fresh runner fetches BST metadata from remote CAS
# (no full artifact download needed; bst show reads element graph only).
# P3: pip wheel cache keyed to the pinned buildstream-sbom commit SHA so
# repeated runs skip the GitLab git fetch entirely.
publish-sbom:
needs: [setup, publish-image]
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- variant: default
element: oci/bluefin.bst
image_suffix: ''
sbom_filename: dakota.spdx.json
continue: true
- variant: nvidia
element: oci/bluefin-nvidia.bst
image_suffix: '-nvidia'
sbom_filename: dakota-nvidia.spdx.json
continue: true
continue-on-error: ${{ matrix.continue }}
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ needs.setup.outputs.sha }}
- name: Setup runner
uses: projectbluefin/actions/bootc-build/setup-runner@99d867d662ab64a1f82b4f5f24f73b4051dbff01 # v1
with:
storage-backend: btrfs
update-podman: true
install-tools: '["just"]'
# BST config for remote CAS — buildstream-sbom calls bst show --deps all
# internally which needs element graph resolution via the remote CAS.
- name: Generate BST fetch config
env:
CASD_CLIENT_CERT: ${{ vars.CASD_CLIENT_CERT }}
CASD_CLIENT_KEY: ${{ secrets.CASD_CLIENT_KEY }}
uses: ./.github/actions/generate-bst-ci-config
with:
enable-remote-execution: 'false'
enable-push: 'false'
# Cache the pip wheel for buildstream-sbom across runs.
# Key is the pinned GitLab commit SHA — auto-invalidates on pin bumps.
# The wheel lives at ~/.cache/pip on the host; mounted into the bst2
# container via -v in just sbom so pip finds it without a network fetch.
- name: Restore pip cache for buildstream-sbom
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ~/.cache/pip
key: pip-sbom-0706fec3bedf6f73bd9d2fed32c2aed585feef8d
restore-keys: pip-sbom-
- name: Generate SBOM
env:
BST_FLAGS: -o x86_64_v3 true --no-interactive --config /src/buildstream-ci.conf
run: just sbom ${{ matrix.variant }}
- name: Upload SBOM artifact for release workflow
if: matrix.variant == 'default'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sbom-${{ needs.setup.outputs.sha }}
path: dakota.spdx.json
retention-days: 30
- name: Login to GHCR
env:
GH_ACTOR: ${{ github.actor }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p ~/.docker
echo "$GH_TOKEN" | podman login ghcr.io --username "$GH_ACTOR" --password-stdin \
--compat-auth-file ~/.docker/config.json
- name: Install oras
uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 # v2.0.0
# Re-derive the image digest via skopeo so this job has no runtime
# dependency on publish-image job outputs (avoids matrix output wiring).
- name: Resolve image digest
id: digest
env:
BUILD_SHA: ${{ needs.setup.outputs.sha }}
GH_ACTOR: ${{ github.actor }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
IMAGE="${{ env.IMAGE_REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image_suffix }}"
DIGEST=$(skopeo inspect \
--creds "$GH_ACTOR:$GH_TOKEN" \
"docker://${IMAGE}:${BUILD_SHA}" \
| python3 -c "import json,sys; print(json.load(sys.stdin)['Digest'])")
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
- name: Attach SBOM
id: sbom-attach
env:
DIGEST: ${{ steps.digest.outputs.digest }}
run: |
set -o pipefail
SBOM_DIGEST=$(oras attach \
--artifact-type application/vnd.spdx+json \
--format json \
"${{ env.IMAGE_REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image_suffix }}@${DIGEST}" \
${{ matrix.sbom_filename }}:application/vnd.spdx+json \
| python3 -c "import json,sys; d=json.load(sys.stdin); print(d['digest'])")
if [[ -z "$SBOM_DIGEST" || "$SBOM_DIGEST" == "None" ]]; then
echo "ERROR: Failed to capture SBOM digest from oras attach"
exit 1
fi
echo "sbom_digest=${SBOM_DIGEST}" >> "$GITHUB_OUTPUT"
- name: Sign SBOM
env:
SBOM_DIGEST: ${{ steps.sbom-attach.outputs.sbom_digest }}
run: |
cosign sign -y \
"${{ env.IMAGE_REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image_suffix }}@${SBOM_DIGEST}"
# ── Promote :testing ──────────────────────────────────────────────────────
# Re-tags :$sha as :testing only after boot-check passes.
# Uses skopeo copy for server-side re-tagging: layers never leave the registry,
# saving the 20–25 min round-trip of the previous podman pull→tag→push approach.
# --preserve-digests keeps the promoted tag pointing at the signed manifest digest.
# NVIDIA is continue-on-error so a failing NVIDIA promote does not
# prevent the default :testing from landing.
promote:
needs: [setup, publish-image, boot-check]
if: >-
needs.publish-image.result == 'success' &&
needs.boot-check.result == 'success' &&
(github.event_name == 'workflow_run' || github.ref_name == 'main')
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
include:
- image_suffix: ''
continue: false
- image_suffix: '-nvidia'
continue: true
continue-on-error: ${{ matrix.continue }}
permissions:
contents: write
packages: write
steps:
- name: Promote :${{ needs.setup.outputs.sha }} to :${{ needs.setup.outputs.testing_tag }}
env:
BUILD_SHA: ${{ needs.setup.outputs.sha }}
TESTING_TAG: ${{ needs.setup.outputs.testing_tag }}
GH_ACTOR: ${{ github.actor }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
IMAGE="${{ env.IMAGE_REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image_suffix }}"
PUSH_OK=false
for attempt in 1 2 3; do
skopeo copy \
--preserve-digests \
--src-creds "$GH_ACTOR:$GH_TOKEN" \
--dest-creds "$GH_ACTOR:$GH_TOKEN" \
"docker://${IMAGE}:${BUILD_SHA}" \
"docker://${IMAGE}:${TESTING_TAG}" && { PUSH_OK=true; break; }
echo "skopeo copy attempt ${attempt} failed for :${TESTING_TAG}, retrying in 5s..."
[ "$attempt" -lt 3 ] && sleep 5
done
$PUSH_OK || { echo "ERROR: all copy attempts failed for :${TESTING_TAG}"; exit 1; }
- name: Push :btw alias (next stream only)
if: needs.setup.outputs.testing_tag == 'next'
env:
BUILD_SHA: ${{ needs.setup.outputs.sha }}
GH_ACTOR: ${{ github.actor }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
IMAGE="${{ env.IMAGE_REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image_suffix }}"
PUSH_OK=false
for attempt in 1 2 3; do
skopeo copy \
--preserve-digests \
--src-creds "$GH_ACTOR:$GH_TOKEN" \
--dest-creds "$GH_ACTOR:$GH_TOKEN" \
"docker://${IMAGE}:${BUILD_SHA}" \
"docker://${IMAGE}:btw" && { PUSH_OK=true; break; }
echo "skopeo copy attempt ${attempt} failed for :btw, retrying in 5s..."
[ "$attempt" -lt 3 ] && sleep 5
done
$PUSH_OK || { echo "ERROR: all copy attempts failed for :btw"; exit 1; }
- name: Fast-forward testing branch
if: matrix.image_suffix == '' && (needs.setup.outputs.branch == 'main' || startsWith(needs.setup.outputs.branch, 'gh-readonly-queue/main/'))
env:
BUILD_SHA: ${{ needs.setup.outputs.sha }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Fast-forward the testing branch to the promoted SHA.
# Creates the branch if it doesn't exist yet (first run after setup).
# No-op if testing is already at BUILD_SHA (idempotent re-runs).
CURRENT_SHA=$(gh api repos/${{ github.repository }}/git/refs/heads/testing \
--jq .object.sha 2>/dev/null || echo "")
if [ "$CURRENT_SHA" = "$BUILD_SHA" ]; then
echo "testing branch already at $BUILD_SHA — nothing to do"
elif [ -z "$CURRENT_SHA" ]; then
gh api repos/${{ github.repository }}/git/refs \
--method POST \
--field ref="refs/heads/testing" \
--field sha="$BUILD_SHA"
else
gh api repos/${{ github.repository }}/git/refs/heads/testing \
--method PATCH \
--field sha="$BUILD_SHA" \
--field force=true
fi