Files
goodwe-addon/.gitea/workflows/checks.yml
T
glenn schrooyenandClaude Opus 5.5 841424463a
checks / unit (pull_request) Successful in 8m42s
infra-pin / fresh (pull_request) Successful in 1s
CI-12 review: INFRA_READ_TOKEN via an env-set header, not a remote URL
The long-lived token no longer lands in $WS/goodwe/.git/config (mounted into
the tests' container, left behind by a failed job) or in git's argv. A missing
secret (fork PR) fails with its own message. README: addon-pin is not re-run
after a bump.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-25 18:01:15 +02:00

584 lines
35 KiB
YAML

# 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 EVENT FILTER OF ANY KIND - NO `paths:`, AND SINCE CI-9 NO `branches:`
# EITHER - AND THAT ABSENCE IS LOAD-BEARING. Branch protection on release/1.0
# requires the context `checks / unit (pull_request)`. Condition a STEP if you
# must; never the workflow.
#
# ⚠️ CI-01 WROTE THE REASON DOWN WRONG, AND THE WRONG REASON WAS THE SAFE-
# SOUNDING ONE. It said an unreceived required context sits PENDING forever, so
# a sat-out pull request becomes unmergeable - annoying, but fail-safe. That is
# not what this Gitea does. MEASURED on Gitea 1.27.3, 2026-09-19, throwaway
# PR #94: when a workflow exists but its event filter does not match, Gitea does
# not stay silent. It POSTS the required context itself, with state `skipped`:
#
# GET /api/v1/repos/admin/goodwe-addon/commits/<head sha>/statuses
# [{"status":"skipped","context":"checks / unit (pull_request)",
# "description":"Skipped","target_url":""}] <- no run, no job, no url
#
# GET /api/v1/repos/admin/goodwe-addon/commits/<head sha>/status
# {"state":"success","total_count":1} <- the rollup LAUNDERS it
#
# and Gitea's merge gate accepts it. Same PR, same head sha, one status on it
# and that status a skip, POST .../pulls/94/merge answered 409 `head out of
# date` - the 409 being a deliberately wrong `head_commit_id` in the probe, i.e.
# the request had ALREADY CLEARED BRANCH PROTECTION. The control, a required
# context with no status at all, answered 405 `Not all required status checks
# successful`. So the true failure mode is the exact inverse of the one written
# here before: sitting a pull request out does not make it unmergeable, it makes
# it MERGEABLE WITHOUT EVER BEING TESTED, and the badge says success while it
# happens. Fail-open, not fail-closed. Do not reintroduce a filter here.
#
# ⚠️ 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 plain git below - and since CI-02 it is a checkout of the MERGE of
# the pull request into its base, not of the branch tip. See the long note on
# that step; a green from this workflow is a claim about content that can land.
name: checks
# ⚠️ CI-9: `pull_request:` TAKES NO `branches:` LIST, ON PURPOSE. It used to
# read `branches: [release/1.0, master]`, which meant a STACKED pull request -
# one whose base is another feature branch rather than a release branch - did
# not match, and so was skipped, laundered to `success`, and mergeable untested
# by the mechanism described at the top of this file.
#
# ⚠️ AND RETARGETING DID NOT REPAIR IT, WHICH IS WHAT MADE THIS P1. The skip is
# a COMMIT STATUS ON THE HEAD SHA, not a property of the pull request, so moving
# the base afterwards leaves it exactly where it was. Observed live on PR #93
# (2026-09-19): retargeted TEL-10 -> release/1.0, and the 10:05:00 `skipped`
# stayed attached to head a6af332 with the rollup still reading success. It was
# caught by a human who forced a real execution by reopening the pull request
# (run 799, 10:56). Reproduced deliberately on throwaway PR #94: retargeted onto
# a branch carrying a byte-identical copy of release/1.0's protection rule, and
# the single `skipped` status survived the retarget unchanged - no new run, no
# new status - and still cleared the gate.
#
# ⚠️ WHY NOT FIX THIS AT THE GATE INSTEAD, which would be the better place. It
# cannot be done. Gitea's branch protection cannot express "this context must be
# SUCCESS" as opposed to "this context must be PRESENT AND NOT FAILING". The
# whole of its status-check vocabulary is two fields - `enable_status_check`
# (bool) and `status_check_contexts` (a list of globs MATCHING CONTEXT NAMES) -
# confirmed against this server's own schema at /swagger.v1.json, definitions
# EditBranchProtectionOption and CreateBranchProtectionOption. There is no field
# for the required STATE anywhere in either. The success/skip comparison lives
# in Gitea's compiled code, so the only lever left is upstream of it: make sure
# the context is never a skip in the first place. Hence this block.
#
# ⚠️ WHAT THIS COSTS, stated honestly, because it is a real trade. Every pull
# request now runs the full suite, including the intermediate steps of a stack
# that nobody intends to merge on its own. A run is ~8 minutes wall clock
# (run 799: 7m55s) on a runner that is `capacity: 1` and shared with the other
# repo's rig, so each extra run is ~8 minutes of slot that a rig campaign then
# queues behind.
#
# SCALE OF THE NEW WORK, re-derived from the API and stated with its denominator,
# because the first version of this paragraph had a made-up one. Counting the 93
# pull requests that predate this ticket (#1..#93; CI-9's own #94..#98 excluded,
# and note /pulls caps a page at 50, so this needs paging to see at all): 92 were
# opened against release/1.0 and would have run anyway, and exactly ONE was
# stacked - #2, TEL-01 -> SAFETY-04, closed unmerged, 2026-08-24. No pull request
# has ever targeted `master`, so half the old filter was dead from the start.
#
# ⚠️ THAT 92 IS AN UPPER BOUND, NOT A CENSUS, and the reason is this very bug.
# The API reports a pull request's CURRENT base, so one that was opened stacked
# and later retargeted is recorded against its final base and is invisible here.
# #93 is precisely that case - opened 2026-09-19T10:05:00 on TEL-10, retargeted,
# merged into release/1.0 at 11:05:26 the same morning, under three hours before
# CI-9 was opened - and it counts in the 92. So the true historical stacking rate
# is somewhere above 1/93, and unmeasurable from here.
#
# The bill is therefore small today and grows with however much stacking the team
# adopts. It is worth paying: the alternative is that the cheap case stays
# untested and indistinguishable from the tested one.
#
# ⚠️ THIS DOES NOT CHANGE GITEA'S ACCEPTANCE OF A `skipped` REQUIRED CONTEXT -
# that half is in its compiled code and is not reachable from this repository.
# The CONSEQUENCE, though, is very largely closeable from here, and is closed:
# after this edit there is nothing in this repository that produces a skip -
# there is exactly one workflow file, it now has no event filter of any kind,
# and the other candidate cause was MEASURED and is not one. The `guard the
# trigger` step below then keeps it that way mechanically rather than by asking
# the next editor to read this comment.
#
# ⚠️ READ THE BLIND SPOTS ON THAT STEP BEFORE TRUSTING THE SENTENCE ABOVE.
# "Mechanically" holds only for a reintroduced filter that still names the
# PR's own base. For blind spots 1 and 3 the defence IS this comment, because
# the guard lives inside the workflow that a filter makes Gitea skip. Blind
# spot 3 is the one that matters: a filter naming a branch THIS pull request
# does not target is itself skipped, lands green and untested, and then every
# subsequent pull request the filter DOES NOT NAME is skipped too. That is the
# gate off, silently.
#
# ⚠️ THAT DIRECTION IS THE WHOLE POINT AND IT IS EASY TO WRITE BACKWARDS. A
# pull request that MATCHES the filter still runs; it is the one the filter
# excludes that Gitea sits out, reports `skipped`, and the rollup launders
# into `success`. An earlier draft of this very clause said "matching it is
# skipped", which is the inverse of the measurement and contradicted the
# paragraph directly below.
#
# ⚠️ AND THE DANGEROUS ONE IS NOT `branches: [main]`, WHICH AT LEAST LOOKS
# WRONG. It is `branches: [release/1.0]`, which looks like tightening onto the
# protected branch and is what a careful person would write. Every day-to-day
# pull request still runs and still goes green, so nothing appears broken -
# and the only thing switched off is the `release/1.0` -> `master` release
# PR, i.e. the one whose content reaches an installed system. SEC-2 made that
# PR the mandated release path (docs/RELEASE.md), so this is now a live route
# rather than a hypothetical one.
#
# Closing it needs a SECOND permanently unfiltered workflow posting its own
# context, with that context added to the `status_check_contexts` of BOTH
# `release/1.0` AND `master` - both carry the rule since SEC-2, and adding it
# to one alone leaves the hole open on the other. A forge change, with the
# owner.
#
# THE MEASUREMENT, because the obvious guess is wrong and was nearly written
# here as fact. A skip-ci keyword in a commit message (`[skip ci]`, `[ci skip]`,
# `[no ci]`, `[skip actions]`, `[actions skip]`) does NOT behave like a filter
# miss. On throwaway PR #94, head b36effb, commit subject ending `[skip ci]`:
#
# .../commits/b36effb/statuses -> [] <- NO status, not a skip
# .../commits/b36effb/status -> {"state":"pending"} <- so the rollup is pending
# POST .../pulls/94/merge -> 405 Not all required status checks successful
#
# i.e. skip-ci is FAIL-CLOSED - it withholds the context entirely, which is the
# pending-forever behaviour CI-01's header wrongly attributed to a filter miss.
# It blocks the merge rather than laundering it, so it needs no fix and no
# app.ini change. Only the filter miss ever posted a `skipped`.
on:
pull_request:
workflow_dispatch:
# ⚠️ NO `concurrency:` BLOCK, AND ITS ABSENCE IS AS LOAD-BEARING AS THE MISSING
# `paths:` FILTER ABOVE. CI-01 put one here - `group: addon-checks-<pr number>`,
# `cancel-in-progress: true` - to stop a second pull request superseding a
# first one's run. It did stop that. It also did this, twice, on PR #14:
#
# run 531 created 17:08:19 `pending / Waiting to run`
# cancelled 17:10:44 <- the exact second run 532 was created
# run 533 created 17:45:11 `pending / Waiting to run`
# cancelled 18:11:22 <- the exact second run 534 was created
#
# Both cancelled runs have `started_at` at the Unix epoch: they never executed.
# A cancelled run posts `failure` on the required context, described
# `Canceled after 0s`. Branch protection compares STATE, not description, so
# "nothing ran" and "your tests are broken" arrive at the gate as the same red,
# and the correct operator response to each is the opposite of the other.
#
# CI-02 MEASURED THE THREE CANDIDATE SETTINGS ON A THROWAWAY PR (#29) RATHER
# THAN READING THE DOCS, because the docs and this Gitea disagree about which
# knob does it. In each trial one run was made to hold the single slot and two
# further pushes followed:
#
# trial 1, NO concurrency block - runs 561 and 562 both sat queued together
# and both executed. Nothing was cancelled.
# trial 2, group kept, cancel-in-progress: FALSE
# - run 564 sat `pending / Blocked by required
# conditions` and was cancelled at 0s the
# second run 565 was created.
#
# So `cancel-in-progress` IS NOT THE KNOB. A shared group cancels a run that has
# not started no matter what that flag says - GitHub documents this ("any
# previously pending run in the group will be cancelled") and Gitea 1.27
# implements it. The only configuration that leaves a queued run alive is having
# no group at all, which is what is written here.
#
# ⚠️ DO NOT REPLACE THIS WITH A SHA-KEYED GROUP as a compromise. It looks
# tempting - two runs on different commits could never share a group, so nothing
# would be cancelled - but the only cancellations it still permits are the
# same-sha ones, and those are precisely the dangerous half: a red for a run
# that never started, landing on a commit that IS still the head. It
# concentrates the failure instead of removing it.
#
# WHAT THIS COSTS, stated honestly: the runner is `capacity: 1` and shared with
# the other repo's rig, so a developer who pushes four times in a minute now
# occupies it four times instead of once. That is CI-01's own trade written the
# other way round - it accepted "a delayed verdict, never a destroyed one" for
# the rig, and this accepts the same for pushes. A destroyed verdict here is
# worse than a slow one, because it is indistinguishable from a real failure.
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/<id>/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.
#
# ⚠️ WHAT THIS CHECKS OUT IS THE MERGE, NOT THE BRANCH, AND THAT IS THE
# WHOLE OF CI-02. What was here before was one line:
#
# git fetch -q --depth 1 origin "$GITHUB_REF" \
# || git fetch -q --depth 1 origin "$GITHUB_SHA"
#
# CI-02 measured what those two names actually are on this Gitea, on a
# deliberately conflicted throwaway PR (#29, runs 558 and 559), by asking
# one question per STEP - the Actions log endpoints 404 here, so a step's
# own conclusion is the only channel out of a job. The answers:
#
# GITHUB_REF == refs/pull/<n>/head (step P01 passed)
# GITHUB_REF != refs/pull/<n>/merge (step P02 failed)
# GITHUB_SHA == the pull request head sha (step P03 passed)
# fetching GITHUB_REF succeeds (step P04 passed)
# ...and lands on the bare head (step P05 passed)
# refs/pull/<n>/merge does not exist (step P06 failed)
#
# ⚠️ SO THE FALLBACK NEVER FIRED, AND THE DEFECT IS WORSE THAN THE ONE
# REPORTED. The ticket says a conflicted PR loses its merge ref and the
# `||` quietly grades the bare head instead. It does not: this Gitea has
# no merge refs at all - `git ls-remote origin 'refs/pull/*'` advertises
# only `/head`, for open and closed pull requests alike - so the FIRST
# half always succeeds, and both halves name the same commit anyway
# (GITHUB_REF resolves to GITHUB_SHA). The gate has never once graded
# merged content, on any pull request in this repository's history. Green
# has meant "this branch passed on its own", which on a base that has
# moved - and `block_on_outdated_branch` is off here, so it usually has -
# is not the content that lands. PR #14 reporting success while
# `mergeable: false` was not the fallback misfiring; it was the gate
# working exactly as written.
#
# A fallback that cannot fire cannot be "kept for the case it was for", so
# it is gone from this path and kept, ANNOUNCED, on the one path that has
# no pull request to merge (workflow_dispatch, below).
#
# ⚠️ NOT `--depth 1` ANY MORE. A merge needs the merge base, and a shallow
# pair does not have one. Measured before changing it: a full fetch of
# this repository is 6.8 MB and 1.1 s against this server, over 95
# commits. The depth flag was buying nothing worth a wrong verdict.
#
# ⚠️ THE WORKFLOW AND THE CONTENT NOW COME FROM DIFFERENT TREES. Gitea
# picks the workflow file from refs/pull/<n>/head, so THIS file is always
# the branch's; the tree graded below - including .gitea/ci/Dockerfile -
# is the merge. That is the right way round (a pull request must not be
# able to grade itself with a gate the base has not seen) but it means a
# change to this file is tested by the branch it is on, which is how you
# are reading this.
- name: check out the base branch and the pull request head (git; no node for actions/checkout)
env:
URL: ${{ github.server_url }}/${{ github.repository }}.git
TOKEN: ${{ secrets.GITHUB_TOKEN }}
PRNUM: ${{ github.event.pull_request.number }}
BASEREF: ${{ github.base_ref }}
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}@#")"
if [ -n "$PRNUM" ]; then
# Two fetches, not one, so that a failure to reach either ref fails
# HERE with the ref in the message, rather than inside the merge
# step where it would read as a conflict. `github.base_ref` being
# populated at all was checked on the probe PR too (step Q01).
git fetch -q origin "+refs/heads/$BASEREF:refs/remotes/origin/base"
git fetch -q origin "+refs/pull/$PRNUM/head:refs/remotes/origin/pr"
git checkout -qf refs/remotes/origin/base
echo "base $BASEREF $(git rev-parse refs/remotes/origin/base)"
echo "head refs/pull/$PRNUM/head $(git rev-parse refs/remotes/origin/pr)"
else
# NO PULL REQUEST (workflow_dispatch). There is nothing to merge
# into, so this path grades a bare ref and SAYS SO - which is all
# the old `||` ever needed to do. It is kept here because a branch
# can be deleted or renamed between the dispatch and the fetch, and
# unlike the pull_request path that is a case nobody has ruled out.
if git fetch -q origin "$GITHUB_REF"; then
echo "GRADED: the bare ref $GITHUB_REF - NOT merged content (no pull request in this event)."
else
git fetch -q origin "$GITHUB_SHA"
echo "GRADED: the bare commit $GITHUB_SHA - NOT merged content, and NOT $GITHUB_REF either, which would not fetch."
fi
git checkout -qf FETCH_HEAD
fi
echo "checked out $(git log --oneline -1)"
# ⚠️ THIS STEP IS THE ONE THAT MAKES A GREEN MEAN SOMETHING. It exists as
# its OWN step rather than as three lines of the checkout because the
# Actions log endpoints 404 on this Gitea and
# /api/v1/repos/.../actions/runs/<id>/jobs - which carries a per-step
# conclusion - is the only machine-readable account of what a run did. A
# reader who sees this step red knows the pull request does not apply and
# that NO TEST RAN; a reader who sees the next two red knows the tests ran
# and spoke. Collapse them back together and that distinction is gone
# again, which is the shape of defect this whole ticket is about.
#
# The refusal is a FAILURE rather than a skip on purpose. "Reports
# nothing" would leave the required context pending forever, which this
# file's own header explains is unmergeable-forever; and a conflicted pull
# request cannot be merged by Gitea anyway, so the red blocks nothing that
# was not already blocked. What it does is stop a green being posted about
# content that cannot land.
- name: grade the merge, not the branch - refuse a pull request that does not apply
env:
PRNUM: ${{ github.event.pull_request.number }}
BASEREF: ${{ github.base_ref }}
run: |
set -eu
cd "$WS/goodwe-addon"
if [ -z "$PRNUM" ]; then
echo "no pull request in this event - nothing to merge, and the previous step said what it graded."
exit 0
fi
if git -c user.email=checks@localhost -c user.name=checks \
merge --no-edit refs/remotes/origin/pr; then
echo "GRADED: refs/pull/$PRNUM/head merged into $BASEREF"
echo " result $(git rev-parse HEAD)"
echo " tree $(git rev-parse 'HEAD^{tree}')"
else
git merge --abort 2>/dev/null || true
echo "REFUSED: refs/pull/$PRNUM/head does not merge into $BASEREF."
echo " NOTHING HAS BEEN TESTED. This is not a test failure - no test ran."
echo " Rebase or merge $BASEREF into the branch and push again."
exit 1
fi
# ⚠️ THE GUARD ON THE TRIGGER. THIS STEP EXISTS BECAUSE PROSE ALREADY
# FAILED IN THIS FILE, ONCE, FOR TWENTY DAYS.
#
# `git show 9c1a23328a` is CI-01's first commit. It shipped the header
# "⚠️ NO `paths:` FILTER, AND ITS ABSENCE IS LOAD-BEARING" and the line
# `branches: [release/1.0, master]` IN THE SAME COMMIT, EIGHTEEN LINES
# APART. The comment forbade one kind of event filter while the code
# directly above the job carried another, and nobody caught it through 84
# merges until the gate let a stacked pull request through. So the whole of
# CI-9's fix rests on an invariant - "checks.yml has no event filter" -
# that has already been silently violated in this exact file by the person
# who wrote the warning against violating it. A louder comment is not a
# remedy for that. A red step is.
#
# WHY THIS CAN WORK AT ALL, given CI-9 rejected the card's option 3 (a step
# that detects its own workflow having been skipped) as impossible. That
# option is impossible because a skipped workflow runs no steps, so there
# is nothing alive to notice. This is the different, tractable case: it
# fires on the pull request that REINTRODUCES a filter, and it does so
# whenever the filter being reintroduced STILL NAMES THAT PULL REQUEST'S
# OWN BASE - the ordinary case, because a filter put back by hand names
# the release branch people are actually targeting. The edit then has to
# get past this step before it can start causing skips. It is a ratchet,
# not a skip detector.
#
# ⚠️ AND IT IS THE HEAD'S WORKFLOW THAT DECIDES WHETHER ANYTHING RUNS, NOT
# THE BASE'S. An earlier draft of this comment claimed the reintroducing
# pull request "is still graded by the unfiltered workflow which is on the
# base today". That was wrong, it contradicted the checkout step's own note
# a few lines below, and the error was not cosmetic: it made this step
# sound like an unconditional ratchet, which is exactly the false comfort
# CI-9 exists to stamp out. MEASURED on PR #101 - head carrying a filter
# that excludes the base, base carrying the unfiltered workflow - and the
# result was no run at all, the context posted `skipped`, the rollup
# reading `success`, and the gate CLEARED (409). Gitea takes the workflow
# file from refs/pull/<n>/head, so a head that filters itself out is simply
# never graded, and nothing on the base can reach out and stop it. That is
# what blind spot 3 below is made of.
#
# WHAT IT INSPECTS is the MERGED tree in $WS - the content that would
# actually land on the base - not the branch tip and not the copy of this
# file that Gitea used to decide whether to run. Those are three different
# trees here (see the long note on the checkout step) and only the merged
# one is the thing being authorised.
#
# ⚠️ THREE ACKNOWLEDGED BLIND SPOTS. NOT ONE OF THEM IS CLOSEABLE FROM
# INSIDE THIS FILE, and the reason is the same in all three: each is a case
# where THIS WORKFLOW IS ITSELF THE THING THAT GETS SKIPPED, and a guard
# that lives inside a skipped workflow does not run. Closing them needs a
# SECOND, permanently unfiltered workflow whose own context is added to
# release/1.0's `status_check_contexts`, so that something survives to
# report when this file is sat out. That is a forge change and the
# repository owner's decision; it is being put to them separately and is
# deliberately NOT held as a blocker on CI-9.
#
# 1. A reintroduced `paths:` filter crafted to exclude the reintroducing
# pull request's OWN diff would skip that pull request, so this step
# would not run to object to it.
#
# 2. An edit that lands on a feature branch through a stacked pull
# request is graded by the HEAD's workflow, so a filter already
# present in the head hides the edit from this step - until that
# branch is proposed for release/1.0, where (unless it is case 3) the
# base matches the reintroduced filter and this step runs and bites.
# Caught at the release boundary, which is the boundary that matters.
#
# 3. ⚠️ THE WORST OF THE THREE, AND WORSE THAN BOTH OF THE OTHERS PUT
# TOGETHER: a reintroduced `branches:` filter that simply DOES NOT
# NAME THE BASE escapes this step entirely, because the pull request
# carrying it is itself skipped. It takes no craft and no malice to
# write - `branches: [main]` copied out of a GitHub template, or a
# tidy-up to `[master]`, a branch that NO pull request in this
# repository's history has ever targeted - and it arrives as a
# skipped, laundered, untested merge into release/1.0.
#
# What makes it categorically worse is what happens the moment after.
# Once that file is ON release/1.0, every subsequent pull request is
# graded against it and every one of them is skipped too. This is not
# the stacked case leaking through a crack; it is the gate off
# completely, for everything, silently, with every badge green. The
# only thing standing between the repository and that state today is
# a human reading this file - which is precisely the kind of defence
# this step exists because it has already failed once (9c1a23328a).
#
# It is deliberately a FAILURE and not a warning: a warning would leave
# the required context green, which is the precise defect CI-9 is about.
- name: guard the trigger - refuse a checks.yml that has regained an event filter
run: |
set -eu
f="$WS/goodwe-addon/.gitea/workflows/checks.yml"
test -f "$f" || { echo "checks.yml is missing from the merged tree."; exit 1; }
# Walk the top-level `on:` block of the MERGED file. Full-line comments
# at column 0 are skipped outright - this file's own prose quotes
# `paths:` and `branches:` many times and must not trip its own guard -
# and an inline `#` is trimmed from any line that is kept.
bad=$(awk '
function scan(t) {
sub(/#.*/, "", t)
if (t ~ /(^|[^A-Za-z0-9_-])(branches|branches-ignore|paths|paths-ignore|tags|tags-ignore|types)[[:space:]]*:/)
return 1
return 0
}
/^[[:space:]]*#/ { next }
/^("on"|'"'"'on'"'"'|on)[[:space:]]*:/ {
inon = 1
rest = $0; sub(/^[^:]*:/, "", rest)
if (scan(rest)) print NR ": " $0
next
}
inon == 1 {
if ($0 ~ /^[[:space:]]*$/) next
if ($0 ~ /^[[:space:]]/) { if (scan($0)) print NR ": " $0; next }
inon = 0
}
' "$f")
if [ -n "$bad" ]; then
echo "REFUSED: checks.yml has regained an event filter in its \`on:\` block."
echo "$bad"
echo
echo " A filter means some pull request stops matching this workflow. Gitea does"
echo " not stay silent about a workflow it sits out - it posts the required context"
echo " \`checks / unit (pull_request)\` itself with state \`skipped\`, the combined"
echo " status rolls that up as \`success\`, and the merge gate ACCEPTS it. That is"
echo " CI-9: a pull request merged on a workflow that never ran a step. It was"
echo " reproduced by merging PR #96 for real through a protected branch."
echo
echo " Branch protection cannot save you here - it can require that a context is"
echo " PRESENT, never that it is SUCCESS - so the filter is the whole defence."
echo " If you genuinely need to narrow what runs, condition a STEP, never the"
echo " workflow. See the \`on:\` block's comments for the measurements."
exit 1
fi
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)
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.
#
# test_freeze.py part 3 runs against the real `sim/hwsim.py` from the
# checkout above; GOODWE_REQUIRE_HWSIM=1 makes it fail rather than use its
# stub (CI-12). Lines a test prints with a `CI: ` prefix are echoed on PASS.
# ⚠️ 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
# checkout below is bind mounted `:ro`, so the interpreter physically
# cannot write a __pycache__ into the tree it is grading - it fails
# SILENTLY, no error and exit status 0 - and every job `rm -rf`s $WS and
# clones afresh, so nothing survives from a previous job on this shared
# runner either. The variable is here because the `:ro` is the load-bearing
# part and it is one character wide: delete it while debugging a write and
# the gate quietly starts caching bytecode across a rebuild again. Note
# what it does NOT do, per TEST-15 part 2 - it stops bytecode being
# WRITTEN, never a stale one being READ. It is not a substitute for the
# purge in goodwe_controller/tools/mutation_run.sh.
- name: the add-on's own tests
run: |
set -eu
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
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)"
grep "^CI: " /tmp/out || true
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