Publish Bluefin dakota #350
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |