`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>
226 lines
8.7 KiB
Python
Executable File
226 lines
8.7 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
|
|
|
|
The header says *failed* by default, which is what that guard means. A step
|
|
guarded by `if: always()` -- a probe, or a job whose output is the point rather
|
|
than its exit code -- has to say so, or the comment reports a failure that did
|
|
not happen:
|
|
|
|
report_job_log.py /tmp/build.log --status "${{ job.status }}"
|
|
|
|
Everything else comes from the Actions environment: `GITHUB_REPOSITORY`,
|
|
`GITHUB_EVENT_PATH` (for the PR number), `GITEA_TOKEN`/`GITHUB_TOKEN`,
|
|
`GITHUB_SERVER_URL`, and `JOB_STATUS` if `--status` is not passed.
|
|
|
|
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]")
|
|
|
|
# The header used to hardcode "failed". Every caller guards the step with
|
|
# `if: failure()`, so that was true by construction -- until an `if: always()`
|
|
# probe in weblib-viewer#10 posted a failure report for a job that had passed.
|
|
# The default stays `failed` so those guarded callers are unchanged.
|
|
DEFAULT_STATUS = "failed"
|
|
|
|
# `${{ job.status }}` yields success/failure/cancelled/skipped; a human writing
|
|
# the flag by hand reaches for passed/failed. Accept both spellings so a call
|
|
# site can pass the expression straight through.
|
|
STATUS_PHRASES = {
|
|
"failed": "failed",
|
|
"failure": "failed",
|
|
"passed": "passed",
|
|
"success": "passed",
|
|
"succeeded": "passed",
|
|
"cancelled": "was cancelled",
|
|
"canceled": "was cancelled",
|
|
"skipped": "was skipped",
|
|
}
|
|
|
|
|
|
def status_phrase(status):
|
|
"""The verb for the comment header.
|
|
|
|
An unrecognised status is reported verbatim rather than rejected. This
|
|
script exists because a red job says nothing, so it must not itself become
|
|
the thing that says nothing: `argparse`'s `choices=` would exit 2 on a
|
|
status this table has not heard of, and the log would never be posted.
|
|
"""
|
|
key = (status or "").strip().lower()
|
|
if not key:
|
|
key = DEFAULT_STATUS
|
|
if key in STATUS_PHRASES:
|
|
return STATUS_PHRASES[key]
|
|
return "finished with status `" + status.strip() + "`"
|
|
|
|
|
|
def missing_log_note(path, phrase):
|
|
"""What to say when the log file the step named is not there."""
|
|
if phrase == "failed":
|
|
return (f"The step produced no log file at `{path}` — it failed "
|
|
"before the build started.")
|
|
return (f"The step produced no log file at `{path}` — nothing was "
|
|
"captured.")
|
|
|
|
|
|
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)
|
|
ap.add_argument("--status",
|
|
default=os.environ.get("JOB_STATUS") or DEFAULT_STATUS,
|
|
help="outcome of the job being reported: failed (the "
|
|
"default, and what `if: failure()` means), passed, "
|
|
"cancelled, skipped, or ${{ job.status }} verbatim")
|
|
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
|
|
|
|
phrase = status_phrase(args.status)
|
|
|
|
if not os.path.exists(args.log):
|
|
text = missing_log_note(args.log, phrase)
|
|
else:
|
|
text = "```\n" + tail(args.log, args.tail).rstrip() + "\n```"
|
|
|
|
sha = (os.environ.get("GITHUB_SHA") or "")[:8]
|
|
body = (f"**`{args.job}` {phrase}**{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())
|