auto-log: fetch failing-job log from Gitea API into the details block

New optional inputs (all default to off / safe):
  - auto-log: when true and details is empty, fetch and render the run's
    failing-job log via /api/v1/repos/<repo>/actions/jobs/<id>/logs.
  - token: defaults to github.token (per-run, repo-scoped). Overridable.
  - log-lines: tail-length fallback when no ::error:: annotation present.

Selection heuristic: if any line has ::error::, return those + 12 lines of
context above each (merged windows). Otherwise the last `log-lines` lines.
Timestamp prefixes (act_runner's leading ISO-8601 + Z) are stripped.

The details block already existed; this just makes it cheap for failure
emails to surface the actual build error inline instead of forcing the
reader to click "view run →".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Donavan Fritz
2026-05-28 14:19:32 -05:00
parent a1a09d988e
commit 591fb5b5d6
3 changed files with 151 additions and 0 deletions
+22
View File
@@ -37,6 +37,25 @@ With richer context:
from: agent-ci@fritzlab.net from: agent-ci@fritzlab.net
``` ```
Auto-include the actual build error from the failing step in the email body:
```yaml
- name: notify on failure
if: failure()
uses: https://code.fritzlab.net/action/notify-email@v1
with:
status: failure
summary: "latchkey build failed"
auto-log: 'true'
# token defaults to ${{ github.token }} which has read access to the
# current repo's actions; no caller-side config needed
```
`auto-log` fetches the run's failing-job log via the Gitea API, surfaces the
`::error::` annotation context (or the last 40 lines if there isn't one),
strips the runner's timestamp prefixes, and renders it in the details block.
Skipped if `details:` is already set (manual takes precedence).
Success notification (e.g. confirm a base-image rebuild cascaded): Success notification (e.g. confirm a base-image rebuild cascaded):
```yaml ```yaml
@@ -55,6 +74,9 @@ Success notification (e.g. confirm a base-image rebuild cascaded):
| `status` | no | `info` | `failure` / `success` / `info` — drives accent colour + subject prefix. | | `status` | no | `info` | `failure` / `success` / `info` — drives accent colour + subject prefix. |
| `subject` | no | `summary` | Mail subject after the status prefix. | | `subject` | no | `summary` | Mail subject after the status prefix. |
| `details` | no | — | Multiline preformatted block, rendered in a monospace card. | | `details` | no | — | Multiline preformatted block, rendered in a monospace card. |
| `auto-log` | no | `false` | When `true` and `details` is empty, fetch the failing-job log via the Gitea API and render its error context. |
| `token` | no | `${{ github.token }}` | Gitea API token used for `auto-log`. The per-run GITHUB_TOKEN normally suffices. |
| `log-lines` | no | `40` | Tail length used by `auto-log` when no `::error::` annotation is found. |
| `to` | no | `noc@fritzlab.net` | Recipient address. | | `to` | no | `noc@fritzlab.net` | Recipient address. |
| `from` | no | `ci@fritzlab.net` | Sender address. Must be permitted by the relay. | | `from` | no | `ci@fritzlab.net` | Sender address. Must be permitted by the relay. |
| `smtp-host` | no | `mail.fritzlab.net` | SMTP relay host. | | `smtp-host` | no | `mail.fritzlab.net` | SMTP relay host. |
+21
View File
@@ -28,6 +28,24 @@ inputs:
description: Optional multiline preformatted block (rendered in a monospace card). Leave empty to omit. description: Optional multiline preformatted block (rendered in a monospace card). Leave empty to omit.
required: false required: false
default: '' default: ''
auto-log:
description: |
If 'true' AND details is empty, fetch the current run's failing-job log via the
Gitea API and render the relevant tail as the details block. Useful for failure
notifications so the email shows the actual error inline. Requires `token`.
required: false
default: 'false'
token:
description: |
Gitea API token used to fetch the run log when `auto-log: true`. Defaults to
the run's per-job GITHUB_TOKEN which has read access to the current repo's
actions. Pass `${{ secrets.CI_BOT_TOKEN }}` only if GITHUB_TOKEN isn't enough.
required: false
default: ${{ github.token }}
log-lines:
description: How many tail lines of the failing step to include when no `::error::` annotation is found.
required: false
default: '40'
smtp-host: smtp-host:
description: SMTP relay host. description: SMTP relay host.
required: false required: false
@@ -48,6 +66,9 @@ runs:
NOTIFY_SUBJECT: ${{ inputs.subject }} NOTIFY_SUBJECT: ${{ inputs.subject }}
NOTIFY_SUMMARY: ${{ inputs.summary }} NOTIFY_SUMMARY: ${{ inputs.summary }}
NOTIFY_DETAILS: ${{ inputs.details }} NOTIFY_DETAILS: ${{ inputs.details }}
NOTIFY_AUTO_LOG: ${{ inputs.auto-log }}
NOTIFY_TOKEN: ${{ inputs.token }}
NOTIFY_LOG_LINES: ${{ inputs.log-lines }}
NOTIFY_SMTP_HOST: ${{ inputs.smtp-host }} NOTIFY_SMTP_HOST: ${{ inputs.smtp-host }}
NOTIFY_SMTP_PORT: ${{ inputs.smtp-port }} NOTIFY_SMTP_PORT: ${{ inputs.smtp-port }}
GH_SERVER_URL: ${{ github.server_url }} GH_SERVER_URL: ${{ github.server_url }}
+108
View File
@@ -8,8 +8,11 @@ to mail.fritzlab.net, which relays via trusted-CIDR rules (see mail.md).
from __future__ import annotations from __future__ import annotations
import os import os
import re
import smtplib import smtplib
import sys import sys
import urllib.error
import urllib.request
from email.message import EmailMessage from email.message import EmailMessage
from html import escape from html import escape
from typing import Iterable from typing import Iterable
@@ -78,6 +81,93 @@ def required(name: str) -> str:
return v return v
# Strip the leading `2026-05-28T17:09:35.337087Z ` timestamp that act_runner
# prepends to every job-log line. Pattern: ISO-8601 + variable-precision micros
# + Z + single space.
_TS_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s")
def _api_get(url: str, token: str) -> bytes:
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
with urllib.request.urlopen(req, timeout=10) as r:
return r.read()
def fetch_failing_log(
*,
server_url: str,
repo: str,
run_id: str,
token: str,
log_lines: int,
) -> str:
"""Return a digestible tail of the current run's failing-job log.
Resolution: enumerate jobs in this run, pick the one whose conclusion is
'failure' (or — for an in-flight notify-on-failure step — whose status is
'in_progress' with at least one step in failure). Fetch that job's full
text log. If any line carries an `::error::` annotation, return those
lines plus a small context window above each. Otherwise, return the last
`log_lines` lines. Timestamp prefixes are stripped.
"""
import json as _json
base = server_url.rstrip("/")
jobs_url = f"{base}/api/v1/repos/{repo}/actions/runs/{run_id}/jobs"
try:
jobs = _json.loads(_api_get(jobs_url, token)).get("jobs", [])
except (urllib.error.HTTPError, urllib.error.URLError, ValueError) as e:
print(f"notify-email: auto-log: jobs lookup failed: {e}", file=sys.stderr)
return ""
failed = None
for j in jobs:
if j.get("conclusion") == "failure":
failed = j
break
# Notify step itself is still in_progress at this point — pick the job
# if any of its prior steps failed.
if j.get("status") == "in_progress" and any(
s.get("status") == "failure" or s.get("conclusion") == "failure"
for s in j.get("steps", [])
):
failed = j
break
if not failed:
# Heuristic last resort: most recent job in the run.
failed = jobs[0] if jobs else None
if not failed:
return ""
job_id = failed.get("id")
log_url = f"{base}/api/v1/repos/{repo}/actions/jobs/{job_id}/logs"
try:
raw = _api_get(log_url, token).decode("utf-8", errors="replace")
except (urllib.error.HTTPError, urllib.error.URLError) as e:
print(f"notify-email: auto-log: log fetch failed: {e}", file=sys.stderr)
return ""
cleaned = [_TS_PREFIX.sub("", ln) for ln in raw.splitlines()]
error_idxs = [i for i, ln in enumerate(cleaned) if "::error::" in ln]
if error_idxs:
# Window: 12 lines of context above each ::error:: annotation,
# plus the annotation itself; merge overlapping windows.
ranges: list[tuple[int, int]] = []
for i in error_idxs:
lo = max(0, i - 12)
hi = i + 1
if ranges and lo <= ranges[-1][1] + 1:
ranges[-1] = (ranges[-1][0], max(ranges[-1][1], hi))
else:
ranges.append((lo, hi))
chunks: list[str] = []
for lo, hi in ranges:
chunks.append("\n".join(cleaned[lo:hi]))
return "\n\n".join(chunks).strip()
return "\n".join(cleaned[-log_lines:]).strip()
def fact_rows(items: Iterable[tuple[str, str, str]]) -> tuple[str, str]: def fact_rows(items: Iterable[tuple[str, str, str]]) -> tuple[str, str]:
"""Build the HTML <table> rows and plain-text lines for the fact list. """Build the HTML <table> rows and plain-text lines for the fact list.
@@ -221,6 +311,24 @@ def main() -> int:
run_id = os.environ.get("GH_RUN_ID", "") run_id = os.environ.get("GH_RUN_ID", "")
run_url = f"{server_url}/{repo}/actions/runs/{run_id}" if server_url and repo and run_id else "" run_url = f"{server_url}/{repo}/actions/runs/{run_id}" if server_url and repo and run_id else ""
auto_log = os.environ.get("NOTIFY_AUTO_LOG", "").strip().lower() in {"1", "true", "yes"}
if auto_log and not details.strip() and server_url and repo and run_id:
token = os.environ.get("NOTIFY_TOKEN", "").strip()
if token:
try:
lines = int(os.environ.get("NOTIFY_LOG_LINES", "40") or "40")
except ValueError:
lines = 40
fetched = fetch_failing_log(
server_url=server_url,
repo=repo,
run_id=run_id,
token=token,
log_lines=lines,
)
if fetched:
details = fetched
commit_html = "" commit_html = ""
if sha: if sha:
if server_url and repo: if server_url and repo: