From e66bd5cdbf56320ce9960270329790ee2c37d9d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 18 Mar 2026 18:07:35 +0900 Subject: [PATCH 1/2] ci: add Linux installation QA workflow for .deb and .rpm packages Adds a separate reusable workflow (.github/workflows/linux-install-test.yml) that installs the devolutions-gateway packages produced by ci.yml inside Docker containers and verifies the filesystem layout, binary executability, and service registration artifacts. - DEB lane: ubuntu:18.04 container - RPM lane: rockylinux:9 container (RHEL 9-compatible) - Triggers: workflow_call, workflow_dispatch, weekly schedule (Monday 06:00 UTC) - Consumes the `devolutions-gateway` artifact from ci.yml; resolves run ID automatically for scheduled/manual triggers via the GitHub API - Integrated into create-new-release.yml to run in parallel with packaging --- .github/scripts/smoke-test-deb.sh | 177 +++++++++++++++ .github/scripts/smoke-test-lib.sh | 158 ++++++++++++++ .github/scripts/smoke-test-rpm.sh | 203 ++++++++++++++++++ .github/workflows/create-new-release.yml | 9 +- .github/workflows/linux-install-test.yml | 260 +++++++++++++++++++++++ 5 files changed, 806 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/smoke-test-deb.sh create mode 100644 .github/scripts/smoke-test-lib.sh create mode 100644 .github/scripts/smoke-test-rpm.sh create mode 100644 .github/workflows/linux-install-test.yml diff --git a/.github/scripts/smoke-test-deb.sh b/.github/scripts/smoke-test-deb.sh new file mode 100644 index 000000000..227241050 --- /dev/null +++ b/.github/scripts/smoke-test-deb.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────────────────────────── +# DEB Package Installation Test for Devolutions Gateway +# +# Runs inside an Ubuntu container to validate: +# - Package installs correctly via apt-get +# - Expected files and directories are present +# - Binary is functional (--help, --config-init-only) +# - systemd unit file is installed (part of the .deb package) +# - Default configuration file is generated +# +# Environment variables (required): +# PACKAGE_FILE Absolute path to the .deb file inside the container. +# VERSION Expected package version (e.g. 2026.1.0). +# PACKAGE_NAME Package name (e.g. devolutions-gateway). +# +# LIMITATION — systemd in containers: +# Docker containers do not normally run systemd, so the postinst script +# skips config initialization and service enablement (both gated on +# /run/systemd/system). This script compensates by running +# --config-init-only manually. Full service start/stop validation is +# best-effort and only attempted when systemd is detected. +# ────────────────────────────────────────────────────────────────────────────── + +set -euo pipefail + +# ── Validate environment ────────────────────────────────────────────────────── + +: "${PACKAGE_FILE:?PACKAGE_FILE must be set}" +: "${VERSION:?VERSION must be set}" +: "${PACKAGE_NAME:?PACKAGE_NAME must be set}" + +# ── Constants ───────────────────────────────────────────────────────────────── + +BINARY=/usr/bin/devolutions-gateway +LIB_DIR=/usr/lib/devolutions-gateway +LIB_PATH=$LIB_DIR/libxmf.so +WEBAPP_DIR=/usr/share/devolutions-gateway/webapp +CONFIG_DIR=/etc/devolutions-gateway +CONFIG_FILE=$CONFIG_DIR/gateway.json + +# The .deb package installs the unit file via dh_installsystemd. +UNIT_FILE_PATHS=( + /lib/systemd/system/devolutions-gateway.service + /usr/lib/systemd/system/devolutions-gateway.service +) + +# The library is in a non-standard path; cover the LD_LIBRARY_PATH lookup +# case in addition to RPATH or env-var-based resolution the binary may use. +export LD_LIBRARY_PATH="$LIB_DIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + +# ── Source shared library ───────────────────────────────────────────────────── + +# shellcheck source=smoke-test-lib.sh +source "$(dirname "${BASH_SOURCE[0]}")/smoke-test-lib.sh" + +# ── Diagnostics (deb-specific) ──────────────────────────────────────────────── + +diagnostics() { + echo "" + echo "── Diagnostics ──────────────────────────────────────────────" + echo "" + echo "Package metadata:" + dpkg -s "$PACKAGE_NAME" 2>/dev/null || echo " (not installed)" + echo "" + echo "Package file list:" + dpkg -L "$PACKAGE_NAME" 2>/dev/null || echo " (not installed)" + echo "" + echo "Config directory:" + ls -la "$CONFIG_DIR/" 2>/dev/null || echo " (not found)" + echo "" + echo "Binary info:" + ls -la "$BINARY" 2>/dev/null || echo " (not found)" + file "$BINARY" 2>/dev/null || true + echo "" + echo "Dynamic library dependencies (ldd):" + ldd "$BINARY" 2>/dev/null || echo " (ldd failed or binary not found)" + echo "" + echo "Webapp directory:" + ls -laR "$WEBAPP_DIR/" 2>/dev/null | head -40 || echo " (not found)" + echo "" + echo "Library directory:" + ls -la "$LIB_DIR/" 2>/dev/null || echo " (not found)" + echo "" + echo "systemd unit files:" + UNIT_FILES=$(find /lib/systemd /usr/lib/systemd /etc/systemd -name '*devolutions*' 2>/dev/null) + if [ -n "$UNIT_FILES" ]; then echo "$UNIT_FILES"; else echo " (none found)"; fi + echo "────────────────────────────────────────────────────────────" +} + +# ── Main ══════════════════════════════════════════════════════════════════════ + +echo "════════════════════════════════════════════════════════════════" +echo " DEB Package Installation Test" +echo " Package: $(basename "$PACKAGE_FILE")" +echo " Version: $VERSION" +echo " Container: $(grep PRETTY_NAME /etc/os-release 2>/dev/null | cut -d= -f2 | tr -d '"' || echo unknown)" +echo "════════════════════════════════════════════════════════════════" +echo "" + +# ── Install ─────────────────────────────────────────────────────────────────── + +info "Updating apt and installing prerequisites…" +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +PREREQ_LOG=$(mktemp) +if apt-get install -y -qq file python3 > "$PREREQ_LOG" 2>&1; then + rm -f "$PREREQ_LOG" +else + echo "Prerequisites installation output:" + cat "$PREREQ_LOG" + rm -f "$PREREQ_LOG" + fail "Prerequisites installation failed (file, python3)" + diagnostics + summary +fi + +info "Installing package: $(basename "$PACKAGE_FILE")" +# apt-get resolves dependencies automatically and supports local .deb paths. +# The package declares Depends: libc6 (>= 2.27); Ubuntu 18.04 provides 2.27. +INSTALL_LOG=$(mktemp) +if apt-get install -y "$PACKAGE_FILE" > "$INSTALL_LOG" 2>&1; then + pass "Package installation succeeded" +else + echo "Installation output:" + cat "$INSTALL_LOG" + fail "Package installation failed" + diagnostics + summary +fi +rm -f "$INSTALL_LOG" + +# ── Package metadata ────────────────────────────────────────────────────────── + +info "Checking package metadata…" +INSTALLED_VERSION=$(dpkg -s "$PACKAGE_NAME" 2>/dev/null | grep '^Version:' | awk '{print $2}') +if echo "$INSTALLED_VERSION" | grep -q "$VERSION"; then + pass "Installed version ($INSTALLED_VERSION) contains expected version ($VERSION)" +else + fail "Version mismatch: installed=$INSTALLED_VERSION expected contains=$VERSION" +fi + +# ── File existence ──────────────────────────────────────────────────────────── + +info "Checking expected files and directories…" +check_binary_executable +check_native_library +check_webapp +check_config_dir + +# ── Binary functionality ────────────────────────────────────────────────────── + +info "Checking binary functionality…" +check_binary_help + +# ── Config initialization ───────────────────────────────────────────────────── +# The postinst runs --config-init-only only when systemd is present. +# In a container without systemd we run it manually. + +info "Checking config initialization…" +check_config_init + +# ── systemd unit file ───────────────────────────────────────────────────────── +# The .deb package installs the unit file via dh_installsystemd, +# so it must be present regardless of whether systemd is running. + +info "Checking systemd unit file…" +check_unit_file "fail" + +# ── Service startup (best-effort) ───────────────────────────────────────────── + +check_service_startup + +# ── Final output ────────────────────────────────────────────────────────────── + +diagnostics +summary diff --git a/.github/scripts/smoke-test-lib.sh b/.github/scripts/smoke-test-lib.sh new file mode 100644 index 000000000..d0f19f4a4 --- /dev/null +++ b/.github/scripts/smoke-test-lib.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────────────────────────── +# Shared library for Devolutions Gateway Linux packaging smoke tests. +# Sourced by smoke-test-deb.sh and smoke-test-rpm.sh. +# +# Expects the following constants to be defined in the sourcing script +# before any check function is called: +# BINARY Path to the gateway binary. +# LIB_DIR Directory containing native libraries. +# LIB_PATH Path to libxmf.so. +# WEBAPP_DIR Path to the webapp root directory. +# CONFIG_DIR Path to the config directory. +# CONFIG_FILE Path to gateway.json. +# UNIT_FILE_PATHS Array of candidate systemd unit file paths (in priority order). +# ────────────────────────────────────────────────────────────────────────────── + +# ── Test bookkeeping ────────────────────────────────────────────────────────── + +TESTS_PASSED=0 +TESTS_FAILED=0 + +pass() { echo "✅ PASS: $1"; TESTS_PASSED=$((TESTS_PASSED + 1)); } +fail() { echo "❌ FAIL: $1" >&2; TESTS_FAILED=$((TESTS_FAILED + 1)); } +info() { echo "ℹ️ $1"; } +warn() { echo "⚠️ WARN: $1"; } + +# ── Summary ─────────────────────────────────────────────────────────────────── + +summary() { + echo "" + echo "════════════════════════════════════════════════════════════════" + echo " Test Summary: $TESTS_PASSED passed, $TESTS_FAILED failed" + echo "════════════════════════════════════════════════════════════════" + if [ "$TESTS_FAILED" -gt 0 ]; then + exit 1 + fi +} + +# ── Check functions ─────────────────────────────────────────────────────────── + +check_binary_executable() { + if [ -x "$BINARY" ]; then + pass "Main binary exists and is executable: $BINARY" + else + fail "Main binary missing or not executable: $BINARY" + fi +} + +check_native_library() { + if [ -f "$LIB_PATH" ] && file "$LIB_PATH" 2>/dev/null | grep -q 'ELF'; then + pass "Native library exists and is a valid ELF: $LIB_PATH" + else + fail "Native library missing or not a valid ELF: $LIB_PATH" + fi +} + +check_webapp() { + if [ -d "$WEBAPP_DIR" ]; then + pass "Webapp directory exists: $WEBAPP_DIR" + else + fail "Webapp directory missing: $WEBAPP_DIR" + fi + for app in client player; do + if find "$WEBAPP_DIR/$app" -name 'index.html' 2>/dev/null | grep -q .; then + pass "Webapp $app contains index.html" + else + fail "Webapp $app missing index.html" + fi + done +} + +check_config_dir() { + if [ -d "$CONFIG_DIR" ]; then + pass "Config directory exists: $CONFIG_DIR" + else + fail "Config directory missing: $CONFIG_DIR" + fi +} + +check_binary_help() { + HELP_OUTPUT=$("$BINARY" --help 2>&1) && HELP_RC=$? || HELP_RC=$? + if [ "$HELP_RC" -eq 0 ] || echo "$HELP_OUTPUT" | grep -qi 'gateway\|usage\|help'; then + pass "Binary responds to --help" + else + fail "Binary does not respond to --help (exit code: $HELP_RC)" + fi +} + +check_config_init() { + if [ ! -f "$CONFIG_FILE" ]; then + info "Config file not generated by postinst (expected without systemd)." + info "Running config initialization manually…" + CONFIG_INIT_LOG=$(mktemp) + if "$BINARY" --config-init-only > "$CONFIG_INIT_LOG" 2>&1; then + pass "Config initialization command succeeded" + else + echo "config-init-only output:" + cat "$CONFIG_INIT_LOG" + fail "Config initialization command failed" + fi + rm -f "$CONFIG_INIT_LOG" + fi + + if [ -f "$CONFIG_FILE" ]; then + pass "Default config file exists: $CONFIG_FILE" + if python3 -c "import json; json.load(open('$CONFIG_FILE'))" 2>/dev/null; then + pass "$(basename "$CONFIG_FILE") is valid JSON" + else + fail "$(basename "$CONFIG_FILE") exists but is not valid JSON" + fi + else + fail "Default config file missing after initialization: $CONFIG_FILE" + fi +} + +# Usage: check_unit_file +# Searches UNIT_FILE_PATHS in order; on absence, either fails or warns. +check_unit_file() { + local on_absent="$1" + local unit_file="" + for path in "${UNIT_FILE_PATHS[@]}"; do + if [ -f "$path" ]; then + unit_file="$path" + break + fi + done + + if [ -n "$unit_file" ]; then + pass "systemd unit file exists: $unit_file" + if grep -q "$BINARY" "$unit_file"; then + pass "Unit file references correct binary path" + else + fail "Unit file does not reference $BINARY" + fi + elif [ "$on_absent" = "fail" ]; then + fail "systemd unit file not found" + else + warn "systemd unit file not found after registration attempt." + info "This is expected in container environments without systemd." + fi +} + +check_service_startup() { + info "[Best-effort] Checking service startup…" + warn "systemd service startup testing is best-effort in containers." + warn "Full service validation requires a real systemd environment." + if [ -d /run/systemd/system ]; then + info "systemd detected; attempting service start…" + if systemctl start devolutions-gateway 2>&1; then + pass "[Best-effort] Service started successfully" + systemctl status devolutions-gateway 2>&1 || true + else + warn "Service start failed (expected in some container environments)." + fi + else + info "No systemd detected; skipping service startup test." + fi +} diff --git a/.github/scripts/smoke-test-rpm.sh b/.github/scripts/smoke-test-rpm.sh new file mode 100644 index 000000000..c053f5332 --- /dev/null +++ b/.github/scripts/smoke-test-rpm.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────────────────────────── +# RPM Package Installation Test for Devolutions Gateway +# +# Runs inside a Rocky Linux 9 (RHEL 9-compatible) container to validate: +# - Package installs correctly via dnf +# - Expected files and directories are present +# - Binary is functional (--help, --config-init-only) +# - Service registration creates the expected systemd unit file +# - Default configuration file is generated +# +# Environment variables (required): +# PACKAGE_FILE Absolute path to the .rpm file inside the container. +# VERSION Expected package version (e.g. 2026.1.0). +# PACKAGE_NAME Package name (e.g. devolutions-gateway). +# +# LIMITATION — systemd in containers: +# Docker containers do not normally run systemd. The RPM postinst +# script (after-install) gates ALL service-related actions on the +# presence of /run/systemd/system. This means: +# - Config initialization is skipped. +# - Service registration is skipped (no unit file is created). +# - Service enable/start is skipped. +# This script compensates by running --config-init-only and +# service register manually. +# +# DIFFERENCE FROM DEB: +# The .deb package includes the systemd unit file directly (installed +# by dpkg via dh_installsystemd). The .rpm package does NOT bundle the +# unit file; instead, the postinst calls `devolutions-gateway service +# register` to create it at install time. This means that in a container +# without systemd, the unit file will only exist if we manually run +# `service register`. +# ────────────────────────────────────────────────────────────────────────────── + +set -euo pipefail + +# ── Validate environment ────────────────────────────────────────────────────── + +: "${PACKAGE_FILE:?PACKAGE_FILE must be set}" +: "${VERSION:?VERSION must be set}" +: "${PACKAGE_NAME:?PACKAGE_NAME must be set}" + +# ── Constants ───────────────────────────────────────────────────────────────── + +BINARY=/usr/bin/devolutions-gateway +LIB_DIR=/usr/lib/devolutions-gateway +LIB_PATH=$LIB_DIR/libxmf.so +WEBAPP_DIR=/usr/share/devolutions-gateway/webapp +CONFIG_DIR=/etc/devolutions-gateway +CONFIG_FILE=$CONFIG_DIR/gateway.json + +# The .rpm package does NOT bundle the unit file; it is created by +# `devolutions-gateway service register` at install time (or manually below). +UNIT_FILE_PATHS=( + /etc/systemd/system/devolutions-gateway.service + /usr/lib/systemd/system/devolutions-gateway.service + /lib/systemd/system/devolutions-gateway.service +) + +# The library is in a non-standard path; cover the LD_LIBRARY_PATH lookup +# case in addition to RPATH or env-var-based resolution the binary may use. +export LD_LIBRARY_PATH="$LIB_DIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + +# ── Source shared library ───────────────────────────────────────────────────── + +# shellcheck source=smoke-test-lib.sh +source "$(dirname "${BASH_SOURCE[0]}")/smoke-test-lib.sh" + +# ── Diagnostics (rpm-specific) ──────────────────────────────────────────────── + +diagnostics() { + echo "" + echo "── Diagnostics ──────────────────────────────────────────────" + echo "" + echo "Package metadata:" + rpm -qi "$PACKAGE_NAME" 2>/dev/null || echo " (not installed)" + echo "" + echo "Package file list:" + rpm -ql "$PACKAGE_NAME" 2>/dev/null || echo " (not installed)" + echo "" + echo "Config directory:" + ls -la "$CONFIG_DIR/" 2>/dev/null || echo " (not found)" + echo "" + echo "Binary info:" + ls -la "$BINARY" 2>/dev/null || echo " (not found)" + file "$BINARY" 2>/dev/null || true + echo "" + echo "Dynamic library dependencies (ldd):" + ldd "$BINARY" 2>/dev/null || echo " (ldd failed or binary not found)" + echo "" + echo "Webapp directory:" + ls -laR "$WEBAPP_DIR/" 2>/dev/null | head -40 || echo " (not found)" + echo "" + echo "Library directory:" + ls -la "$LIB_DIR/" 2>/dev/null || echo " (not found)" + echo "" + echo "systemd unit files:" + UNIT_FILES=$(find /lib/systemd /usr/lib/systemd /etc/systemd -name '*devolutions*' 2>/dev/null) + if [ -n "$UNIT_FILES" ]; then echo "$UNIT_FILES"; else echo " (none found)"; fi + echo "────────────────────────────────────────────────────────────" +} + +# ── Main ══════════════════════════════════════════════════════════════════════ + +echo "════════════════════════════════════════════════════════════════" +echo " RPM Package Installation Test" +echo " Package: $(basename "$PACKAGE_FILE")" +echo " Version: $VERSION" +echo " Container: $(grep PRETTY_NAME /etc/os-release 2>/dev/null | cut -d= -f2 | tr -d '"' || echo unknown)" +echo "════════════════════════════════════════════════════════════════" +echo "" + +# ── Install ─────────────────────────────────────────────────────────────────── + +info "Installing prerequisites…" +PREREQ_LOG=$(mktemp) +if dnf install -y -q file python3 > "$PREREQ_LOG" 2>&1; then + rm -f "$PREREQ_LOG" +else + echo "Prerequisites installation output:" + cat "$PREREQ_LOG" + rm -f "$PREREQ_LOG" + fail "Prerequisites installation failed (file, python3)" + diagnostics + summary +fi + +info "Installing package: $(basename "$PACKAGE_FILE")" +# Use dnf to resolve and satisfy dependencies automatically. +# The package declares a glibc dependency; Rocky Linux 9 provides glibc 2.34+. +INSTALL_LOG=$(mktemp) +if dnf install -y "$PACKAGE_FILE" > "$INSTALL_LOG" 2>&1; then + pass "Package installation succeeded" +else + echo "Installation output:" + cat "$INSTALL_LOG" + fail "Package installation failed" + diagnostics + summary +fi +rm -f "$INSTALL_LOG" + +# ── Package metadata ────────────────────────────────────────────────────────── + +info "Checking package metadata…" +INSTALLED_VERSION=$(rpm -qi "$PACKAGE_NAME" 2>/dev/null | grep '^Version' | awk -F: '{print $2}' | tr -d ' ') +if echo "$INSTALLED_VERSION" | grep -q "$VERSION"; then + pass "Installed version ($INSTALLED_VERSION) contains expected version ($VERSION)" +else + fail "Version mismatch: installed=$INSTALLED_VERSION expected contains=$VERSION" +fi + +# ── File existence ──────────────────────────────────────────────────────────── + +info "Checking expected files and directories…" +check_binary_executable +check_native_library +check_webapp +check_config_dir + +# ── Binary functionality ────────────────────────────────────────────────────── + +info "Checking binary functionality…" +check_binary_help + +# ── Config initialization ───────────────────────────────────────────────────── +# RPM postinst runs --config-init-only only when systemd is present. +# In a container without systemd we run it manually. + +info "Checking config initialization…" +check_config_init + +# ── Service registration ────────────────────────────────────────────────────── +# The RPM does NOT bundle the systemd unit file. The postinst calls +# `devolutions-gateway service register` to create it. In containers +# without systemd, the postinst skips this, so we try it manually. + +info "Checking service registration…" +info "Running service registration manually…" +SERVICE_REG_OUTPUT=$("$BINARY" service register 2>&1) && SERVICE_REG_RC=$? || SERVICE_REG_RC=$? +if [ "$SERVICE_REG_RC" -eq 0 ]; then + pass "Service registration command succeeded" +else + warn "Service registration returned exit code $SERVICE_REG_RC (may require systemd)." + info "Output: $SERVICE_REG_OUTPUT" +fi + +# ── systemd unit file ───────────────────────────────────────────────────────── +# Unit file is only present if service register succeeded above; +# absence is a warning rather than a hard failure. + +info "Checking systemd unit file…" +check_unit_file "warn" + +# ── Service startup (best-effort) ───────────────────────────────────────────── + +check_service_startup + +# ── Final output ────────────────────────────────────────────────────────────── + +diagnostics +summary diff --git a/.github/workflows/create-new-release.yml b/.github/workflows/create-new-release.yml index e6c3a39b7..26ce3feea 100644 --- a/.github/workflows/create-new-release.yml +++ b/.github/workflows/create-new-release.yml @@ -21,6 +21,13 @@ jobs: ref: ${{ inputs.ref }} sccache: false + # Run Linux install QA against the packages produced by ci.yml before + # proceeding with signing and release. Runs in parallel with packaging. + call-linux-install-test: + needs: [call-ci-workflow] + uses: ./.github/workflows/linux-install-test.yml + secrets: inherit + call-package-workflow: needs: [call-ci-workflow] uses: ./.github/workflows/package.yml @@ -30,7 +37,7 @@ jobs: call-release-workflow: if: ${{ github.ref == 'refs/heads/master' || inputs.dry-run == true }} - needs: [call-package-workflow] + needs: [call-package-workflow, call-linux-install-test] uses: ./.github/workflows/release.yml secrets: inherit with: diff --git a/.github/workflows/linux-install-test.yml b/.github/workflows/linux-install-test.yml new file mode 100644 index 000000000..41e6778d7 --- /dev/null +++ b/.github/workflows/linux-install-test.yml @@ -0,0 +1,260 @@ +name: Linux Install Tests + +# ────────────────────────────────────────────────────────────────────────────── +# PURPOSE: +# Smoke-test Linux .deb and .rpm packages produced by the CI workflow. +# Catches packaging regressions before release: missing files, broken +# installs, service registration issues, configuration initialization +# failures, and obvious startup problems. +# +# DESIGN: +# - Separate reusable workflow (workflow_call + workflow_dispatch + schedule). +# - Consumes the merged `devolutions-gateway` artifact from ci.yml. +# - Runs package installation tests inside Docker containers on +# GitHub-hosted runners (x86_64 only). +# - DEB lane: Ubuntu 18.04 container (minimum supported distro; the binary +# is built against an Ubuntu 18.04 sysroot / glibc 2.27). +# - RPM lane: Rocky Linux 9 container (RHEL 9-compatible). +# - Tests are split into: +# 1. Mandatory package smoke tests (must pass). +# 2. Best-effort systemd service tests (informational only). +# +# TRADEOFFS: +# - Separate workflow: Keeps the main CI fast; install tests can run +# independently or be chained into a release pipeline. +# - GitHub-hosted runners: Sufficient for package QA; no self-hosted +# infrastructure required. +# - Container-based: Close enough to real distros for catching packaging +# regressions, though not a perfect replica of production systems. +# - RHEL 9-compatible (Rocky Linux 9): Free, widely-used RHEL 9 rebuild; +# no exact RHEL version matching is required. +# - x86_64 only: GitHub-hosted runners are x86_64; ARM64 packages would +# require QEMU or ARM runners, not worth the complexity for v1. +# - Service testing is best-effort: systemd inside Docker containers is +# unreliable. Full service validation requires a real systemd environment. +# ────────────────────────────────────────────────────────────────────────────── + +on: + # Callable from release pipelines (e.g. create-new-release.yml). + # The caller may supply an explicit CI run ID; if omitted the shared run + # context is used (all workflow_call children share the parent run ID). + workflow_call: + inputs: + run: + description: > + CI workflow run ID whose package artifacts to test. + Leave blank to use the current (shared) run context. + type: string + required: false + + # Allow ad-hoc manual triggers so engineers can test packages from any + # previous CI run without triggering a full rebuild. + workflow_dispatch: + inputs: + run: + description: > + CI workflow run ID whose package artifacts to test. + Leave empty to use the latest successful CI run on the default branch. + type: string + required: false + + # Weekly scheduled run to catch regressions even when no release is in progress. + schedule: + - cron: "0 6 * * 1" # Monday 06:00 UTC + +permissions: + contents: read + actions: read + +jobs: + # ─── Resolve which CI run to pull artifacts from, and read its version ────── + # + # Priority: + # 1. Explicit input (workflow_dispatch or workflow_call with run supplied). + # 2. Current run ID when called as workflow_call without explicit input + # (all reusable workflows in a chain share the same top-level run ID, + # so ci.yml artifacts are already in scope). + # 3. Latest successful ci.yml run on the default branch (schedule or + # workflow_dispatch without input). + preflight: + name: Preflight + runs-on: ubuntu-latest + outputs: + run-id: ${{ steps.resolve.outputs.run-id }} + version: ${{ steps.version.outputs.version }} + + steps: + - name: Resolve CI run ID + id: resolve + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $explicitRun = "${{ inputs.run }}" + + if ($explicitRun) { + $runId = $explicitRun + Write-Host "Using provided run ID: $runId" + } elseif ("${{ github.event_name }}" -eq 'workflow_call') { + # All reusable workflows in a chain share the parent run ID. + $runId = "${{ github.run_id }}" + Write-Host "workflow_call: using shared run ID: $runId" + } else { + # schedule or workflow_dispatch without explicit input. + Write-Host "Looking up latest successful ci.yml run on ${{ github.event.repository.default_branch }}..." + $runId = gh run list ` + --workflow ci.yml ` + --status success ` + --branch "${{ github.event.repository.default_branch }}" ` + --limit 1 ` + --json databaseId ` + --jq '.[0].databaseId' ` + --repo $env:GITHUB_REPOSITORY + + if (-not $runId -or $runId -eq 'null') { + Write-Host "::error::No successful CI run found on ${{ github.event.repository.default_branch }}" + exit 1 + } + Write-Host "Found latest successful CI run: $runId" + } + + "run-id=$runId" >> $env:GITHUB_OUTPUT + + - name: Download version artifact + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh run download "${{ steps.resolve.outputs.run-id }}" ` + -n version ` + --repo $env:GITHUB_REPOSITORY + + - name: Read version + id: version + shell: pwsh + run: | + $version = Get-Content VERSION -First 1 + "version=$version" >> $env:GITHUB_OUTPUT + Write-Host "::notice::Testing packages for version $version from run ${{ steps.resolve.outputs.run-id }}" + + # ─── DEB and RPM install tests run in parallel via matrix ─────────────────── + install-test: + name: ${{ matrix.display-name }} + needs: [preflight] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + # DEB lane: Ubuntu 18.04 (minimum supported; built against Ubuntu 18.04 sysroot / glibc 2.27) + - package-type: deb + display-name: "DEB Install Test (Ubuntu 18.04)" + container-image: "ubuntu:18.04" + test-script: ".github/scripts/smoke-test-deb.sh" + package-pattern: "*.deb" + # RPM lane: Rocky Linux 9 (RHEL 9-compatible) + - package-type: rpm + display-name: "RPM Install Test (Rocky Linux 9)" + container-image: "rockylinux:9" + test-script: ".github/scripts/smoke-test-rpm.sh" + package-pattern: "*.rpm" + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download devolutions-gateway artifact + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh run download "${{ needs.preflight.outputs.run-id }}" ` + -n devolutions-gateway ` + -D gateway-artifacts ` + --repo $env:GITHUB_REPOSITORY + + - name: Locate package file + id: find-package + shell: pwsh + run: | + $packageFile = Get-ChildItem -Path "gateway-artifacts/linux/x86_64" ` + -Filter "${{ matrix.package-pattern }}" -File | Select-Object -First 1 + + if (-not $packageFile) { + Write-Host "::error::No ${{ matrix.package-type }} package found in gateway-artifacts/linux/x86_64" + Write-Host "Available files in gateway-artifacts:" + Get-ChildItem -Path "gateway-artifacts" -Recurse -File | Sort-Object FullName | ForEach-Object { Write-Host $_.FullName } + exit 1 + } + + $packageFilePath = "gateway-artifacts/linux/x86_64/$($packageFile.Name)" + Write-Host "Found package: $packageFilePath" + "package-file=$packageFilePath" >> $env:GITHUB_OUTPUT + "package-basename=$($packageFile.Name)" >> $env:GITHUB_OUTPUT + + - name: Run install tests in container + id: run-tests + shell: pwsh + run: | + chmod +x "${{ matrix.test-script }}" + + # Strip the gateway-artifacts/ prefix so the path is relative to + # the /artifacts mount point inside the container. + $packageRelPath = "${{ steps.find-package.outputs.package-file }}" + $packageInContainer = "/artifacts/" + ($packageRelPath -replace '^gateway-artifacts/', '') + + docker run --rm ` + -v "${{ github.workspace }}:/workspace:ro" ` + -v "${{ github.workspace }}/gateway-artifacts:/artifacts:ro" ` + -e "PACKAGE_FILE=$packageInContainer" ` + -e "VERSION=${{ needs.preflight.outputs.version }}" ` + -e "PACKAGE_NAME=devolutions-gateway" ` + "${{ matrix.container-image }}" ` + /workspace/${{ matrix.test-script }} + + - name: Write job summary + if: always() + shell: pwsh + run: | + $outcome = "${{ steps.run-tests.outcome }}" + $status = if ($outcome -eq 'success') { "✅ Passed" } else { "❌ Failed" } + + @" + ## ${{ matrix.display-name }} — $status + + | Property | Value | + |----------|-------| + | Result | $status | + | Package | ``${{ steps.find-package.outputs.package-basename }}`` | + | Version | ``${{ needs.preflight.outputs.version }}`` | + | Container | ``${{ matrix.container-image }}`` | + | CI Run | ``${{ needs.preflight.outputs.run-id }}`` | + "@ | Add-Content -Path $env:GITHUB_STEP_SUMMARY + + # ─── Slack notification on scheduled failure ───────────────────────────────── + notify: + name: Notify failure + runs-on: ubuntu-latest + if: ${{ always() && contains(needs.*.result, 'failure') && github.event_name == 'schedule' }} + needs: + - preflight + - install-test + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_ARCHITECTURE }} + SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK + steps: + - name: Send Slack notification + uses: slackapi/slack-github-action@v1.26.0 + with: + payload: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*${{ github.repository }}* :fire::fire::fire::fire::fire: \n The scheduled Linux install test for *${{ github.repository }}* is <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|broken>" + } + } + ] + } From 6a3fb13bdab7d327cf38c2ff091a6408cee287ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 18 Mar 2026 23:37:06 +0900 Subject: [PATCH 2/2] . --- .github/scripts/smoke-test-deb.sh | 2 +- .github/scripts/smoke-test-rpm.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/smoke-test-deb.sh b/.github/scripts/smoke-test-deb.sh index 227241050..24a4e1a08 100644 --- a/.github/scripts/smoke-test-deb.sh +++ b/.github/scripts/smoke-test-deb.sh @@ -83,7 +83,7 @@ diagnostics() { ls -la "$LIB_DIR/" 2>/dev/null || echo " (not found)" echo "" echo "systemd unit files:" - UNIT_FILES=$(find /lib/systemd /usr/lib/systemd /etc/systemd -name '*devolutions*' 2>/dev/null) + UNIT_FILES=$(find /lib/systemd /usr/lib/systemd /etc/systemd -name '*devolutions*' 2>/dev/null || true) if [ -n "$UNIT_FILES" ]; then echo "$UNIT_FILES"; else echo " (none found)"; fi echo "────────────────────────────────────────────────────────────" } diff --git a/.github/scripts/smoke-test-rpm.sh b/.github/scripts/smoke-test-rpm.sh index c053f5332..c5b89155c 100644 --- a/.github/scripts/smoke-test-rpm.sh +++ b/.github/scripts/smoke-test-rpm.sh @@ -96,7 +96,7 @@ diagnostics() { ls -la "$LIB_DIR/" 2>/dev/null || echo " (not found)" echo "" echo "systemd unit files:" - UNIT_FILES=$(find /lib/systemd /usr/lib/systemd /etc/systemd -name '*devolutions*' 2>/dev/null) + UNIT_FILES=$(find /lib/systemd /usr/lib/systemd /etc/systemd -name '*devolutions*' 2>/dev/null || true) if [ -n "$UNIT_FILES" ]; then echo "$UNIT_FILES"; else echo " (none found)"; fi echo "────────────────────────────────────────────────────────────" }