From 9c1a23328a1ec7313502e060e6d2573a5ce22c1d Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sun, 30 Aug 2026 15:46:30 +0200 Subject: [PATCH] CI-01: this repository's first automated check Before this commit `admin/goodwe-addon` had no `.gitea/workflows/` directory at all. Eight runnable test files - `test_loop.py` alone drives the real `run_control()` for 93 s of wall clock - and not one of them had ever been run by a machine. Every merge here was a human reading a diff, and the product owner's requirement that a green CI gate a merge could not be met on a repository with no CI to be green. One job, `unit`, with no `paths:` filter, so it reports on every PR and can therefore be a REQUIRED status check: Gitea leaves a required context it never receives pending forever, which is the failure mode on the other side of this ticket. Concurrency is scoped per PR from the start rather than to a bare group name - the bare name is what let the infra repo's run 521 be superseded while still pending, so its four jobs concluded `cancelled` with `started_at` at the Unix epoch and its PR merged on no evidence at all. The tests run in `.gitea/ci/Dockerfile`: `alpine:3` plus `py3-aiohttp` and `py3-paho-mqtt` from apk, matching goodwe_controller/Dockerfile exactly, because pip would install glibc wheels of a library the shipped image gets from Alpine. The runner itself cannot host them - bare python3, no aiohttp, no pip - and mutating a shared long-lived runner with `apk add` is invisible to anyone reading this repo. Two things measured while building it and recorded in the file rather than learned again: the checkout must sit two levels below the mount root or test_freeze.py's `find_hwsim()` dies on `IndexError: 1` before its first assertion, and test_freeze's hwsim section is silently absent here because admin/goodwe is private - so its PASS is a weaker claim than it looks, and the file says so. Verified green on release/1.0 content before this workflow was written: 8 of 8 files pass, ~4 minutes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC --- .gitattributes | 8 +++ .gitea/ci/Dockerfile | 22 ++++++ .gitea/workflows/checks.yml | 136 ++++++++++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 .gitea/ci/Dockerfile create mode 100644 .gitea/workflows/checks.yml diff --git a/.gitattributes b/.gitattributes index d9b2706..372420a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,3 +8,11 @@ * text=auto eol=lf *.png binary *.gz binary + +# `.gitea/workflows/*.yml` steps embed shell heredocs; a `\r` on the terminator +# line means the heredoc never closes and the job fails on a syntax error that is +# invisible in the diff. A Windows checkout with core.autocrlf=true would +# reintroduce it on the next edit. CI-01. +*.yml text eol=lf +*.yaml text eol=lf +Dockerfile text eol=lf diff --git a/.gitea/ci/Dockerfile b/.gitea/ci/Dockerfile new file mode 100644 index 0000000..2e8c778 --- /dev/null +++ b/.gitea/ci/Dockerfile @@ -0,0 +1,22 @@ +# The image the add-on's tests run in (CI-01). This repo had NO CI at all before +# that ticket - eight runnable test files, none of them ever run by a machine. +# +# ⚠️ THE DEPENDENCIES ARE THE SHIPPED ONES, FROM APK, AND THAT IS THE POINT. +# goodwe_controller/Dockerfile installs `py3-aiohttp py3-paho-mqtt` from Alpine +# packages rather than pip, because Alpine ships musl and there are no musl +# wheels for aiohttp - pip would compile it from source on a client's Raspberry +# Pi. Testing against pip-installed glibc wheels would be testing a different +# add-on from the one that ships. Same base, same package manager, same names. +# +# ⚠️ NOT `FROM ghcr.io/home-assistant/...-base`. The real Dockerfile takes its +# base from the Supervisor's `BUILD_FROM` build-arg, which is per-architecture +# and not resolvable outside a Supervisor build. `alpine:3` is what those images +# are, minus the s6 supervision the tests do not use - and it is already cached +# on this runner's host, so the gate does not depend on a registry pull. +# +# ⚠️ The runner container itself CANNOT run these tests: it is Alpine with a bare +# python3, no aiohttp, no pip (`No module named pip`; checked on the box, not +# assumed). An image is not gold-plating here, it is the only option that does +# not mutate a shared long-lived runner. +FROM alpine:3 +RUN apk add --no-cache python3 py3-aiohttp py3-paho-mqtt diff --git a/.gitea/workflows/checks.yml b/.gitea/workflows/checks.yml new file mode 100644 index 0000000..0d4f451 --- /dev/null +++ b/.gitea/workflows/checks.yml @@ -0,0 +1,136 @@ +# CI-01: this repository's FIRST automated check. Before this file, admin/goodwe-addon +# had no `.gitea/workflows/` directory at all - eight runnable test files, ~100 s +# of real coverage including a wall-clock cadence test, and not one machine ever +# ran them. Every merge here was a human reading a diff. +# +# ⚠️ NO `paths:` FILTER, AND ITS ABSENCE IS LOAD-BEARING. Branch protection on +# release/1.0 requires the context `checks / unit (pull_request)`. Gitea leaves a +# required context it never receives PENDING forever, so the moment this workflow +# learns to sit a PR out, that PR becomes unmergeable. Condition a STEP if you +# must; never the workflow. +# +# ⚠️ THE RUNNER IS SHARED WITH THE OTHER REPO'S RIG. `goodwe-runner` on +# 192.168.2.147 carries the labels `self-hosted` and `goodwe-rig` and is +# registered `:host`, and it is the only runner there is - so this job wears the +# rig label without wanting a rig. Nothing below starts, stops, or reads the +# `goodwe-sim` compose project or takes `sim/riglock.py`; it runs one short +# container and exits. It does occupy the runner's single slot (`capacity: 1`), +# so it QUEUES behind a rig campaign - a delayed verdict, never a destroyed one. +# +# NO JS ACTIONS: the runner image has no node, so `actions/checkout` cannot run. +# Checkout is seven lines of git below. +name: checks + +on: + pull_request: + branches: [release/1.0, master] + workflow_dispatch: + +concurrency: + # ⚠️ PER PULL REQUEST, NOT A BARE NAME. A shared group holds one running and + # one pending run, so a bare name lets a second PR supersede a first PR's run + # before any runner has picked it up - which is exactly what happened to the + # other repo's run 521 (four jobs, `started_at` at the Unix epoch, + # `runner_id: 0`, concluded `cancelled`, and the PR merged anyway). Do not + # replace this with a constant. + group: addon-checks-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + unit: + runs-on: [self-hosted, goodwe-rig] + timeout-minutes: 70 # ~4 to run; the rest is queueing for the single slot + env: + # ⚠️ UNDER /home/homelab, NOT $GITHUB_WORKSPACE (ENV-01, and re-measured + # during CI-01). act's workspace is `/root/.cache/act//hostexecutor`, a + # path that exists only INSIDE the runner container. Hand it to the host's + # snap-confined dockerd and the build fails with "failed to read + # dockerfile", or - worse, and this is the documented case - a bind mount + # resolves to an EMPTY DIRECTORY with no error at all. /home/homelab is + # mirrored 1:1 into the runner and is under $HOME, where snap confinement + # accepts it. The same probe under /tmp failed and under $HOME succeeded. + WS: /home/homelab/ci/addon-checks-${{ github.run_number }} + steps: + # ⚠️ THE CHECKOUT IS TWO LEVELS BELOW THE MOUNT ROOT, DELIBERATELY. + # test_freeze.py's find_hwsim() reads `HERE.parents[1] / "goodwe" / "sim"`, + # looking for the infra repo as a SIBLING of this one. Check out flat - + # goodwe_controller directly at the mount root - and `parents[1]` does not + # exist, so the test dies on `IndexError: 1` before its first assertion. + # Measured during CI-01; the flat layout was tried first and did exactly + # that. $WS/goodwe-addon/goodwe_controller gives parents[1] == $WS. + - name: checkout into a path the host shares (git; no node for actions/checkout) + env: + URL: ${{ github.server_url }}/${{ github.repository }}.git + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eu + rm -rf "$WS" && mkdir -p "$WS/goodwe-addon" && cd "$WS/goodwe-addon" + git init -q . + git remote add origin "$(echo "$URL" | sed "s#://#://x-access-token:${TOKEN}@#")" + git fetch -q --depth 1 origin "$GITHUB_REF" || git fetch -q --depth 1 origin "$GITHUB_SHA" + git checkout -qf FETCH_HEAD + echo "checked out $(git log --oneline -1)" + + - name: build the test image (shipped deps, from apk - see .gitea/ci/Dockerfile) + run: | + set -eu + docker build -q -t goodwe-addon-checks:1 "$WS/goodwe-addon/.gitea/ci" >/dev/null + docker run --rm goodwe-addon-checks:1 python3 -c \ + 'import aiohttp, paho.mqtt.client as m, sys; print("python", sys.version.split()[0], "aiohttp", aiohttp.__version__)' + + # ⚠️ THE FILE LIST IS DERIVED FROM THE TREE, NOT TYPED HERE. A hand-written + # list is how a new test file joins the repo and is never run - the exact + # shape of failure this ticket exists to close. The step globs `test_*.py`, + # prints the count, and REFUSES A COUNT OF ZERO: a loop over an empty glob + # exits 0 and would report a green gate that examined nothing. + # + # ⚠️ test_loop.py TAKES ~93 SECONDS OF WALL CLOCK ON PURPOSE (its own + # docstring says so, and says why: it drives the real `run_control()` + # against a real wall-clock floor, and mocking the clock would leave the + # assertion testing the mock). It is not hung. Do not "fix" it with a + # shorter timeout - a 60 s limit was tried during CI-01 and reported + # exactly the false hang the docstring predicts. + # + # ⚠️ WHAT THIS RUN DOES NOT COVER, stated because a pass here would + # otherwise over-claim. test_freeze.py's third section wants the infra + # repo's `sim/hwsim.py` to drive a real HTTP meter through the real + # transport. admin/goodwe is PRIVATE (checked via the API) and this + # workflow's GITHUB_TOKEN is minted for THIS repository only, so hwsim is + # absent here and `find_hwsim()` returns None. The rest of the file runs + # and passes. It does so SILENTLY - the file prints nothing to say the + # hwsim section was skipped - so "test_freeze.py: PASS" below is a weaker + # claim than it looks. That belongs to whoever owns test_freeze.py next; + # naming it here is the least this gate can do about it. + - name: the add-on's own tests + run: | + set -eu + docker run --rm -v "$WS:/w:ro" goodwe-addon-checks:1 sh -eu -c ' + cd /w/goodwe-addon/goodwe_controller + set -- test_*.py + if [ "$1" = "test_*.py" ]; then + echo "NO test_*.py FOUND - the mount is empty or the layout moved. NOTHING has been checked." + exit 1 + fi + echo "$# test file(s) to run" + rc=0 + for f in "$@"; do + start=$(date +%s) + if python3 "$f" > /tmp/out 2>&1; then + echo "PASS $f ($(( $(date +%s) - start ))s)" + else + echo "FAIL $f ($(( $(date +%s) - start ))s)" + tail -40 /tmp/out + rc=1 + fi + done + exit $rc + ' + + - name: clean up + if: always() + run: | + rm -rf "$WS" || true + # Root-owned by this runner inside a directory the `homelab` user owns, + # and sudo on that box is interactive-only, so the user cannot clean + # this up and the job that made it has to. + ls -1dt /home/homelab/ci/addon-checks-*/ 2>/dev/null | tail -n +6 | xargs -r rm -rf || true