Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 174 additions & 55 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,64 +3,183 @@ name: Build Image
on:
repository_dispatch:
push:
schedule:
schedule:
- cron: '0 0 * * *'

jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- board: raspberrypiarmhf
arch: armhf
- board: raspberrypiarm64
arch: arm64
steps:
- name: Install Dependencies
run: |
sudo apt update
sudo apt install coreutils p7zip-full qemu-user-static python3-git

- name: Checkout CustomPiOS
uses: actions/checkout@v2
with:
repository: 'guysoft/CustomPiOS'
path: CustomPiOS

- name: Checkout Project Repository
uses: actions/checkout@v2
with:
path: repository
submodules: true

- name: Download Raspbian Image
run: |
cd repository/src/image
wget -c --trust-server-names 'https://downloads.raspberrypi.org/raspios_lite_armhf_latest'

- name: Update CustomPiOS Paths
run: |
cd repository/src
../../CustomPiOS/src/update-custompios-paths

# - name: Force apt mirror to work around intermittent mirror hiccups
# run: |
# echo "OCTOPI_APTMIRROR=http://mirror.us.leaseweb.net/raspbian/raspbian" > repository/src/config.local

- name: Build Image
run: |
sudo modprobe loop
cd repository/src
sudo bash -x ./build_dist

- name: Copy output
id: copy
run: |
source repository/src/config
NOW=$(date +"%Y-%m-%d-%H%M")
IMAGE=$NOW-octopi-$DIST_VERSION

