diff --git a/.github/workflows/pi-image.yml b/.github/workflows/pi-image.yml new file mode 100644 index 00000000..64d42943 --- /dev/null +++ b/.github/workflows/pi-image.yml @@ -0,0 +1,75 @@ +name: Build Raspberry Pi Image +on: + release: + types: [published] + # Check Pifile changes against the latest release (artifact only) + pull_request: + paths: + - "pimod/**" + - ".github/workflows/pi-image.yml" + workflow_dispatch: + inputs: + tag: + description: "Release tag to build the image for" + required: true + upload_to_release: + description: "Attach the image to the release" + type: boolean + default: false + +permissions: + contents: write # Required to upload release assets + +jobs: + build_static_ip_image: + # Preview builds are published as prereleases, and -blueos tags are Docker-only; + # only real releases get an image + if: >- + github.event_name != 'release' || + (!github.event.release.prerelease && !endsWith(github.event.release.tag_name, '-blueos')) + # Native arm64, so the arm64 image's chroot doesn't run under QEMU emulation + runs-on: ubuntu-24.04-arm + timeout-minutes: 180 + env: + # Empty for pull requests, which use the latest release + TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.tag }} + UPLOAD: ${{ github.event_name == 'release' || inputs.upload_to_release == true }} + GH_TOKEN: ${{ github.token }} + steps: + # Releases build from their own tag; manual runs and PRs use the Pifile from + # the selected branch, so images can be made for releases that predate it + - name: "Checkout code" + uses: actions/checkout@v6 + + - name: "Download release files" + run: | + if [ -z "$TAG" ]; then + TAG=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName) + echo "TAG=$TAG" >> "$GITHUB_ENV" + fi + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern release.tar.gz --dir pimod + echo "IMAGE_NAME=dweOS-staticIP-RPi-image-$TAG.img.xz" >> "$GITHUB_ENV" + + - name: "Build image" + uses: Nature40/pimod@v0.9.3 + with: + pifile: pimod/dweOS-static.Pifile + + - name: "Compress image" + run: | + sudo chown "$USER" pimod/dweOS-static.img + xz -T0 -9 --stdout pimod/dweOS-static.img > "$IMAGE_NAME" + ls -lh "$IMAGE_NAME" + + - name: "Upload image to release" + if: env.UPLOAD == 'true' + run: gh release upload "$TAG" "$IMAGE_NAME" --repo "$GITHUB_REPOSITORY" --clobber + + - name: "Upload image as workflow artifact" + if: env.UPLOAD != 'true' + uses: actions/upload-artifact@v7 + with: + name: ${{ env.IMAGE_NAME }} + path: ${{ env.IMAGE_NAME }} + compression-level: 0 # Already xz-compressed + retention-days: 7 diff --git a/pimod/.gitignore b/pimod/.gitignore new file mode 100644 index 00000000..feb8a512 --- /dev/null +++ b/pimod/.gitignore @@ -0,0 +1,4 @@ +# Build inputs and outputs (see dweOS-static.Pifile) +*.img +*.img.xz +.cache/ diff --git a/pimod/dweOS-static.Pifile b/pimod/dweOS-static.Pifile new file mode 100644 index 00000000..8dcffd11 --- /dev/null +++ b/pimod/dweOS-static.Pifile @@ -0,0 +1,133 @@ +# Minimal Raspberry Pi OS Lite image with DWE OS preinstalled, static IP variant. +# The DHCP variant is dweOS-dhcp.Pifile; keep the two in sync. +# +# - user dwe / password deepwater (sudo) +# - hostname dweos (dweos.local), SSH enabled +# - locale en_US.UTF-8, US keyboard, Wi-Fi country US +# - static IP 192.168.2.2/24 (255.255.255.0) on eth0, gateway + DNS 192.168.2.1 +# - hardware PWM (pwm-2chan overlay) +# - DWE OS from release.tar.gz (next to this file) installed and enabled at boot +# +# CI (.github/workflows/pi-image.yml) builds this for every published release. +# Local build, from this directory: +# gh release download v0.7.7 -p release.tar.gz +# docker run --rm --privileged -v "$PWD":/work -w /work nature40/pimod pimod.sh dweOS-static.Pifile +# -> dweOS-static.img + +# The Pifile is sourced by pimod, so resolve paths relative to it rather than +# to wherever pimod was started. +PIFILE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ ! -f "${PIFILE_DIR}/release.tar.gz" ]]; then + echo "### Error: ${PIFILE_DIR}/release.tar.gz is missing (gh release download -p release.tar.gz)" >&2 + false +fi + +TO "${PIFILE_DIR}/dweOS-static.img" +FROM https://downloads.raspberrypi.org/raspios_lite_arm64/images/raspios_lite_arm64-2026-10-06/2026-10-06-raspios-trixie-arm64-lite.img.xz + +# Room for GStreamer and the Python venv; reclaimed again by SHRINK below. +PUMP 2G + +# --- User: dwe / deepwater ------------------------------------------------- +RUN bash -c ' +set -e +groups="" +for g in adm dialout cdrom sudo audio video plugdev games users input render netdev gpio i2c spi; do + getent group "$g" >/dev/null && groups="${groups:+$groups,}$g" +done +# The image ships a placeholder "pi" user (shell: nologin). Rename it with +# Raspberry Pi'"'"'s own userconf tool, which also sets bash as the shell and +# cancels the first-boot rename wizard. +if [ -x /usr/lib/userconf-pi/userconf ] && id pi >/dev/null 2>&1; then + /usr/lib/userconf-pi/userconf pi dwe "" +elif ! id dwe >/dev/null 2>&1; then + useradd -m -s /bin/bash dwe +fi +usermod -s /bin/bash -a -G "$groups" dwe +echo "dwe:deepwater" | chpasswd + +# Skip the first-boot user wizard and cloud-init, which would otherwise +# prompt for / override the user and network configuration. +systemctl disable userconfig.service 2>/dev/null || true +systemctl enable getty@tty1.service +rm -f /etc/ssh/sshd_config.d/rename_user.conf /etc/sudoers.d/010_pi-nopasswd +if [ -d /etc/cloud ]; then touch /etc/cloud/cloud-init.disabled; fi +' + +# --- Hostname: dweos (reachable as dweos.local via avahi) ------------------- +RUN bash -c ' +echo dweos > /etc/hostname +sed -i "s/^127\.0\.1\.1.*/127.0.1.1\tdweos/" /etc/hosts +grep -q "^127\.0\.1\.1" /etc/hosts || printf "127.0.1.1\tdweos\n" >> /etc/hosts +' + +# --- Locale en_US.UTF-8, US keyboard, Wi-Fi country US ---------------------- +RUN raspi-config nonint do_change_locale en_US.UTF-8 +# raspi-config's keyboard helper also reloads the live console, so only do +# the part that matters for the image. +RUN bash -c ' +sed -i "s/^XKBLAYOUT=.*/XKBLAYOUT=\"us\"/" /etc/default/keyboard +DEBIAN_FRONTEND=noninteractive dpkg-reconfigure -f noninteractive keyboard-configuration +' +# Adds cfg80211.ieee80211_regdom=US to cmdline.txt. +RUN raspi-config nonint do_wifi_country US + +# --- SSH ------------------------------------------------------------------- +RUN systemctl enable ssh + +# --- Static IP 192.168.2.2/24, gateway/DNS 192.168.2.1 (NetworkManager) --- +RUN tee /etc/NetworkManager/system-connections/eth0-static.nmconnection <> /boot/firmware/config.txt' + +# --- Slim down --------------------------------------------------------------- +# Keep dpkg from installing docs, man pages and non-English locales (applies +# to the DWE OS dependencies below), then drop the ones already present. +RUN tee /etc/dpkg/dpkg.cfg.d/01-nodoc <.img.xz (xz -T0 -9). +ZERO +SHRINK diff --git a/pimod/image_install_script.sh b/pimod/image_install_script.sh new file mode 100755 index 00000000..0214f13c --- /dev/null +++ b/pimod/image_install_script.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +# Installs DWE OS from a release tarball while building a Raspberry Pi image +# (see dweOS-static.Pifile). Unlike install.sh, it doesn't download anything and +# only enables the service, since it runs inside pimod's chroot rather than on +# a booted Pi. +# +# Usage: image_install_script.sh /path/to/release.tar.gz + +set -euo pipefail + +if [ "$EUID" -ne 0 ]; then + echo "This script must be run as root" + exit 1 +fi + +if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then + echo "Usage: $0 /path/to/release.tar.gz" + exit 1 +fi + +TARBALL=$1 +INSTALL_DIR=/opt/DWE_OS_2 + +WORK_DIR=$(mktemp -d) +trap 'rm -rf "$WORK_DIR"' EXIT + +echo "Installing DWE OS from $TARBALL" + +tar -xzf "$TARBALL" -C "$WORK_DIR" + +rm -rf "$INSTALL_DIR" +mkdir -p "$INSTALL_DIR" +cp -r "$WORK_DIR"/release/. "$INSTALL_DIR"/ + +cd "$INSTALL_DIR" + +bash install_requirements.sh +bash create_venv.sh + +# Neither script stops on errors, so make sure the result is usable instead of +# shipping an image whose service can't start. +command -v gst-launch-1.0 >/dev/null || { + echo "GStreamer was not installed" + exit 1 +} +.venv/bin/python3 -c "import backend_py.src" || { + echo "The Python environment is missing dependencies" + exit 1 +} + +cp service/* /etc/systemd/system/ +systemctl enable dwe_os_2 + +echo "Successfully installed DWE OS"