chore(deps): update registry.fedoraproject.org/fedora-minimal:latest … #33
Workflow file for this run
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
| # 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 | ||
| - 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'', | ||
| '_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 | ||