Closes weblib-ci#11 in part: the reconciler could add Status/Blocked but
never remove it when every blocker lived in another repo, and, found live
on weblib-archive#30 (2026-09-09), it went further and *reasserted* the
label over a human's hand-removal every 15 minutes.
Established which of the two candidate mechanisms in #11 was real by
instrumenting the script and running it under a real Gitea Actions
token scoped to weblib-archive, against weblib-archive#68 (a throwaway
issue depending on the already-closed weblib/cfbypass#1) and the live
weblib-archive#30: `deps` is never empty for a cross-repo edge (rules out
"Gitea omits the entry"), but every cross-repo entry comes back as Go's
zero-value Issue struct -- id 0, state "", repository null -- rather than
being omitted or erroring. The old `d.get("state") != "closed"` read that
blank state as "still open", which is why the label could never come off,
and why it kept getting silently reapplied.
`is_resolved()` keys on `id == 0` rather than on the empty `state`, since
`id` cannot legitimately be zero for a real issue and a future Gitea could
start populating `state` while still stubbing the rest of the entry.
`reconcile()` now keeps the existing add/remove behaviour whenever every
blocker resolves (same-repo dependencies always do; pinned by
TestSameRepoUnchanged so this cannot regress), but when a blocker cannot
be resolved and none of the resolvable ones are positively open, it
changes nothing and logs the issue number instead of guessing -- keeping
the asymmetry from cfbypass#19 (a false "unblocked" sends someone at work
that cannot be done; a false "blocked" only hides work) rather than
inverting it.
Also probed whether the repo-scoped token could resolve a cross-repo
blocker by querying the other repo directly instead of via
`/dependencies`: `GET repos/weblib/cfbypass/issues/1` and
`GET repos/weblib/cfbypass` both 404 under weblib-archive's own Actions
token, while the same call against its own repo succeeds. So this token
cannot see cfbypass at all; making cross-repo state resolvable would need
a broader-scoped credential, which is a real blast-radius decision left
for bit rather than assumed here. The "unknown -> leave alone, log it"
behaviour in this PR fixes the stuck/reasserted label either way, with or
without that credential ever existing.
12 new tests in test_sync_blocked_label.py, offline against a local
http.server fake. Proved by fault injection: reverting sync_blocked_label.py
to its pre-fix form (keeping the new tests) fails exactly the 5 tests in
TestUnresolvableCrossRepoBlocker -- one of them reproducing the live
reassert bug verbatim (`[(30, 'add', 'blocked by #0, #0')]`) -- while every
other test still passes; restoring the fix (diffed byte-identical against
the pre-revert copy) turns the suite green again.
python3 test_sync_blocked_label.py # 12 tests, OK
python3 test_report_job_log.py # 22 tests, OK (unaffected)
Co-authored-by: bit <bit@das-labor.org>
`report_job_log.py` hardcoded the word *failed* into the comment header. Every
caller guards the step with `if: failure()`, so it was true by construction --
until an unguarded probe in weblib-viewer#10 ran it on a job that passed, and
the successful run posted a comment reading as a failure report. Anyone
scrolling that PR would conclude the probe had failed.
The interesting uses of this script are exactly the ones that want
`if: always()`: a probe, or a job whose *output* is the point rather than its
exit code. Those all lied in the header.
`--status` now supplies the outcome. **The default is `failed`**, which is what
`if: failure()` means, so the four consuming repos are untouched -- a change in
required arguments would have broken all of them at once, since they take this
script from `@main`. `JOB_STATUS` in the environment does the same, matching how
every other argument here already reads its default from the Actions
environment.
`${{ job.status }}` yields `success`/`failure`/`cancelled`/`skipped` while a
human writing the flag reaches for `passed`/`failed`, so both spellings are
accepted and the expression can be passed straight through. An **unrecognised**
status goes into the header verbatim rather than being rejected: `argparse`'s
`choices=` would exit 2 on a value the table has not heard of, and the log --
the whole reason this script exists -- would never be posted. A reporter must
not become the thing that reports nothing.
The "no log file" note was status-dependent too; it claimed the step "failed
before the build started" regardless.
## Verified
`test_report_job_log.py`, new here: stdlib only and offline, posting to an
`http.server` on localhost that keeps what it is sent, so each test reads the
comment back. An exit status of 0 proves nothing -- the script deliberately
swallows HTTP errors so a failure to report cannot mask the failure being
reported. 21 tests, 0 skipped, 1.2s. Three of them drive the CLI as a
subprocess with only environment variables set, the way a workflow does.
Each check was shown to fire by injecting the fault and reverting it:
| injected fault | result |
|---|---|
| header hardcodes `failed` again (the original bug) | 10 failures |
| `DEFAULT_STATUS = "passed"` (would break the four callers) | 8 failures |
| unknown status raises, as `choices=` would | 2 errors |
| missing-log note keeps the failure wording | 1 failure |
| a stray `%` in the `--status` help text | 1 failure |
All five reverted; the file's checksum matches the pre-injection copy.
Also drops a tracked `__pycache__/report_job_log.cpython-313.pyc` and adds a
`.gitignore`. It was committed by accident in 061d8b2 and importing the module
from the tests rewrites it, so it would otherwise show up in every future diff
as stale bytecode of a file that had already changed.
Closes#8
Co-authored-by: bit <bit@das-labor.org>
Two behaviours landed in #4 that the one-line description did not mention, and
both are the kind of thing someone debugging would want to know before reading
the source:
* the label is resolved from the repo *or the organisation* - labels moved to
the org on 2026-09-07, and resolving from the repo alone is what silently
turned the whole script into a no-op
* an issue marked Status/On Hold or Status/Abandoned is left entirely alone,
neither labelled nor unlabelled
Co-authored-by: bit <bit@das-labor.org>
The seed commit said `uses:` did not work. That was measured while this repo
was private and is no longer true: with the full URL it works, and the action's
outputs.path reaches the caller and can run the tools.
The bare owner/repo form still fails - it resolves against the instance default
actions URL, not this host - so the README says which to use and why, with the
per-form results.
Co-authored-by: bit <bit@das-labor.org>
Split out of the four repos per weblib-archive#44. All three files were
byte-identical across every repo at this moment, which will not stay true --
they converged only because four twin PRs landed within hours today, and
report_job_log.py had already drifted once before that.
Taken from weblib-archive, verified identical to every other copy first:
with-nixpkgs.sh ca43fa20 (cfbypass, archive, fs)
report_job_log.py aaef8f62 (cfbypass, archive)
sync_blocked_label.py e6ddb21d (all four)
action.yml is included so the `uses:` question can be re-measured now the repo
is public; it did not work while private.
Co-authored-by: bit <bit@das-labor.org>