diff --git a/README.md b/README.md index fa9b4b7..85b24b3 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,25 @@ With richer context: 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): ```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. | | `subject` | no | `summary` | Mail subject after the status prefix. | | `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. | | `from` | no | `ci@fritzlab.net` | Sender address. Must be permitted by the relay. | | `smtp-host` | no | `mail.fritzlab.net` | SMTP relay host. | diff --git a/action.yaml b/action.yaml index 97320b7..96c2fa4 100644 --- a/action.yaml +++ b/action.yaml @@ -28,6 +28,24 @@ inputs: description: Optional multiline preformatted block (rendered in a monospace card). Leave empty to omit. required: false 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: description: SMTP relay host. required: false @@ -48,6 +66,9 @@ runs: NOTIFY_SUBJECT: ${{ inputs.subject }} NOTIFY_SUMMARY: ${{ inputs.summary }} 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_PORT: ${{ inputs.smtp-port }} GH_SERVER_URL: ${{ github.server_url }} diff --git a/scripts/send.py b/scripts/send.py index e9fb163..0b92cc6 100644 --- a/scripts/send.py +++ b/scripts/send.py @@ -8,8 +8,11 @@ to mail.fritzlab.net, which relays via trusted-CIDR rules (see mail.md). from __future__ import annotations import os +import re import smtplib import sys +import urllib.error +import urllib.request from email.message import EmailMessage from html import escape from typing import Iterable @@ -78,6 +81,93 @@ def required(name: str) -> str: 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]: """Build the HTML