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>
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).
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
- 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:
- 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.