Compare commits
8 Commits
061d8b266c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| be73235c01 | |||
| 67b133a115 | |||
| 259bdd93ca | |||
| e6f18b5a3b | |||
| 3a0d8db656 | |||
| f9777ccbd2 | |||
| 9c26afd8d2 | |||
| d41e4ca2c6 |
43
README.md
43
README.md
@@ -15,32 +15,47 @@ fetch it — which was measured to be the difference between one step and three.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `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. |
|
| `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. |
|
| `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. |
|
||||||
| `sync_blocked_label.py` | Keeps `Status/Blocked` in step with Gitea's dependency graph. |
|
| `sync_blocked_label.py` | Keeps `Status/Blocked` in step with Gitea's dependency graph. Resolves the label from the repo *or the organisation*, and never touches an issue marked `Status/On Hold` or `Status/Abandoned`. |
|
||||||
|
|
||||||
All three are standard library / plain bash only. They are *run*, not built, so
|
All three are standard library / plain bash only. They are *run*, not built, so
|
||||||
this repo has no flake.
|
this repo has no flake.
|
||||||
|
|
||||||
## Using it
|
## Using it
|
||||||
|
|
||||||
`with-nixpkgs.sh` reads the **consuming** repo's `flake.lock` relative to the
|
|
||||||
working directory, so it keeps working from a subdirectory.
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: fetch the shared CI tools
|
- id: ci
|
||||||
run: git clone --depth 1 https://git.chaosbit.de/weblib/weblib-ci.git .ci
|
uses: https://git.chaosbit.de/weblib/weblib-ci@main
|
||||||
- run: bash .ci/with-nixpkgs.sh python3 python3 .ci/report_job_log.py /tmp/build.log
|
- run: bash ${{ steps.ci.outputs.path }}/with-nixpkgs.sh python3 \
|
||||||
|
python3 ${{ steps.ci.outputs.path }}/report_job_log.py /tmp/build.log
|
||||||
```
|
```
|
||||||
|
|
||||||
No credentials: the repo is public, which is the whole point of it being so.
|
No credentials anywhere: the repo is public, which is the point of it being so.
|
||||||
|
|
||||||
### Why not `uses:`
|
`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.
|
||||||
|
|
||||||
`uses:` pointing at a repo on this instance was measured on weblib-archive#44
|
### Use the full URL, not `weblib/weblib-ci@main`
|
||||||
and did not work, in either the bare `weblib/weblib-ci@main` form or with a
|
|
||||||
full URL — while a plain clone with the same access did. `action.yml` is kept
|
Measured on weblib-archive#44 (2026-09-07), one job per form because Gitea
|
||||||
here so the question can be re-checked cheaply if the instance changes; the
|
posts one commit status per job and job logs return 500:
|
||||||
README records the answer so nobody has to re-derive it.
|
|
||||||
|
| 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.
|
||||||
|
|
||||||
### Why not a flake input
|
### Why not a flake input
|
||||||
|
|
||||||
|
|||||||
BIN
__pycache__/report_job_log.cpython-313.pyc
Normal file
BIN
__pycache__/report_job_log.cpython-313.pyc
Normal file
Binary file not shown.
@@ -1,10 +1,9 @@
|
|||||||
# Makes the shared tools available to a workflow and reports where they are.
|
# Makes the shared tools available to a workflow and reports where they are.
|
||||||
#
|
#
|
||||||
# Whether this is usable at all depends on `uses:` resolving a repo on this
|
# Consume it with the **full URL** - `uses: https://git.chaosbit.de/weblib/
|
||||||
# Gitea, which is instance configuration rather than something a repo controls.
|
# weblib-ci@main`. The bare `weblib/weblib-ci@main` form resolves against the
|
||||||
# Measured on weblib-archive#44 while weblib-ci was still private: it did not
|
# instance's default actions URL rather than this host and fails. Both forms
|
||||||
# work. Re-measured once the repo was public - see the README for the outcome
|
# failed while this repo was private; see the README table.
|
||||||
# and for the fallback, which is a plain clone and always works.
|
|
||||||
name: weblib CI tools
|
name: weblib CI tools
|
||||||
description: Shared CI scripts for the weblib repos.
|
description: Shared CI scripts for the weblib repos.
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ can be read, next to the change that caused it.
|
|||||||
Standard library only, like `sync_blocked_label.py`, so it needs nothing but an
|
Standard library only, like `sync_blocked_label.py`, so it needs nothing but an
|
||||||
interpreter.
|
interpreter.
|
||||||
|
|
||||||
|
Lives in `weblib/weblib-ci` and is used by the other repos from there, so the
|
||||||
|
comment it posts must not name a path inside the repo it is reporting on --
|
||||||
|
there is no copy there to find.
|
||||||
|
|
||||||
Usage, from a workflow step guarded by `if: failure()`:
|
Usage, from a workflow step guarded by `if: failure()`:
|
||||||
|
|
||||||
report_job_log.py /tmp/build.log
|
report_job_log.py /tmp/build.log
|
||||||
@@ -143,7 +147,8 @@ def main(argv=None):
|
|||||||
sha = (os.environ.get("GITHUB_SHA") or "")[:8]
|
sha = (os.environ.get("GITHUB_SHA") or "")[:8]
|
||||||
body = (f"**`{args.job}` failed**{f' on `{sha}`' if sha else ''}.\n\n"
|
body = (f"**`{args.job}` failed**{f' on `{sha}`' if sha else ''}.\n\n"
|
||||||
"Job logs return 500 on this Gitea, so here is the tail of the "
|
"Job logs return 500 on this Gitea, so here is the tail of the "
|
||||||
"build output, posted by `tools/report_job_log.py`.\n\n"
|
"build output, posted by [`report_job_log.py`]"
|
||||||
|
"(https://git.chaosbit.de/weblib/weblib-ci).\n\n"
|
||||||
f"{text}\n")
|
f"{text}\n")
|
||||||
|
|
||||||
# A failure to report a failure must not itself be silent, but it also must
|
# A failure to report a failure must not itself be silent, but it also must
|
||||||
|
|||||||
@@ -45,6 +45,18 @@ import urllib.request
|
|||||||
DEFAULT_LABEL = "Status/Blocked"
|
DEFAULT_LABEL = "Status/Blocked"
|
||||||
DEFAULT_HOST = "https://git.chaosbit.de"
|
DEFAULT_HOST = "https://git.chaosbit.de"
|
||||||
|
|
||||||
|
#: Statuses that mean a human has decided something about this issue which
|
||||||
|
#: outranks the dependency graph. bit, 2026-09-07: *"the reconciler must not
|
||||||
|
#: touch issues that are already on hold or abandoned"*.
|
||||||
|
#:
|
||||||
|
#: "Not touch" is literal - neither add nor remove. Adding would be actively
|
||||||
|
#: destructive once `Status/*` is exclusive again, because the add would
|
||||||
|
#: *replace* the human's label rather than sit beside it, and `Status/On Hold`
|
||||||
|
#: is precisely what makes the backlog sweep skip an issue. A parked issue
|
||||||
|
#: would silently become an available one, every fifteen minutes, with nothing
|
||||||
|
#: in the log to say so.
|
||||||
|
HANDS_OFF = ("Status/On Hold", "Status/Abandoned")
|
||||||
|
|
||||||
|
|
||||||
class Forge:
|
class Forge:
|
||||||
def __init__(self, base, token):
|
def __init__(self, base, token):
|
||||||
@@ -75,15 +87,29 @@ class Forge:
|
|||||||
|
|
||||||
|
|
||||||
def label_id(forge, repo, name):
|
def label_id(forge, repo, name):
|
||||||
"""The label's id, or None if this repo has no such label.
|
"""The label's id, or None if neither the repo nor its org has one.
|
||||||
|
|
||||||
|
Repo first, then the organisation. Labels moved to the org on 2026-09-07
|
||||||
|
(weblib-archive#63) and `repos/<r>/labels` now returns `[]` in all five
|
||||||
|
repos, which made this return None everywhere and turned the whole script
|
||||||
|
into a silent no-op - every run printed "skipped" and reported success.
|
||||||
|
Checking both means it does not care how a given instance is arranged.
|
||||||
|
|
||||||
Returning None rather than exiting matters when several repos are passed:
|
Returning None rather than exiting matters when several repos are passed:
|
||||||
aborting on the third would leave the first two already modified, which is
|
aborting on the third would leave the first two already modified, which is
|
||||||
a worse state than doing nothing. weblib-viewer has no labels at all.
|
a worse state than doing nothing.
|
||||||
"""
|
"""
|
||||||
for label in forge.get(f"repos/{repo}/labels?limit=100"):
|
owner = repo.split("/")[0]
|
||||||
if label["name"] == name:
|
for path in (f"repos/{repo}/labels?limit=100",
|
||||||
return label["id"]
|
f"orgs/{owner}/labels?limit=100"):
|
||||||
|
try:
|
||||||
|
labels = forge.get(path) or []
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
# A user-owned repo has no org endpoint; not an error worth dying on.
|
||||||
|
continue
|
||||||
|
for label in labels:
|
||||||
|
if label["name"] == name:
|
||||||
|
return label["id"]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -110,6 +136,15 @@ def reconcile(forge, repo, label_name, dry_run):
|
|||||||
changed = []
|
changed = []
|
||||||
for item in open_items(forge, repo):
|
for item in open_items(forge, repo):
|
||||||
number = item["number"]
|
number = item["number"]
|
||||||
|
names = {l["name"] for l in item.get("labels") or []}
|
||||||
|
# A human has already ruled on this one. Leave it entirely alone -
|
||||||
|
# neither add nor remove - rather than letting the dependency graph
|
||||||
|
# overwrite a deliberate decision. See HANDS_OFF.
|
||||||
|
held = names.intersection(HANDS_OFF)
|
||||||
|
if held:
|
||||||
|
print(f" hands off {repo}#{number} ({', '.join(sorted(held))})",
|
||||||
|
flush=True)
|
||||||
|
continue
|
||||||
deps = forge.get(f"repos/{repo}/issues/{number}/dependencies") or []
|
deps = forge.get(f"repos/{repo}/issues/{number}/dependencies") or []
|
||||||
if not deps:
|
if not deps:
|
||||||
# No edges, no opinion. See the docstring.
|
# No edges, no opinion. See the docstring.
|
||||||
|
|||||||
Reference in New Issue
Block a user