Files
weblib-ci/report_job_log.py
claude f9777ccbd2 Stop the reporter naming a path that no longer exists
The comment it posts said "posted by `tools/report_job_log.py`". Since
weblib-archive#44 there is no such file in any consuming repo -- the script
lives here. So it pointed a reader at a path they cannot find, on the one
occasion they are already looking for the cause of a failure.

Links here instead.

Found by deliberately breaking a build on weblib-fs#31 to prove the failure
path worked. A green run never renders this message, so nothing else would
have surfaced it.

Co-authored-by: bit <bit@das-labor.org>
2026-09-07 12:15:05 +00:00

168 lines
6.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""Post the tail of a build log as a pull request comment.
Gitea 1.25.2 returns 500 from `actions/jobs/{id}/logs` for **every** id, so a
red job reports `Failing after 48s` and nothing else. Every hypothesis about
why then costs a push, a wait, and a one-bit answer. This puts the log where it
can be read, next to the change that caused it.
Standard library only, like `sync_blocked_label.py`, so it needs nothing but an
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()`:
report_job_log.py /tmp/build.log
Everything else comes from the Actions environment: `GITHUB_REPOSITORY`,
`GITHUB_EVENT_PATH` (for the PR number), `GITEA_TOKEN`/`GITHUB_TOKEN`, and
`GITHUB_SERVER_URL`.
Remove this once the instance is updated and logs can be read directly.
"""
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.request
DEFAULT_HOST = "https://git.chaosbit.de"
# Gitea renders a comment body wholesale; a full nix log is megabytes and the
# failure is always at the end.
DEFAULT_TAIL = 12000
# nix colours its output, and the raw escapes are noise in Markdown.
ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
def pull_request_number(event_path):
"""The PR this job is running for, from the event payload, or None."""
if not event_path or not os.path.exists(event_path):
return None
with open(event_path) as fh:
event = json.load(fh)
pr = event.get("pull_request") or {}
return pr.get("number")
def pull_request_for_branch(host, repo, token, branch):
"""The open PR whose head is `branch`, or None.
Only a `pull_request` event carries the PR in its payload. A
`workflow_dispatch` or `schedule` run does not, and the first version of
this script simply printed "not a pull request build" and exited 0 -- so a
dispatched run that failed stayed exactly as silent as the one this script
exists to fix. Looking the branch up covers those.
A push to a branch with no PR still has nowhere to comment, which is
honest: there is no thread for it.
"""
if not branch:
return None
url = (f"{host.rstrip('/')}/api/v1/repos/{repo}/pulls"
f"?state=open&limit=50")
req = urllib.request.Request(url)
req.add_header("Authorization", f"token {token}")
try:
with urllib.request.urlopen(req, timeout=30) as res:
pulls = json.loads(res.read() or b"[]")
except (urllib.error.URLError, OSError, ValueError):
# A reporter that raises reports nothing. HTTPError is a URLError, and
# ValueError covers a body that is not JSON.
return None
for pull in pulls:
if ((pull.get("head") or {}).get("ref")) == branch:
return pull.get("number")
return None
def tail(path, limit):
with open(path, "rb") as fh:
try:
fh.seek(0, os.SEEK_END)
size = fh.tell()
fh.seek(max(0, size - limit * 4))
except OSError: # not seekable; read it all
size = None
raw = fh.read()
text = ANSI.sub("", raw.decode("utf-8", "replace"))
if len(text) > limit:
text = text[-limit:]
# Do not start mid-line; it reads as corruption rather than truncation.
text = text.split("\n", 1)[-1]
text = "[…truncated…]\n" + text
return text
def comment(host, repo, number, token, body):
url = f"{host.rstrip('/')}/api/v1/repos/{repo}/issues/{number}/comments"
req = urllib.request.Request(url, data=json.dumps({"body": body}).encode(),
method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", f"token {token}")
with urllib.request.urlopen(req, timeout=30) as res:
return json.loads(res.read() or b"null")
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("log", help="path to the captured build log")
ap.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY"))
ap.add_argument("--host", default=os.environ.get("GITEA_HOST")
or os.environ.get("GITHUB_SERVER_URL") or DEFAULT_HOST)
ap.add_argument("--token", default=os.environ.get("GITEA_TOKEN")
or os.environ.get("GITHUB_TOKEN"))
ap.add_argument("--pr", type=int, default=None)
ap.add_argument("--job", default=os.environ.get("GITHUB_JOB") or "job")
ap.add_argument("--tail", type=int, default=DEFAULT_TAIL)
args = ap.parse_args(argv)
if not args.repo or not args.token:
print("no repo or no token; set GITHUB_REPOSITORY and GITEA_TOKEN")
return 0
number = args.pr or pull_request_number(os.environ.get("GITHUB_EVENT_PATH"))
if number is None:
branch = (os.environ.get("GITHUB_HEAD_REF")
or os.environ.get("GITHUB_REF_NAME"))
number = pull_request_for_branch(args.host, args.repo, args.token, branch)
if number is None:
print("no pull request for this run: nothing to comment on")
return 0
if not os.path.exists(args.log):
text = ("The step produced no log file at "
f"`{args.log}` — it failed before the build started.")
else:
text = "```\n" + tail(args.log, args.tail).rstrip() + "\n```"
sha = (os.environ.get("GITHUB_SHA") or "")[:8]
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 "
"build output, posted by [`report_job_log.py`]"
"(https://git.chaosbit.de/weblib/weblib-ci).\n\n"
f"{text}\n")
# A failure to report a failure must not itself be silent, but it also must
# not mask the real one: the step is already `if: failure()`.
try:
result = comment(args.host, args.repo, number, args.token, body)
except urllib.error.HTTPError as e:
print(f"could not comment: {e.code} "
f"{e.read()[:300].decode('utf-8', 'replace')}", file=sys.stderr)
return 0
print(f"reported to {args.repo}#{number}: {(result or {}).get('html_url')}")
return 0
if __name__ == "__main__":
sys.exit(main())