From a0306ce95b1447c0ed21dc6207314c3ba6ab35de Mon Sep 17 00:00:00 2001 From: Andrea Restello Date: Wed, 16 Sep 2026 15:29:57 +0200 Subject: [PATCH 1/3] Add CI gate and auto-merge workflow (warren-ready) Wire up the repo so warren PRs auto-merge once CI passes, per docs/project-setup.md. The repo had no CI at all, so auto-merge would have had nothing to wait on and every owner PR would have merged unverified. Two workflows: - auto-merge.yml: verbatim from the warren checklist. Enables squash auto-merge on non-draft PRs authored by the repo owner only, using a GitHub App installation token so the merge commit still triggers downstream workflows. - ci.yml: the gate it waits on. Validates add-on metadata (required HA keys, known arch values, options/schema and ports/description symmetry, version vs CHANGELOG), shellchecks the cont-init script, builds the amd64 image, then boots it and smoke-tests it. The smoke test asserts the things that have actually broken here: compiled filters (rastertokpsl, raster2dymolw/m, rastertogutenprint) and vendored PPDs are present, avahi is up for AirPrint, the generated cupsd.conf keeps the LAN/IPv6 ACLs and is not allow-all, Cancel-Job is authorised in a Policy, and a LAN client gets 200 on the web UI. The job id and name are both `ci` so the branch-protection required check context is exactly `ci`. --- .github/scripts/smoke_test.sh | 156 +++++++++++++++++++++++++++ .github/scripts/validate_addon.py | 174 ++++++++++++++++++++++++++++++ .github/workflows/auto-merge.yml | 29 +++++ .github/workflows/ci.yml | 61 +++++++++++ 4 files changed, 420 insertions(+) create mode 100755 .github/scripts/smoke_test.sh create mode 100644 .github/scripts/validate_addon.py create mode 100644 .github/workflows/auto-merge.yml create mode 100644 .github/workflows/ci.yml diff --git a/.github/scripts/smoke_test.sh b/.github/scripts/smoke_test.sh new file mode 100755 index 0000000..39f5f6f --- /dev/null +++ b/.github/scripts/smoke_test.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# Boot the built add-on image the way the Supervisor does and verify that CUPS +# actually comes up, that the printer driver artifacts we compile/patch into the +# image are present, and that the access policy still lets a LAN client reach the +# web UI (the regression fixed in 1.3.2). +# +# Usage: smoke_test.sh +set -euo pipefail + +IMAGE="${1:?usage: smoke_test.sh }" + +SUFFIX="$$-$(date +%s)" +NET="cups-smoke-net-${SUFFIX}" +SERVER="cups-smoke-server-${SUFFIX}" + +pass=0 +fail=0 + +ok() { printf 'ok - %s\n' "$1"; pass=$((pass + 1)); } +bad() { printf 'FAIL - %s\n' "$1"; fail=$((fail + 1)); } + +expect_eq() { # description, actual, expected + if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (want '$3', got '$2')"; fi +} + +expect_contains() { # description, haystack, needle + case "$2" in + *"$3"*) ok "$1" ;; + *) bad "$1 (missing '$3')" ;; + esac +} + +expect_absent() { # description, haystack, needle + case "$2" in + *"$3"*) bad "$1 (unexpectedly found '$3')" ;; + *) ok "$1" ;; + esac +} + +cleanup() { + docker rm -f "$SERVER" >/dev/null 2>&1 || true + docker network rm "$NET" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +server_exec() { docker exec "$SERVER" sh -c "$1"; } + +echo "==> starting $IMAGE on an isolated bridge network" +docker network create "$NET" >/dev/null +docker run -d --name "$SERVER" --network "$NET" "$IMAGE" >/dev/null + +# The Supervisor treats `init: false` services as up once s6 finishes the +# oneshot init; wait for cupsd to answer on its socket instead of guessing. +scheduler="" +for _ in $(seq 1 36); do + scheduler="$(server_exec 'lpstat -r 2>/dev/null' || true)" + case "$scheduler" in + *"scheduler is running"*) break ;; + esac + sleep 5 +done +expect_eq "cupsd scheduler is running" "$scheduler" "scheduler is running" + +expect_eq "container is still running" \ + "$(docker inspect "$SERVER" --format '{{.State.Status}}')" "running" + +if [ "$(server_exec 'curl -s -o /dev/null -w "%{http_code}" --max-time 20 http://127.0.0.1:631/' || echo 000)" = "200" ]; then + ok "cupsd serves the web UI on port 631" +else + bad "cupsd does not serve the web UI on port 631" +fi + +# The declared HEALTHCHECK is what the Supervisor health-checks the add-on with, +# so prove it actually passes rather than trusting the CMD line. +health="" +for _ in $(seq 1 24); do + health="$(docker inspect "$SERVER" --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}' 2>/dev/null || true)" + [ "$health" = "healthy" ] && break + sleep 5 +done +expect_eq "declared HEALTHCHECK reports healthy" "$health" "healthy" + +# --- printer driver artifacts ------------------------------------------------ +# Each of these is either compiled from vendored source or shipped as a PPD in +# rootfs/, so a broken COPY/build step shows up here rather than on a user's Pi. +missing="" +for filter in rastertokpsl raster2dymolw raster2dymolm rastertogutenprint.5.3 gstoraster; do + server_exec "test -x /usr/lib/cups/filter/$filter" || missing="$missing $filter" +done +expect_eq "compiled CUPS filter binaries are present and executable" "$missing" "" + +for ppd in \ + /usr/share/cups/model/lw4xl.ppd \ + /usr/share/cups/model/lw450.ppd \ + /usr/share/cups/model/kyocera/Kyocera_FS-1040GDI.ppd \ + /usr/share/cups/model/kyocera/Kyocera_FS-1060DNGDI.ppd +do + server_exec "test -f $ppd" && ok "PPD present: $ppd" || bad "PPD missing: $ppd" +done + +dymo_count="$(server_exec 'ls /usr/share/cups/model/*.ppd 2>/dev/null | wc -l' || echo 0)" +kyocera_count="$(server_exec 'ls /usr/share/cups/model/kyocera/*.ppd 2>/dev/null | wc -l' || echo 0)" +if [ "${dymo_count:-0}" -ge 20 ]; then + ok "Dymo PPD set installed ($dymo_count)" +else + bad "Dymo PPD set looks truncated (got $dymo_count, want >= 20)" +fi +if [ "${kyocera_count:-0}" -ge 6 ]; then + ok "Kyocera PPD set installed ($kyocera_count)" +else + bad "Kyocera PPD set looks truncated (got $kyocera_count, want >= 6)" +fi + +# --- AirPrint / Avahi -------------------------------------------------------- +if server_exec 'pgrep -x avahi-daemon >/dev/null'; then + ok "avahi-daemon is running for AirPrint/Bonjour" +else + bad "avahi-daemon is not running" +fi + +# --- access policy ----------------------------------------------------------- +# Assert the generated cupsd.conf keeps the LAN ranges from 1.3.2 open and does +# not silently degrade into an allow-all policy. +cupsd_conf="$(server_exec 'cat /share/cups/config/cupsd.conf' || true)" +if [ -z "$cupsd_conf" ]; then + bad "could not read the generated cupsd.conf" +else + expect_contains "ACL allows localhost" "$cupsd_conf" "Allow localhost" + expect_contains "ACL allows @LOCAL interfaces" "$cupsd_conf" "Allow @LOCAL" + expect_contains "ACL allows RFC1918 IPv4 ranges" "$cupsd_conf" "Allow 192.168.0.0/16" + expect_contains "ACL allows IPv6 link-local" "$cupsd_conf" "Allow fe80::/10" + expect_contains "ACL allows IPv6 unique-local" "$cupsd_conf" "Allow fd00::/8" + expect_absent "ACL is not an allow-all policy" "$cupsd_conf" "Allow all" + expect_contains "Cancel-Job is authorised in a Policy" \ + "$cupsd_conf" "Cancel-Job Cancel-Jobs" +fi + +# --- reachability from a LAN client ----------------------------------------- +# A second container on the same bridge is the closest stand-in for a LAN +# printer client. Requests must use the server IP: cupsd rejects requests whose +# Host header it does not recognise with 400. +server_ip="$(docker inspect "$SERVER" --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')" +if [ -z "$server_ip" ]; then + bad "could not determine the container IP address" +else + for path in / /printers/ /admin; do + code="$(docker run --rm --network "$NET" --entrypoint /bin/sh "$IMAGE" \ + -c "curl -s -o /dev/null -w '%{http_code}' --max-time 20 http://$server_ip:631$path" \ + 2>/dev/null || echo "000")" + expect_eq "LAN client GET $path" "$code" "200" + done +fi + +echo +echo "==> $pass passed, $fail failed" +[ "$fail" -eq 0 ] diff --git a/.github/scripts/validate_addon.py b/.github/scripts/validate_addon.py new file mode 100644 index 0000000..02ff042 --- /dev/null +++ b/.github/scripts/validate_addon.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Validate Home Assistant add-on metadata before the container build. + +Catches the mistakes that otherwise only surface inside the Supervisor: +bad YAML/JSON, a missing required key, an unknown CPU arch, and options or +ports that are declared without a matching schema/description entry. +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +ADDON_DIR = REPO_ROOT / "cups" +CONFIG_PATH = ADDON_DIR / "config.yaml" +REPOSITORY_PATH = REPO_ROOT / "repository.json" +CHANGELOG_PATH = REPO_ROOT / "CHANGELOG.md" + +REQUIRED_KEYS = ("name", "version", "slug", "description", "arch", "startup") +VALID_ARCHES = {"aarch64", "amd64", "armv7", "armhf", "i386"} +SEMVER = re.compile(r"^\d+\.\d+\.\d+$") +CHANGELOG_VERSION = re.compile(r"^##\s+\[(\d+\.\d+\.\d+)\]", re.MULTILINE) +PORT_KEY = re.compile(r"^\d+/(tcp|udp)$") +MAP_ENTRY = re.compile(r"^[A-Za-z0-9_.-]+:(rw|ro)$") + +errors: list[str] = [] + + +def fail(message: str) -> None: + errors.append(message) + + +def load_yaml(path: Path): + if not path.is_file(): + fail(f"{path.relative_to(REPO_ROOT)}: file not found") + return None + try: + return yaml.safe_load(path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + fail(f"{path.relative_to(REPO_ROOT)}: invalid YAML: {exc}") + return None + + +def validate_repository_json() -> None: + if not REPOSITORY_PATH.is_file(): + fail("repository.json: file not found") + return + try: + data = json.loads(REPOSITORY_PATH.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + fail(f"repository.json: invalid JSON: {exc}") + return + for key in ("name", "url", "maintainer"): + if not data.get(key): + fail(f"repository.json: missing or empty '{key}'") + + +def validate_arch(config: dict) -> None: + arch = config.get("arch") + if not isinstance(arch, list) or not arch: + fail("config.yaml: 'arch' must be a non-empty list") + return + if len(set(arch)) != len(arch): + fail(f"config.yaml: 'arch' has duplicates: {arch}") + unknown = [a for a in arch if a not in VALID_ARCHES] + if unknown: + fail(f"config.yaml: unknown arch value(s) {unknown}; allowed: {sorted(VALID_ARCHES)}") + if "amd64" not in arch: + fail("config.yaml: 'arch' must include amd64 (CI builds linux/amd64)") + + +def validate_version(config: dict) -> None: + version = str(config.get("version", "")) + if not SEMVER.match(version): + fail(f"config.yaml: 'version' must be X.Y.Z, got {version!r}") + return + if not CHANGELOG_PATH.is_file(): + fail("CHANGELOG.md: file not found") + return + match = CHANGELOG_VERSION.search(CHANGELOG_PATH.read_text(encoding="utf-8")) + if not match: + fail("CHANGELOG.md: no '## [X.Y.Z]' release heading found") + elif match.group(1) != version: + fail( + f"version mismatch: config.yaml has {version}, " + f"CHANGELOG.md latest heading is {match.group(1)}" + ) + + +def validate_symmetry(config: dict, declared: str, described: str) -> None: + left = config.get(declared) + right = config.get(described) + if left is None and right is None: + return + if not isinstance(left, dict) or not isinstance(right, dict): + fail(f"config.yaml: '{declared}' and '{described}' must both be mappings") + return + for key in sorted(set(left) - set(right)): + fail(f"config.yaml: '{declared}' entry {key!r} has no '{described}' entry") + for key in sorted(set(right) - set(left)): + fail(f"config.yaml: '{described}' entry {key!r} has no '{declared}' entry") + + +def validate_options_schema(config: dict) -> None: + options = config.get("options") or {} + schema = config.get("schema") or {} + if not isinstance(options, dict) or not isinstance(schema, dict): + fail("config.yaml: 'options' and 'schema' must both be mappings") + return + for key in sorted(set(options) - set(schema)): + fail(f"config.yaml: option {key!r} has no 'schema' entry") + for key in sorted(set(schema) - set(options)): + fail(f"config.yaml: schema entry {key!r} has no 'options' default") + + +def validate_ports(config: dict) -> None: + validate_symmetry(config, "ports", "ports_description") + for key in sorted(config.get("ports") or {}): + if not PORT_KEY.match(str(key)): + fail(f"config.yaml: port key {key!r} must look like '631/tcp'") + + +def validate_map(config: dict) -> None: + for entry in config.get("map") or []: + if not MAP_ENTRY.match(str(entry)): + fail(f"config.yaml: map entry {entry!r} must look like 'share:rw'") + + +def validate_slug(config: dict) -> None: + slug = config.get("slug") + if slug != ADDON_DIR.name: + fail(f"config.yaml: slug {slug!r} must match add-on directory {ADDON_DIR.name!r}") + + +def main() -> int: + validate_repository_json() + + config = load_yaml(CONFIG_PATH) + if config is None: + for message in errors: + print(f"::error::{message}") + return 1 + if not isinstance(config, dict): + print("::error::config.yaml: top level must be a mapping") + return 1 + + for key in REQUIRED_KEYS: + if config.get(key) in (None, "", [], {}): + fail(f"config.yaml: missing or empty required key '{key}'") + + validate_slug(config) + validate_arch(config) + validate_version(config) + validate_options_schema(config) + validate_ports(config) + validate_map(config) + + if errors: + for message in errors: + print(f"::error::{message}") + print(f"\n{len(errors)} add-on metadata problem(s) found.") + return 1 + + print(f"add-on metadata OK: {ADDON_DIR.name} {config['version']}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml new file mode 100644 index 0000000..5e8577b --- /dev/null +++ b/.github/workflows/auto-merge.yml @@ -0,0 +1,29 @@ +name: auto-merge + +on: + pull_request: + types: [opened, ready_for_review, reopened, synchronize] + +permissions: + contents: write + pull-requests: write + +jobs: + enable-auto-merge: + runs-on: ubuntu-latest + if: >- + !github.event.pull_request.draft && + github.event.pull_request.user.login == github.repository_owner + steps: + - name: Mint app installation token + id: app-token + uses: actions/create-github-app-token@v3.2.0 + with: + app-id: ${{ vars.AUTO_MERGE_APP_ID }} + private-key: ${{ secrets.AUTO_MERGE_APP_PRIVATE_KEY }} + + - name: Enable auto-merge (squash) + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + PR_URL: ${{ github.event.pull_request.html_url }} + run: gh pr merge --auto --squash "$PR_URL" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bf9c4b5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: ci + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Job id and name are both deliberately `ci`: this is the status check + # context that branch protection on main requires before auto-merge fires. + # Keep it a single non-matrix job so the context stays exactly `ci`. + ci: + name: ci + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + - name: Install validation dependencies + run: python3 -m pip install --quiet pyyaml + + - name: Validate add-on metadata + run: python3 .github/scripts/validate_addon.py + + - name: Validate init scripts + run: | + set -euo pipefail + for script in cups/rootfs/etc/cont-init.d/*; do + echo "bash -n $script" + bash -n "$script" + done + + - name: ShellCheck init scripts + run: | + set -euo pipefail + sudo apt-get update -qq + sudo apt-get install -y -qq shellcheck + shellcheck --severity=error cups/rootfs/etc/cont-init.d/* + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build add-on image (amd64) + uses: docker/build-push-action@v6 + with: + context: cups + platforms: linux/amd64 + load: true + tags: cups-addon:ci + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Smoke test image + run: .github/scripts/smoke_test.sh cups-addon:ci From 088c937d961e42c6d2ec71f3ad7488c41b7db969 Mon Sep 17 00:00:00 2001 From: Andrea Restello Date: Wed, 16 Sep 2026 15:31:14 +0200 Subject: [PATCH 2/3] Fix ShellCheck step: pass -s bash for the s6 with-contenv shebang CI caught this on the first run: the s6 init shebang (`#!/usr/bin/with-contenv bash`) is not one ShellCheck recognizes, so SC1008 fired as an error before any real check ran. --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf9c4b5..d2591ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,9 @@ jobs: set -euo pipefail sudo apt-get update -qq sudo apt-get install -y -qq shellcheck - shellcheck --severity=error cups/rootfs/etc/cont-init.d/* + # -s bash: the s6 shebang (`#!/usr/bin/with-contenv bash`) is not + # recognized by ShellCheck and would otherwise trip SC1008. + shellcheck --severity=error -s bash cups/rootfs/etc/cont-init.d/* - name: Set up Buildx uses: docker/setup-buildx-action@v3 From 6c9052a87a018f85da9a977ecb677106146568a3 Mon Sep 17 00:00:00 2001 From: Andrea Restello Date: Wed, 16 Sep 2026 15:35:59 +0200 Subject: [PATCH 3/3] Poll for avahi-daemon and dump diagnostics on smoke-test failure The avahi assertion sampled the process once, which is racy: avahi-daemon is started with --daemonize, which always returns 0 even if the child exits shortly after (e.g. when D-Bus was not usable yet). Poll for it like we do for cupsd, assert the control socket too, and dump container logs / process list / runtime sockets whenever the smoke test fails so CI output explains itself. --- .github/scripts/smoke_test.sh | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/.github/scripts/smoke_test.sh b/.github/scripts/smoke_test.sh index 39f5f6f..5eef648 100755 --- a/.github/scripts/smoke_test.sh +++ b/.github/scripts/smoke_test.sh @@ -112,10 +112,24 @@ else fi # --- AirPrint / Avahi -------------------------------------------------------- -if server_exec 'pgrep -x avahi-daemon >/dev/null'; then +# avahi-daemon is started with --daemonize, which always returns 0 even when the +# child then exits (e.g. because D-Bus was not actually usable yet), so poll for +# the process rather than sampling once. +avahi_procs="" +for _ in $(seq 1 12); do + avahi_procs="$(server_exec 'pgrep -a avahi-daemon 2>/dev/null' || true)" + [ -n "$avahi_procs" ] && break + sleep 5 +done +if [ -n "$avahi_procs" ]; then ok "avahi-daemon is running for AirPrint/Bonjour" else - bad "avahi-daemon is not running" + bad "avahi-daemon is not running (no process matches 'avahi-daemon')" +fi +if server_exec '[ -S /run/avahi-daemon/socket ]'; then + ok "avahi-daemon control socket exists" +else + bad "avahi-daemon control socket is missing" fi # --- access policy ----------------------------------------------------------- @@ -151,6 +165,18 @@ else done fi +if [ "$fail" -ne 0 ]; then + echo + echo "==> diagnostics (container logs)" + docker logs "$SERVER" 2>&1 | tail -60 || true + echo + echo "==> diagnostics (processes)" + server_exec 'ps -ef 2>/dev/null || ps w 2>/dev/null || true' + echo + echo "==> diagnostics (runtime sockets)" + server_exec 'ls -la /run/dbus /run/avahi-daemon 2>&1 || true' +fi + echo echo "==> $pass passed, $fail failed" [ "$fail" -eq 0 ]