Build firmware #24
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build firmware | |
| on: | |
| pull_request: | |
| branches: [master, main] | |
| paths: | |
| - ".github/workflows/build-firmware.yml" | |
| - ".github/ci/targets.yml" | |
| - "tools/ci/**" | |
| - "packages/**" | |
| workflow_dispatch: | |
| inputs: | |
| targets: | |
| description: "Comma-separated devices from .github/ci/targets.yml or 'all'" | |
| required: false | |
| default: "all" | |
| openwrt_releases: | |
| description: "Comma-separated OpenWrt releases (defaults to targets.yml openwrt_releases)" | |
| required: false | |
| default: "" | |
| physical_releases: | |
| description: "Comma-separated releases to run on physical lab (defaults to targets.yml default_physical_releases)" | |
| required: false | |
| default: "" | |
| physical_single: | |
| description: "Run single-node tests on every physical lab DUT" | |
| required: false | |
| type: boolean | |
| default: false | |
| physical_mesh_count: | |
| description: "Number of physical DUTs for the mesh test (0 = skip, 2 or 3)" | |
| required: false | |
| type: choice | |
| options: | |
| - "0" | |
| - "2" | |
| - "3" | |
| default: "0" | |
| physical_mesh_pairs: | |
| description: "Run 2-node mesh pairs sweep (same as daily cron)" | |
| required: false | |
| type: boolean | |
| default: false | |
| schedule: | |
| # Daily lab sweep at 06:00 UTC (03:00 ART). | |
| - cron: "0 6 * * *" | |
| concurrency: | |
| # PR/push share a per-ref lane; schedule and workflow_dispatch share a single lab lane so lab-bound runs never race on a labgrid place. | |
| group: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && 'physical-lab-shared' || format('build-firmware-{0}', github.ref) }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| jobs: | |
| prepare-matrix: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| targets_matrix: ${{ steps.prepare.outputs.targets_matrix }} | |
| test_targets_matrix: ${{ steps.prepare.outputs.test_targets_matrix }} | |
| mesh_test_matrix: ${{ steps.prepare.outputs.mesh_test_matrix }} | |
| mesh_pairs_matrix: ${{ steps.prepare.outputs.mesh_pairs_matrix }} | |
| qemu_single_matrix: ${{ steps.prepare.outputs.qemu_single_matrix }} | |
| qemu_mesh_matrix: ${{ steps.prepare.outputs.qemu_mesh_matrix }} | |
| archs_matrix: ${{ steps.prepare.outputs.archs_matrix }} | |
| lime_packages_list: ${{ steps.prepare.outputs.lime_packages_list }} | |
| feed_hash: ${{ steps.prepare.outputs.feed_hash }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Install yq | |
| run: sudo apt-get update && sudo apt-get install -y yq jq | |
| - name: Prepare matrices | |
| id: prepare | |
| env: | |
| TARGETS_INPUT: ${{ github.event.inputs.targets || 'all' }} | |
| RELEASES_OVERRIDE: ${{ github.event.inputs.openwrt_releases || '' }} | |
| PHYSICAL_RELEASES_OVERRIDE: ${{ github.event.inputs.physical_releases || '' }} | |
| MESH_COUNT_INPUT: ${{ github.event.inputs.physical_mesh_count || '0' }} | |
| EVENT_NAME: ${{ github.event_name }} | |
| run: tools/ci/prepare_matrix.sh | |
| build-feed: | |
| runs-on: ubuntu-latest | |
| needs: prepare-matrix | |
| timeout-minutes: 180 | |
| strategy: | |
| fail-fast: false | |
| matrix: ${{ fromJson(needs.prepare-matrix.outputs.archs_matrix) }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Free disk | |
| run: sudo rm -rf /opt/hostedtoolcache /usr/share/dotnet /usr/local/lib/android | |
| - name: Restore feed cache | |
| id: cache-feed | |
| uses: actions/cache@v4 | |
| with: | |
| path: feed-artifact/lime_packages | |
| # `extra_hash` is a 12-char sha256 prefix over (extra_feeds, | |
| # extra_packages); restore-keys deliberately omits it so a stale | |
| # extras pin never falls back to a partial match. | |
| key: lime-feed-v3-${{ matrix.arch }}-${{ matrix.openwrt_release }}-${{ needs.prepare-matrix.outputs.feed_hash }}-${{ matrix.extra_hash }} | |
| restore-keys: | | |
| lime-feed-v3-${{ matrix.arch }}-${{ matrix.openwrt_release }}-${{ needs.prepare-matrix.outputs.feed_hash }}- | |
| - name: Build packages with openwrt/gh-action-sdk | |
| if: steps.cache-feed.outputs.cache-hit != 'true' | |
| uses: openwrt/gh-action-sdk@v9 | |
| env: | |
| ARCH: ${{ matrix.sdk_arch }} | |
| FEEDNAME: lime_packages | |
| PACKAGES: ${{ needs.prepare-matrix.outputs.lime_packages_list }} ${{ matrix.extra_packages }} | |
| # Extra src-git feeds, space-separated `<type>|<name>|<url>[^<sha>]` | |
| # entries; gh-action-sdk appends each to feeds.conf. Pinning the | |
| # full 40-char SHA is required (smart-HTTP rejects short prefixes). | |
| EXTRA_FEEDS: ${{ matrix.extra_feeds }} | |
| NO_REFRESH_CHECK: "1" | |
| NO_SHFMT_CHECK: "1" | |
| # SDK-side `make package/index` is unreliable in CI; we index the | |
| # feed downstream with ImageBuilder's ipkg-make-index / apk mkndx. | |
| INDEX: "0" | |
| IGNORE_ERRORS: "n m y" | |
| - name: Diagnose feed build output | |
| if: steps.cache-feed.outputs.cache-hit != 'true' | |
| shell: bash | |
| env: | |
| OPENWRT_RELEASE: ${{ matrix.openwrt_release }} | |
| run: | | |
| set -euo pipefail | |
| shopt -s nullglob | |
| case "${OPENWRT_RELEASE}" in | |
| 24.10.*) PKG_FORMAT=ipk ;; | |
| *) PKG_FORMAT=apk ;; | |
| esac | |
| echo "Package format for ${OPENWRT_RELEASE}: ${PKG_FORMAT}" | |
| echo "=== bin/packages tree ===" | |
| find bin/packages -maxdepth 4 -type d 2>/dev/null | sort || true | |
| echo "=== bin/targets tree (where target-binary extras land) ===" | |
| find bin/targets -maxdepth 4 -type d 2>/dev/null | sort || true | |
| echo "=== bin/packages/${{ matrix.arch }}/lime_packages/ ===" | |
| ls -la "bin/packages/${{ matrix.arch }}/lime_packages/" 2>/dev/null || echo "(missing)" | |
| echo "=== bin/packages/all/lime_packages/ ===" | |
| ls -la "bin/packages/all/lime_packages/" 2>/dev/null || echo "(missing)" | |
| arch_files=( | |
| "bin/packages/${{ matrix.arch }}/lime_packages/"*.ipk | |
| "bin/packages/${{ matrix.arch }}/lime_packages/"*.apk | |
| ) | |
| all_files=( | |
| "bin/packages/all/lime_packages/"*.ipk | |
| "bin/packages/all/lime_packages/"*.apk | |
| ) | |
| echo "Arch packages: ${#arch_files[@]} | All packages: ${#all_files[@]}" | |
| if (( ${#arch_files[@]} == 0 && ${#all_files[@]} == 0 )); then | |
| echo "ERROR: feed produced no .ipk or .apk packages" >&2 | |
| exit 1 | |
| fi | |
| # Best-effort listing of every extra feed output dir; missing | |
| # dirs are tolerated since target-binary packages land under | |
| # bin/targets/ instead. | |
| extra_feeds_str="${{ matrix.extra_feeds }}" | |
| if [[ -n "$extra_feeds_str" ]]; then | |
| echo "=== Extra src-git feeds output ===" | |
| for entry in $extra_feeds_str; do | |
| feed_name="$(echo "$entry" | cut -d'|' -f2)" | |
| extra_dir="bin/packages/${{ matrix.arch }}/${feed_name}" | |
| echo "--- ${extra_dir} ---" | |
| ls -la "$extra_dir" 2>/dev/null || echo "(missing - target-binary extras live under bin/targets/)" | |
| done | |
| echo "=== bin/targets/**/packages/ (target-built packages) ===" | |
| find bin/targets \( -name "*.ipk" -o -name "*.apk" \) -path "*/packages/*" 2>/dev/null \ | |
| | sort || echo "(none)" | |
| fi | |
| - name: Assemble lime_packages feed artifact (index with ImageBuilder) | |
| if: steps.cache-feed.outputs.cache-hit != 'true' | |
| env: | |
| OPENWRT_RELEASE: ${{ matrix.openwrt_release }} | |
| run: | | |
| set -euo pipefail | |
| # 24.10.x emits .ipk indexed by Packages/Packages.gz (opkg); | |
| # 25.12.x emits .apk indexed by packages.adb (apk-tools). | |
| case "${OPENWRT_RELEASE}" in | |
| 24.10.*) PKG_FORMAT=ipk ;; | |
| *) PKG_FORMAT=apk ;; | |
| esac | |
| echo "Assembling feed for ${OPENWRT_RELEASE} (PKG_FORMAT=${PKG_FORMAT})" | |
| rm -rf feed-artifact | |
| mkdir -p feed-artifact/lime_packages | |
| arch_dir="bin/packages/${{ matrix.arch }}/lime_packages" | |
| all_dir="bin/packages/all/lime_packages" | |
| if [[ -d "$arch_dir" ]]; then | |
| cp -a "$arch_dir"/. feed-artifact/lime_packages/ | |
| fi | |
| if [[ -d "$all_dir" ]]; then | |
| cp -a "$all_dir"/. feed-artifact/lime_packages/ | |
| fi | |
| # Merge per-feed packages from the extra src-git feeds. PKGARCH:=all | |
| # entries land under bin/packages/<arch>/<feed>/; target-binary | |
| # entries (e.g. vwifi) land under bin/targets/<t>/<st>/packages/. | |
| extra_feeds_str="${{ matrix.extra_feeds }}" | |
| extra_packages_str="${{ matrix.extra_packages }}" | |
| if [[ -n "$extra_feeds_str" ]]; then | |
| for entry in $extra_feeds_str; do | |
| feed_name="$(echo "$entry" | cut -d'|' -f2)" | |
| for src in \ | |
| "bin/packages/${{ matrix.arch }}/${feed_name}" \ | |
| "bin/packages/all/${feed_name}" | |
| do | |
| if [[ -d "$src" ]]; then | |
| echo "Merging $src into feed-artifact/lime_packages/" | |
| shopt -s nullglob | |
| for f in "$src"/*.ipk "$src"/*.apk; do | |
| cp -a "$f" feed-artifact/lime_packages/ | |
| done | |
| fi | |
| done | |
| done | |
| # Pull each declared extra_packages entry from bin/targets/ | |
| # if not already present (handles target-binary feeds like | |
| # vwifi where the SDK lands the package under bin/targets/). | |
| if [[ -n "$extra_packages_str" && -d bin/targets ]]; then | |
| for pkg in $extra_packages_str; do | |
| shopt -s nullglob | |
| already=( | |
| feed-artifact/lime_packages/${pkg}_*.ipk | |
| feed-artifact/lime_packages/${pkg}-*.ipk | |
| feed-artifact/lime_packages/${pkg}-*.apk | |
| ) | |
| if [[ ${#already[@]} -gt 0 ]]; then | |
| continue | |
| fi | |
| while IFS= read -r -d '' f; do | |
| echo "Merging $f (from bin/targets) into feed-artifact/lime_packages/" | |
| cp -a "$f" feed-artifact/lime_packages/ | |
| done < <(find bin/targets -path '*/packages/*' \ | |
| \( -name "${pkg}_*.ipk" -o -name "${pkg}-*.ipk" -o -name "${pkg}-*.apk" \) \ | |
| -print0 2>/dev/null) | |
| done | |
| fi | |
| fi | |
| # Each declared extra_packages entry must have produced at least | |
| # one .ipk/.apk; gh-action-sdk exits 0 when an arch is unsupported. | |
| if [[ -n "$extra_packages_str" ]]; then | |
| for pkg in $extra_packages_str; do | |
| shopt -s nullglob | |
| matches=( | |
| feed-artifact/lime_packages/${pkg}_*.ipk | |
| feed-artifact/lime_packages/${pkg}-*.ipk | |
| feed-artifact/lime_packages/${pkg}-*.apk | |
| ) | |
| if [[ ${#matches[@]} -eq 0 ]]; then | |
| echo "ERROR: extra_packages entry '${pkg}' produced no .ipk or .apk" >&2 | |
| echo "Searched feed-artifact/lime_packages/${pkg}{_,-}*.{ipk,apk}" >&2 | |
| echo "Also searched bin/targets/**/packages/${pkg}*:" >&2 | |
| find bin/targets -path '*/packages/*' \ | |
| \( -name "${pkg}_*.ipk" -o -name "${pkg}-*.ipk" -o -name "${pkg}-*.apk" \) \ | |
| 2>/dev/null >&2 || true | |
| exit 1 | |
| fi | |
| done | |
| fi | |
| shopt -s nullglob | |
| all_pkgs=(feed-artifact/lime_packages/*.ipk feed-artifact/lime_packages/*.apk) | |
| if [[ ${#all_pkgs[@]} -eq 0 ]]; then | |
| echo "ERROR: No .ipk or .apk files found under feed-artifact/lime_packages" >&2 | |
| echo "Expected sources: $arch_dir and/or $all_dir" >&2 | |
| exit 1 | |
| fi | |
| docker pull "ghcr.io/openwrt/imagebuilder:${{ matrix.index_imagebuilder }}-v${OPENWRT_RELEASE}" | |
| chmod -R a+rwX feed-artifact/lime_packages | |
| if [[ "$PKG_FORMAT" == "ipk" ]]; then | |
| # ipkg-make-index puts the path it receives straight into the | |
| # `Filename:` field after stripping a leading `./`. Pass `.` | |
| # so the index ships bare basenames; `/work` would emit | |
| # `Filename: /work/...` which opkg cannot resolve. | |
| docker run --rm \ | |
| --user root \ | |
| -e MKHASH=/builder/staging_dir/host/bin/mkhash \ | |
| -e PATH=/builder/staging_dir/host/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ | |
| -v "$(pwd)/feed-artifact/lime_packages:/work" \ | |
| "ghcr.io/openwrt/imagebuilder:${{ matrix.index_imagebuilder }}-v${OPENWRT_RELEASE}" \ | |
| sh -lc 'cd /work && /builder/scripts/ipkg-make-index.sh . > Packages && gzip -9nc Packages > Packages.gz' | |
| echo "=== Final feed-artifact/lime_packages/ contents (ipk) ===" | |
| ls -la feed-artifact/lime_packages/ | |
| echo "=== Packages index summary (first 60 lines + count) ===" | |
| head -n 60 feed-artifact/lime_packages/Packages || true | |
| pkg_count=$(grep -c '^Package:' feed-artifact/lime_packages/Packages || echo 0) | |
| echo "Total Package: entries in index: ${pkg_count}" | |
| echo "=== Sanity: Filename fields must be bare basenames ===" | |
| bad_fn=$(grep -E '^Filename: (/|\./)' feed-artifact/lime_packages/Packages || true) | |
| if [[ -n "$bad_fn" ]]; then | |
| echo "ERROR: Packages index has non-basename Filename entries:" >&2 | |
| echo "$bad_fn" >&2 | |
| exit 1 | |
| fi | |
| grep -m 3 '^Filename:' feed-artifact/lime_packages/Packages | |
| else | |
| # apk-tools 3.x quirks: `--allow-untrusted` is a global flag | |
| # (must precede the sub-command); `apk <sub> --help` exits 1 | |
| # even on valid sub-commands, so probe via the global help. | |
| # The IB ships `apk` under staging_dir/host{,/usr}/bin/apk. | |
| docker run --rm \ | |
| --user root \ | |
| -e PATH=/builder/staging_dir/host/usr/bin:/builder/staging_dir/host/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ | |
| -v "$(pwd)/feed-artifact/lime_packages:/work" \ | |
| "ghcr.io/openwrt/imagebuilder:${{ matrix.index_imagebuilder }}-v${OPENWRT_RELEASE}" \ | |
| sh -lc ' | |
| set -e | |
| cd /work | |
| APK="" | |
| for cand in \ | |
| /builder/staging_dir/host/usr/bin/apk \ | |
| /builder/staging_dir/host/bin/apk \ | |
| /builder/staging_dir/hostpkg/usr/bin/apk \ | |
| /usr/bin/apk \ | |
| $(command -v apk 2>/dev/null || true) | |
| do | |
| if [ -n "$cand" ] && [ -x "$cand" ]; then | |
| APK="$cand"; break | |
| fi | |
| done | |
| if [ -z "$APK" ]; then | |
| echo "ERROR: cannot locate apk binary in the IB" >&2 | |
| echo "=== /builder/staging_dir/host listing ===" >&2 | |
| find /builder/staging_dir/host -maxdepth 4 -name apk 2>/dev/null >&2 || true | |
| echo "=== /builder layout (top 2 levels) ===" >&2 | |
| find /builder -maxdepth 2 -type d 2>/dev/null >&2 || true | |
| exit 1 | |
| fi | |
| echo "=== apk binary: $APK ===" | |
| "$APK" --version 2>&1 || true | |
| help_text=$("$APK" --help 2>&1 || true) | |
| if printf "%s\n" "$help_text" | grep -qw mkndx; then | |
| echo "Using: apk --allow-untrusted mkndx" | |
| "$APK" --allow-untrusted mkndx --output packages.adb -- *.apk | |
| elif printf "%s\n" "$help_text" | grep -qw index; then | |
| echo "Using: apk --allow-untrusted index" | |
| "$APK" --allow-untrusted index --output packages.adb *.apk | |
| else | |
| echo "WARNING: neither mkndx nor index advertised in apk --help; attempting mkndx anyway" >&2 | |
| printf "%s\n" "$help_text" | head -80 >&2 || true | |
| "$APK" --allow-untrusted mkndx --output packages.adb -- *.apk | |
| fi | |
| ' | |
| echo "=== Final feed-artifact/lime_packages/ contents (apk) ===" | |
| ls -la feed-artifact/lime_packages/ | |
| if [[ ! -f feed-artifact/lime_packages/packages.adb ]]; then | |
| echo "ERROR: packages.adb was not generated" >&2 | |
| exit 1 | |
| fi | |
| adb_size=$(stat -c%s feed-artifact/lime_packages/packages.adb) | |
| apk_count=$(find feed-artifact/lime_packages -maxdepth 1 -name "*.apk" | wc -l) | |
| echo "packages.adb size: ${adb_size} bytes | .apk count: ${apk_count}" | |
| fi | |
| - name: Diagnose cached feed (cache hit) | |
| if: steps.cache-feed.outputs.cache-hit == 'true' | |
| env: | |
| OPENWRT_RELEASE: ${{ matrix.openwrt_release }} | |
| run: | | |
| set -euo pipefail | |
| case "${OPENWRT_RELEASE}" in | |
| 24.10.*) PKG_FORMAT=ipk ;; | |
| *) PKG_FORMAT=apk ;; | |
| esac | |
| echo "=== Cache hit: feed-artifact/lime_packages/ (${PKG_FORMAT}) ===" | |
| ls -la feed-artifact/lime_packages/ | |
| if [[ "$PKG_FORMAT" == "ipk" ]]; then | |
| pkg_count=$(grep -c '^Package:' feed-artifact/lime_packages/Packages 2>/dev/null || echo 0) | |
| echo "Cached opkg index has ${pkg_count} packages" | |
| else | |
| apk_count=$(find feed-artifact/lime_packages -maxdepth 1 -name "*.apk" | wc -l) | |
| adb_size=$(stat -c%s feed-artifact/lime_packages/packages.adb 2>/dev/null || echo 0) | |
| echo "Cached apk feed: ${apk_count} .apk files, packages.adb=${adb_size} bytes" | |
| fi | |
| - name: Upload local feed artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: lime-feed-${{ matrix.arch }}-${{ matrix.openwrt_release }} | |
| path: feed-artifact/lime_packages/ | |
| retention-days: 7 | |
| - name: Upload feed debug logs | |
| if: failure() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: build-debug-feed-${{ matrix.arch }}-${{ matrix.openwrt_release }} | |
| path: | | |
| bin/packages/${{ matrix.arch }}/ | |
| logs/ | |
| if-no-files-found: ignore | |
| retention-days: 7 | |
| build-image: | |
| runs-on: ubuntu-latest | |
| needs: [prepare-matrix, build-feed] | |
| strategy: | |
| fail-fast: false | |
| matrix: ${{ fromJson(needs.prepare-matrix.outputs.targets_matrix) }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Free disk | |
| run: sudo rm -rf /opt/hostedtoolcache /usr/share/dotnet /usr/local/lib/android | |
| - name: Download matching local feed | |
| uses: actions/download-artifact@v5 | |
| with: | |
| name: lime-feed-${{ matrix.arch }}-${{ matrix.openwrt_release }} | |
| path: ./feed-in/lime_packages/ | |
| - name: Diagnose downloaded feed | |
| run: | | |
| set -euo pipefail | |
| echo "=== feed-in/lime_packages/ contents ===" | |
| ls -la ./feed-in/lime_packages/ | |
| echo "=== Packages index head ===" | |
| head -n 80 ./feed-in/lime_packages/Packages || echo "(no Packages file)" | |
| ipk_count=$(find ./feed-in/lime_packages -maxdepth 1 -name '*.ipk' | wc -l) | |
| pkg_idx_count=$(grep -c '^Package:' ./feed-in/lime_packages/Packages 2>/dev/null || echo 0) | |
| echo "IPK files: ${ipk_count} | Packages index entries: ${pkg_idx_count}" | |
| - name: Pull ImageBuilder image | |
| run: docker pull ghcr.io/openwrt/imagebuilder:${{ matrix.imagebuilder }}-v${{ matrix.openwrt_release }} | |
| - name: Build firmware image | |
| env: | |
| DEVICE_NAME: ${{ matrix.device }} | |
| ARCH: ${{ matrix.arch }} | |
| FEED_BRANCH: ${{ matrix.feed_branch }} | |
| PACKAGES: ${{ matrix.packages }} | |
| BUILD_INITRAMFS: ${{ matrix.build_initramfs }} | |
| IMAGE_FORMAT: ${{ matrix.image_format }} | |
| FIT_ARCH: ${{ matrix.fit_arch }} | |
| FIT_KERNEL_LOADADDR: ${{ matrix.fit_kernel_loadaddr }} | |
| FIT_DTS: ${{ matrix.fit_dts }} | |
| FIT_CONFIG: ${{ matrix.fit_config }} | |
| FIT_BOOTARGS: ${{ matrix.fit_bootargs }} | |
| DTB_PATCH_NVMEM_MAC: ${{ matrix.dtb_patch_nvmem_mac }} | |
| DTB_FORCE_LEGACY_PARTITIONS: ${{ matrix.dtb_force_legacy_partitions }} | |
| run: | | |
| chmod +x tools/ci/build_image.sh | |
| tools/ci/build_image.sh \ | |
| "${{ matrix.imagebuilder }}" \ | |
| "${{ matrix.profile }}" \ | |
| "${{ matrix.openwrt_release }}" \ | |
| "./feed-in" \ | |
| "./out" | |
| - name: Upload firmware artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: firmware-${{ matrix.device }}-${{ matrix.openwrt_release }} | |
| path: ./out/firmware-${{ matrix.device }}.* | |
| retention-days: 7 | |
| - name: Upload image debug files | |
| if: failure() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: build-debug-image-${{ matrix.device }}-${{ matrix.openwrt_release }} | |
| path: | | |
| ./out/ | |
| ./feed-in/ | |
| if-no-files-found: ignore | |
| retention-days: 7 | |
| # Physical single-node test on the self-hosted lab. Runs on schedule | |
| # (full sweep), on workflow_dispatch -f physical_single=true, and on | |
| # pull_request (every place; gated by the physical-lab environment). | |
| # A single approval here covers test-mesh too (test-mesh needs: | |
| # test-firmware, so it waits for this job to finish). | |
| test-firmware: | |
| name: test-firmware (${{ matrix.place }} / ${{ matrix.openwrt_release }}) | |
| needs: [prepare-matrix, build-image] | |
| if: | | |
| ( | |
| github.event_name == 'schedule' | |
| || (github.event_name == 'workflow_dispatch' && github.event.inputs.physical_single == 'true') | |
| || github.event_name == 'pull_request' | |
| ) | |
| && fromJson(needs.prepare-matrix.outputs.test_targets_matrix).include[0] != null | |
| environment: | |
| name: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && 'physical-lab' || '' }} | |
| runs-on: [self-hosted, testbed-fcefyn] | |
| strategy: | |
| fail-fast: false | |
| matrix: ${{ fromJson(needs.prepare-matrix.outputs.test_targets_matrix) }} | |
| env: | |
| PYTHONUNBUFFERED: "1" | |
| PYTEST_ADDOPTS: "--color=yes" | |
| LG_CONSOLE: internal | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: actions/download-artifact@v4 | |
| with: | |
| name: firmware-${{ matrix.device }}-${{ matrix.openwrt_release }} | |
| path: fw | |
| - uses: actions/checkout@v6 | |
| with: | |
| repository: libremesh/libremesh-tests | |
| ref: main | |
| path: libremesh-tests | |
| - uses: actions/checkout@v6 | |
| with: | |
| repository: aparcar/openwrt-tests | |
| ref: main | |
| path: openwrt-tests | |
| - name: Set OPENWRT_TESTS_DIR for pytest | |
| run: echo "OPENWRT_TESTS_DIR=$GITHUB_WORKSPACE/openwrt-tests" >> "$GITHUB_ENV" | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| - name: Sync libremesh-tests environment | |
| run: | | |
| echo "$(date -u +'%Y-%m-%dT%H:%M:%SZ') uv sync (libremesh-tests) starting" | |
| cd libremesh-tests && uv sync | |
| echo "$(date -u +'%Y-%m-%dT%H:%M:%SZ') uv sync finished" | |
| - name: Stage firmware on TFTP | |
| env: | |
| DEVICE: ${{ matrix.device }} | |
| PLACE: ${{ matrix.place }} | |
| OPENWRT_RELEASE: ${{ matrix.openwrt_release }} | |
| RUN_ID: ${{ github.run_id }} | |
| run: tools/ci/lab_stage_firmware.sh | |
| - name: Lock labgrid place | |
| working-directory: libremesh-tests | |
| run: | | |
| set -euo pipefail | |
| echo "LG_PROXY=labgrid-fcefyn" >> "$GITHUB_ENV" | |
| PLACE="labgrid-fcefyn-${{ matrix.place }}" | |
| LOCK_OUT=$(uv run labgrid-client -v -p "$PLACE" lock 2>&1) || { | |
| echo "$LOCK_OUT" | |
| echo "::error::labgrid lock failed for $PLACE" | |
| exit 1 | |
| } | |
| echo "$LOCK_OUT" | |
| echo "LG_PLACE=$PLACE" >> "$GITHUB_ENV" | |
| echo "LG_ENV=targets/${{ matrix.device }}.yaml" >> "$GITHUB_ENV" | |
| echo "Locked $PLACE for device=${{ matrix.device }}" | |
| - name: Run single-node LibreMesh tests | |
| working-directory: libremesh-tests | |
| env: | |
| LG_PROXY: labgrid-fcefyn | |
| LG_IMAGE: ${{ env.LG_IMAGE }} | |
| LG_IMAGE_INITRD: ${{ env.LG_IMAGE_INITRD }} | |
| LG_PLACE: ${{ env.LG_PLACE }} | |
| LG_ENV: ${{ env.LG_ENV }} | |
| LG_MESH_UBOOT_INTERRUPT_SPAM_SEC: ${{ matrix.uboot_interrupt_spam_sec }} | |
| run: | | |
| set -euo pipefail | |
| LOG_DIR="$GITHUB_WORKSPACE/logs/${{ matrix.place }}-${{ matrix.openwrt_release }}" | |
| mkdir -p "$LOG_DIR" | |
| uv run pytest tests/test_libremesh.py \ | |
| --lg-log "$LOG_DIR/" \ | |
| --junitxml="$LOG_DIR/report.xml" \ | |
| --lg-colored-steps \ | |
| --log-cli-level=CONSOLE | |
| - name: Collect lime-report from DUT | |
| id: lime-report | |
| if: always() && env.LG_PLACE != '' | |
| working-directory: libremesh-tests | |
| env: | |
| LG_PLACE: ${{ env.LG_PLACE }} | |
| run: | | |
| set +e | |
| REPORT=$(uv run labgrid-client -p "$LG_PLACE" ssh -- lime-report -m 2>/dev/null | head -c 50000) | |
| if [ -z "$REPORT" ]; then | |
| REPORT="_Could not collect lime-report (device unreachable or command not found)._" | |
| fi | |
| echo "$REPORT" > "$GITHUB_WORKSPACE/lime-report-${{ matrix.place }}.md" | |
| echo "collected=true" >> "$GITHUB_OUTPUT" | |
| - name: Poweroff and unlock device | |
| if: always() | |
| working-directory: libremesh-tests | |
| env: | |
| LG_PLACE: ${{ env.LG_PLACE }} | |
| run: | | |
| set +e | |
| if [ -n "${LG_PLACE:-}" ]; then | |
| uv run labgrid-client -p "$LG_PLACE" power off || true | |
| uv run labgrid-client -p "$LG_PLACE" unlock || true | |
| else | |
| echo "No labgrid lock acquired by this job; skipping power off / unlock." | |
| fi | |
| rm -rf "/srv/tftp/firmwares/ci/${{ github.run_id }}/${{ matrix.place }}/${{ matrix.openwrt_release }}" 2>/dev/null || true | |
| - name: Upload test logs | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: test-results-${{ matrix.place }}-${{ matrix.openwrt_release }} | |
| path: ${{ github.workspace }}/logs/${{ matrix.place }}-${{ matrix.openwrt_release }}/* | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| - name: Open or close issue on test result | |
| if: always() && github.event_name == 'schedule' | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const fs = require('fs'); | |
| const device = "${{ matrix.device }}"; | |
| const place = "${{ matrix.place }}"; | |
| const release = "${{ matrix.openwrt_release }}"; | |
| const issueTitle = `CI healthcheck: ${place} (${release})`; | |
| const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; | |
| const isSuccess = "${{ job.status }}" === "success"; | |
| let limeReport = "_Not collected._"; | |
| const reportPath = `${process.env.GITHUB_WORKSPACE}/lime-report-${place}.md`; | |
| try { limeReport = fs.readFileSync(reportPath, 'utf8').trim(); } catch {} | |
| const failureBody = [ | |
| `### CI healthcheck failed`, | |
| ``, | |
| `| Field | Value |`, | |
| `|-------|-------|`, | |
| `| **Place** | \`${place}\` |`, | |
| `| **Device** | \`${device}\` |`, | |
| `| **OpenWrt release** | \`${release}\` |`, | |
| `| **Run** | [#${context.runId}](${runUrl}) |`, | |
| `| **Date** | ${new Date().toISOString().slice(0,10)} |`, | |
| ``, | |
| `### lime-report`, | |
| ``, | |
| `<details><summary>Click to expand</summary>`, | |
| ``, | |
| '```', | |
| limeReport, | |
| '```', | |
| `</details>`, | |
| ].join('\n'); | |
| const issues = await github.rest.issues.listForRepo({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| state: 'all', | |
| labels: 'healthcheck', | |
| per_page: 100, | |
| }); | |
| let testIssue = issues.data.find(i => i.title === issueTitle); | |
| if (isSuccess) { | |
| if (testIssue && testIssue.state === 'open') { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: testIssue.number, | |
| body: `Healthcheck passed on [run #${context.runId}](${runUrl}). Closing.`, | |
| }); | |
| await github.rest.issues.update({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: testIssue.number, | |
| state: 'closed', | |
| }); | |
| console.log(`Closed issue #${testIssue.number} (test passed).`); | |
| } | |
| } else { | |
| if (testIssue) { | |
| if (testIssue.state === 'closed') { | |
| await github.rest.issues.update({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: testIssue.number, | |
| state: 'open', | |
| body: failureBody, | |
| }); | |
| console.log(`Reopened issue #${testIssue.number}.`); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: testIssue.number, | |
| body: failureBody, | |
| }); | |
| console.log(`Commented on open issue #${testIssue.number}.`); | |
| } | |
| } else { | |
| const created = await github.rest.issues.create({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| title: issueTitle, | |
| body: failureBody, | |
| labels: ['healthcheck'], | |
| }); | |
| console.log(`Created issue #${created.data.number}.`); | |
| } | |
| } | |
| # Physical mesh test (N=2 or N=3 places). Runs on workflow_dispatch | |
| # (-f physical_mesh_count=2|3) and on pull_request (forced N=3). | |
| # Daily mesh coverage comes from `test-mesh-pairs`. | |
| test-mesh: | |
| name: test-mesh (N=${{ github.event.inputs.physical_mesh_count || '3 (PR)' }} / ${{ matrix.openwrt_release }}) | |
| # test-firmware is in `needs` only to serialise lab access (both reserve | |
| # the same labgrid places). When test-firmware is skipped via dispatch | |
| # inputs we still want this job to run, so `if:` uses `!cancelled()` and | |
| # checks needs.test-firmware.result != 'failure' instead of relying on | |
| # GitHub's default skip-cascade behaviour. | |
| needs: [prepare-matrix, build-image, test-firmware] | |
| if: | | |
| !cancelled() | |
| && needs.prepare-matrix.result == 'success' | |
| && needs.build-image.result == 'success' | |
| && needs.test-firmware.result != 'failure' | |
| && ( | |
| (github.event_name == 'workflow_dispatch' && github.event.inputs.physical_mesh_count != '0') | |
| || github.event_name == 'pull_request' | |
| ) | |
| && fromJson(needs.prepare-matrix.outputs.mesh_test_matrix).include[0] != null | |
| runs-on: [self-hosted, testbed-fcefyn] | |
| strategy: | |
| fail-fast: false | |
| matrix: ${{ fromJson(needs.prepare-matrix.outputs.mesh_test_matrix) }} | |
| env: | |
| PYTHONUNBUFFERED: "1" | |
| PYTEST_ADDOPTS: "--color=yes" | |
| LG_CONSOLE: internal | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: actions/checkout@v6 | |
| with: | |
| repository: libremesh/libremesh-tests | |
| ref: main | |
| path: libremesh-tests | |
| - uses: actions/checkout@v6 | |
| with: | |
| repository: aparcar/openwrt-tests | |
| ref: main | |
| path: openwrt-tests | |
| - name: Set OPENWRT_TESTS_DIR for pytest | |
| run: echo "OPENWRT_TESTS_DIR=$GITHUB_WORKSPACE/openwrt-tests" >> "$GITHUB_ENV" | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| - name: Sync libremesh-tests environment | |
| run: cd libremesh-tests && uv sync | |
| # Static download steps gated by `contains(matrix.devices, ...)`. | |
| # N=2 fires only openwrt_one + bananapi_bpi-r4; N=3 also pulls | |
| # linksys_e8450 (consumed by belkin_rt3200_2). | |
| - name: Download firmware (openwrt_one) | |
| if: contains(matrix.devices, 'openwrt_one') | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: firmware-openwrt_one-${{ matrix.openwrt_release }} | |
| path: fw-mesh/openwrt_one | |
| - name: Download firmware (bananapi_bpi-r4) | |
| if: contains(matrix.devices, 'bananapi_bpi-r4') | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: firmware-bananapi_bpi-r4-${{ matrix.openwrt_release }} | |
| path: fw-mesh/bananapi_bpi-r4 | |
| - name: Download firmware (linksys_e8450) | |
| if: contains(matrix.devices, 'linksys_e8450') | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: firmware-linksys_e8450-${{ matrix.openwrt_release }} | |
| path: fw-mesh/linksys_e8450 | |
| - name: Stage firmware and run mesh tests | |
| env: | |
| MODE: full | |
| OPENWRT_RELEASE: ${{ matrix.openwrt_release }} | |
| RUN_ID: ${{ github.run_id }} | |
| MESH_PLACES_JSON: ${{ toJson(matrix.places) }} | |
| MESH_DEVICES_JSON: ${{ toJson(matrix.devices) }} | |
| SRC_DIR: fw-mesh | |
| LOGS_DIR: mesh-logs | |
| run: tools/ci/lab_stage_mesh.sh | |
| - name: Cleanup TFTP on failure | |
| if: failure() | |
| run: rm -rf "/srv/tftp/firmwares/ci/${{ github.run_id }}/mesh/${{ matrix.openwrt_release }}" || true | |
| - name: Upload mesh test logs | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: test-results-mesh-${{ matrix.openwrt_release }} | |
| path: mesh-logs/* | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| # Walking-chain mesh smoke. Three sequential 2-node pairs share devices | |
| # on purpose; max-parallel:1 serialises lab access. Runs on daily cron | |
| # and on workflow_dispatch with physical_mesh_pairs=true. | |
| # Place layout is computed in `prepare_matrix.sh::mesh_pairs_matrix`. | |
| test-mesh-pairs: | |
| name: test-mesh-pairs (#${{ matrix.pair }} ${{ matrix.place_a }}+${{ matrix.place_b }} / ${{ matrix.openwrt_release }}) | |
| # test-firmware is in `needs` only to serialise lab access (both reserve | |
| # the same labgrid places). When test-firmware is skipped via dispatch | |
| # inputs we still want this job to run, so `if:` uses `!cancelled()` and | |
| # checks needs.test-firmware.result != 'failure' instead of relying on | |
| # GitHub's default skip-cascade behaviour. | |
| needs: [prepare-matrix, build-image, test-firmware] | |
| if: | | |
| !cancelled() | |
| && needs.prepare-matrix.result == 'success' | |
| && needs.build-image.result == 'success' | |
| && needs.test-firmware.result != 'failure' | |
| && ( | |
| github.event_name == 'schedule' | |
| || (github.event_name == 'workflow_dispatch' && github.event.inputs.physical_mesh_pairs == 'true') | |
| ) | |
| && fromJson(needs.prepare-matrix.outputs.mesh_pairs_matrix).include[0] != null | |
| runs-on: [self-hosted, testbed-fcefyn] | |
| strategy: | |
| fail-fast: false | |
| max-parallel: 1 | |
| matrix: ${{ fromJson(needs.prepare-matrix.outputs.mesh_pairs_matrix) }} | |
| env: | |
| PYTHONUNBUFFERED: "1" | |
| PYTEST_ADDOPTS: "--color=yes" | |
| LG_CONSOLE: internal | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: actions/checkout@v6 | |
| with: | |
| repository: libremesh/libremesh-tests | |
| ref: main | |
| path: libremesh-tests | |
| - uses: actions/checkout@v6 | |
| with: | |
| repository: aparcar/openwrt-tests | |
| ref: main | |
| path: openwrt-tests | |
| - name: Set OPENWRT_TESTS_DIR for pytest | |
| run: echo "OPENWRT_TESTS_DIR=$GITHUB_WORKSPACE/openwrt-tests" >> "$GITHUB_ENV" | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| - name: Sync libremesh-tests environment | |
| run: cd libremesh-tests && uv sync | |
| - name: Download firmware (${{ matrix.device_a }}) | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: firmware-${{ matrix.device_a }}-${{ matrix.openwrt_release }} | |
| path: fw-pair/${{ matrix.device_a }} | |
| # Skip the second download when both endpoints share the same | |
| # device artifact (download-artifact errors on a duplicate path). | |
| - name: Download firmware (${{ matrix.device_b }}) | |
| if: matrix.device_a != matrix.device_b | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: firmware-${{ matrix.device_b }}-${{ matrix.openwrt_release }} | |
| path: fw-pair/${{ matrix.device_b }} | |
| - name: Stage firmware and run mesh tests | |
| env: | |
| MODE: pair | |
| OPENWRT_RELEASE: ${{ matrix.openwrt_release }} | |
| RUN_ID: ${{ github.run_id }} | |
| PAIR: ${{ matrix.pair }} | |
| PLACE_A: ${{ matrix.place_a }} | |
| DEVICE_A: ${{ matrix.device_a }} | |
| PLACE_B: ${{ matrix.place_b }} | |
| DEVICE_B: ${{ matrix.device_b }} | |
| SRC_DIR: fw-pair | |
| LOGS_DIR: mesh-pairs-logs | |
| run: tools/ci/lab_stage_mesh.sh | |
| - name: Cleanup TFTP on failure | |
| if: failure() | |
| run: rm -rf "/srv/tftp/firmwares/ci/${{ github.run_id }}/mesh-pairs/${{ matrix.pair }}/${{ matrix.openwrt_release }}" || true | |
| - name: Upload mesh-pairs test logs | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: test-results-mesh-pairs-${{ matrix.pair }}-${{ matrix.openwrt_release }} | |
| path: mesh-pairs-logs/* | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| # QEMU single-node smoke test on a GitHub-hosted runner. One job per | |
| # release, for every target with `test_qemu: true`. Runs in TCG mode | |
| # (KVM only available on the multi-node mesh job below). | |
| test-firmware-qemu-single: | |
| name: test-firmware-qemu-single (${{ matrix.device }} / ${{ matrix.openwrt_release }}) | |
| needs: [prepare-matrix, build-image] | |
| if: | | |
| fromJson(needs.prepare-matrix.outputs.qemu_single_matrix).include[0] != null | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: ${{ fromJson(needs.prepare-matrix.outputs.qemu_single_matrix) }} | |
| env: | |
| PYTHONUNBUFFERED: "1" | |
| PYTEST_ADDOPTS: "--color=yes" | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: actions/checkout@v6 | |
| with: | |
| repository: libremesh/libremesh-tests | |
| ref: main | |
| path: libremesh-tests | |
| - uses: actions/checkout@v6 | |
| with: | |
| repository: aparcar/openwrt-tests | |
| ref: main | |
| path: openwrt-tests | |
| - name: Set OPENWRT_TESTS_DIR for pytest | |
| run: echo "OPENWRT_TESTS_DIR=$GITHUB_WORKSPACE/openwrt-tests" >> "$GITHUB_ENV" | |
| - name: Download firmware artifact | |
| uses: actions/download-artifact@v5 | |
| with: | |
| name: firmware-${{ matrix.device }}-${{ matrix.openwrt_release }} | |
| path: fw | |
| - name: Install QEMU | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y --no-install-recommends qemu-system-x86 ovmf | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| - name: Sync libremesh-tests environment | |
| run: | | |
| cd libremesh-tests | |
| uv sync | |
| - name: Locate firmware image | |
| id: firmware | |
| run: | | |
| set -euo pipefail | |
| shopt -s nullglob | |
| candidates=(fw/firmware-${{ matrix.device }}.img) | |
| if [[ ${#candidates[@]} -eq 0 ]]; then | |
| echo "::error::No firmware-${{ matrix.device }}.img under fw/" >&2 | |
| ls -la fw/ || true | |
| exit 1 | |
| fi | |
| echo "path=${candidates[0]}" >> "$GITHUB_OUTPUT" | |
| file "${candidates[0]}" || true | |
| ls -la "${candidates[0]}" | |
| sha256sum "${candidates[0]}" | |
| - name: Run single-node QEMU LibreMesh tests | |
| working-directory: libremesh-tests | |
| env: | |
| # The qemu_x86-64_libremesh.yaml env in libremesh-tests | |
| # configures a QEMUDriver + QEMUNetworkStrategyLibreMesh | |
| # that boots the supplied disk image, waits for dropbear | |
| # (~30s after lime-config completes), and forwards SSH | |
| # to the guest's anygw 10.13.0.1:22. This is the | |
| # canonical LibreMesh-on-x86_64 test path. | |
| LG_ENV: targets/qemu_x86-64_libremesh.yaml | |
| run: | | |
| set -euo pipefail | |
| LOG_DIR="$GITHUB_WORKSPACE/qemu-single-logs/${{ matrix.device }}-${{ matrix.openwrt_release }}" | |
| mkdir -p "$LOG_DIR" | |
| # Single-node validation: lime-* runtime, base shell, LAN. | |
| # Multi-node coverage lives in `test-mesh-qemu` below. | |
| uv run pytest \ | |
| tests/test_libremesh.py \ | |
| tests/test_base.py \ | |
| tests/test_lan.py \ | |
| --lg-env "${LG_ENV}" \ | |
| --firmware "$GITHUB_WORKSPACE/${{ steps.firmware.outputs.path }}" \ | |
| --lg-log "$LOG_DIR/" \ | |
| --junitxml="$LOG_DIR/report.xml" \ | |
| --log-cli-level=INFO \ | |
| -v | |
| - name: Upload QEMU single-node test logs | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: test-results-qemu-single-${{ matrix.device }}-${{ matrix.openwrt_release }} | |
| path: ${{ github.workspace }}/qemu-single-logs/${{ matrix.device }}-${{ matrix.openwrt_release }}/* | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| # QEMU multi-node mesh test. Spawns N=3 QEMU instances of the | |
| # x86-combined image tied through vwifi-server (mac80211_hwsim ↔ | |
| # 802.11s). Requires KVM acceleration (TCG can't fit three VMs in | |
| # the runner budget) and a vwifi-server binary on PATH. | |
| test-mesh-qemu: | |
| name: test-mesh-qemu (${{ matrix.device }} / ${{ matrix.openwrt_release }}) | |
| needs: [prepare-matrix, build-image, test-firmware-qemu-single] | |
| if: | | |
| fromJson(needs.prepare-matrix.outputs.qemu_mesh_matrix).include[0] != null | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: ${{ fromJson(needs.prepare-matrix.outputs.qemu_mesh_matrix) }} | |
| env: | |
| PYTHONUNBUFFERED: "1" | |
| PYTEST_ADDOPTS: "--color=yes" | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: actions/checkout@v6 | |
| with: | |
| repository: libremesh/libremesh-tests | |
| ref: main | |
| path: libremesh-tests | |
| - uses: actions/checkout@v6 | |
| with: | |
| repository: aparcar/openwrt-tests | |
| ref: main | |
| path: openwrt-tests | |
| - name: Set OPENWRT_TESTS_DIR for pytest | |
| run: echo "OPENWRT_TESTS_DIR=$GITHUB_WORKSPACE/openwrt-tests" >> "$GITHUB_ENV" | |
| - name: Install QEMU and build deps | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y --no-install-recommends \ | |
| qemu-system-x86 ovmf cmake build-essential libnl-3-dev libnl-genl-3-dev pkg-config | |
| - name: Enable KVM access for runner user | |
| run: tools/ci/enable_kvm.sh | |
| # vwifi-server pinned at Raizo62/vwifi@4a9842e6 (release v7.0). | |
| # The in-guest vwifi-client comes from packages/vwifi/. | |
| - name: Cache vwifi-server build | |
| id: cache-vwifi-server | |
| uses: actions/cache@v4 | |
| with: | |
| path: ~/.vwifi-server-bin | |
| key: vwifi-server-4a9842e6-${{ runner.os }} | |
| - name: Build vwifi-server from source | |
| if: steps.cache-vwifi-server.outputs.cache-hit != 'true' | |
| run: | | |
| set -euo pipefail | |
| mkdir -p ~/.vwifi-server-bin | |
| tmp=$(mktemp -d) | |
| git clone https://github.com/Raizo62/vwifi.git "$tmp/vwifi" | |
| git -C "$tmp/vwifi" checkout 4a9842e6 | |
| cmake -S "$tmp/vwifi" -B "$tmp/build" | |
| cmake --build "$tmp/build" -j"$(nproc)" | |
| for bin in vwifi-server vwifi-client vwifi-ctrl; do | |
| cp "$tmp/build/$bin" ~/.vwifi-server-bin/ | |
| done | |
| rm -rf "$tmp" | |
| - name: Install vwifi-server | |
| run: | | |
| sudo install -m 0755 ~/.vwifi-server-bin/vwifi-server /usr/local/bin/ | |
| vwifi-server --help || vwifi-server -h || true | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| - name: Sync libremesh-tests environment | |
| run: | | |
| cd libremesh-tests | |
| uv sync | |
| - name: Download firmware artifact | |
| uses: actions/download-artifact@v5 | |
| with: | |
| name: firmware-${{ matrix.device }}-${{ matrix.openwrt_release }} | |
| path: fw | |
| - name: Run multi-node QEMU mesh tests | |
| working-directory: libremesh-tests | |
| env: | |
| # `LG_VIRTUAL_MESH=1` switches the `mesh_nodes` fixture from the | |
| # labgrid path to virtual_mesh_launcher.launch_virtual_mesh(). | |
| LG_VIRTUAL_MESH: "1" | |
| VIRTUAL_MESH_NODES: "3" | |
| VIRTUAL_MESH_MAX_NODES: "3" | |
| VIRTUAL_MESH_IMAGE: ${{ github.workspace }}/fw/firmware-${{ matrix.device }}.img | |
| run: | | |
| set -euo pipefail | |
| LOG_DIR="$GITHUB_WORKSPACE/qemu-mesh-logs/${{ matrix.device }}-${{ matrix.openwrt_release }}" | |
| mkdir -p "$LOG_DIR" | |
| vwifi-server -u >"$LOG_DIR/vwifi-server.log" 2>&1 & | |
| echo $! > "$LOG_DIR/vwifi-server.pid" | |
| trap 'kill "$(cat "$LOG_DIR/vwifi-server.pid")" 2>/dev/null || true' EXIT | |
| sleep 1 | |
| uv run pytest tests/test_mesh.py \ | |
| --lg-log "$LOG_DIR/" \ | |
| --junitxml="$LOG_DIR/report.xml" \ | |
| --log-cli-level=INFO \ | |
| -v | |
| - name: Upload QEMU mesh test logs | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: test-results-qemu-mesh-${{ matrix.device }}-${{ matrix.openwrt_release }} | |
| path: ${{ github.workspace }}/qemu-mesh-logs/${{ matrix.device }}-${{ matrix.openwrt_release }}/* | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| summary: | |
| runs-on: ubuntu-latest | |
| if: always() | |
| needs: | |
| - prepare-matrix | |
| - build-feed | |
| - build-image | |
| - test-firmware | |
| - test-mesh | |
| - test-mesh-pairs | |
| - test-firmware-qemu-single | |
| - test-mesh-qemu | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Build summary | |
| env: | |
| TRIGGER: ${{ github.event_name }} | |
| TARGETS_MATRIX: ${{ needs.prepare-matrix.outputs.targets_matrix }} | |
| ARCHS_MATRIX: ${{ needs.prepare-matrix.outputs.archs_matrix }} | |
| TEST_TARGETS_MATRIX: ${{ needs.prepare-matrix.outputs.test_targets_matrix }} | |
| MESH_TEST_MATRIX: ${{ needs.prepare-matrix.outputs.mesh_test_matrix }} | |
| MESH_PAIRS_MATRIX: ${{ needs.prepare-matrix.outputs.mesh_pairs_matrix }} | |
| QEMU_SINGLE_MATRIX: ${{ needs.prepare-matrix.outputs.qemu_single_matrix }} | |
| QEMU_MESH_MATRIX: ${{ needs.prepare-matrix.outputs.qemu_mesh_matrix }} | |
| BUILD_FEED_RESULT: ${{ needs.build-feed.result }} | |
| BUILD_IMAGE_RESULT: ${{ needs.build-image.result }} | |
| TEST_FIRMWARE_RESULT: ${{ needs.test-firmware.result }} | |
| TEST_MESH_RESULT: ${{ needs.test-mesh.result }} | |
| TEST_MESH_PAIRS_RESULT: ${{ needs.test-mesh-pairs.result }} | |
| TEST_FIRMWARE_QEMU_SINGLE_RESULT: ${{ needs.test-firmware-qemu-single.result }} | |
| TEST_MESH_QEMU_RESULT: ${{ needs.test-mesh-qemu.result }} | |
| run: tools/ci/build_summary.sh |