Files
weblib-ci/README.md
T
claudeandbit 05e8103eb0 Treat an unresolvable cross-repo blocker as unknown, not as open forever
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>
2026-09-09 15:48:45 +00:00

112 lines
5.1 KiB
Markdown

# weblib-ci
The CI scripts shared by [cfbypass], [weblib-archive], [weblib-fs] and
[weblib-viewer]. Split out per weblib-archive#44, where they had been
hand-copied into each repo and had already drifted once.
**Public deliberately.** Nothing here is a secret or specific to the archive's
contents: a nixpkgs-pinning wrapper, a log poster and a label reconciler.
Public means a consumer needs no deploy key, no ssh setup and no secret to
fetch it — which was measured to be the difference between one step and three.
## What is here
| file | what it does |
|---|---|
| `with-nixpkgs.sh` | Runs a command with one nixpkgs package on PATH, pinned to the *consuming* repo's `flake.lock`. Avoids `nix shell nixpkgs#x`, which re-resolves the registry and refetches a channel tarball whenever the branch moves. |
| `report_job_log.py` | Posts the tail of a build log as a PR comment. Exists because `actions/jobs/{id}/logs` returns 500 for every id on Gitea 1.25.2, so a red job otherwise says only that it failed. Takes `--status`; see below. |
| `sync_blocked_label.py` | Keeps `Status/Blocked` in step with Gitea's dependency graph. Resolves the label from the repo *or the organisation*, never touches an issue marked `Status/On Hold` or `Status/Abandoned`, and leaves the label alone (rather than guessing) on a blocker whose state a repo-scoped token cannot resolve — see weblib-ci#11. |
All three are standard library / plain bash only. They are *run*, not built, so
this repo has no flake.
`test_report_job_log.py` covers the reporter and `test_sync_blocked_label.py`
covers the reconciler. Both are standard library and offline — the forge each
talks to is an `http.server` on localhost that keeps what it is sent, so a
test reads state back rather than trusting an exit status of 0 (which the
reporter in particular returns even when its POST failed).
```bash
python3 test_report_job_log.py # 22 tests, ~1.2s, no network
python3 test_sync_blocked_label.py # 12 tests, no network
```
There is no workflow running it: this repo has no `.gitea/workflows` at all,
and no `flake.lock` for `with-nixpkgs.sh` to read. Run it by hand before
pushing.
## Using it
```yaml
- uses: actions/checkout@v4
- id: ci
uses: https://git.chaosbit.de/weblib/weblib-ci@main
- run: bash ${{ steps.ci.outputs.path }}/with-nixpkgs.sh python3 \
python3 ${{ steps.ci.outputs.path }}/report_job_log.py /tmp/build.log
```
No credentials anywhere: the repo is public, which is the point of it being so.
`with-nixpkgs.sh` reads the **consuming** repo's `flake.lock` relative to the
working directory, so it keeps working when invoked by absolute path from
outside the checkout.
### Use the full URL, not `weblib/weblib-ci@main`
Measured on weblib-archive#44 (2026-09-07), one job per form because Gitea
posts one commit status per job and job logs return 500:
| form | result |
|---|---|
| `uses: https://git.chaosbit.de/weblib/weblib-ci@main` | **works** |
| `uses: weblib/weblib-ci@main` | **fails** |
| `git clone https://…/weblib-ci.git` with no credentials | works |
| `steps.<id>.outputs.path`, then running a tool through it | works |
The bare `owner/repo` form resolves against the instance's default actions URL
rather than this host, so it has to be the full URL. Both forms failed while
this repo was **private**, which is the other half of why it is public — the
alternative was a deploy key and an ssh setup step in four repos.
The `outputs.path` row is listed separately on purpose: the action *running* and
its output *reaching the caller* are different claims, and a composite action
returning an empty string is exactly the sort of thing that looks green.
### `report_job_log.py --status`
The header used to be hardcoded to *failed*. That is true of every caller here,
because each guards the step with `if: failure()` — but a probe run under
`if: always()` posted a failure report for a job that had passed
(weblib-viewer#10, filed as #8).
**The default is still `failed`**, so a caller passing only the log path is
unchanged. A step that can run on success has to say so:
```yaml
- name: report the log
if: always()
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
bash "${{ steps.ci.outputs.path }}/with-nixpkgs.sh" python3 \
python3 "${{ steps.ci.outputs.path }}/report_job_log.py" /tmp/build.log \
--status "${{ job.status }}"
```
`${{ job.status }}` yields `success`/`failure`/`cancelled`/`skipped`, so those
spellings are accepted alongside `passed`/`failed`. `JOB_STATUS` in the
environment does the same thing if a flag is awkward. An **unrecognised**
status is put in the header verbatim rather than rejected: `argparse`'s
`choices=` would exit 2 on a value this list has not heard of, and the log —
the entire reason the script exists — would never be posted.
### Why not a flake input
These are scripts a workflow runs, not derivations. A flake input would cost a
`flake.lock` bump in four repos every time one changes, and buys nothing.
[cfbypass]: https://git.chaosbit.de/weblib/cfbypass
[weblib-archive]: https://git.chaosbit.de/weblib/weblib-archive
[weblib-fs]: https://git.chaosbit.de/weblib/weblib-fs
[weblib-viewer]: https://git.chaosbit.de/weblib/weblib-viewer