Skip to content

chore(deps): update registry.fedoraproject.org/fedora-minimal:latest … #33

chore(deps): update registry.fedoraproject.org/fedora-minimal:latest …

chore(deps): update registry.fedoraproject.org/fedora-minimal:latest … #33

Workflow file for this run

# E2E Reusable Workflow — GNOME/KDE desktop testing in QEMU on GitHub Actions
#
# Called by consumer repos (e.g. projectbluefin/dakota) to gate PRs.
# Boots a bootc OCI image in a KVM-accelerated QEMU VM, starts a GNOME
# or KDE session, and runs behave tests via qecore-headless or runner-side SSH.
#
# No self-hosted runners. Pure GHA ubuntu-latest.
#
# Inputs:
# image — OCI image to test (default: ghcr.io/projectbluefin/dakota:latest)
# suites — comma-separated suite names (default: smoke)
# common runs in SSH mode on the runner; GUI suites use qecore.
name: E2E — GNOME/KDE in QEMU
on:
workflow_call:
inputs:
image:
description: "OCI image to test"
type: string
default: "ghcr.io/projectbluefin/dakota:latest"
target-image:
description: "Full OCI ref to upgrade TO (optional). When set and lifecycle suite is running, stages this image via bootc switch before the test suite."
type: string
default: ""
required: false
suites:
description: "Comma-separated suites: smoke,developer,dx,software,vanilla-gnome,bazzite,common,lifecycle,kde-smoke"
type: string
default: "smoke"
skip_native_apps:
description: "Skip @native_app scenarios (non-Flatpak app tests: Calculator, Files, Settings, etc.)"
type: boolean
default: false
chunked_enabled:
description: >
Enable @zstd_chunked scenarios in the lifecycle suite.
Set to true once projectbluefin/bluefin ships with zstd:chunked layer
compression. Defaults to false so CI stays green until the image is ready.
type: boolean
default: false
screenshot_flatpaks:
description: >
Comma-separated Flatpak app IDs to launch-and-screenshot after the test run.
Each app is started, held open for a few seconds, then captured via GNOME Shell.
Screenshots are uploaded in the results artifact and pushed to GHCR as
ghcr.io/projectbluefin/testsuite/desktop-screenshot:flatpak-<slug>-latest.
Example: "org.gnome.Calculator,io.github.kolunmi.Bazaar"
type: string
default: ""
test_ref:
description: >
Git ref (branch, SHA, or tag) from which to check out the tests/ directory.
Defaults to main when the caller does not pass it. Caller workflows must
resolve branch selection before entering workflow_call; do not use
github.ref_name inside this file because GitHub resolves it to main here.
type: string
default: "main"
jobs:
# Resolve comma-separated suite list into a JSON matrix
matrix:
name: Resolve test matrix
runs-on: ubuntu-latest
outputs:
suites: ${{ steps.resolve.outputs.suites }}
steps:
- name: Resolve suites
id: resolve
run: |
JSON=$(python3 -c "
import json, sys
parts = [s.strip() for s in sys.argv[1].split(',') if s.strip()]
expanded = []
for p in parts:
if p in {'smoke', 'common', 'kde-smoke'}:
expanded.extend([f'{p}-a', f'{p}-b'])
else:
expanded.append(p)
print(json.dumps(expanded))
" "${{ inputs.suites }}")
echo "suites=${JSON}" >> "$GITHUB_OUTPUT"
echo "Suites: ${JSON}"
e2e:
name: ${{ startsWith(matrix.suite, 'kde') && 'KDE Plasma' || 'GNOME 50' }} — ${{ matrix.suite }}
needs: matrix
runs-on: ubuntu-latest
timeout-minutes: 120
permissions:
contents: read
packages: write # needed to push desktop screenshot OCI artifact to GHCR
strategy:
fail-fast: false
matrix:
suite: ${{ fromJson(needs.matrix.outputs.suites) }}
env:
IMAGE: ${{ inputs.image }}
SUITE: ${{ matrix.suite }}
SCREENSHOT_IMAGE: ghcr.io/projectbluefin/testsuite/desktop-screenshot
# KDE suites use a dedicated runner image with KDE/Appium orchestration
# deps; GNOME suites keep the original GNOME/qecore runner.
RUNNER_IMAGE: ${{ startsWith(matrix.suite, 'kde') && 'ghcr.io/projectbluefin/testsuite-kde-runner:kde-runner' || 'ghcr.io/projectbluefin/testsuite:runner' }}
steps:
# Always fetch the latest testsuite tests so test fixes are picked up
# immediately. Explicitly target projectbluefin/testsuite so that caller
# repos (e.g. projectbluefin/common, dakota) without their own tests/
# directory work correctly — github.repository resolves to the caller
# when a reusable workflow is invoked cross-repo.
#
# IMPORTANT: branch selection is owned by the caller via inputs.test_ref.
# Do not add github.ref_name fallback here: inside workflow_call it resolves
# to the default branch (main) and silently tests the wrong branch.
- name: Checkout testsuite
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
repository: projectbluefin/testsuite
ref: ${{ inputs.test_ref }}
fetch-depth: 0
sparse-checkout: |
flatpak-app-list.txt
tests
scripts/assert_kde_passed.py
scripts/check_quarantine_age.py
scripts/install-kde-webdriver.sh
sparse-checkout-cone-mode: false
# smoke-a/smoke-b and common-a/common-b are parallel shards of their
# parent suites. SUITE_DIR is the physical directory. FEATURE_ARGS lists
# the specific feature files for this shard (empty = run all).
- name: Resolve suite shard
id: shard
run: |
python3 - <<'PY'
import glob
import math
import os
suite = os.environ["SUITE"]
suite_dir = suite
feature_args = ""
if "-" in suite:
base, label = suite.rsplit("-", 1)
if base in {"smoke", "common", "kde-smoke"} and label in {"a", "b"}:
files = sorted(glob.glob(f"tests/{base}/features/*.feature"))
if not files:
raise SystemExit(f"No feature files found for tests/{base}/features/*.feature")
chunk_size = math.ceil(len(files) / 2)
shard_index = ord(label) - ord("a")
chunk = files[shard_index * chunk_size:(shard_index + 1) * chunk_size]
if not chunk:
raise SystemExit(f"Shard {suite} resolved to no feature files")
suite_dir = base
feature_args = " ".join(chunk)
screenshot_suite = suite_dir if suite != suite_dir else suite
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
print(f"suite_dir={suite_dir}", file=fh)
print(f"feature_args={feature_args}", file=fh)
print(f"screenshot_suite={screenshot_suite}", file=fh)
PY

Check failure on line 166 in .github/workflows/e2e.yml

View workflow run for this annotation

GitHub Actions / .github/workflows/e2e.yml

Invalid workflow file

You have an error in your yaml syntax on line 166
- name: Export suite shard environment
run: |
echo "SUITE_DIR=${{ steps.shard.outputs.suite_dir }}" >> "$GITHUB_ENV"
echo "FEATURE_ARGS=${{ steps.shard.outputs.feature_args }}" >> "$GITHUB_ENV"
echo "SCREENSHOT_SUITE=${{ steps.shard.outputs.screenshot_suite }}" >> "$GITHUB_ENV"
# flatpak-preinstall.service is masked in KERNEL_ARGS, so Bluefin-family GUI
# suites must preload any explicitly-tested Flatpaks after SSH comes up.
# Cache a small user Flatpak repo on the runner, then inject and deploy it in-VM.
- name: Restore Flatpak download cache
if: ${{ contains(inputs.image, '/bluefin') && steps.shard.outputs.suite_dir != 'common' && steps.shard.outputs.suite_dir != 'lifecycle' }}
id: flatpak-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ${{ github.workspace }}/.flatpak-cache-home/.local/share/flatpak
key: flatpak-home-${{ runner.os }}-${{ hashFiles('flatpak-app-list.txt') }}
restore-keys: |
flatpak-home-${{ runner.os }}-
- name: Prime Flatpak download cache
if: ${{ contains(inputs.image, '/bluefin') && steps.shard.outputs.suite_dir != 'common' && steps.shard.outputs.suite_dir != 'lifecycle' && steps.flatpak-cache.outputs.cache-hit != 'true' }}
run: |
sudo apt-get update -q
sudo apt-get install -y --no-install-recommends flatpak
export HOME="${GITHUB_WORKSPACE}/.flatpak-cache-home"
export XDG_DATA_HOME="${HOME}/.local/share"
mkdir -p "${XDG_DATA_HOME}"
flatpak --user remote-add --if-not-exists \
flathub https://dl.flathub.org/repo/flathub.flatpakrepo
mapfile -t APPS < <(grep -vE '^[[:space:]]*(#|$)' flatpak-app-list.txt)
if [[ ${#APPS[@]} -eq 0 ]]; then
echo "No Flatpak apps listed for cache priming."
exit 0
fi
flatpak install --user --assumeyes --noninteractive --no-deploy \
flathub "${APPS[@]}"
- name: Free disk space
uses: ublue-os/remove-unwanted-software@cc0becac701cf642c8f0a6613bbdaf5dc36b259e # v9
- name: Enable KVM access
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
ls -la /dev/kvm
# Pull OCI image in background while apt installs QEMU — saves ~2 min
- name: Install QEMU and pull OCI image
run: |
sudo podman pull "${IMAGE}" &
PULL_PID=$!
# Pull runner container in parallel — piped into VM after boot.
# Suite type selects the GNOME/qecore runner or the KDE/Appium runner.
sudo podman pull "${RUNNER_IMAGE}" &
RUNNER_PID=$!
sudo apt-get update -q
sudo apt-get install -y --no-install-recommends qemu-system-x86
wait $PULL_PID
wait $RUNNER_PID
- name: Generate SSH keypair
run: |
ssh-keygen -t ed25519 -f /tmp/vm_key -N "" -C "e2e@gha"
echo "VM_PUBKEY=$(cat /tmp/vm_key.pub)" >> "$GITHUB_ENV"
# Install OCI image to a raw disk and configure it for CI testing.
# bootc install to-disk deploys ostree layers but fails at the bootloader
# step (bootupd not in the image). We catch that, then set up direct QEMU
# kernel boot so OVMF/systemd-boot is not needed.
- name: Install OCI image and configure disk
env:
SUITE_DIR: ${{ steps.shard.outputs.suite_dir }}
run: |
fallocate -l 30G disk.raw
# --bootloader was added in bootc ≥0.1.13. Older images (e.g. nvidia:latest
# built Oct 2025) ship an earlier bootc that rejects the flag. Probe
# support before constructing the install command.
# --karg is also not present in all bootc versions; omit it entirely since
# systemd.firstboot=no is already included in the QEMU -append KERNEL_ARGS.
BOOTLOADER_FLAG=""
if sudo podman run --rm --quiet "${IMAGE}" bootc install to-disk --help 2>&1 | grep -q "\-\-bootloader"; then
BOOTLOADER_FLAG="--bootloader systemd"
fi
BOOTC_INSTALL_LOG="$(pwd)/bootc-install.log"
set +e
sudo podman run \
--rm --privileged --pid=host \
--security-opt label=type:unconfined_t \
-v /var/lib/containers:/var/lib/containers \
-v /dev:/dev \
-v "$(pwd):/data" \
"${IMAGE}" bootc install to-disk \
--via-loopback /data/disk.raw \
--filesystem ext4 \
--wipe \
${BOOTLOADER_FLAG} \
2>&1 | tee "${BOOTC_INSTALL_LOG}"
BOOTC_INSTALL_RC=${PIPESTATUS[0]}
set -e
if [[ ${BOOTC_INSTALL_RC} -ne 0 ]]; then
echo "bootc install exited ${BOOTC_INSTALL_RC}; checking whether deployment was written before continuing"
echo "bootc install log saved to ${BOOTC_INSTALL_LOG}"
fi
LOOP=$(sudo losetup -f --show -P disk.raw)
echo "Loop: ${LOOP}"
sudo mkdir -p /mnt/root
ROOT_PART="${LOOP}p3"
ROOT_UUID=$(sudo blkid -s UUID -o value "${ROOT_PART}")
echo "root=${ROOT_PART} uuid=${ROOT_UUID}"
sudo mount "${ROOT_PART}" /mnt/root
DEPLOY=$(sudo find /mnt/root/ostree/deploy/default/deploy/ -mindepth 1 -maxdepth 1 -type d -printf '%f\n' 2>/dev/null | head -1)
if [[ -z "${DEPLOY}" ]]; then
echo "ERROR: ostree deployment missing — bootc install failed before writing layers"
if [[ ${BOOTC_INSTALL_RC} -ne 0 ]]; then
echo "bootc install exited ${BOOTC_INSTALL_RC}; full install log:"
cat "${BOOTC_INSTALL_LOG}"
fi
sudo ls -la /mnt/root/ || true
exit 1
fi
if [[ ${BOOTC_INSTALL_RC} -ne 0 ]]; then
echo "bootc install exited ${BOOTC_INSTALL_RC} after writing deployment ${DEPLOY}; continuing with direct kernel boot"
fi
D="/mnt/root/ostree/deploy/default/deploy/${DEPLOY}"
VAR="/mnt/root/ostree/deploy/default/var"
# el10/LTS images may have multiple entries in usr/lib/modules/ (e.g., a
# modules-only dir for the old kernel + a full dir with vmlinuz for the new
# one). Pick the version that actually contains a vmlinuz; fall back to the
# newest version by sort -V if none do (boot-partition path handles that).
KVER=""
for kv in $(ls "${D}/usr/lib/modules/" | sort -V); do
if [[ -f "${D}/usr/lib/modules/${kv}/vmlinuz" ]]; then
KVER="${kv}"
fi
done
if [[ -z "${KVER}" ]]; then
KVER=$(ls "${D}/usr/lib/modules/" | sort -V | tail -1)
fi
echo "deploy=${DEPLOY} kver=${KVER}"
echo "ROOT_UUID=${ROOT_UUID}" >> "$GITHUB_ENV"
echo "KVER=${KVER}" >> "$GITHUB_ENV"
# Copy kernel + initramfs to workspace for direct QEMU boot (no OVMF).
# Fedora images pack vmlinuz into the deployment at usr/lib/modules/VERSION/.
# RHEL/el10 images put the kernel in the boot partition instead.
# Try deployment path first; fall back to mounting the boot partition.
if [[ -f "${D}/usr/lib/modules/${KVER}/vmlinuz" ]]; then
sudo cp "${D}/usr/lib/modules/${KVER}/vmlinuz" ./vmlinuz
sudo cp "${D}/usr/lib/modules/${KVER}/initramfs.img" ./initramfs.img
else
echo "vmlinuz not in deployment — trying boot partition (${LOOP}p2)"
sudo mkdir -p /mnt/boot
sudo mount "${LOOP}p2" /mnt/boot
# Search by exact KVER first, then fall back to any vmlinuz in the partition.
BOOT_VMLINUZ=$(sudo find /mnt/boot -name "vmlinuz-${KVER}" 2>/dev/null | head -1)
BOOT_INITRD=$(sudo find /mnt/boot -name "initramfs-${KVER}.img" 2>/dev/null | head -1)
if [[ -z "${BOOT_VMLINUZ}" ]]; then
BOOT_VMLINUZ=$(sudo find /mnt/boot -name "vmlinuz*" 2>/dev/null | grep -v '\.efi$' | sort -V | tail -1)
BOOT_INITRD=$(sudo find /mnt/boot -name "initramfs*.img" 2>/dev/null | sort -V | tail -1)
# Update KVER from the filename we found so the kernel args stay consistent.
KVER=$(basename "${BOOT_VMLINUZ}" | sed 's/^vmlinuz-//')
echo "KVER=${KVER}" >> "$GITHUB_ENV"
fi
if [[ -z "${BOOT_VMLINUZ}" ]]; then
echo "ERROR: vmlinuz not found in deployment or boot partition"
sudo find /mnt/boot /mnt/root -name "vmlinuz*" 2>/dev/null | head -20
exit 1
fi
sudo cp "${BOOT_VMLINUZ}" ./vmlinuz
sudo cp "${BOOT_INITRD}" ./initramfs.img
sudo umount /mnt/boot
fi
sudo chown runner:runner ./vmlinuz ./initramfs.img
# Find the ostree boot entry symlink (boot.N/default/<hash>/serial).
# ostree-prepare-root requires this to be a symlink pointing to the
# deployment directory. bootc may fail at the bootloader step (bootupd
# not in image) before creating it. If missing, create it ourselves.
BOOT_ENTRY=$(sudo find /mnt/root/ostree -maxdepth 4 -type l -name '0' 2>/dev/null \
| grep -E '/boot\.[0-9]' | grep '/default/' | head -1)
if [[ -z "${BOOT_ENTRY}" ]]; then
echo "No boot.N symlink found — constructing from deployment hash"
DEPLOY_HASH="${DEPLOY%.*}"
DEPLOY_SERIAL="${DEPLOY##*.}"
BOOT_DIR="/mnt/root/ostree/boot.0/default/${DEPLOY_HASH}"
sudo mkdir -p "${BOOT_DIR}"
# symlink target is relative from the symlink's parent directory:
# boot.0/default/<hash>/ -> ../../../deploy/default/deploy/<hash>.<serial>
sudo ln -sfT "../../../deploy/default/deploy/${DEPLOY}" \
"${BOOT_DIR}/${DEPLOY_SERIAL}"
BOOT_ENTRY="${BOOT_DIR}/${DEPLOY_SERIAL}"
echo "Created symlink: ${BOOT_ENTRY}"
fi
OSTREE_PATH="${BOOT_ENTRY#/mnt/root}"
# ostree-system-generator regex only accepts /ostree/boot.[01]/...
# Newer bootc (Fedora 44+) installs deployments under a versioned directory
# (e.g. boot.1.1) for atomic updates. The generator fails to parse this path
# → /var never bind-mounted → dbus-broker/logind/GDM cascade failure.
# Fix: create a canonical boot.N symlink pointing to the versioned dir so the
# generator gets a path it can parse, while composefs stays untouched.
if [[ "${OSTREE_PATH}" =~ /ostree/(boot\.[0-9]+)\.[0-9]+ ]]; then
VERSIONED_NAME=$(echo "${OSTREE_PATH}" | grep -oE 'boot\.[0-9]+\.[0-9]+')
CANONICAL_NAME="${BASH_REMATCH[1]}" # e.g. boot.1
CANONICAL_ABS="/mnt/root/ostree/${CANONICAL_NAME}"
if [[ ! -e "${CANONICAL_ABS}" ]]; then
sudo ln -s "${VERSIONED_NAME}" "${CANONICAL_ABS}"
echo "Created canonical boot symlink: ${CANONICAL_ABS} -> ${VERSIONED_NAME}"
fi
OSTREE_PATH="${OSTREE_PATH/${VERSIONED_NAME}/${CANONICAL_NAME}}"
fi
echo "ostree boot path: ${OSTREE_PATH}"
# Mask services not needed in CI to shorten first-boot time.
# NetworkManager-wait-online can block for up to 120s — most
# expensive to leave unmasked on a SLIRP QEMU network.
# flatpak-preinstall: pulls flatpaks from internet on first boot (slow + fails in VM).
# podman-auto-update: pulls container images from internet (slow).
# malcontent-webd + update: web-filter update service, downloads data on boot.
# speech-dispatcherd: speech synthesis daemon, not needed for CI.
# systemd-udev-settle: times out in QEMU (~125s) because there is no real
# hardware for udev to settle against. Masking prevents a spurious
# "No failed systemd units at boot" test failure on CentOS/LTS images.
#
# systemd.journald.forward_to_console=1 — forward full journal to serial for
# diagnostics when SSH fails (output captured in vm-serial.log artifact).
KERNEL_ARGS="root=UUID=${ROOT_UUID} rw ostree=${OSTREE_PATH} systemd.firstboot=no selinux=0 console=ttyS0,115200 systemd.journald.forward_to_console=1 systemd.mask=tailscaled.service systemd.mask=brew-setup.service systemd.mask=NetworkManager-wait-online.service systemd.mask=firewalld.service systemd.mask=wpa_supplicant.service systemd.mask=flatpak-preinstall.service systemd.mask=podman-auto-update.service systemd.mask=podman-auto-update.timer systemd.mask=malcontent-webd.service systemd.mask=malcontent-webd-update.service systemd.mask=malcontent-control.service systemd.mask=speech-dispatcherd.service systemd.mask=avahi-daemon.service systemd.mask=avahi-daemon.socket systemd.mask=cups.service systemd.mask=cups.path systemd.mask=cups.socket systemd.mask=cups.browsed.service systemd.mask=blueman-mechanism.service systemd.mask=gnome-remote-desktop.service systemd.mask=bazzite-hardware-setup.service systemd.mask=greenboot-healthcheck.service systemd.mask=greenboot-set-rollback-trigger.service systemd.mask=systemd-udev-settle.service"
echo "KERNEL_ARGS=${KERNEL_ARGS}" >> "$GITHUB_ENV"
# Apply user + sshd config to ALL deployments in this stateroot.
# bootc may deploy with a different checksum than what `ls|head-1`
# returns, so we iterate to ensure the booted deployment gets set up.
BFT_UID=1001
for DEP in $(sudo find /mnt/root/ostree/deploy/default/deploy \
-maxdepth 1 -mindepth 1 -type d 2>/dev/null); do
# Enable sshd
sudo mkdir -p "${DEP}/etc/systemd/system/multi-user.target.wants/"
sudo ln -sf /usr/lib/systemd/system/sshd.service \
"${DEP}/etc/systemd/system/multi-user.target.wants/sshd.service"
# Pre-generate SSH host keys so sshd starts without key-gen delay
sudo ssh-keygen -A -f "${DEP}" 2>/dev/null || true
# Create bluefin-test user (UID 1001)
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"
# Use '*' (no-password, not locked) so pam_unix account check passes
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: named 00- so it sorts BEFORE 20-systemd-userdb.conf.
# The image's sshd_config has Include at the END, meaning main-config
# directives (UsePAM, StrictModes, AuthorizedKeysFile) take precedence
# over any drop-in. However, AuthorizedKeysCommand is NOT set in the
# main config, so the FIRST drop-in alphabetically wins. By naming ours
# 00-ci-auth.conf we ensure our AuthorizedKeysCommand takes effect and
# the userdbctl command in 20-systemd-userdb.conf is ignored.
# /bin/cat just outputs the key file — no userdb socket dependency.
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"
# Create home dir via tmpfiles.d so it exists at runtime regardless
# of whether bootc initialises the stateroot var from the image's /var.
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"
# Passwordless sudo for test operations
printf 'bluefin-test ALL=(ALL) NOPASSWD:ALL\n' | \
sudo tee "${DEP}/etc/sudoers.d/bluefin-test"
sudo chmod 440 "${DEP}/etc/sudoers.d/bluefin-test"
# Pre-bake GDM autologin
sudo mkdir -p "${DEP}/etc/gdm"
printf '[daemon]\nAutomaticLoginEnable=True\nAutomaticLogin=bluefin-test\n' | \
sudo tee "${DEP}/etc/gdm/custom.conf"
# KDE/Plasma suites need SDDM autologin and deterministic session settings.
# These files are harmless on GNOME images and required on KDE images.
if [[ "${SUITE_DIR}" == kde* ]]; then
sudo mkdir -p "${DEP}/etc/sddm.conf.d"
printf '[Autologin]\nUser=bluefin-test\nSession=plasmawayland.desktop\nRelogin=false\n\n[X11]\nMinimumVT=1\n' | \
sudo tee "${DEP}/etc/sddm.conf.d/00-ci-autologin.conf" >/dev/null
# Determinism drop-in: disable animations, force software GL, enable a11y.
sudo mkdir -p "${DEP}/etc/environment.d"
printf 'KWIN_WAYLAND_NO_PERMISSION_CHECKS=1\nKWIN_SCREENSHOT_NO_PERMISSION_CHECKS=1\nQT_ACCESSIBILITY=1\nQT_LINUX_ACCESSIBILITY_ALWAYS_ON=1\nQT_QPA_PLATFORM=wayland\nLIBGL_ALWAYS_SOFTWARE=1\nKWIN_NO_ANIMATIONS=1\nQT_NO_ANIMATIONS=1\nMESA_LOADER_DRIVER_OVERRIDE=llvmpipe\n' | \
sudo tee "${DEP}/etc/environment.d/99-kde-ci.conf" >/dev/null
fi
# dconf system override: ensure allow-extension-installation=true so
# gnome-shell scans ~/.local/share/gnome-shell/extensions/ at startup,
# and pre-add our UUID to enabled-extensions so it activates automatically.
sudo mkdir -p "${DEP}/etc/dconf/db/local.d"
printf '[org/gnome/shell]\nallow-extension-installation=true\nenabled-extensions=['\''unsafe-mode@bluefin-test'\'']\n' | \
sudo tee "${DEP}/etc/dconf/db/local.d/00-ci-testing" > /dev/null
# Best-effort compile: works if host glibc matches the deployment binary.
# If this fails (glibc mismatch), dconf-service will auto-compile on first start.
sudo chroot "${DEP}" /bin/sh -c 'dconf update 2>/dev/null || true' 2>/dev/null || true
# Mask CI-irrelevant services to shorten first-boot systemd startup
for svc in bluetooth cups cups.path cups.socket cups.browsed avahi-daemon avahi-daemon.socket ModemManager fwupd colord podman-auto-update malcontent-control blueman-mechanism gnome-remote-desktop; do
sudo ln -sf /dev/null "${DEP}/etc/systemd/system/${svc}.service" 2>/dev/null || true
sudo ln -sf /dev/null "${DEP}/etc/systemd/system/${svc}.socket" 2>/dev/null || true
sudo ln -sf /dev/null "${DEP}/etc/systemd/system/${svc}.path" 2>/dev/null || true
sudo ln -sf /dev/null "${DEP}/etc/systemd/system/${svc}.timer" 2>/dev/null || true
done
done
# Inject SSH public key into var (runtime /var/home) and into each
# deployment's etc/ssh/ci-authorized-keys (read by our AuthorizedKeysCommand).
sudo mkdir -p "${VAR}/home/bluefin-test/.ssh"
sudo tee "${VAR}/home/bluefin-test/.ssh/authorized_keys" < /tmp/vm_key.pub
# Ensure ~/.local/bin is in the GNOME session PATH.
# qecore-headless reads the GNOME session environment from
# /proc/<gnome-session-pid>/environ, not the SSH shell.
# ~/.config/environment.d/ is read by systemd-environment-d-generator
# when the user session starts, so writing it here (before boot) ensures
# the GNOME session has ~/.local/bin in PATH for qecore_create_keyring.
sudo mkdir -p "${VAR}/home/bluefin-test/.config/environment.d"
printf 'PATH=/var/home/bluefin-test/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin\n' | \
sudo tee "${VAR}/home/bluefin-test/.config/environment.d/99-ci-path.conf"
# Pre-install gnome-shell unsafe-mode extension before first boot.
# gnome-shell's _loadExtensions() scans ~/.local/share/gnome-shell/extensions/
# ONCE at session startup. Extensions installed AFTER that scan are not
# registered and cannot be enabled via D-Bus (enableExtension returns false).
# Writing the files here (before the VM boots) guarantees gnome-shell finds
# and registers the extension during its startup scan. After the GNOME
# session is ready, we just call 'gnome-extensions enable' — no zip, no
# gnome-extensions install, no ReloadExtension (removed in GNOME 47).
if [[ "${SUITE_DIR}" != kde* ]]; then
sudo mkdir -p "${VAR}/home/bluefin-test/.local/share/gnome-shell/extensions/unsafe-mode@bluefin-test"
printf '{"uuid":"unsafe-mode@bluefin-test","name":"Unsafe Mode for Testing","description":"Enables Shell.Eval for automated testing","shell-version":["50","49","48","47","46","45"],"version":1}\n' | \
sudo tee "${VAR}/home/bluefin-test/.local/share/gnome-shell/extensions/unsafe-mode@bluefin-test/metadata.json" > /dev/null
sudo tee "${VAR}/home/bluefin-test/.local/share/gnome-shell/extensions/unsafe-mode@bluefin-test/extension.js" > /dev/null << 'EXTEOF'
import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
export default class UnsafeModeExtension extends Extension {
enable() {
global.context.unsafe_mode = true;
// GNOME Shell resets unsafe_mode to false after certain UI events
// (overview open/close, modal dialogs, screen lock, etc.).
// Re-enable it whenever it gets reset so Shell.Eval stays active.
this._id = global.context.connect('notify::unsafe-mode', () => {
if (!global.context.unsafe_mode)
global.context.unsafe_mode = true;
});
}
disable() {
if (this._id) {
global.context.disconnect(this._id);
this._id = 0;
}
}
}
EXTEOF
fi
sudo chown -R "${BFT_UID}:${BFT_UID}" "${VAR}/home/bluefin-test"
sudo chmod 700 "${VAR}/home/bluefin-test/.ssh"
sudo chmod 600 "${VAR}/home/bluefin-test/.ssh/authorized_keys"
# Write key to AuthorizedKeysCommand target in every deployment
for DEP in $(sudo find /mnt/root/ostree/deploy/default/deploy \
-maxdepth 1 -mindepth 1 -type d 2>/dev/null); do
sudo cp /tmp/vm_key.pub "${DEP}/etc/ssh/ci-authorized-keys"
sudo chown root:root "${DEP}/etc/ssh/ci-authorized-keys"
sudo chmod 644 "${DEP}/etc/ssh/ci-authorized-keys"
done
# Pre-boot diagnostics: confirm key files and sshd drop-in are correct.
echo "=== ci-authorized-keys ==="
for DEP in $(sudo find /mnt/root/ostree/deploy/default/deploy \
-maxdepth 1 -mindepth 1 -type d 2>/dev/null); do
echo "--- ${DEP}/etc/ssh/ci-authorized-keys ---"
sudo cat "${DEP}/etc/ssh/ci-authorized-keys" || echo "(missing)"
echo "--- ${DEP}/etc/ssh/sshd_config.d/00-ci-auth.conf ---"
sudo cat "${DEP}/etc/ssh/sshd_config.d/00-ci-auth.conf" || echo "(missing)"
done
echo "=== /var/home authorized_keys ==="
sudo ls -la "${VAR}/home/bluefin-test/.ssh/" || echo "(missing)"
sudo cat "${VAR}/home/bluefin-test/.ssh/authorized_keys" || echo "(missing)"
sudo umount /mnt/root
sudo losetup -d "${LOOP}"
sudo podman rmi "${IMAGE}" 2>/dev/null || true
ls -lh disk.raw vmlinuz initramfs.img
df -h .
# Pre-create serial log world-writable: root QEMU writes it, runner reads it
- name: Boot VM with QEMU + KVM
run: |
touch "$(pwd)/vm-serial.log"
chmod 666 "$(pwd)/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,hostfwd=tcp:127.0.0.1:4723-:4723 \
-device virtio-net-pci,netdev=net0 \
-device virtio-gpu-pci \
-display none \
-monitor unix:/tmp/qemu-monitor.sock,server,nowait \
-serial file:$(pwd)/vm-serial.log \
-daemonize \
-pidfile /tmp/qemu.pid
echo "VM booting (pid=$(sudo cat /tmp/qemu.pid))"
# Make the monitor socket accessible to the runner user for screendump.
# QEMU creates the socket immediately on startup; wait up to 5s.
for i in $(seq 1 10); do
[[ -S /tmp/qemu-monitor.sock ]] && sudo chmod 666 /tmp/qemu-monitor.sock && break
sleep 0.5
done
[[ -S /tmp/qemu-monitor.sock ]] && echo "QEMU monitor socket ready." \
|| echo "WARNING: QEMU monitor socket not found after 5s"
# selinux=0 + pre-generated host keys + masked slow services = SSH ready in ~90s
- name: Wait for SSH
run: |
SSH_COMMON="-i /tmp/vm_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ControlMaster=auto -o ControlPersist=600 -o ControlPath=/tmp/ssh-ctrl-%C -o ConnectTimeout=3"
DEADLINE=$((SECONDS + 900))
while [ $SECONDS -lt $DEADLINE ]; do
if ssh ${SSH_COMMON} -p 2222 bluefin-test@127.0.0.1 "echo up" 2>/dev/null; then
echo "SSH ready after ${SECONDS}s"
# Quick runtime sanity check: confirm home dir and tmpfiles worked
ssh ${SSH_COMMON} -p 2222 bluefin-test@127.0.0.1 \
"ls -la /var/home/ 2>&1; id; echo HOME=\$HOME" 2>&1 || true
exit 0
fi
sleep 3
done
echo "ERROR: SSH never became ready after 15 minutes"
ssh -v ${SSH_COMMON} -p 2222 bluefin-test@127.0.0.1 "echo up" 2>&1 || true
echo "=== Tail of serial log at SSH timeout ==="
tail -100 "$(pwd)/vm-serial.log" 2>/dev/null || echo "(no serial log)"
echo "=== dbus / ostree lines in serial log ==="
grep -i "dbus\|broker\|ostree-system\|composefs\|FAILED\|Error" "$(pwd)/vm-serial.log" 2>/dev/null | tail -40 || echo "(none found)"
exit 1
- name: Pre-stage target image via bootc switch
if: ${{ inputs.target-image != '' && contains(inputs.suites, 'lifecycle') && env.SUITE == 'lifecycle' }}
run: |
SSH_COMMON="-i /tmp/vm_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ControlMaster=auto -o ControlPersist=600 -o ControlPath=/tmp/ssh-ctrl-%C -o ConnectTimeout=3"
echo "Pre-staging target image ${{ inputs.target-image }} via bootc switch..."
timeout 900 ssh ${SSH_COMMON} -p 2222 bluefin-test@127.0.0.1 \
"sudo bootc switch '${{ inputs.target-image }}'"
- name: Dump VM serial log
if: always()
run: |
echo "=== VM serial console output ==="
cat "$(pwd)/vm-serial.log" 2>/dev/null || echo "(no serial log)"
# GDM/SDDM autologin is pre-baked — common also needs a live user session.
- name: Wait for desktop session
run: |
SSH_COMMON="-i /tmp/vm_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ControlMaster=auto -o ControlPersist=600 -o ControlPath=/tmp/ssh-ctrl-%C -o ConnectTimeout=3"
SSH="ssh ${SSH_COMMON} -p 2222 bluefin-test@127.0.0.1"
DEADLINE=$((SECONDS + 180))
while [ $SECONDS -lt $DEADLINE ]; do
if $SSH "test -S /run/user/1001/wayland-0" 2>/dev/null; then
echo "Desktop session active after ${SECONDS}s"
exit 0
fi
sleep 3
done
echo "ERROR: Desktop session did not start within 3 minutes"
if [[ "${SUITE_DIR}" == kde* ]]; then
$SSH "journalctl -u sddm --no-pager -n 50" 2>/dev/null || true
else
$SSH "journalctl -u gdm --no-pager -n 50" 2>/dev/null || true
fi
exit 1
- name: Capture boot time
if: always()
run: |
SSH_COMMON="-i /tmp/vm_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ControlMaster=auto -o ControlPersist=600 -o ControlPath=/tmp/ssh-ctrl-%C -o ConnectTimeout=3"
SSH="ssh ${SSH_COMMON} -p 2222 bluefin-test@127.0.0.1"
IMAGE_SLUG="${IMAGE##*/}"
IMAGE_SLUG="${IMAGE_SLUG//:/-}"
BOOT_TIME=$($SSH "systemd-analyze time 2>/dev/null | head -1" 2>/dev/null || echo "unavailable")
echo "### Boot time: ${IMAGE_SLUG}" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
echo "${BOOT_TIME}" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
- name: Install cached Flatpaks in VM
if: ${{ contains(inputs.image, '/bluefin') && steps.shard.outputs.suite_dir != 'common' && steps.shard.outputs.suite_dir != 'lifecycle' }}
run: |
SSH_COMMON="-i /tmp/vm_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ControlMaster=auto -o ControlPersist=600 -o ControlPath=/tmp/ssh-ctrl-%C -o ConnectTimeout=3"
CACHE_ROOT="${GITHUB_WORKSPACE}/.flatpak-cache-home/.local/share"
CACHE_TAR="${GITHUB_WORKSPACE}/flatpak-user-cache.tar"
REMOTE_CACHE_DIR="/var/home/bluefin-test/.cache/flatpak-ci"
if [[ ! -d "${CACHE_ROOT}/flatpak" ]]; then
echo "No restored Flatpak cache found on the runner; skipping VM preload."
exit 0
fi
tar -C "${CACHE_ROOT}" -cf "${CACHE_TAR}" flatpak
ssh ${SSH_COMMON} -p 2222 bluefin-test@127.0.0.1 \
"mkdir -p ${REMOTE_CACHE_DIR} \$HOME/.local/share"
scp -i /tmp/vm_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o ControlPath=/tmp/ssh-ctrl-%C -P 2222 "${CACHE_TAR}" \
"bluefin-test@127.0.0.1:${REMOTE_CACHE_DIR}/flatpak-user-cache.tar"
scp -i /tmp/vm_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o ControlPath=/tmp/ssh-ctrl-%C -P 2222 flatpak-app-list.txt \
"bluefin-test@127.0.0.1:${REMOTE_CACHE_DIR}/flatpak-app-list.txt"
ssh ${SSH_COMMON} -p 2222 bluefin-test@127.0.0.1 'bash -s' <<'EOF'
set -euo pipefail
CACHE_DIR="$HOME/.cache/flatpak-ci"
mkdir -p "$CACHE_DIR"
tar -xf "$CACHE_DIR/flatpak-user-cache.tar" -C "$CACHE_DIR"
rm -f "$CACHE_DIR/flatpak-user-cache.tar"
sudo flatpak remote-add --if-not-exists \
flathub https://dl.flathub.org/repo/flathub.flatpakrepo
mapfile -t APPS < <(grep -vE '^[[:space:]]*(#|$)' "$CACHE_DIR/flatpak-app-list.txt")
if [[ ${#APPS[@]} -eq 0 ]]; then
echo "No Flatpak apps requested for VM preload."
exit 0
fi
for app in "${APPS[@]}"; do
if flatpak info --system "$app" >/dev/null 2>&1; then
echo "$app already installed in VM; skipping."
continue
fi
if sudo flatpak install --system --assumeyes --noninteractive \
--sideload-repo="$CACHE_DIR/flatpak/repo" flathub "$app"; then
echo "Installed $app from restored sideload cache."
else
echo "Cache incomplete for $app; falling back to Flathub."
sudo flatpak install --system --assumeyes --noninteractive flathub "$app"
fi
done
EOF
rm -f "${CACHE_TAR}"
# brew-setup.service is masked in CI to save ~60s boot time. Install the
# CLI tools the common suite validates (zsh, fish, eza, fd, rg, bat, fzf,
# starship) directly so scenarios don't need quarantine.
- name: Install shell tools for common suite
if: ${{ steps.shard.outputs.suite_dir == 'common' }}
run: |
SSH_COMMON="-i /tmp/vm_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ControlMaster=auto -o ControlPersist=600 -o ControlPath=/tmp/ssh-ctrl-%C -o ConnectTimeout=3"
SSH="ssh ${SSH_COMMON} -p 2222 bluefin-test@127.0.0.1"
# zsh and fish are RPMs in the image — verify they exist, install if missing.
$SSH "command -v zsh >/dev/null 2>&1 && echo 'zsh already installed' \
|| (echo 'Installing zsh...' && sudo rpm-ostree install --apply-live --allow-inactive zsh 2>/dev/null \
|| sudo dnf install -y zsh 2>/dev/null \
|| echo 'WARNING: could not install zsh')"
$SSH "command -v fish >/dev/null 2>&1 && echo 'fish already installed' \
|| (echo 'Installing fish...' && sudo rpm-ostree install --apply-live --allow-inactive fish 2>/dev/null \
|| sudo dnf install -y fish 2>/dev/null \
|| echo 'WARNING: could not install fish')"
# Brew CLI tools: install via brew if available; fall back to dnf for
# composed test images where the linuxbrew var isn't initialised.
# brew-setup.service is masked in CI, so we install manually.
$SSH "
BREW=/home/linuxbrew/.linuxbrew/bin/brew
if [ -x \"\$BREW\" ]; then
echo 'Homebrew found — installing CLI tools...'
eval \"\$(\$BREW shellenv)\"
for tool in fzf bat eza fd ripgrep starship; do
if command -v \"\$tool\" >/dev/null 2>&1 || \
command -v \"\$(echo \$tool | sed 's/ripgrep/rg/')\" >/dev/null 2>&1; then
echo \" \$tool: already available\"
else
echo \" \$tool: installing via brew...\"
\$BREW install \"\$tool\" 2>&1 | tail -1 || echo \" WARNING: brew install \$tool failed\"
fi
done
echo 'Brew CLI tools install complete (brew path)'
else
echo 'Homebrew not found — falling back to rpm-ostree/dnf for CLI tools'
# Map tool names to Fedora RPM package names.
# eza may not be in older Fedora repos; ripgrep is 'rg' in path.
declare -A pkgmap=([bat]=bat [eza]=eza [fd]=fd-find [fzf]=fzf [ripgrep]=ripgrep [starship]=starship)
# On ostree-based images /usr is immutable; use rpm-ostree --apply-live.
# Fall back to dnf only on mutable (non-ostree) filesystems.
MISSING_PKGS=()
for tool in bat eza fd fzf ripgrep starship; do
cmd=\$tool
[ \"\$tool\" = ripgrep ] && cmd=rg
[ \"\$tool\" = fd ] && cmd=fd
if command -v \"\$cmd\" >/dev/null 2>&1; then
echo \" \$tool: already available as \$cmd\"
else
pkg=\${pkgmap[\$tool]:-\$tool}
echo \" \$tool: will install (\$pkg)\"
MISSING_PKGS+=(\"\$pkg\")
fi
done
if [[ \${#MISSING_PKGS[@]} -gt 0 ]]; then
echo \" Installing: \${MISSING_PKGS[*]}\"
if test -e /run/ostree-booted; then
echo \" Using rpm-ostree --apply-live (ostree image)\"
# Stop services that may hold an rpm-ostree/bootc sysroot lock at boot time.
sudo systemctl stop bootc-unified-storage.service bootc-fetch-apply-updates.service rpm-ostreed.service 2>/dev/null || true
sleep 2
install_out=\$(sudo rpm-ostree install --apply-live --allow-inactive -y \${MISSING_PKGS[@]} 2>&1)
install_exit=\$?
echo \"\$install_out\" | tail -5
if [[ \$install_exit -eq 0 ]]; then
echo \" rpm-ostree install complete\"
else
echo \" WARNING: rpm-ostree install failed (exit \$install_exit) — some tools may be missing\"
fi
else
echo \" Using dnf (non-ostree image)\"
sudo dnf install -y --quiet \${MISSING_PKGS[@]} 2>&1 | tail -1 \
&& echo \" dnf install complete\" \
|| echo \" WARNING: dnf install failed — some tools may be missing\"
fi
fi
echo 'CLI tools install complete (rpm-ostree/dnf fallback path)'
fi
" || echo "WARNING: shell tools install step had errors — some common scenarios may fail"
# Verify tool availability for CI diagnostics
$SSH "
echo '=== Shell tool availability ==='
for tool in zsh fish fzf bat eza fd rg starship; do
if command -v \"\$tool\" >/dev/null 2>&1; then
echo \" \$tool: \$(command -v \$tool)\"
elif [ -x /home/linuxbrew/.linuxbrew/bin/\$tool ]; then
echo \" \$tool: /home/linuxbrew/.linuxbrew/bin/\$tool (brew)\"
else
echo \" \$tool: NOT FOUND\"
fi
done
" || true
- name: Load runner container into VM
if: ${{ steps.shard.outputs.suite_dir != 'common' && !startsWith(steps.shard.outputs.suite_dir, 'kde') }}
run: |
SSH_COMMON="-i /tmp/vm_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ControlMaster=auto -o ControlPersist=600 -o ControlPath=/tmp/ssh-ctrl-%C -o ConnectTimeout=3"
# Ensure subuid/subgid mappings exist for bluefin-test so rootless podman
# can unpack image layers with non-root ownership (e.g. gid 12 for
# /var/spool/mail in the Fedora 44 base layer inside the runner image).
ssh ${SSH_COMMON} -p 2222 bluefin-test@127.0.0.1 \
"sudo bash -c 'grep -q bluefin-test /etc/subuid || echo \"bluefin-test:100000:65536\" >> /etc/subuid; grep -q bluefin-test /etc/subgid || echo \"bluefin-test:100000:65536\" >> /etc/subgid'"
ssh ${SSH_COMMON} -p 2222 bluefin-test@127.0.0.1 podman system migrate
echo "Piping runner container into VM (this replaces in-VM pip install)..."
sudo podman save "${RUNNER_IMAGE}" | \
ssh ${SSH_COMMON} -p 2222 bluefin-test@127.0.0.1 podman load
echo "Runner container loaded in VM"
# Patch runner container: add openssh-clients if the pre-built image
# predates the Containerfile update that included it. When already
# present (new images) this is a fast no-op. Required so that
# _run_host() in step files can SSH from the container to the VM.
SSH="ssh ${SSH_COMMON} -p 2222 bluefin-test@127.0.0.1"
$SSH "
if podman run --rm --entrypoint '' ${RUNNER_IMAGE} sh -c 'command -v ssh >/dev/null 2>&1'; then
echo 'openssh-clients already in runner image'
else
echo 'Patching runner image: adding openssh-clients...'
CID=\$(podman run -d --entrypoint '' ${RUNNER_IMAGE} sh -c 'microdnf install -y openssh-clients >/dev/null 2>&1')
podman wait \$CID
podman commit --change 'ENTRYPOINT [\"qecore-headless\"]' \$CID ${RUNNER_IMAGE}
podman rm \$CID
echo 'Runner image patched with openssh-clients'
fi
"
- name: Install Python test stack
if: ${{ steps.shard.outputs.suite_dir != 'common' }}
run: |
SSH_COMMON="-i /tmp/vm_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ControlMaster=auto -o ControlPersist=600 -o ControlPath=/tmp/ssh-ctrl-%C -o ConnectTimeout=3"
SSH="ssh ${SSH_COMMON} -p 2222 bluefin-test@127.0.0.1"
# pip installs are skipped — packages are in the pre-built runner container.
# Only set up kernel module, device permission, and session environment.
$SSH "sudo modprobe uinput 2>/dev/null || true"
$SSH "sudo chmod 0666 /dev/uinput 2>/dev/null || true"
# Copy SSH private key into VM so DX @plain_ssh tests can SSH to localhost.
# The DX suite runs inside the VM via qecore-headless; @plain_ssh scenarios
# SSH from inside the VM to itself (loopback) to get a login shell with
# the full user environment (PATH, mise, etc.).
SCP_KEY="scp -i /tmp/vm_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ControlPath=/tmp/ssh-ctrl-%C -P 2222"
$SCP_KEY /tmp/vm_key bluefin-test@127.0.0.1:/home/bluefin-test/.ssh/id_ed25519
$SSH "chmod 600 /home/bluefin-test/.ssh/id_ed25519"
$SSH "
XDG=/run/user/1001
SESSION_BUS=\$(systemctl --user show-environment 2>/dev/null \
| grep '^DBUS_SESSION_BUS_ADDRESS=' | head -1 | cut -d= -f2-)
[[ -z \"\${SESSION_BUS}\" ]] && SESSION_BUS=\"unix:path=\${XDG}/bus\"
WAYLAND_DISP=\$(ls /run/user/1001/wayland-* 2>/dev/null \
| head -1 | xargs basename 2>/dev/null || echo wayland-0)
# Query the real AT-SPI accessibility bus address.
# AT-SPI uses a separate socket from the D-Bus session bus; the address
# is published by at-spi-bus-launcher via org.a11y.Bus.GetAddress.
# Retry up to 10s to tolerate at-spi-bus-launcher startup delay.
ATSPI_ADDR=\"\"
for _i in 1 2 3 4 5; do
ATSPI_ADDR=\$(DBUS_SESSION_BUS_ADDRESS=\"\${SESSION_BUS}\" \
gdbus call --session --dest org.a11y.Bus --object-path /org/a11y/bus \
--method org.a11y.Bus.GetAddress 2>/dev/null \
| sed \"s/.*'\\(unix:[^']*\\)'.*/\\1/\")
[[ -n \"\${ATSPI_ADDR}\" ]] && break
sleep 2
done
if [[ -z \"\${ATSPI_ADDR}\" ]]; then
echo 'WARNING: Could not query AT-SPI bus address from org.a11y.Bus; falling back to session bus (AT-SPI tests may fail)'
ATSPI_ADDR=\"\${SESSION_BUS}\"
else
echo \"AT-SPI bus address: \${ATSPI_ADDR}\"
fi
printf 'export DBUS_SESSION_BUS_ADDRESS=%s\nexport WAYLAND_DISPLAY=%s\nexport XDG_RUNTIME_DIR=%s\nexport AT_SPI_BUS_ADDRESS=%s\nexport PATH=%s\nexport XDG_SESSION_TYPE=wayland\nexport XDG_SESSION_DESKTOP=gnome\n' \
\"\${SESSION_BUS}\" \"\${WAYLAND_DISP}\" \"\${XDG}\" \"\${ATSPI_ADDR}\" \"\$HOME/.local/bin:\$PATH\" \
> /tmp/session.env
cat /tmp/session.env
"
# Enable the pre-installed unsafe-mode gnome-shell extension.
# The extension files were written to ~/.local/share/gnome-shell/extensions/
# before first boot (in the "Install OCI image and configure disk" step),
# so gnome-shell found and registered the extension during _loadExtensions()
# at session startup. We just need to enable it — gnome-shell's
# enableExtension() succeeds because the UUID is already in its internal map.
# (gnome-extensions install --force at this point would fail: ReloadExtension
# was removed in GNOME 47, so runtime-installed extensions are never registered.)
$SSH "
source /tmp/session.env
gnome-extensions enable unsafe-mode@bluefin-test \
&& echo 'unsafe-mode extension enabled — Shell.Eval is active' \
|| echo 'WARNING: Failed to enable unsafe-mode extension'
"
# Poll until Shell.Eval confirms unsafe_mode is active (up to 10s).
$SSH "
source /tmp/session.env
for i in 1 2 3 4 5; do
result=\$(gdbus call --session --dest org.gnome.Shell \
--object-path /org/gnome/Shell --method org.gnome.Shell.Eval '1' 2>&1)
echo \"Shell.Eval check \$i: \$result\"
echo \"\$result\" | grep -q '(true,' && echo 'Shell.Eval confirmed' && break
sleep 2
done
" || true
# Enable AT-SPI accessibility for GTK4 apps (gnome-control-center, etc.).
# qecore-headless tries to do this with `dbus-run-session gsettings set ...`
# but that creates a new D-Bus session and does not affect the running GNOME
# session. Setting toolkit-accessibility here (directly in the GDM session)
# causes GTK4 apps to register with at-spi2-registryd when they start, so
# dogtail/pyatspi2 can enumerate them in the AT-SPI tree.
$SSH "
source /tmp/session.env
gsettings set org.gnome.desktop.interface toolkit-accessibility true \
&& echo 'AT-SPI toolkit-accessibility enabled' \
|| echo 'WARNING: toolkit-accessibility not set (gsettings failed)'
"
# Re-query AT-SPI bus address after enabling toolkit-accessibility.
# On images where toolkit-accessibility was not pre-enabled (e.g. older
# production images), the AT-SPI bus is only started when the setting is
# applied above. The initial query (before toolkit-accessibility) fell back
# to the session bus; now that AT-SPI has had a chance to start, re-query
# and update session.env with the real socket address.
$SSH "
source /tmp/session.env
# Only re-query if we used the session-bus fallback.
if [[ \"\${AT_SPI_BUS_ADDRESS}\" == \"\${DBUS_SESSION_BUS_ADDRESS}\" ]]; then
echo 'AT-SPI address was fallback — re-querying after toolkit-accessibility was set...'
ATSPI_ADDR=\"\"
for _i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
ATSPI_ADDR=\$(DBUS_SESSION_BUS_ADDRESS=\"\${DBUS_SESSION_BUS_ADDRESS}\" \
gdbus call --session --dest org.a11y.Bus --object-path /org/a11y/bus \
--method org.a11y.Bus.GetAddress 2>/dev/null \
| sed \"s/.*'\\(unix:[^']*\\)'.*/\\1/\")
[[ -n \"\${ATSPI_ADDR}\" ]] && [[ \"\${ATSPI_ADDR}\" != \"\${DBUS_SESSION_BUS_ADDRESS}\" ]] && break
sleep 2
done
if [[ -n \"\${ATSPI_ADDR}\" ]] && [[ \"\${ATSPI_ADDR}\" != \"\${DBUS_SESSION_BUS_ADDRESS}\" ]]; then
echo \"AT-SPI bus address (re-queried): \${ATSPI_ADDR}\"
sed -i \"s|AT_SPI_BUS_ADDRESS=.*|AT_SPI_BUS_ADDRESS=\${ATSPI_ADDR}|\" /tmp/session.env
else
echo 'WARNING: AT-SPI bus address still unavailable after retry — tests may fail'
fi
fi
"
# Gracefully terminate gnome-control-center if it started before toolkit-
# accessibility was set (it would never register with AT-SPI). The Settings
# test launches a fresh instance via gio launch (D-Bus activation) which
# registers correctly. Use pgrep + kill -TERM by PID — broad signal-by-name
# commands can unexpectedly drop the GNOME user session on GNOME 50.
$SSH "
if pid=\$(pgrep -x gnome-control-center 2>/dev/null); then
kill -TERM \"\$pid\" 2>/dev/null \
&& echo \"gnome-control-center (PID \$pid) sent SIGTERM\" \
|| echo \"SIGTERM to gnome-control-center failed (OK)\"
else
echo 'gnome-control-center not running (OK)'
fi
" || true
# Introspect GNOME Shell D-Bus interfaces to identify available screenshot APIs.
$SSH "
source /tmp/session.env
echo '=== org.gnome.Shell.Screenshot interface ==='
gdbus introspect --session --dest org.gnome.Shell \
--object-path /org/gnome/Shell/Screenshot 2>&1
" || true
- name: Prepare KDE session environment
if: ${{ startsWith(steps.shard.outputs.suite_dir, 'kde') }}
run: |
SSH_COMMON="-i /tmp/vm_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ControlMaster=auto -o ControlPersist=600 -o ControlPath=/tmp/ssh-ctrl-%C -o ConnectTimeout=3"
SSH="ssh ${SSH_COMMON} -p 2222 bluefin-test@127.0.0.1"
# Overwrite /tmp/session.env with KDE/Plasma-specific values.
# AT-SPI and Wayland discovery are identical to GNOME; the desktop/session
# identifiers and Qt a11y env differ.
$SSH "
XDG=/run/user/1001
SESSION_BUS=\$(systemctl --user show-environment 2>/dev/null \
| grep '^DBUS_SESSION_BUS_ADDRESS=' | head -1 | cut -d= -f2-)
[[ -z \"\${SESSION_BUS}\" ]] && SESSION_BUS=\"unix:path=\${XDG}/bus\"
WAYLAND_DISP=\$(ls /run/user/1001/wayland-* 2>/dev/null \
| head -1 | xargs basename 2>/dev/null || echo wayland-0)
ATSPI_ADDR=\"\"
for _i in 1 2 3 4 5 6 7 8 9 10; do
ATSPI_ADDR=\$(DBUS_SESSION_BUS_ADDRESS=\"\${SESSION_BUS}\" \
gdbus call --session --dest org.a11y.Bus --object-path /org/a11y/bus \
--method org.a11y.Bus.GetAddress 2>/dev/null \
| sed \"s/.*'\\(unix:[^']*\\)'.*/\\1/\")
[[ -n \"\${ATSPI_ADDR}\" ]] && break
sleep 2
done
if [[ -z \"\${ATSPI_ADDR}\" ]]; then
echo 'WARNING: Could not query AT-SPI bus address; falling back to session bus'
ATSPI_ADDR=\"\${SESSION_BUS}\"
else
echo \"AT-SPI bus address: \${ATSPI_ADDR}\"
fi
printf 'export DBUS_SESSION_BUS_ADDRESS=%s\nexport WAYLAND_DISPLAY=%s\nexport XDG_RUNTIME_DIR=%s\nexport AT_SPI_BUS_ADDRESS=%s\nexport PATH=%s\nexport XDG_SESSION_TYPE=wayland\nexport XDG_SESSION_DESKTOP=kde\nexport QT_ACCESSIBILITY=1\nexport QT_LINUX_ACCESSIBILITY_ALWAYS_ON=1\nexport QT_QPA_PLATFORM=wayland\n' \
\"\${SESSION_BUS}\" \"\${WAYLAND_DISP}\" \"\${XDG}\" \"\${ATSPI_ADDR}\" \"\$HOME/.local/bin:\$PATH\" \
> /tmp/session.env
cat /tmp/session.env
"
- name: Install gnome-ponytail-daemon
if: ${{ steps.shard.outputs.suite_dir != 'common' && !startsWith(steps.shard.outputs.suite_dir, 'kde') }}
run: |
SSH_COMMON="-i /tmp/vm_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ControlMaster=auto -o ControlPersist=600 -o ControlPath=/tmp/ssh-ctrl-%C -o ConnectTimeout=3"
SSH="ssh ${SSH_COMMON} -p 2222 bluefin-test@127.0.0.1"
SCP_OPTS="-i /tmp/vm_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o ControlPath=/tmp/ssh-ctrl-%C -P 2222"
# gnome-ponytail-daemon is not yet baked into the image (PR #618).
# Build it without libei on debian:bookworm (glibc 2.36 < VM glibc 2.42).
# Without libei the daemon uses the Mutter D-Bus fallback for input events.
# Also build grim (wlr-screencopy client) for permission-free screenshots.
# grim includes its own copy of wlr-screencopy-unstable-v1.xml so no
# external wlr-protocols package is needed.
mkdir -p /tmp/ponytail-build
podman run --rm -v /tmp/ponytail-build:/out:Z debian:bookworm bash -c "
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq build-essential meson ninja-build pkg-config \
libglib2.0-dev libwayland-dev wayland-protocols libcairo2-dev libpng-dev git
git clone --quiet --depth 1 --branch 0.0.11 https://gitlab.gnome.org/ofourdan/gnome-ponytail-daemon.git /src
# systemd dep is only used to find systemduserunitdir; we provide it directly
sed -i \"s/dependency('systemd')/dependency('systemd', required: false)/\" /src/meson.build
meson setup /src/build /src --prefix=/usr -Dsystemd_user_unit_dir=/usr/lib/systemd/user -Dponytail_python=false
ninja -C /src/build
cp /src/build/src/gnome-ponytail-daemon /out/
# Build wayland-protocols >=1.37 from source (bookworm ships 1.31,
# grim 1.4+ needs 1.37 for ext-image-copy-capture-v1).
git clone --quiet --depth 1 --branch 1.37 \
https://gitlab.freedesktop.org/wayland/wayland-protocols.git /wp
meson setup /wp/build /wp --prefix=/usr
ninja -C /wp/build install
# Build grim (uses ext-image-copy-capture-v1, supported by mutter 47+)
git clone --quiet --depth 1 https://git.sr.ht/~emersion/grim /grim
meson setup /grim/build /grim --prefix=/usr
ninja -C /grim/build
cp /grim/build/grim /out/
"
$SSH "mkdir -p \$HOME/.local/libexec \$HOME/.local/bin"
scp ${SCP_OPTS} /tmp/ponytail-build/gnome-ponytail-daemon \
bluefin-test@127.0.0.1:/home/bluefin-test/.local/libexec/gnome-ponytail-daemon
scp ${SCP_OPTS} /tmp/ponytail-build/grim \
bluefin-test@127.0.0.1:/home/bluefin-test/.local/bin/grim
$SSH "chmod +x \$HOME/.local/bin/grim && echo 'grim deployed to ~/.local/bin/'"
$SSH "
set -e
chmod +x \$HOME/.local/libexec/gnome-ponytail-daemon
# ponytail Python module is now baked into the runner container image.
# Only install the D-Bus service file and pre-start the daemon here.
# User D-Bus session service file enables auto-activation
mkdir -p \$HOME/.local/share/dbus-1/services
printf '[D-BUS Service]\nName=org.gnome.Ponytail\nExec=%s/.local/libexec/gnome-ponytail-daemon\n' \
\"\$HOME\" > \$HOME/.local/share/dbus-1/services/org.gnome.Ponytail.service
# Pre-start the daemon and wait for it to register on the session bus
source /tmp/session.env
nohup \$HOME/.local/libexec/gnome-ponytail-daemon >/tmp/ponytail-daemon.log 2>&1 &
for i in \$(seq 1 15); do
dbus-send --session --print-reply \
--dest=org.freedesktop.DBus /org/freedesktop/DBus \
org.freedesktop.DBus.NameHasOwner string:org.gnome.Ponytail 2>/dev/null \
| grep -q 'boolean true' && echo 'gnome-ponytail-daemon registered on DBus' && break
sleep 1
done
"
- name: Install KDE webdriver stack
id: kde_webdriver
if: ${{ startsWith(steps.shard.outputs.suite_dir, 'kde') }}
run: |
SSH_COMMON="-i /tmp/vm_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ControlMaster=auto -o ControlPersist=600 -o ControlPath=/tmp/ssh-ctrl-%C -o ConnectTimeout=3"
SSH="ssh ${SSH_COMMON} -p 2222 bluefin-test@127.0.0.1"
SCP="scp -i /tmp/vm_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ControlPath=/tmp/ssh-ctrl-%C -P 2222"
$SCP scripts/install-kde-webdriver.sh bluefin-test@127.0.0.1:/home/bluefin-test/install-kde-webdriver.sh
install_out="${GITHUB_WORKSPACE}/kde-webdriver-install.log"
if $SSH "bash /home/bluefin-test/install-kde-webdriver.sh" 2>&1 | tee "${install_out}"; then
skip_reason=$(grep -oE 'KDE_WEBDRIVER_SKIP=.*' "${install_out}" | head -1 | cut -d= -f2- || true)
if [[ -n "${skip_reason}" ]]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "skip_reason=${skip_reason}" >> "$GITHUB_OUTPUT"
echo "::warning::KDE suite skipped: ${skip_reason}"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
else
echo "::error::KDE webdriver stack install failed"
echo "skip=false" >> "$GITHUB_OUTPUT"
exit 1
fi
rm -f "${install_out}"
- name: Run behave suite
id: run
timeout-minutes: 110
env:
BEHAVE_RETRIES: 2
SKIP_NATIVE_APPS: ${{ inputs.skip_native_apps && 'true' || '' }}
TARGET_IMAGE: ${{ inputs.target-image }}
ZSTD_CHUNKED: ${{ inputs.chunked_enabled && 'true' || 'false' }}
KDE_SKIP_REASON: ${{ steps.kde_webdriver.outputs.skip_reason }}
run: |
mkdir -p results
BEHAVE_RC=0
BEHAVE_TAG_ARGS="--tags ~quarantine"
[[ "${SKIP_NATIVE_APPS}" == "true" ]] && BEHAVE_TAG_ARGS="${BEHAVE_TAG_ARGS} --tags ~native_app"
# If the DUT was unsupported for KDE testing, emit a single skipped
# scenario so the run is reported clearly instead of as a phantom failure.
if [[ "${SUITE_DIR}" == kde* && "${{ steps.kde_webdriver.outputs.skip }}" == "true" ]]; then
python3 - <<'PY'
import json, os
reason = os.environ.get('KDE_SKIP_REASON', 'KDE webdriver stack skipped this suite')
data = [{
"keyword": "Feature",
"name": "KDE suite skipped",
"uri": "kde-webdriver-skip.feature",
"elements": [{
"keyword": "Scenario",
"name": "kde-webdriver-skip",
"type": "scenario",
"status": "skipped",
"steps": [{
"keyword": "Given ",
"name": "the KDE webdriver stack is available",
"result": {"status": "skipped", "error_message": reason},
"status": "skipped"
}]
}]
}]
with open('results/results.json', 'w') as f:
json.dump(data, f, indent=2)
print(f"Wrote skip results: {reason}")
PY
echo "behave_rc=0" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ "${SUITE_DIR}" == "common" || "${SUITE_DIR}" == "lifecycle" ]]; then
python3 -m pip install -q --user behave
if [[ -n "${FEATURE_ARGS}" ]]; then
LOCAL_FEATURE_ARGS="${FEATURE_ARGS}"
else
LOCAL_FEATURE_ARGS="tests/${SUITE_DIR}/features/"
fi
PYTHONPATH="$(pwd)" \
VM_IP=127.0.0.1 \
VM_USER=bluefin-test \
SSH_KEY=/tmp/vm_key \
SSH_PORT=2222 \
ZSTD_CHUNKED="${ZSTD_CHUNKED}" \
python3 tests/shared/behave_retry.py ${LOCAL_FEATURE_ARGS} \
--format json.pretty --outfile results/results.json \
--no-capture ${BEHAVE_TAG_ARGS} || BEHAVE_RC=$?
elif [[ "${SUITE_DIR}" == kde* ]]; then
# KDE runner image is host-side only: run behave on the GitHub Actions
# runner inside the KDE/Appium container, connecting to the DUT over SSH.
if [[ -n "${FEATURE_ARGS}" ]]; then
KDE_FEATURE_ARGS="${FEATURE_ARGS}"
else
KDE_FEATURE_ARGS="tests/${SUITE_DIR}/features/"
fi
mkdir -p results
sudo podman run --rm \
--network=host \
-v "$(pwd):/tmp/bluefin-tests:ro" \
-v "$(pwd)/results:/tmp/results" \
-v /tmp/vm_key:/tmp/vm_key:ro \
-e VM_IP=127.0.0.1 \
-e VM_USER=bluefin-test \
-e SSH_KEY=/tmp/vm_key \
-e SSH_PORT=2222 \
-e KDE_WEBDRIVER_URL=http://127.0.0.1:4723 \
-e NO_PROXY=127.0.0.1,localhost \
-e no_proxy=127.0.0.1,localhost \
-e PYTHONPATH=/tmp/bluefin-tests \
-e BEHAVE_RETRIES=${BEHAVE_RETRIES:-2} \
-e TARGET_IMAGE="${TARGET_IMAGE}" \
"${RUNNER_IMAGE}" \
"python3 /tmp/bluefin-tests/tests/shared/behave_retry.py ${KDE_FEATURE_ARGS} --format json.pretty --outfile /tmp/results/results.json --no-capture ${BEHAVE_TAG_ARGS}" || BEHAVE_RC=$?
else
SSH_COMMON="-i /tmp/vm_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ControlMaster=auto -o ControlPersist=600 -o ControlPath=/tmp/ssh-ctrl-%C -o ConnectTimeout=3"
SSH="ssh ${SSH_COMMON} -p 2222 bluefin-test@127.0.0.1"
SCP="scp -i /tmp/vm_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ControlPath=/tmp/ssh-ctrl-%C -P 2222 -r"
# Copy into tests/ subdir so 'from tests.shared import ...' resolves.
# PYTHONPATH=/tmp/bluefin-tests exposes the tests package to behave.
$SSH "mkdir -p /tmp/bluefin-tests/tests /tmp/results"
$SCP "tests/${SUITE_DIR}" "bluefin-test@127.0.0.1:/tmp/bluefin-tests/tests/"
[[ -d "tests/shared" ]] && \
$SCP "tests/shared" "bluefin-test@127.0.0.1:/tmp/bluefin-tests/tests/" || true
[[ -f "tests/__init__.py" ]] && \
$SCP "tests/__init__.py" "bluefin-test@127.0.0.1:/tmp/bluefin-tests/tests/__init__.py" || true
# Build the behave path: specific feature files for shards, directory otherwise.
if [[ -n "${FEATURE_ARGS}" ]]; then
REMOTE_FEATURE_ARGS="${FEATURE_ARGS//tests\//\/tmp\/bluefin-tests\/tests\/}"
else
REMOTE_FEATURE_ARGS="/tmp/bluefin-tests/tests/${SUITE_DIR}/features/"
fi
$SSH "
set -e
source /tmp/session.env
mkdir -p /tmp/results
# Run tests inside the pre-loaded runner container.
# --pid=host: qecore reads GNOME session env from /proc/<pid>/environ.
# --privileged: AT-SPI socket access + uinput device passthrough.
# --network host: D-Bus session bus traffic reaches ponytail daemon.
podman run --userns=keep-id --rm \
--pid=host \
--privileged \
--network host \
-v /run/user/1001:/run/user/1001 \
-v /tmp/bluefin-tests:/tmp/bluefin-tests:ro \
-v /tmp/results:/tmp/results \
-v /home/bluefin-test/.ssh/id_ed25519:/home/bluefin-test/.ssh/id_ed25519:ro \
-e DBUS_SESSION_BUS_ADDRESS=\"\${DBUS_SESSION_BUS_ADDRESS}\" \
-e WAYLAND_DISPLAY=\"\${WAYLAND_DISPLAY}\" \
-e XDG_RUNTIME_DIR=/run/user/1001 \
-e XDG_SESSION_TYPE=wayland \
-e XDG_SESSION_DESKTOP=gnome \
-e AT_SPI_BUS_ADDRESS=\"\${AT_SPI_BUS_ADDRESS}\" \
-e VM_IP=127.0.0.1 \
-e VM_USER=bluefin-test \
-e SSH_KEY=/home/bluefin-test/.ssh/id_ed25519 \
-e SSH_PORT=22 \
-e PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
-e BEHAVE_RETRIES=${BEHAVE_RETRIES:-2} \
-e TARGET_IMAGE=\"${TARGET_IMAGE}\" \
-e PYTHONPATH=/tmp/bluefin-tests \
ghcr.io/projectbluefin/testsuite:runner \
\"python3 /tmp/bluefin-tests/tests/shared/behave_retry.py ${REMOTE_FEATURE_ARGS} --format json.pretty --outfile /tmp/results/results.json --no-capture ${BEHAVE_TAG_ARGS}\"
" || BEHAVE_RC=$?
$SCP "bluefin-test@127.0.0.1:/tmp/results/." ./results/ 2>/dev/null || true
fi
echo "behave_rc=${BEHAVE_RC}" >> "$GITHUB_OUTPUT"
- name: Capture post-upgrade desktop screenshot
if: always() && env.SUITE == 'lifecycle'
continue-on-error: true
run: |
# Use a fresh connection without ControlMaster — the VM may have rebooted
# during the lifecycle suite, invalidating any existing SSH multiplex socket.
SSH_CLEAN="-i /tmp/vm_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ControlMaster=no -o ConnectTimeout=30"
SSH="ssh ${SSH_CLEAN} -p 2222 bluefin-test@127.0.0.1"
mkdir -p results
# Wait up to 60 s for the GNOME Wayland socket — the session may still be
# starting after the last upgrade reboot in the lifecycle suite.
DEADLINE=$((SECONDS + 60))
while [ $SECONDS -lt $DEADLINE ]; do
if $SSH "test -S /run/user/1001/wayland-0" 2>/dev/null; then
echo "GNOME session ready for screenshot"
sleep 5 # Settle sleep to allow GNOME Shell to finish rendering the desktop
break
fi
sleep 3
done
# Capture the virtual framebuffer directly from the runner host via QEMU monitor.
# Bypasses GNOME Wayland session permissions and DBus accessibility limits completely.
OUT="results/screenshot_lifecycle_upgrade_final.png"
sudo python3 tests/shared/qemu_screendump.py "$OUT" \
&& echo "Post-upgrade screenshot captured: $OUT" \
|| echo "WARNING: QEMU screendump failed — screenshot may be missing"
sudo chown runner:runner "$OUT" 2>/dev/null || true
sudo chmod 644 "$OUT" 2>/dev/null || true
- name: Capture post-migration screenshot and status
if: always() && env.SUITE == 'lifecycle'
continue-on-error: true
run: |
mkdir -p results
# Option A: QEMU framebuffer capture — produces an actual PNG of the
# VM display (GDM login screen or desktop if autologin succeeded).
# The monitor socket was opened at VM boot with -monitor unix:...
OUT="results/screenshot-post-migration.png"
sudo python3 tests/shared/qemu_screendump.py "$OUT" \
&& echo "Post-migration screenshot captured: $OUT" \
|| echo "WARNING: QEMU screendump failed — screenshot may be missing"
sudo chmod 644 "$OUT" 2>/dev/null || true
# Option C (supplemental): bootc status + fastfetch text dump.
# Always available even if the QEMU monitor screenshot fails.
SSH_CLEAN="-i /tmp/vm_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ControlMaster=no -o ConnectTimeout=30"
SSH="ssh ${SSH_CLEAN} -p 2222 bluefin-test@127.0.0.1"
{
echo "=== bootc status ==="
$SSH "sudo bootc status" 2>&1 || echo "(bootc status failed)"
echo ""
echo "=== fastfetch ==="
$SSH "fastfetch" 2>&1 || echo "(fastfetch not available)"
echo ""
echo "=== os-release ==="
$SSH "cat /etc/os-release" 2>&1 || echo "(os-release not available)"
} > results/migration-status.txt 2>&1
echo "Migration status captured: results/migration-status.txt"
- name: Capture Flatpak screenshots
if: always() && inputs.screenshot_flatpaks != '' && steps.shard.outputs.suite_dir != 'common' && !startsWith(steps.shard.outputs.suite_dir, 'kde')
continue-on-error: true
env:
SCREENSHOT_FLATPAKS: ${{ inputs.screenshot_flatpaks }}
run: |
SSH_COMMON="-i /tmp/vm_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ControlMaster=auto -o ControlPersist=600 -o ControlPath=/tmp/ssh-ctrl-%C -o ConnectTimeout=3"
SSH="ssh ${SSH_COMMON} -p 2222 bluefin-test@127.0.0.1"
SCP="scp -i /tmp/vm_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ControlPath=/tmp/ssh-ctrl-%C -P 2222 -r"
# Build a space-separated list of app IDs from the comma-separated input.
# Read from env var (not inline GHA expression) to avoid shell injection.
APP_IDS=$(python3 -c "
import os
ids = [a.strip() for a in os.environ.get('SCREENSHOT_FLATPAKS', '').split(',') if a.strip()]
print(' '.join(ids))
")
echo "Capturing Flatpak screenshots for: ${APP_IDS}"
# screenshot_cli.py is already in /tmp/bluefin-tests/tests/shared/ (copied
# with the shared/ directory during the test run). Run it in the same
# runner container so it shares the same GNOME session access.
$SSH "
set -e
source /tmp/session.env
podman run --userns=keep-id --rm \
--pid=host \
--privileged \
--network host \
-v /run/user/1001:/run/user/1001 \
-v /tmp/bluefin-tests:/tmp/bluefin-tests:ro \
-v /tmp/results:/tmp/results \
-v /home/bluefin-test/.ssh/id_ed25519:/home/bluefin-test/.ssh/id_ed25519:ro \
-e DBUS_SESSION_BUS_ADDRESS=\"\${DBUS_SESSION_BUS_ADDRESS}\" \
-e WAYLAND_DISPLAY=\"\${WAYLAND_DISPLAY}\" \
-e XDG_RUNTIME_DIR=/run/user/1001 \
-e XDG_SESSION_TYPE=wayland \
-e XDG_SESSION_DESKTOP=gnome \
-e AT_SPI_BUS_ADDRESS=\"\${AT_SPI_BUS_ADDRESS}\" \
-e PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
-e SUITE=${SUITE} \
-e PYTHONPATH=/tmp/bluefin-tests \
\"${RUNNER_IMAGE}\" \
\"python3 /tmp/bluefin-tests/tests/shared/screenshot_cli.py ${APP_IDS}\"
" || echo "WARNING: Flatpak screenshot step exited non-zero — partial results may exist"
mkdir -p results
$SCP "bluefin-test@127.0.0.1:/tmp/results/." ./results/ 2>/dev/null || true
- name: Capture desktop screenshot (QEMU screendump fallback)
if: always() && steps.shard.outputs.suite_dir != 'common'
continue-on-error: true
run: |
# If the in-VM screenshot path (grim/gdbus inside the container) produced
# nothing, capture the QEMU virtual framebuffer directly from the runner.
# mutter renders the GNOME desktop to bochs-drm (card1 / primary VGA),
# which QEMU maintains internally even with -display none. The monitor
# socket was opened at VM boot; screendump writes a PPM to the runner host.
if find results/ -name "screenshot_*fastfetch*.png" 2>/dev/null | grep -q .; then
echo "In-VM screenshot already present -- skipping QEMU screendump fallback."
exit 0
fi
echo "No in-VM screenshot found; capturing via QEMU monitor screendump..."
mkdir -p results
# Pass the PNG output path directly; the script handles PPM capture and
# conversion using Python stdlib (no ImageMagick or ffmpeg required).
OUT="results/screenshot_${SUITE}_fastfetch_endofrun.png"
# QEMU runs as root (sudo); the screendump socket is accessible because
# we chmod 666'd it at boot, but the PPM it writes is also root-owned.
# Run the script as root so it can read/chmod the output file.
sudo python3 tests/shared/qemu_screendump.py "$OUT" \
&& echo "QEMU screendump captured: $OUT" \
|| echo "WARNING: QEMU screendump/conversion failed"
# The PNG was written as root; make it readable by the runner user.
sudo chmod 644 "$OUT" 2>/dev/null || true
- name: Promote desktop screenshot
if: always()
id: desktop-screenshot
continue-on-error: true
run: |
# GUI suites produce a fastfetch screenshot in after_all; the lifecycle
# suite produces a post-upgrade screenshot or a post-migration screenshot.
# Find whichever is present (prefer migration > upgrade > fastfetch).
SHOT=$(find results/ \( -name "screenshot-post-migration.png" -o -name "screenshot_*upgrade*.png" -o -name "screenshot_*fastfetch*.png" \) 2>/dev/null | head -1)
if [[ -n "$SHOT" ]]; then
cp "$SHOT" desktop-screenshot.png
echo "found=true" >> "$GITHUB_OUTPUT"
echo "Desktop screenshot found: $SHOT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
if [[ "${SUITE_DIR}" != "common" && "${SUITE_DIR}" != "lifecycle" ]]; then
echo "::error::No desktop screenshot found for suite=${SUITE} — runner container may not have loaded or behave exited before after_all"
exit 1
fi
echo "No desktop screenshot in results/ — skipping promotion"
fi
- name: Push desktop screenshot to GHCR
if: steps.desktop-screenshot.outputs.found == 'true'
id: upload-screenshot
continue-on-error: true
run: |
# Install oras — push the screenshot as an OCI artifact so any workflow
# in the org can pull it with: oras pull ghcr.io/projectbluefin/testsuite/desktop-screenshot:latest
ORAS_VERSION="1.2.0"
curl -sSfL \
"https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_linux_amd64.tar.gz" \
| tar xz oras
sudo mv oras /usr/local/bin/oras
echo "${{ github.token }}" | oras login ghcr.io -u "${{ github.actor }}" --password-stdin
SHORT_SHA="${GITHUB_SHA::8}"
# Tag with short SHA (immutable) and suite-stable tag.
oras push "${SCREENSHOT_IMAGE}:${SHORT_SHA}" \
--annotation "org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "org.opencontainers.image.revision=${GITHUB_SHA}" \
--annotation "org.opencontainers.image.description=Bluefin desktop screenshot (fastfetch) from e2e suite: ${SUITE}" \
desktop-screenshot.png:image/png
oras push "${SCREENSHOT_IMAGE}:${SCREENSHOT_SUITE}-latest" \
--annotation "org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "org.opencontainers.image.revision=${GITHUB_SHA}" \
--annotation "org.opencontainers.image.description=Bluefin desktop screenshot (fastfetch) from e2e suite: ${SUITE}" \
desktop-screenshot.png:image/png
# Push image-slug-specific tag so publish-to-pages can pull without metadata artifacts.
# Slug: strip ghcr.io/<org>/, replace : with - (e.g. bluefin-testing)
IMAGE_SLUG=$(echo "${IMAGE}" | sed 's|ghcr.io/[^/]*/||' | tr ':' '-')
# Push both the screenshot AND results.json if it exists
ORAS_FILES="desktop-screenshot.png:image/png"
if [[ -f results/results.json ]]; then
ORAS_FILES="${ORAS_FILES} results/results.json:application/json"
fi
oras push "${SCREENSHOT_IMAGE}:${IMAGE_SLUG}-${SCREENSHOT_SUITE}-latest" \
--annotation "org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "org.opencontainers.image.revision=${GITHUB_SHA}" \
--annotation "org.opencontainers.image.description=Bluefin desktop screenshot (fastfetch) from e2e suite: ${SUITE}" \
--annotation "io.github.projectbluefin.run_id=${{ github.run_id }}" \
--annotation "io.github.projectbluefin.caller_repo=${{ github.repository }}" \
${ORAS_FILES}
echo "Pushed ${SCREENSHOT_IMAGE}:${IMAGE_SLUG}-${SCREENSHOT_SUITE}-latest"
echo "ref=${SCREENSHOT_IMAGE}:${SHORT_SHA}" >> "$GITHUB_OUTPUT"
echo "suite_tag=${SCREENSHOT_IMAGE}:${SCREENSHOT_SUITE}-latest" >> "$GITHUB_OUTPUT"
echo "image_slug=${IMAGE_SLUG}" >> "$GITHUB_OUTPUT"
echo "Pushed ${SCREENSHOT_IMAGE}:${SHORT_SHA}, :${SCREENSHOT_SUITE}-latest, :${IMAGE_SLUG}-${SCREENSHOT_SUITE}-latest"
# Push any per-Flatpak screenshots captured by the "Screenshot requested Flatpaks" step.
# Filename pattern: screenshot_<suite>_<app_slug>_flatpak_gallery.png
# GHCR tag: flatpak-<app_slug_with_dashes>-latest
FLATPAK_REFS=""
for f in results/screenshot_*_flatpak_gallery.png; do
[[ -f "$f" ]] || continue
bname=$(basename "$f" .png)
# Strip leading "screenshot_${SUITE}_" and trailing "_flatpak_gallery"
inner="${bname#screenshot_${SUITE}_}"
inner="${inner%_flatpak_gallery}"
tag_slug=$(echo "$inner" | tr '_' '-')
tag="flatpak-${tag_slug}-latest"
oras push "${SCREENSHOT_IMAGE}:${tag}" \
--annotation "org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "org.opencontainers.image.revision=${GITHUB_SHA}" \
--annotation "org.opencontainers.image.description=Flatpak screenshot: ${inner} (suite: ${SUITE})" \
"$f:image/png"
echo "Pushed ${SCREENSHOT_IMAGE}:${tag}"
FLATPAK_REFS="${FLATPAK_REFS}${SCREENSHOT_IMAGE}:${tag} "
done
echo "flatpak_refs=${FLATPAK_REFS}" >> "$GITHUB_OUTPUT"
- name: Write job summary
if: always()
continue-on-error: true
env:
SCREENSHOT_REF: ${{ steps.upload-screenshot.outputs.ref }}
SCREENSHOT_SUITE_TAG: ${{ steps.upload-screenshot.outputs.suite_tag }}
SCREENSHOT_FLATPAK_REFS: ${{ steps.upload-screenshot.outputs.flatpak_refs }}
SCREENSHOT_IMAGE: ${{ env.SCREENSHOT_IMAGE }}
run: |
if [[ -f results/results.json ]]; then
python3 - << 'PY'
import json, os
import subprocess, json as _json
data = json.load(open('results/results.json'))
failed = sum(1 for f in data for s in f.get('elements', []) if s.get('status') == 'failed')
skipped = sum(1 for f in data for s in f.get('elements', []) if s.get('status') == 'skipped')
total = sum(len(f.get('elements', [])) for f in data)
passed = total - failed - skipped
suite = os.environ.get('SUITE', 'smoke')
icon = '✅' if failed == 0 else '❌'
lines = [
f'## {icon} E2E Results — GNOME 50 ({suite})', '',
'| Result | Count |', '|--------|-------|',
f'| ✅ Passed | {passed} |',
f'| ❌ Failed | {failed} |',
f'| ⏭️ Skipped | {skipped} |',
f'| **Total** | **{total}** |',
]
if failed:
lines += ['', '### Failed scenarios', '']
for feat in data:
for s in feat.get('elements', []):
if s.get('status') == 'failed':
lines.append(f"- **{feat['name']}** › {s['name']}")
for step in s.get('steps', []):
if step.get('result', {}).get('status') == 'failed':
err_raw = step['result'].get('error_message', '')
err = ('\n'.join(err_raw) if isinstance(err_raw, list) else err_raw).strip()[:500]
lines.append(f" - `{step['name']}`")
if err:
lines.append(f" <pre>{err}</pre>")
# Desktop screenshot — pushed to GHCR, pull with oras from any org workflow
screenshot_ref = os.environ.get('SCREENSHOT_REF', '').strip()
screenshot_suite_tag = os.environ.get('SCREENSHOT_SUITE_TAG', '').strip()
screenshot_image = os.environ.get('SCREENSHOT_IMAGE', '').strip()
if screenshot_ref:
lines += [
'',
'### 🖥️ Desktop Screenshot',
'',
f'```sh',
f'# Suite-stable tag (recommended):',
f'oras pull {screenshot_suite_tag}',
f'# Immutable (this run):',
f'oras pull {screenshot_ref}',
f'```',
'',
'_Desktop screenshot (fastfetch open) pushed to GHCR as an OCI artifact. '
'Pull from any org workflow for release notes or visual regression._',
]
# gh-pages stable URL
image_env = os.environ.get('IMAGE', '')
if '/' in image_env:
parts = image_env.replace('ghcr.io/', '').split('/')
slug = parts[-1] if len(parts) > 1 else parts[0]
else:
slug = 'bluefin-testing'
slug = slug.replace(':', '-')
suite = os.environ.get('SUITE', 'smoke')
gh_pages_url = f'https://projectbluefin.github.io/testsuite/screenshots/{slug}-{suite}-latest.png'
lines += [
'',
'### Desktop Screenshot (gh-pages)',
f'![{slug} {suite}]({gh_pages_url})',
'_Note: URL updates after the publish-to-pages workflow completes (~1 min after this job)_',
]
try:
result = subprocess.run(['python3', 'scripts/check_quarantine_age.py', '--json'], capture_output=True, text=True)
if result.returncode == 0 and result.stdout.strip():
qdata = _json.loads(result.stdout)
count = len(qdata)
oldest = max((s['days'] for s in qdata), default=0)
lines.append(f'\n> {count} scenarios quarantined (oldest: {oldest} days)')
except Exception:
pass
# Flatpak gallery — one entry per requested app
flatpak_refs_raw = os.environ.get('SCREENSHOT_FLATPAK_REFS', '').strip()
if flatpak_refs_raw:
refs = flatpak_refs_raw.split()
lines += ['', '### 📦 Flatpak Screenshot Gallery', '']
lines += ['| App | Pull command |', '|-----|-------------|']
for ref in refs:
# ref: ghcr.io/.../desktop-screenshot:flatpak-org-gnome-calculator-latest
tag = ref.split(':')[-1] # flatpak-org-gnome-calculator-latest
slug = tag[len('flatpak-'):-len('-latest')] # org-gnome-calculator
app_id = slug.replace('-', '.', slug.count('-') - slug.count('_'))
lines.append(f'| `{slug}` | `oras pull {ref}` |')
lines += [
'',
'_Each Flatpak was launched, held open, and screenshotted at end of the test run._',
]
with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as f:
f.write('\n'.join(lines) + '\n')
PY
else
echo "## ⚠️ No results.json — test run did not complete" >> "$GITHUB_STEP_SUMMARY"
echo "<details><summary>VM serial console output</summary>" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
tail -150 "$(pwd)/vm-serial.log" 2>/dev/null >> "$GITHUB_STEP_SUMMARY" || echo "(no serial log)" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
echo "</details>" >> "$GITHUB_STEP_SUMMARY"
fi
- name: Prepare artifact metadata
if: always()
id: artifact-metadata
run: |
mkdir -p results
python3 - <<'PY'
import json
import os
import re
from pathlib import Path
image = os.environ["IMAGE"]
suite = os.environ["SUITE"]
artifact_suffix = re.sub(r"[^A-Za-z0-9._-]+", "-", image).strip("-").lower()
metadata = {
"image": image,
"suite": suite,
"artifact_suffix": artifact_suffix,
}
Path("results/artifact-metadata.json").write_text(
json.dumps(metadata, sort_keys=True) + "\n",
encoding="utf-8",
)
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output_file:
output_file.write(f"artifact_suffix={artifact_suffix}\n")
PY
- name: Upload results artifact
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: e2e-results-${{ steps.artifact-metadata.outputs.artifact_suffix }}-${{ env.SUITE }}
path: results/
retention-days: 30
if-no-files-found: ignore
- name: Upload serial log artifact
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: vm-serial-log-${{ steps.artifact-metadata.outputs.artifact_suffix }}-${{ env.SUITE }}
path: vm-serial.log
retention-days: 3
if-no-files-found: ignore
- name: Fail job if tests failed
if: steps.run.outputs.behave_rc != '0'
run: exit "${{ steps.run.outputs.behave_rc }}"
# Backstop: an all-skipped KDE run must NOT be reported as green.
# If the suite silently self-disables (e.g. webdriver not reachable),
# CI would stay green with zero real coverage. Assert passed > 0.
- name: Assert KDE suite has passing scenarios
if: startsWith(env.SUITE_DIR, 'kde') && steps.run.outputs.behave_rc == '0'
run: python3 scripts/assert_kde_passed.py results/results.json
- name: Write e2e metadata
if: always()
run: |
mkdir -p meta
printf '{"image":"%s","suite":"%s","conclusion":"%s"}\n' \
"${{ inputs.image }}" "${{ matrix.suite }}" "${{ job.status }}" \
> meta/e2e-metadata.json
- name: Upload e2e metadata
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: e2e-metadata-${{ matrix.suite }}
path: meta/e2e-metadata.json
retention-days: 1