# 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//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//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-`, # `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//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//head (step P01 passed) # GITHUB_REF != refs/pull//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//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//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 "$URL" # Token as an env-set header: not in argv or .git/config (TOOL-9). export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: 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//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//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 # `|| true`: under pipefail a missing or comment-only pin would skip the FAIL line (TOOL-9). PIN=$(grep -v '^#' "$WS/goodwe-addon/.gitea/infra-pin" | tr -d '[:space:]') || true 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