Build OS Image (Simple) #75
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 OS Image (Simple) | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| board: | |
| description: "Board to build for" | |
| required: true | |
| type: choice | |
| options: | |
| - rpi4b | |
| - rock-5b | |
| - rock-5a | |
| - orangepi5 | |
| - orangepi5-plus | |
| - uefi-x86 | |
| - rock-5c | |
| - rock-5b-plus | |
| - nanopct6 | |
| - nanopi-r6s | |
| - odroidn2 | |
| - odroidc4 | |
| - orangepizero3 | |
| - orangepi3-lts | |
| - lepotato | |
| - khadas-vim3 | |
| - rock-5t | |
| - orangepi5-max | |
| - orangepi3b | |
| - radxa-zero3 | |
| - rock-3a | |
| - odroidm1 | |
| - odroidc2 | |
| - orangepizero2w | |
| - nanopi-r4s | |
| - orangepi4-lts | |
| release: | |
| description: "Release" | |
| required: true | |
| type: choice | |
| options: | |
| - trixie | |
| - bookworm | |
| - noble | |
| branch: | |
| description: "Kernel branch (rk3588 boards need 'vendor')" | |
| required: true | |
| default: "current" | |
| type: choice | |
| options: | |
| - current | |
| - vendor | |
| - legacy | |
| - edge | |
| bundle_containers: | |
| description: "Bake the release's manager+gateway containers into the image (offline first boot)" | |
| required: true | |
| default: true | |
| type: boolean | |
| channel: | |
| description: "Release channel manifest to bundle containers + os_version from" | |
| required: true | |
| default: "stable" | |
| type: choice | |
| options: | |
| - stable | |
| - dev | |
| - beta | |
| # The Armbian build framework tag is pinned in armbian/build.sh (single source | |
| # of truth); this workflow invokes build.sh, so no override is needed here. | |
| jobs: | |
| build: | |
| name: Build ${{ github.event.inputs.board }} (${{ github.event.inputs.release }}) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 240 | |
| steps: | |
| - name: Free disk space | |
| run: | | |
| sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache | |
| sudo apt-get clean | |
| df -h / | |
| - name: Install dependencies | |
| run: | | |
| sudo apt-get update | |
| # qemu/binfmt for the Armbian build; gdisk(sgdisk)+fdisk(sfdisk)+dosfstools | |
| # for the AWCFG partition; squashfs-tools + grub-*-bin for the Live USB image. | |
| sudo apt-get install -y qemu-user-static binfmt-support gdisk fdisk dosfstools \ | |
| squashfs-tools grub-efi-amd64-bin grub-pc-bin | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| # Persist Armbian's compiled kernel/u-boot packages, the debootstrap base | |
| # rootfs, and ccache across runs. The big win is output/debs: Armbian | |
| # content-hashes each kernel artifact from its config + patches, so on an | |
| # unchanged kernel it finds the cached .deb and SKIPS the ~1h45m compile. | |
| # This is a performance hint only — if the kernel actually changed, the | |
| # hash differs and Armbian rebuilds, so a stale cache can never ship the | |
| # wrong kernel. restore-keys lets script-only changes (which bump the key | |
| # via userpatches/** but leave the kernel untouched) still reuse the debs. | |
| - name: Cache Armbian kernel + rootfs | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| .armbian-build/output/debs | |
| .armbian-build/cache/rootfs | |
| .armbian-build/cache/ccache | |
| key: armbian-${{ github.event.inputs.board }}-${{ github.event.inputs.release }}-${{ github.event.inputs.branch }}-${{ hashFiles('armbian/build.sh', 'armbian/userpatches/**') }} | |
| restore-keys: | | |
| armbian-${{ github.event.inputs.board }}-${{ github.event.inputs.release }}-${{ github.event.inputs.branch }}- | |
| armbian-${{ github.event.inputs.board }}-${{ github.event.inputs.release }}- | |
| # Bake the release's manager+gateway container images INTO the rootfs so the | |
| # device's FIRST boot needs no network. Without this, first boot can't pull | |
| # the containers when there's no connectivity, so the manager/web UI never | |
| # comes up and even Wi-Fi setup is impossible (chicken-and-egg). We pull the | |
| # exact images pinned in releases/stable.json at the board's CPU arch, tag | |
| # them with the version + channel alias + :latest, and docker-save each into | |
| # the userpatches images/ dir, which the airwaves-base extension copies into | |
| # the image and airwaves-init loads on first boot. Runs AFTER the cache step | |
| # so the tarballs never perturb the kernel/rootfs cache key. | |
| - name: Bundle release containers (offline first boot) | |
| if: ${{ inputs.bundle_containers }} | |
| env: | |
| BOARD: ${{ github.event.inputs.board }} | |
| CHANNEL_INPUT: ${{ github.event.inputs.channel }} | |
| GHCR_USER: ${{ github.actor }} | |
| GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| REL="releases/${CHANNEL_INPUT:-stable}.json" | |
| [ -f "$REL" ] || { echo "::error::$REL not found"; exit 1; } | |
| CHANNEL="$(jq -r '.channel // "stable"' "$REL")" | |
| OSVER="$(jq -r '.os_version // empty' "$REL")" | |
| MGR_IMG="$(jq -r '.components.manager.image' "$REL")" | |
| MGR_TAG="$(jq -r '.components.manager.tag' "$REL")" | |
| GW_IMG="$(jq -r '.components.gateway.image' "$REL")" | |
| GW_TAG="$(jq -r '.components.gateway.tag' "$REL")" | |
| for v in "$CHANNEL" "$MGR_IMG" "$MGR_TAG" "$GW_IMG" "$GW_TAG"; do | |
| [ -n "$v" ] && [ "$v" != "null" ] || { echo "::error::missing field in $REL"; exit 1; } | |
| done | |
| case "$BOARD" in | |
| uefi-x86) PLAT="linux/amd64" ;; | |
| *) PLAT="linux/arm64" ;; | |
| esac | |
| echo "channel=$CHANNEL os_version=${OSVER:-?} platform=$PLAT" | |
| echo "manager=${MGR_IMG}:${MGR_TAG} gateway=${GW_IMG}:${GW_TAG}" | |
| echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin | |
| OUT="armbian/userpatches/extensions/airwaves-os/images" | |
| mkdir -p "$OUT" | |
| # Pull the exact pinned image for this arch, then attach the moving tags | |
| # the device may reference (channel alias + :latest) so the loaded image | |
| # satisfies the compose file offline regardless of which tag it pins. | |
| bundle() { | |
| local img="$1" tag="$2" svc="$3" | |
| echo "==> Pulling ${img}:${tag} (${PLAT})" | |
| docker pull --platform "$PLAT" "${img}:${tag}" | |
| docker tag "${img}:${tag}" "${img}:${CHANNEL}" | |
| docker tag "${img}:${tag}" "${img}:latest" | |
| echo "==> Saving ${svc} -> ${OUT}/airwaves-${svc}.tar" | |
| docker save -o "${OUT}/airwaves-${svc}.tar" \ | |
| "${img}:${tag}" "${img}:${CHANNEL}" "${img}:latest" | |
| } | |
| bundle "$MGR_IMG" "$MGR_TAG" "manager" | |
| bundle "$GW_IMG" "$GW_TAG" "gateway" | |
| # Reclaim docker's overlay storage before the heavy Armbian build; the | |
| # tarballs we just wrote to disk are what matter from here on. | |
| docker image prune -af >/dev/null 2>&1 || true | |
| ls -lh "$OUT"/*.tar | |
| df -h / | |
| echo "Bundled ${CHANNEL} (${OSVER:-?}) containers for ${PLAT}" | |
| - name: Build image | |
| env: | |
| BOARD: ${{ github.event.inputs.board }} | |
| RELEASE: ${{ github.event.inputs.release }} | |
| BRANCH: ${{ github.event.inputs.branch }} | |
| COLUMNS: "160" | |
| run: | | |
| sudo -E ./armbian/build.sh airwaves BOARD="$BOARD" BRANCH="$BRANCH" RELEASE="$RELEASE" | |
| - name: List output | |
| run: ls -lh .armbian-build/output/images/ 2>/dev/null || echo "No images found" | |
| # The build runs under sudo, so the cached dirs are root-owned. The | |
| # actions/cache post-step saves as the runner user and can't read them | |
| # otherwise. This runs in the main phase (before the cache post-step), so | |
| # ownership is fixed by the time the cache is saved. | |
| - name: Fix cache ownership for save | |
| if: always() | |
| run: | | |
| sudo mkdir -p .armbian-build/output/debs .armbian-build/cache/rootfs .armbian-build/cache/ccache | |
| sudo chown -R "$(id -u):$(id -g)" \ | |
| .armbian-build/output/debs .armbian-build/cache/rootfs .armbian-build/cache/ccache || true | |
| # Build the separate x86 "Live USB" image from the embedded rootfs: a | |
| # squashfs + live-boot layout that loads the whole OS into RAM (toram) so a | |
| # USB I/O-error or unplug doesn't panic. Runs BEFORE the finalize step so | |
| # the squashfs captures the rootfs WITH the container tarballs (the finalize | |
| # step lean-pre-extracts + removes them from the embedded image afterward). | |
| # Reads the embedded image read-only and writes a NEW Airwaves_OS_LiveUSB_* | |
| # image; the embedded image is untouched. | |
| - name: Build Live USB image (x86, squashfs + live-boot + toram) | |
| if: ${{ github.event.inputs.board == 'uefi-x86' }} | |
| run: | | |
| set -euo pipefail | |
| sudo chown -R "$(id -u):$(id -g)" .armbian-build/output/images 2>/dev/null || true | |
| cd .armbian-build/output/images 2>/dev/null || { echo "no images dir"; exit 0; } | |
| shopt -s nullglob | |
| AWCFG="$GITHUB_WORKSPACE/armbian/userpatches/extensions/airwaves-os/config/awcfg" | |
| # Decompress the embedded image if needed (the finalize step also works | |
| # on the .img; leaving it decompressed here is fine). | |
| imgs=( *.img ); EMB="" | |
| if [ ${#imgs[@]} -gt 0 ]; then EMB="${imgs[0]}" | |
| else | |
| xzs=( *.img.xz ); [ ${#xzs[@]} -gt 0 ] || { echo "no image"; exit 0; } | |
| echo "Decompressing ${xzs[0]} ..."; xz -d -T0 -k "${xzs[0]}"; imgs=( *.img ); EMB="${imgs[0]}" | |
| rm -f "${xzs[0]}" | |
| fi | |
| echo "Embedded image: ${EMB}" | |
| AWVER="$(sed -nE 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' \ | |
| "$GITHUB_WORKSPACE/containers/airwaves-manager/Cargo.toml" | head -1)" | |
| case "$GITHUB_REF" in refs/tags/v*) AWVER="${GITHUB_REF#refs/tags/v}" ;; *) AWVER="${AWVER:-dev}-$(git -C "$GITHUB_WORKSPACE" rev-parse --short HEAD)" ;; esac | |
| WORK="$(mktemp -d)"; mkdir -p "${WORK}/live" | |
| # --- read the embedded rootfs (read-only) --------------------------- | |
| ELOOP="$(sudo losetup -fP --show "${EMB}")" | |
| sudo partprobe "${ELOOP}" 2>/dev/null || true; sudo udevadm settle --timeout=30 2>/dev/null || true | |
| ROOTP="" | |
| for p in "${ELOOP}"p*; do | |
| [ -b "$p" ] || continue | |
| case "$(sudo blkid -o value -s TYPE "$p" 2>/dev/null)" in ext4|ext3) ;; *) continue ;; esac | |
| m="$(mktemp -d)" | |
| if sudo mount -o ro "$p" "$m" 2>/dev/null; then | |
| if [ -d "$m/opt/airwaves" ] && [ -d "$m/boot" ]; then ROOTP="$p"; RMNT="$m"; break; fi | |
| sudo umount "$m" 2>/dev/null | |
| fi | |
| rmdir "$m" 2>/dev/null | |
| done | |
| [ -n "$ROOTP" ] || { echo "::error::could not find embedded rootfs partition"; sudo losetup -d "${ELOOP}"; exit 1; } | |
| echo "Embedded rootfs: ${ROOTP}" | |
| # Kernel + initrd (initrd already includes live-boot via the airwaves-live extension). | |
| KVER="$(ls "${RMNT}/boot"/vmlinuz-* 2>/dev/null | sed 's#.*/vmlinuz-##' | sort -V | tail -1)" | |
| [ -n "$KVER" ] || { echo "::error::no kernel in embedded /boot"; sudo umount "${RMNT}"; sudo losetup -d "${ELOOP}"; exit 1; } | |
| cp "${RMNT}/boot/vmlinuz-${KVER}" "${WORK}/vmlinuz" | |
| cp "${RMNT}/boot/initrd.img-${KVER}" "${WORK}/initrd.img" | |
| # --- squashfs the rootfs --------------------------------------------- | |
| # Keep the container tarballs (live loads them into RAM) AND the empty | |
| # mount-point dirs (/proc /sys /dev /run /tmp /var/lib/docker) — excluding | |
| # those removes the directories the initramfs bind-mounts onto, which | |
| # panics init ("/root/dev: mount point does not exist"). /var/lib/docker | |
| # is empty at this stage (pre-extract runs later) so it costs nothing. | |
| # Only drop genuine junk. | |
| echo "Creating squashfs (this takes a few minutes)..." | |
| sudo mksquashfs "${RMNT}" "${WORK}/live/filesystem.squashfs" -comp xz -noappend -no-progress \ | |
| -e lost+found swapfile var/swap var/log.hdd var/lib/docker.hdd | |
| sudo chown "$(id -u):$(id -g)" "${WORK}/live/filesystem.squashfs" | |
| stat -c %s "${WORK}/live/filesystem.squashfs" > "${WORK}/live/filesystem.size" | |
| SQ_BYTES="$(cat "${WORK}/live/filesystem.size")" | |
| echo "squashfs: ${SQ_BYTES} bytes" | |
| sudo umount "${RMNT}" 2>/dev/null || true; rmdir "${RMNT}" 2>/dev/null || true | |
| sudo losetup -d "${ELOOP}" | |
| # --- assemble the live image --------------------------------------- | |
| SQ_MIB=$(( (SQ_BYTES + 1048575) / 1048576 )) | |
| LIVE_DATA_MIB=$(( SQ_MIB + 192 )) | |
| IMG_MIB=$(( 4 + 260 + LIVE_DATA_MIB + 64 + 2 )) | |
| LIVE="Airwaves_OS_LiveUSB_${AWVER}_x86_UEFI_${{ github.event.inputs.release }}.img" | |
| echo "Live image: ${LIVE} (${IMG_MIB} MiB)" | |
| rm -f "${LIVE}"; truncate -s "${IMG_MIB}M" "${LIVE}" | |
| # GPT: BIOS-boot 4M + ESP 260M + LIVE-DATA + AWCFG 64M. | |
| sudo sgdisk -og \ | |
| -n 1:0:+4M -t 1:EF02 -c 1:"BIOS-BOOT" \ | |
| -n 2:0:+260M -t 2:EF00 -c 2:"AIRWAVESEFI" \ | |
| -n 3:0:+${LIVE_DATA_MIB}M -t 3:8300 -c 3:"AIRWAVES_LIVE" \ | |
| -n 4:0:0 -t 4:0700 -c 4:"AWCFG" "${LIVE}" | |
| LLOOP="$(sudo losetup -fP --show "${LIVE}")" | |
| sudo partprobe "${LLOOP}" 2>/dev/null || true; sudo udevadm settle --timeout=30 2>/dev/null || true | |
| for n in 2 3 4; do for _ in $(seq 1 15); do [ -b "${LLOOP}p${n}" ] && break; sudo udevadm settle --timeout=5 2>/dev/null || true; sleep 1; done; done | |
| sudo mkfs.fat -F32 -n AIRWAVESEFI "${LLOOP}p2" >/dev/null | |
| sudo mkfs.ext4 -q -L AIRWAVES_LIVE "${LLOOP}p3" | |
| sudo mkfs.fat -F32 -n AWCFG "${LLOOP}p4" >/dev/null | |
| ESP="$(mktemp -d)"; sudo mount "${LLOOP}p2" "${ESP}" | |
| sudo mkdir -p "${ESP}/boot" | |
| sudo grub-install --target=x86_64-efi --efi-directory="${ESP}" --boot-directory="${ESP}/boot" \ | |
| --removable --no-nvram --recheck 2>&1 | tail -3 | |
| sudo grub-install --target=i386-pc --boot-directory="${ESP}/boot" --recheck "${LLOOP}" 2>&1 | tail -3 | |
| sudo cp "${WORK}/vmlinuz" "${ESP}/boot/vmlinuz" | |
| sudo cp "${WORK}/initrd.img" "${ESP}/boot/initrd.img" | |
| # No live-media= here: it expects a DEVICE (not LABEL=), and a bad value | |
| # makes live-boot search nothing ("Unable to find a medium"). Let live-boot | |
| # auto-scan all partitions for /live/filesystem.squashfs — only the live | |
| # USB carries it. | |
| # net.ifnames=0 biosdevname=0 -> classic eth0/wlan0 names (consistent | |
| # across hardware; predictable names like wlp1s0 vary per board). | |
| CMN="boot=live ignore_uuid nocomponents net.ifnames=0 biosdevname=0 console=tty0 console=ttyS0,115200" | |
| # Heredoc body is indented to the YAML block level; YAML strips that | |
| # back to column 0 so the shell sees a normal <<GRUBCFG heredoc. | |
| sudo tee "${ESP}/boot/grub/grub.cfg" >/dev/null <<GRUBCFG | |
| insmod part_gpt | |
| insmod fat | |
| insmod ext2 | |
| insmod search_label | |
| set timeout=5 | |
| set default=0 | |
| menuentry "Airwaves OS (Live - auto RAM)" { | |
| search --no-floppy --label --set=root AIRWAVESEFI | |
| linux /boot/vmlinuz ${CMN} | |
| initrd /boot/initrd.img | |
| } | |
| menuentry "Airwaves OS (Live - Load to RAM)" { | |
| search --no-floppy --label --set=root AIRWAVESEFI | |
| linux /boot/vmlinuz ${CMN} toram | |
| initrd /boot/initrd.img | |
| } | |
| menuentry "Airwaves OS (Live - Run from USB)" { | |
| search --no-floppy --label --set=root AIRWAVESEFI | |
| linux /boot/vmlinuz ${CMN} notoram | |
| initrd /boot/initrd.img | |
| } | |
| GRUBCFG | |
| grep -q initrd "${ESP}/boot/grub/grub.cfg" || { echo "::error::live grub.cfg missing initrd"; exit 1; } | |
| grep -q '/dev/' "${ESP}/boot/grub/grub.cfg" && { echo "::error::live grub.cfg has /dev path"; exit 1; } | |
| sudo umount "${ESP}"; rmdir "${ESP}" 2>/dev/null || true | |
| LDATA="$(mktemp -d)"; sudo mount "${LLOOP}p3" "${LDATA}" | |
| sudo mkdir -p "${LDATA}/live" | |
| sudo cp "${WORK}/live/filesystem.squashfs" "${LDATA}/live/" | |
| sudo cp "${WORK}/live/filesystem.size" "${LDATA}/live/" | |
| sync; sudo umount "${LDATA}"; rmdir "${LDATA}" 2>/dev/null || true | |
| AWMP="$(mktemp -d)"; sudo mount "${LLOOP}p4" "${AWMP}" | |
| sudo cp "${AWCFG}/airwaves-install.json" "${AWMP}/airwaves-install.json" | |
| sudo cp "${AWCFG}/README.txt" "${AWMP}/README.txt" | |
| sync; sudo umount "${AWMP}"; rmdir "${AWMP}" 2>/dev/null || true | |
| sudo losetup -d "${LLOOP}" | |
| echo "Compressing ${LIVE} ..." | |
| xz -z -T0 -f "${LIVE}" | |
| sudo chown -R "$(id -u):$(id -g)" . 2>/dev/null || true | |
| rm -rf "${WORK}" | |
| ls -lh Airwaves_OS_LiveUSB_*.img.xz | |
| # Append the AWCFG config partition (FAT32, label AWCFG) as the LAST | |
| # partition of the built image. It carries airwaves-install.json + README, | |
| # is editable on any OS, survives dd-flash (it's recorded in the partition | |
| # table), and is consumed at first setup / via an apply.conf trigger by | |
| # airwaves-preconfig. Handles both GPT (x86/rockchip) and MBR (Pi) tables, | |
| # and never touches the existing partitions or the rockchip raw u-boot area. | |
| - name: Pre-extract containers + add AWCFG config partition | |
| run: | | |
| set -euo pipefail | |
| sudo chown -R "$(id -u):$(id -g)" .armbian-build/output/images 2>/dev/null || true | |
| cd .armbian-build/output/images 2>/dev/null || { echo "no images dir"; exit 0; } | |
| shopt -s nullglob | |
| PAYLOAD="$GITHUB_WORKSPACE/armbian/userpatches/extensions/airwaves-os/config/awcfg" | |
| [ -f "${PAYLOAD}/airwaves-install.json" ] || { echo "::error::AWCFG payload missing"; exit 1; } | |
| # Work on an uncompressed .img; decompress the .xz if that's all we have. | |
| imgs=( *.img ) | |
| if [ ${#imgs[@]} -gt 0 ]; then | |
| IMG="${imgs[0]}" | |
| rm -f "${IMG}.xz" "${IMG}.xz.sha" "${IMG}.sha" # stale; regenerated below | |
| else | |
| xzs=( *.img.xz ) | |
| [ ${#xzs[@]} -gt 0 ] || { echo "no image to modify"; exit 0; } | |
| echo "Decompressing ${xzs[0]} ..." | |
| xz -d -T0 "${xzs[0]}" | |
| imgs=( *.img ); IMG="${imgs[0]}" | |
| fi | |
| echo "Appending AWCFG to ${IMG}" | |
| # -p = low-level probe (reads the table from the file itself); without | |
| # it blkid uses its cache and returns empty for a plain .img, which | |
| # would send GPT images down the MBR branch and corrupt the GPT. | |
| PTTYPE="$(sudo blkid -p -o value -s PTTYPE "${IMG}" 2>/dev/null || echo)" | |
| echo "Partition table type: ${PTTYPE:-unknown}" | |
| # Grow the image to make room (64 MiB partition + alignment/secondary GPT). | |
| truncate -s +68M "${IMG}" | |
| case "${PTTYPE}" in | |
| gpt) | |
| sudo sgdisk -e "${IMG}" # move backup GPT to new end | |
| sudo sgdisk --new=0:0:+64M --typecode=0:0700 --change-name=0:AWCFG "${IMG}" | |
| ;; | |
| dos|"") | |
| # MBR (Pi) or undetected: append a primary FAT32-LBA partition. | |
| echo ',64M,c' | sudo sfdisk --append "${IMG}" | |
| ;; | |
| *) | |
| echo "::error::unsupported partition table '${PTTYPE}'"; exit 1 ;; | |
| esac | |
| LOOP="$(sudo losetup -fP --show "${IMG}")" | |
| echo "Loop device: ${LOOP}" | |
| sudo partprobe "${LOOP}" 2>/dev/null || true | |
| sudo udevadm settle --timeout=30 2>/dev/null || true | |
| # AWCFG is the LAST partition. Target that EXACT node from the on-disk | |
| # table count — do NOT grab the first node that appears, which could be | |
| # the ESP/firmware (mkfs on it would destroy the image). | |
| NPARTS="$(sudo partx -g "${IMG}" 2>/dev/null | wc -l | tr -d ' ')" | |
| [ "${NPARTS:-0}" -ge 1 ] || { echo "::error::could not read partition table from ${IMG}"; sudo losetup -d "${LOOP}"; exit 1; } | |
| PART="${LOOP}p${NPARTS}" | |
| for _ in $(seq 1 15); do | |
| [ -b "${PART}" ] && break | |
| sudo udevadm settle --timeout=5 2>/dev/null || true | |
| sleep 1 | |
| done | |
| [ -b "${PART}" ] || { echo "::error::AWCFG partition node ${PART} not found"; sudo losetup -d "${LOOP}"; exit 1; } | |
| echo "AWCFG partition node: ${PART} (last of ${NPARTS} partitions)" | |
| # --- Pre-extract containers into the image's docker store (lean) ------ | |
| # `docker load` of the ~100 MB tarballs on first boot is slow on cheap | |
| # USB media (it re-writes every layer). Do it here: run a throwaway | |
| # dockerd against the image's /var/lib/docker, load the baked tarballs, | |
| # and on success delete them so first boot just runs. Fully guarded in a | |
| # `set +e` subshell — any failure leaves the tarballs in place so the | |
| # device still loads them on first boot (never ships with neither). | |
| ( | |
| set +e | |
| command -v dockerd >/dev/null 2>&1 || { echo "::warning::dockerd absent; keeping tarballs"; exit 0; } | |
| ROOTMNT=""; | |
| for cand in $(seq 1 "$NPARTS"); do | |
| [ "$cand" -eq "$NPARTS" ] && continue # skip the new AWCFG partition | |
| dev="${LOOP}p${cand}"; [ -b "$dev" ] || continue | |
| case "$(sudo blkid -o value -s TYPE "$dev" 2>/dev/null)" in ext4|ext3|ext2) ;; *) continue ;; esac | |
| m="$(mktemp -d)" | |
| if sudo mount "$dev" "$m" 2>/dev/null; then | |
| if ls "$m"/opt/airwaves/images/*.tar >/dev/null 2>&1; then ROOTMNT="$m"; break; fi | |
| sudo umount "$m" 2>/dev/null | |
| fi | |
| rmdir "$m" 2>/dev/null | |
| done | |
| [ -n "$ROOTMNT" ] || { echo "::warning::no rootfs with baked tarballs; skipping pre-extract"; exit 0; } | |
| echo "Pre-extracting containers into $(basename "$ROOTMNT")/var/lib/docker" | |
| # Free the runner's docker so our throwaway daemon owns the namespace. | |
| sudo systemctl stop docker.service docker.socket containerd.service 2>/dev/null | |
| sudo mkdir -p "$ROOTMNT/var/lib/docker" | |
| SOCK=/tmp/aw-preload.sock; PIDF=/tmp/aw-preload.pid | |
| sudo dockerd --data-root="$ROOTMNT/var/lib/docker" --exec-root=/tmp/aw-preload-exec \ | |
| --host="unix://$SOCK" --pidfile="$PIDF" --iptables=false --bridge=none \ | |
| --storage-driver=overlay2 >/tmp/aw-preload-dockerd.log 2>&1 & | |
| up=0; for _ in $(seq 1 40); do sudo docker --host="unix://$SOCK" info >/dev/null 2>&1 && { up=1; break; }; sleep 1; done | |
| loaded=1 | |
| if [ "$up" = 1 ]; then | |
| for t in "$ROOTMNT"/opt/airwaves/images/*.tar; do | |
| echo " loading $(basename "$t")" | |
| sudo docker --host="unix://$SOCK" load -i "$t" || loaded=0 | |
| done | |
| sudo docker --host="unix://$SOCK" images | |
| else | |
| loaded=0; echo "::warning::transient dockerd failed to start"; cat /tmp/aw-preload-dockerd.log 2>/dev/null | tail -20 | |
| fi | |
| # Stop the throwaway daemon and let it flush the store before unmount. | |
| sudo kill "$(sudo cat "$PIDF" 2>/dev/null)" 2>/dev/null | |
| for _ in $(seq 1 20); do sudo docker --host="unix://$SOCK" info >/dev/null 2>&1 || break; sleep 1; done | |
| sudo pkill -f "data-root=$ROOTMNT/var/lib/docker" 2>/dev/null | |
| sleep 2 | |
| if [ "$loaded" = 1 ]; then | |
| echo " pre-extract OK — removing tarballs (lean)" | |
| sudo rm -f "$ROOTMNT"/opt/airwaves/images/*.tar | |
| else | |
| echo "::warning::container pre-extract failed; keeping tarballs for first-boot load" | |
| fi | |
| sync | |
| sudo umount "$ROOTMNT" 2>/dev/null || sudo umount -l "$ROOTMNT" 2>/dev/null | |
| rmdir "$ROOTMNT" 2>/dev/null | |
| ) || true | |
| # --------------------------------------------------------------------- | |
| sudo mkfs.vfat -F 32 -n AWCFG "${PART}" | |
| MP="$(mktemp -d)" | |
| sudo mount "${PART}" "${MP}" | |
| sudo cp "${PAYLOAD}/airwaves-install.json" "${MP}/airwaves-install.json" | |
| sudo cp "${PAYLOAD}/README.txt" "${MP}/README.txt" | |
| sync | |
| sudo umount "${MP}" | |
| sudo losetup -d "${LOOP}" | |
| rmdir "${MP}" 2>/dev/null || true | |
| echo "Recompressing ${IMG} ..." | |
| xz -z -T0 -f "${IMG}" | |
| sudo chown -R "$(id -u):$(id -g)" . 2>/dev/null || true | |
| ls -lh | |
| # Stamp the Airwaves OS version into the image filename. We can't do this | |
| # via Armbian's REVISION (it feeds base-files' package version, which must | |
| # outrank stock Debian), so rename the output here: replace the REVISION | |
| # field with the Airwaves version (bare on a release tag, <version>-<sha> | |
| # otherwise). Must run before the checksum step so .sha256 matches. | |
| - name: Stamp Airwaves version into image filename | |
| run: | | |
| sudo chown -R "$(id -u):$(id -g)" .armbian-build/output/images 2>/dev/null || true | |
| cd .armbian-build/output/images 2>/dev/null || { echo "no images dir"; exit 0; } | |
| VER="$(sed -nE 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' \ | |
| "$GITHUB_WORKSPACE/containers/airwaves-manager/Cargo.toml" | head -1)" | |
| case "$GITHUB_REF" in | |
| refs/tags/v*) AWVER="${GITHUB_REF#refs/tags/v}" ;; | |
| *) AWVER="${VER:-dev}-$(git -C "$GITHUB_WORKSPACE" rev-parse --short HEAD)" ;; | |
| esac | |
| echo "Airwaves version for filename: ${AWVER}" | |
| shopt -s nullglob | |
| for f in Airwaves_OS_*.img.xz Airwaves_OS_*.img; do | |
| # The Live USB image is already named with the version; skip it (its | |
| # first field is "LiveUSB", not a REVISION to replace). | |
| case "$f" in *LiveUSB*) continue;; esac | |
| rest="${f#Airwaves_OS_}" # <REVISION>_<board>_... | |
| tail="${rest#*_}" # <board>_... (drops the REVISION field) | |
| new="Airwaves_OS_${AWVER}_${tail}" | |
| [ "$f" = "$new" ] && continue | |
| mv -f "$f" "$new" && echo "renamed: $f -> $new" | |
| done | |
| - name: Generate SHA256 checksums | |
| run: | | |
| # The image is built under sudo, so the output dir is root-owned; | |
| # take ownership before writing .sha256 as the runner user. | |
| sudo chown -R "$(id -u):$(id -g)" .armbian-build/output/images | |
| cd .armbian-build/output/images | |
| for f in *.img.xz; do | |
| [ -e "$f" ] || continue | |
| sha256sum "$f" > "$f.sha256" | |
| done | |
| cat *.sha256 | |
| # The rpi4b image boots all 64-bit Raspberry Pi models (3/4/5), so publish | |
| # it under a name Pi 5 users will recognize. | |
| - name: Compute artifact name | |
| id: artname | |
| if: always() | |
| env: | |
| BOARD: ${{ github.event.inputs.board }} | |
| RELEASE: ${{ github.event.inputs.release }} | |
| run: | | |
| if [ "$BOARD" = "rpi4b" ]; then | |
| # arm64 Armbian rpi4b boots Pi 3 (64-bit) and Pi 4. Pi 5 (BCM2712/RP1) | |
| # needs the downstream RPi kernel Armbian does not ship, so it's excluded. | |
| echo "name=airwaves-os-raspberrypi-3-4-${RELEASE}" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "name=airwaves-os-${BOARD}-${RELEASE}" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Upload image | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ${{ steps.artname.outputs.name }} | |
| path: | | |
| .armbian-build/output/images/*.img.xz | |
| .armbian-build/output/images/*.img.xz.sha256 | |
| retention-days: 30 | |
| - name: Upload to release | |
| if: ${{ startsWith(github.ref, 'refs/tags/') }} | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| files: | | |
| .armbian-build/output/images/*.img.xz | |
| .armbian-build/output/images/*.img.xz.sha256 |