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:
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user