Merge pull request 'CI-12: CI grades test_freeze.py against the real hwsim at a pinned admin/goodwe' (#125) from CI-12 into release/1.0
infra-pin / fresh (push) Successful in 1s

This commit was merged in pull request #125.
This commit is contained in:
2026-09-25 18:19:43 +02:00
8 changed files with 103 additions and 28 deletions
+2
View File
@@ -0,0 +1,2 @@
# The admin/goodwe commit CI tests against. Bump: set to the full SHA of admin/goodwe release/1.0 HEAD (README.md, "CI and the infra pin").
2d026dfa7c425f462c6969b2f7cdfddc17b17a81
+29 -11
View File
@@ -488,6 +488,30 @@ jobs:
fi fi
echo "OK: the \`on:\` block of the merged checks.yml carries no event filter." echo "OK: the \`on:\` block of the merged checks.yml carries no event filter."
# CI-12: the infra repo at the ONE pinned commit in .gitea/infra-pin (read
# from the merged tree), as the sibling test_freeze.py looks for. Any
# failure here fails the job; the tests step below refuses the stub.
- name: check out admin/goodwe at the pin in .gitea/infra-pin
env:
URL: ${{ github.server_url }}/admin/goodwe.git
# Read access to admin/goodwe only; this job's own token cannot see it.
TOKEN: ${{ secrets.INFRA_READ_TOKEN }}
run: |
set -eu
PIN=$(grep -v '^#' "$WS/goodwe-addon/.gitea/infra-pin" | tr -d '[:space:]')
echo "$PIN" | grep -Eq '^[0-9a-f]{40}$' || { echo "FAIL: .gitea/infra-pin holds '$PIN', not a full 40-hex SHA."; exit 1; }
test -n "$TOKEN" || { echo "FAIL: secret INFRA_READ_TOKEN is not available to this run (a fork PR?)."; exit 1; }
rm -rf "$WS/goodwe" && mkdir -p "$WS/goodwe" && cd "$WS/goodwe"
git init -q .
# The token goes in a header via env: not in argv, and not in .git/config,
# which is mounted into the tests' container and outlives a failed job.
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $TOKEN" \
git fetch -q "$URL" "$PIN" || { echo "FAIL: could not fetch admin/goodwe at $PIN."; exit 1; }
git -c advice.detachedHead=false checkout -qf FETCH_HEAD
test "$(git rev-parse HEAD)" = "$PIN"
test -f sim/hwsim.py || { echo "FAIL: admin/goodwe@$PIN has no sim/hwsim.py."; exit 1; }
echo "infra: admin/goodwe @ $(git log --oneline -1)"
- name: build the test image (shipped deps, from apk - see .gitea/ci/Dockerfile) - name: build the test image (shipped deps, from apk - see .gitea/ci/Dockerfile)
run: | run: |
set -eu set -eu
@@ -508,16 +532,9 @@ jobs:
# shorter timeout - a 60 s limit was tried during CI-01 and reported # shorter timeout - a 60 s limit was tried during CI-01 and reported
# exactly the false hang the docstring predicts. # exactly the false hang the docstring predicts.
# #
# ⚠️ WHAT THIS RUN DOES NOT COVER, stated because a pass here would # test_freeze.py part 3 runs against the real `sim/hwsim.py` from the
# otherwise over-claim. test_freeze.py's third section wants the infra # checkout above; GOODWE_REQUIRE_HWSIM=1 makes it fail rather than use its
# repo's `sim/hwsim.py` to drive a real HTTP meter through the real # stub (CI-12). Lines a test prints with a `CI: ` prefix are echoed on PASS.
# 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.
# ⚠️ PYTHONDONTWRITEBYTECODE=1 IS BELT AND BRACES, NOT A FIX FOR A LIVE # ⚠️ PYTHONDONTWRITEBYTECODE=1 IS BELT AND BRACES, NOT A FIX FOR A LIVE
# HOLE. TEST-15 measured this runner's actual exposure and found none: the # HOLE. TEST-15 measured this runner's actual exposure and found none: the
# checkout below is bind mounted `:ro`, so the interpreter physically # checkout below is bind mounted `:ro`, so the interpreter physically
@@ -533,7 +550,7 @@ jobs:
- name: the add-on's own tests - name: the add-on's own tests
run: | run: |
set -eu set -eu
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -v "$WS:/w:ro" goodwe-addon-checks:1 sh -eu -c ' docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -e GOODWE_REQUIRE_HWSIM=1 -v "$WS:/w:ro" goodwe-addon-checks:1 sh -eu -c '
cd /w/goodwe-addon/goodwe_controller cd /w/goodwe-addon/goodwe_controller
set -- test_*.py set -- test_*.py
if [ "$1" = "test_*.py" ]; then if [ "$1" = "test_*.py" ]; then
@@ -546,6 +563,7 @@ jobs:
start=$(date +%s) start=$(date +%s)
if python3 "$f" > /tmp/out 2>&1; then if python3 "$f" > /tmp/out 2>&1; then
echo "PASS $f ($(( $(date +%s) - start ))s)" echo "PASS $f ($(( $(date +%s) - start ))s)"
grep "^CI: " /tmp/out || true
else else
echo "FAIL $f ($(( $(date +%s) - start ))s)" echo "FAIL $f ($(( $(date +%s) - start ))s)"
tail -40 /tmp/out tail -40 /tmp/out
+42
View File
@@ -0,0 +1,42 @@
# CI-12: goes red when admin/goodwe release/1.0 has moved past .gitea/infra-pin.
# It runs on add-on activity only; admin/goodwe's own `addon-pin` workflow
# covers a move of release/1.0 there. Deliberately NOT a required check: it
# reports; `checks / unit` stays the only gate. How to bump: README.md,
# "CI and the infra pin".
name: infra-pin
on:
pull_request:
push:
branches: [release/1.0]
jobs:
fresh:
runs-on: [self-hosted, goodwe-rig]
timeout-minutes: 30 # seconds of work; the rest is queueing for the shared slot
steps:
- name: compare .gitea/infra-pin with admin/goodwe release/1.0
env:
SELF: ${{ github.server_url }}/${{ github.repository }}.git
INFRA: ${{ github.server_url }}/admin/goodwe.git
TOKEN: ${{ secrets.GITHUB_TOKEN }}
INFRA_TOKEN: ${{ secrets.INFRA_READ_TOKEN }} # admin/goodwe is private
run: |
set -eu
D=$(mktemp -d) && cd "$D"
git init -q . && git remote add origin "$(echo "$SELF" | sed "s#://#://x-access-token:${TOKEN}@#")"
# The pin of the commit this event is about (the PR head, or the push).
git fetch -q --depth 1 origin "$GITHUB_SHA"
PIN=$(git show FETCH_HEAD:.gitea/infra-pin | grep -v '^#' | tr -d '[:space:]')
test -n "$INFRA_TOKEN" || { echo "FAIL: secret INFRA_READ_TOKEN is not available to this run (a fork PR?)."; exit 1; }
# Header via env, as in checks.yml: the token stays out of argv.
HEAD=$(GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $INFRA_TOKEN" \
git ls-remote "$INFRA" refs/heads/release/1.0 | cut -f1)
cd / && rm -rf "$D"
test -n "$HEAD" || { echo "FAIL: could not read admin/goodwe release/1.0."; exit 1; }
if [ "$PIN" != "$HEAD" ]; then
echo "STALE: .gitea/infra-pin is $PIN but admin/goodwe release/1.0 is $HEAD."
echo " Test against $HEAD and bump the pin (README.md, \"CI and the infra pin\")."
exit 1
fi
echo "OK: .gitea/infra-pin $PIN is admin/goodwe release/1.0 HEAD."
+6
View File
@@ -105,6 +105,12 @@ has the mechanism, the measurement, and what the flags do not do. They are not u
each assertion corresponds to a rule whose absence produced an observed failure each assertion corresponds to a rule whose absence produced an observed failure
on real hardware, and the comments in `control.py` say which. on real hardware, and the comments in `control.py` say which.
### CI and the infra pin
CI (`.gitea/workflows/checks.yml`) runs every `test_*.py`, with `admin/goodwe` checked out next to this repo at the commit in `.gitea/infra-pin`, so `test_freeze.py` grades against the real `sim/hwsim.py`. If that checkout fails, the job fails. The local stub is used only in local runs, and it prints `STUB USED` when it is.
The pin tracks `admin/goodwe` **release/1.0**, not master. Two non-required checks go red when release/1.0 has moved past the pin: `infra-pin / fresh` here, on any add-on PR or push to release/1.0, and `addon-pin / fresh` in `admin/goodwe`, on every push to its release/1.0. Nothing re-runs `addon-pin` after a bump, so it stays red on that admin/goodwe commit; `infra-pin / fresh` green on the bump is the all-clear.
**Bump the pin** whenever either is red. Set `.gitea/infra-pin` to the new release/1.0 SHA (`git ls-remote <gitea>/admin/goodwe.git refs/heads/release/1.0`) in a PR, and let `checks / unit` grade the tests against it. CI reads `admin/goodwe` with the Actions secret `INFRA_READ_TOKEN`.
## Compatibility ## Compatibility
**Check the serial number, not the model name.** Characters 6-8 of the serial are **Check the serial number, not the model name.** Characters 6-8 of the serial are
+3 -5
View File
@@ -10,12 +10,10 @@ IN FULL.
⚠️ WHAT THIS FILE CAN AND CANNOT CLAIM, SAID BEFORE THE FIRST CHECK. ⚠️ WHAT THIS FILE CAN AND CANNOT CLAIM, SAID BEFORE THE FIRST CHECK.
The two-master rig lives in admin/goodwe (TEST-19, `sim/sitesim.py`), where The two-master rig lives in admin/goodwe (TEST-19, `sim/sitesim.py`), where
`compute()` closes the loop against `plantsim.Plant` with the firmware's own `compute()` closes the loop against `plantsim.Plant` with the firmware's own
encoder on the wire. That repository is PRIVATE and this repository's CI token encoder on the wire. CI checks admin/goodwe out at `.gitea/infra-pin` (CI-12),
is minted for this one, so the rig is not reachable from the gate - and a test but this file does not import the rig.
file that needed it would either fail the gate or, like test_freeze.py's hwsim
section, pass SILENTLY having checked nothing.
So the split is deliberate: The split:
* `tools/alloc_sim.py` drives the REAL rig, and its output is quoted in the * `tools/alloc_sim.py` drives the REAL rig, and its output is quoted in the
pull request. It is where "no limit cycle" is a claim about the shipped pull request. It is where "no limit cycle" is a claim about the shipped
+5 -7
View File
@@ -990,13 +990,11 @@ check("⭐ RESTORING slew_w 1000 AT max_w 5000 IS FASTER THAN THE PLANT, and by
# #
# ⚠️ THE PLANT BELOW IS MODELLED HERE AND IS NOT THE RIG. admin/goodwe's # ⚠️ THE PLANT BELOW IS MODELLED HERE AND IS NOT THE RIG. admin/goodwe's
# `plantsim.Plant` is the battery model this project grades control changes # `plantsim.Plant` is the battery model this project grades control changes
# against, and this repository's CI cannot reach it - that repository is private # against. CI checks admin/goodwe out at `.gitea/infra-pin` (CI-12), but this
# and this job's token is minted for this one (see .gitea/workflows/checks.yml). # file does not import `plantsim`. The rig evidence lives in `tools/slew_sim.py`,
# So the rig evidence lives in `tools/slew_sim.py`, is quoted in the pull # is quoted in the pull request and in DECISIONS/SAFETY-39-slew-against-the-rail.md,
# request and in DECISIONS/SAFETY-39-slew-against-the-rail.md, and what runs # and what runs HERE is a first-order lag with the time constant that reproduces
# HERE is a first-order lag with the time constant that reproduces §5.10's # §5.10's measured ~18 s to the deadband. test_alloc.py makes the same split.
# measured ~18 s to the deadband. test_alloc.py makes the same split for the
# same reason and says so in the same words.
# #
# ⚠️ AND IT CANNOT SHOW A BATTERY ABOVE ITS RATING, WHICH IS SAID BEFORE THE # ⚠️ AND IT CANNOT SHOW A BATTERY ABOVE ITS RATING, WHICH IS SAID BEFORE THE
# RESULT RATHER THAN AFTER IT. Neither this model nor `plantsim.Plant` lets the # RESULT RATHER THAN AFTER IT. Neither this model nor `plantsim.Plant` lets the
+13 -2
View File
@@ -19,7 +19,8 @@ shipped `meter_freeze_s` itself:
a check that calls the predicate directly would not be testing the thing a check that calls the predicate directly would not be testing the thing
that ships. Uses the rig's own `sim/hwsim.py --fault freeze` when the infra that ships. Uses the rig's own `sim/hwsim.py --fault freeze` when the infra
repo is next to this one (or `$GOODWE_SIM`), and an equivalent local stub repo is next to this one (or `$GOODWE_SIM`), and an equivalent local stub
otherwise. Both are exercised; the run says which one it used. otherwise. Both are exercised; the run says which one it used. CI sets
`GOODWE_REQUIRE_HWSIM=1`, which refuses the stub (CI-12).
4. THE WIRING: the real `Controller.cycle()`, asserted on the service call 4. THE WIRING: the real `Controller.cycle()`, asserted on the service call
that leaves the process; that leaves the process;
@@ -600,6 +601,15 @@ hw = find_hwsim()
# that cannot fail, from the other end. # that cannot fail, from the other end.
PART3_TRANSPORT = f"the rig hwsim at {hw}" if hw else "the local stub" PART3_TRANSPORT = f"the rig hwsim at {hw}" if hw else "the local stub"
print(f"frozen meter over HTTP - using {PART3_TRANSPORT}") print(f"frozen meter over HTTP - using {PART3_TRANSPORT}")
# CI-12: CI sets GOODWE_REQUIRE_HWSIM=1, so a missing infra checkout fails here
# instead of passing on the stub. Local runs may still use the stub, and say so.
if not hw:
if os.environ.get("GOODWE_REQUIRE_HWSIM"):
print("FAIL: GOODWE_REQUIRE_HWSIM is set but sim/hwsim.py was not found "
"(sibling ../goodwe or $GOODWE_SIM) - refusing to fall back to the stub")
sys.exit(1)
print("STUB USED: part 3 ran against the local stub, NOT the infra repo's "
"sim/hwsim.py - not a CI-grade result")
if hw: if hw:
def argv_for(mode): def argv_for(mode):
return [HWSIM_DRIVER, "8791" if mode == "freeze" else "8792", mode, return [HWSIM_DRIVER, "8791" if mode == "freeze" else "8792", mode,
@@ -1263,7 +1273,8 @@ check("a Controller built with the key absent lands on the SHIPPED hold, so "
_ctl_c.freeze_wd.cfg.hold_s == SHIPPED_HOLD_S) _ctl_c.freeze_wd.cfg.hold_s == SHIPPED_HOLD_S)
print() print()
print(f"part 3 (the live HTTP transport) ran against: {PART3_TRANSPORT}") # `CI: ` lines are echoed into the CI log even on a pass (checks.yml).
print(f"CI: test_freeze part 3 (the live HTTP transport) ran against: {PART3_TRANSPORT}")
if fails: if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}") print(f"{len(fails)} FAILED: {', '.join(fails)}")
sys.exit(1) sys.exit(1)
+3 -3
View File
@@ -90,9 +90,9 @@ guard has to improve on, which is the useful thing a 2-day replay can say.
The two days are committed here as `scenarios/p1-net-<date>.jsonl.gz` The two days are committed here as `scenarios/p1-net-<date>.jsonl.gz`
(`{"t": iso8601, "w": signed_watts}` per line, derived from the HA recorder (`{"t": iso8601, "w": signed_watts}` per line, derived from the HA recorder
export) for the reason `scenarios/p1-age-2026-08-26.jsonl.gz` already is: the export), as `scenarios/p1-age-2026-08-26.jsonl.gz` already is. When they were
infra repo is private, CI's token is minted for this repository only, and a committed, CI could not see the infra repo; since CI-12 it checks admin/goodwe
regression fixture that CI cannot see is not a regression fixture. out at `.gitea/infra-pin`, but these fixtures stay here with the tests.
The recorder's window rolls: 2026-08-01 leaves it around 2026-09-15, which is The recorder's window rolls: 2026-08-01 leaves it around 2026-09-15, which is
why the committed copy is the artefact and not the API call. why the committed copy is the artefact and not the API call.