cp repository/src/workspace/*.img $IMAGE.img

echo "::set-output name=image::$IMAGE"

# artifact upload will take care of zipping for us
- uses: actions/upload-artifact@v4
if: github.event_name == 'schedule'
with:
name: ${{ steps.copy.outputs.image }}
path: ${{ steps.copy.outputs.image }}.img
- name: Install Dependencies
run: |
sudo apt-get update
sudo apt-get install -y coreutils p7zip-full qemu-user-static \
python3-git python3-yaml

- name: Checkout CustomPiOS
uses: actions/checkout@v4
with:
repository: 'guysoft/CustomPiOS'
path: CustomPiOS

- name: Checkout Project Repository
uses: actions/checkout@v4
with:
path: repository
submodules: true

- name: Update CustomPiOS Paths
run: |
cd repository/src
../../CustomPiOS/src/update-custompios-paths

- name: Download Base Image
run: |
cd repository/src
export DIST_PATH=$(pwd)
export CUSTOM_PI_OS_PATH=$(cat custompios_path)
export BASE_BOARD=${{ matrix.board }}
$CUSTOM_PI_OS_PATH/base_image_downloader_wrapper.sh

- name: Build Image
run: |
sudo modprobe loop
cd repository/src
sudo BASE_BOARD=${{ matrix.board }} bash -x ./build_dist

- name: Copy output
id: copy
run: |
source repository/src/config
NOW=$(date +"%Y-%m-%d-%H%M")
IMAGE="${NOW}-octopi-${DIST_VERSION}-${{ matrix.arch }}"
cp repository/src/workspace/*.img ${IMAGE}.img
echo "image=${IMAGE}" >> $GITHUB_OUTPUT

- uses: actions/upload-artifact@v4
with:
name: octopi-${{ matrix.arch }}
path: ${{ steps.copy.outputs.image }}.img

e2e-test:
needs: build
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4

- name: Download arm64 image from build
uses: actions/download-artifact@v4
with:
name: octopi-arm64
path: image/

- name: Build test Docker image
run: DOCKER_BUILDKIT=0 docker build -t octopi-e2e-test ./testing/

- name: Start E2E test container
run: |
mkdir -p artifacts
IMG=$(find image/ -name '*.img' | head -1)
docker run -d --name octopi-test \
-p 9980:9980 \
-v "$PWD/artifacts:/output" \
-v "$(realpath $IMG):/input/image.img:ro" \
-e ARTIFACTS_DIR=/output \
-e KEEP_ALIVE=true \
octopi-e2e-test

- name: Wait for tests to complete
run: |
for i in $(seq 1 180); do
[ -f artifacts/exit-code ] && break
sleep 5
done
if [ ! -f artifacts/exit-code ]; then
echo "ERROR: Tests did not complete within 15 minutes"
docker logs octopi-test 2>&1 | tail -80
exit 1
fi
echo "Tests finished with exit code: $(cat artifacts/exit-code)"
cat artifacts/test-results.txt 2>/dev/null || true

- name: Wait for OctoPrint to fully start
run: |
echo "Waiting for OctoPrint to finish startup..."
for i in $(seq 1 90); do
BODY=$(curl -4 -s --connect-timeout 5 http://127.0.0.1:9980 2>/dev/null || true)
if echo "$BODY" | grep -q "CONFIG_WIZARD"; then
echo "OctoPrint fully started (Setup Wizard ready)"
exit 0
elif echo "$BODY" | grep -q "starting up"; then
printf "S"
else
printf "."
fi
sleep 5
done
echo ""
echo "WARNING: OctoPrint may not be fully started yet, proceeding anyway"

- name: Take OctoPrint screenshot
run: |
npm install puppeteer
node -e "
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch({
headless: 'new', args: ['--no-sandbox']
});
const page = await browser.newPage();
await page.setViewport({width: 1280, height: 900});

// Retry loading until OctoPrint finishes its startup phase
for (let attempt = 0; attempt < 30; attempt++) {
await page.goto('http://127.0.0.1:9980', {
waitUntil: 'networkidle2', timeout: 60000
});
const html = await page.content();
if (html.includes('CONFIG_WIZARD') || html.includes('id=\"login\"')) break;
console.log('OctoPrint still starting up, retrying in 10s... (attempt ' + (attempt+1) + ')');
await new Promise(r => setTimeout(r, 10000));
}

// Wait for the Setup Wizard dialog to appear
try {
await page.waitForSelector('#wizard_dialog', { visible: true, timeout: 120000 });
} catch (e) {
console.log('Wizard did not appear, taking screenshot of current state');
}

// Dismiss notification popovers by clicking the wizard body
try { await page.click('#wizard_dialog .modal-body'); } catch(e) {}
await new Promise(r => setTimeout(r, 2000));

await page.screenshot({path: 'artifacts/octoprint-screenshot.png'});
console.log('Screenshot captured');
await browser.close();
})();
"

- name: Collect logs and stop container
if: always()
run: |
docker logs octopi-test > artifacts/container.log 2>&1 || true
docker stop octopi-test 2>/dev/null || true

- name: Check test result
run: exit "$(cat artifacts/exit-code 2>/dev/null || echo 1)"

- uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-test-results
path: artifacts/
1 change: 1 addition & 0 deletions testing/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
images/
1 change: 1 addition & 0 deletions testing/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
images/
11 changes: 11 additions & 0 deletions testing/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
FROM ptrsr/pi-ci:latest

ENV LIBGUESTFS_BACKEND=direct

RUN apt-get update && apt-get install -y --no-install-recommends sshpass openssh-client curl && rm -rf /var/lib/apt/lists/*

COPY scripts/ /test/scripts/
COPY tests/ /test/tests/
RUN chmod +x /test/scripts/*.sh /test/tests/*.sh

ENTRYPOINT ["/test/scripts/entrypoint.sh"]
82 changes: 82 additions & 0 deletions testing/run-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/bin/bash
set -e

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
IMAGE_DIR="${SCRIPT_DIR}/images"

OCTOPI_URL="${IMAGE_URL:-https://unofficialpi.org/Distros/OctoPi/octopi-bookworm-arm64-lite-1.1.0.zip}"
OCTOPI_ZIP="octopi-bookworm-arm64-lite-1.1.0.zip"
OCTOPI_MD5="74cfd8e6c5b6ff9d8443aaa357201bcd"
DOCKER_IMAGE="octopi-e2e-test"
HTTP_PORT=9980

mkdir -p "$IMAGE_DIR"

if [ -n "$IMAGE_PATH" ]; then
IMG_FILE="$(readlink -f "$IMAGE_PATH")"
echo "Using provided image: $IMG_FILE"
else
ZIP_PATH="${IMAGE_DIR}/${OCTOPI_ZIP}"

if [ ! -f "$ZIP_PATH" ]; then
echo "Downloading OctoPi arm64 image from $OCTOPI_URL..."
wget -q --show-progress -O "$ZIP_PATH" "$OCTOPI_URL"
else
echo "Using cached download: $ZIP_PATH"
fi

if [ "$OCTOPI_URL" = "https://unofficialpi.org/Distros/OctoPi/octopi-bookworm-arm64-lite-1.1.0.zip" ]; then
echo "Verifying checksum..."
ACTUAL_MD5=$(md5sum "$ZIP_PATH" | awk '{print $1}')
if [ "$ACTUAL_MD5" != "$OCTOPI_MD5" ]; then
echo "ERROR: MD5 mismatch! Expected: $OCTOPI_MD5 Got: $ACTUAL_MD5"
rm -f "$ZIP_PATH"
exit 1
fi
echo "Checksum OK."
fi

IMG_NAME=$(unzip -Z1 "$ZIP_PATH" | grep '\.img$' | head -1)
if [ -z "$IMG_NAME" ]; then
echo "ERROR: No .img file found inside $ZIP_PATH"
exit 1
fi

IMG_FILE="${IMAGE_DIR}/${IMG_NAME}"

if [ ! -f "$IMG_FILE" ]; then
echo "Extracting $IMG_NAME..."
unzip -o "$ZIP_PATH" "$IMG_NAME" -d "$IMAGE_DIR"
else
echo "Using cached image: $IMG_FILE"
fi
fi

if [ ! -f "$IMG_FILE" ]; then
echo "ERROR: Image file not found: $IMG_FILE"
exit 1
fi

echo ""
echo "Image: $IMG_FILE"
echo "Size: $(du -h "$IMG_FILE" | awk '{print $1}')"
echo ""

echo "Building Docker image..."
DOCKER_BUILDKIT=0 docker build -t "$DOCKER_IMAGE" "$SCRIPT_DIR"

DOCKER_RUN_ARGS="docker run --rm"
if [ -n "$KEEP_ALIVE" ]; then
DOCKER_RUN_ARGS+=" -p ${HTTP_PORT}:${HTTP_PORT}"
DOCKER_RUN_ARGS+=" -e KEEP_ALIVE=true"
fi
if [ -n "$ARTIFACTS_DIR" ]; then
DOCKER_RUN_ARGS+=" -v $(realpath "$ARTIFACTS_DIR"):/output"
DOCKER_RUN_ARGS+=" -e ARTIFACTS_DIR=/output"
fi

echo ""
echo "Running E2E test..."
$DOCKER_RUN_ARGS \
-v "${IMG_FILE}:/input/image.img:ro" \
"$DOCKER_IMAGE"
29 changes: 29 additions & 0 deletions testing/scripts/boot-qemu.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/bin/bash
set -e

IMAGE_FILE="${1:?Usage: $0 <image.qcow2>}"
KERNEL="${2:-/base/kernel.img}"
SSH_PORT="${3:-2222}"
LOG_FILE="${4:-/tmp/qemu-serial.log}"
HTTP_PORT="${5:-8080}"

echo "=== Starting QEMU (aarch64, -M virt) ==="
echo " Image: $IMAGE_FILE"
echo " Kernel: $KERNEL"
echo " SSH: port $SSH_PORT -> guest:22"
echo " HTTP: port $HTTP_PORT -> guest:80"

qemu-system-aarch64 \
-machine virt \
-cpu cortex-a72 \
-m 2G \
-smp 4 \
-kernel "$KERNEL" \
-append "rw console=ttyAMA0 root=/dev/vda2 rootfstype=ext4 rootdelay=1 loglevel=2" \
-drive "file=$IMAGE_FILE,format=qcow2,id=hd0,if=none,cache=writeback" \
-device virtio-blk,drive=hd0,bootindex=0 \
-netdev "user,id=mynet,hostfwd=tcp::${SSH_PORT}-:22,hostfwd=tcp::${HTTP_PORT}-:80" \
-device virtio-net-pci,netdev=mynet \
-nographic \
-no-reboot \
2>&1 | tee "$LOG_FILE"
Loading