fix: ci-log durable persisted-log fallback for pruned runs

ci-log drove only the live run-view streamer (POST .../runs/<n>/jobs/<j>
with logCursors), which reads the live act_runner task record. Forgejo
prunes that record once a run completes, so the streamer 500s with
'task ... resource does not exist' on quick or older runs even though
the web UI still shows the logs — the reader (argus, atlas) then had to
ask the operator to relay the error.

Add a fallback: when the streamer errors or returns no lines, download
the persisted whole-job log via the same web route the run page's view-
raw-logs link uses, .../runs/<n>/jobs/<job>/attempt/<a>/logs, keyed by
the per-repo run number with the attempt segment. It survives the task
prune. Live and recent runs keep the rich per-step streamed view; only
the pruned case takes the flat fallback (where --step can't apply). New
--attempt flag (default 1) selects the run attempt for re-runs.

Verified against a real pruned run whose streamer 500'd: the fallback
prints the full persisted log; a completed short job ends cleanly at
'Job succeeded', confirming the route returns complete logs.
This commit is contained in:
atlas 2026-06-23 22:16:18 +02:00 committed by mara
commit d340c1773a
2 changed files with 124 additions and 68 deletions

View file

@ -165,16 +165,21 @@ plain comment show under `last comment`, not `reviews`.
the run's internal global id, so the verb translates the run number
first. Saves a zip to `/tmp/forge-artifact-<name>.zip` by default; pass
`-o -` to stream to stdout.
- `ci-log --run <n> [--job i] [--step i]` prints a CI run's job step
logs. `<n>` is the run number from the run-page URL (same value
`artifact-get` takes; `pr-status` surfaces it as a CI context's
target_url). Forgejo exposes no REST endpoint for job logs, so the
verb drives the web run-view streamer the run page polls. `--job`
selects the job within the run (0-based, default 0); `--step` narrows
to one step. `act_runner` garbage-collects completed-run logs, so this
is reliable for live + recently-finished runs; when logs are gone the
verb says so rather than printing nothing. `--json` dumps the raw
run-view response.
- `ci-log --run <n> [--job i] [--step i] [--attempt n]` prints a CI
run's job step logs. `<n>` is the run number from the run-page URL
(same value `artifact-get` takes; `pr-status` surfaces it as a CI
context's target_url). Two log sources are tried in order: first the
web run-view **streamer** the run page polls (rich per-step framing,
honors `--step`) — but that reads the live `act_runner` task record,
which Forgejo prunes once a run completes; then, when the streamer is
pruned (500 / no lines), the **durable persisted-log download** the
run page's "view raw logs" link uses
(`…/runs/<n>/jobs/<job>/attempt/<a>/logs`), a flat whole-job log that
survives the prune (`--step` is not honored on this path). So quick /
older runs that the streamer can no longer serve still print instead
of erroring. `--job` selects the job (0-based, default 0); `--attempt`
picks the run attempt for the durable path (default 1; re-runs
increment it). `--json` wraps the output.
- `ci-rerun` re-runs CI without pushing an empty commit (the old
retrigger path, which littered PR history). Forgejo has no token-usable
REST endpoint to re-run an *existing* run (the run-page rerun buttons

View file

@ -1,23 +1,28 @@
//! `ci-log --run <run-number> [--job <idx>] [--step <idx>]` — print a CI
//! Actions run's job step logs.
//!
//! Forgejo exposes no REST endpoint for Actions job logs; the only path
//! is the web run-view streamer the run page polls,
//! `POST <base>/<owner>/<repo>/actions/runs/<run>/jobs/<job>` with a
//! `{"logCursors":[{"step":N,"cursor":C,"expanded":bool}]}` body. Unlike
//! the artifact-download route (which keys off the run's GLOBAL id), this
//! route uses the per-repo run NUMBER — the `runs/<n>` the UI shows and
//! `pr-status` surfaces as a CI context `target_url` — so the caller's
//! `--run` value is used directly, no id translation.
//! Two log sources, tried in order, because each covers a different
//! lifecycle window:
//!
//! Protocol: POST once with an empty cursor set to fetch the run state
//! and enumerate the job's steps, then POST again requesting each step's
//! log from cursor 0 (`expanded: true`) and print the assembled lines.
//! 1. **Live run-view streamer** — `POST <base>/<owner>/<repo>/actions/runs/
//! <run>/jobs/<job>` with a `{"logCursors":[{"step":N,"cursor":C,
//! "expanded":bool}]}` body, the same endpoint the run page polls. It
//! gives rich per-step framing but reads the **live `act_runner` task
//! record**, which Forgejo prunes once a run completes — so a run that
//! finished a while ago returns `500 "task ... resource does not exist"`
//! or no lines. Keyed by the per-repo run NUMBER (the `runs/<n>` the UI
//! shows and `pr-status` surfaces as a CI context `target_url`).
//! 2. **Durable persisted-log download** — when the streamer is pruned the
//! step-log files still exist on disk (the web UI reads them fine after
//! the streamer 500s). We fetch them via the web download route
//! `<base>/<owner>/<repo>/actions/runs/<run-id>/jobs/<job>/logs`, keyed
//! by the run's GLOBAL id (mirrors `artifact-get`: the REST runs list
//! translates run number → global id). This is a flat whole-job log with
//! no per-step framing, so `--step` is not honored on this path.
//!
//! Caveat: `act_runner` garbage-collects step logs for completed runs, so
//! a run that finished a while ago may return no log lines. The verb is
//! reliable for in-progress and recently-finished runs; when no lines
//! come back it says so rather than printing nothing.
//! Result: live + recent runs get the rich streamed view; older / quickly-
//! pruned runs (the #1781 case argus + atlas both hit) still print via the
//! durable fallback instead of a bare "logs garbage-collected" error.
use anyhow::{Result, bail};
use clap::Args as ClapArgs;
@ -35,8 +40,14 @@ pub struct Args {
#[arg(long, default_value_t = 0)]
job: u64,
/// Print only this step's log (0-based). Omit to print every step.
/// Honored on the live-streamer path only; the durable persisted-log
/// fallback serves a flat whole-job log and ignores `--step`.
#[arg(long)]
step: Option<usize>,
/// Run attempt number for the durable persisted-log download (re-runs
/// increment it; default 1 covers the common single-attempt case).
#[arg(long, default_value_t = 1)]
attempt: u64,
}
/// Extract the step list from a run-view response
@ -63,39 +74,18 @@ fn step_name(step: Option<&Value>) -> String {
.to_owned()
}
/// # Errors
///
/// Returns an error if the run/job can't be fetched (network, or a
/// non-2xx such as `404` for an unknown run), if the run has no steps,
/// or if no log lines are returned (e.g. logs already garbage-collected).
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo();
let path = format!("/{repo}/actions/runs/{}/jobs/{}", args.run, args.job);
/// Try the live run-view streamer. Returns `Ok(true)` when it printed (or
/// emitted JSON for) actual log lines, `Ok(false)` when the streamer is
/// reachable but has no lines (pruned task), and `Err` on a transport / HTTP
/// failure (e.g. the `500 "resource does not exist"` a pruned run returns).
/// Both the `Ok(false)` and `Err` cases tell the caller to fall back to the
/// durable persisted-log download.
fn streamer_logs(client: &Client, path: &str, args: &Args) -> Result<bool> {
// 1. Discover the job's steps (state is returned regardless of cursors).
// A 404 (run/job gone) or 500 ("task ... resource does not exist", which
// the forge returns once act_runner has pruned a completed run's task
// record) surfaces here as an Err — translate it into the same
// logs-unavailable guidance rather than leaking the raw HTTP error.
let view = match client.post_json_web(&path, &json!({ "logCursors": [] })) {
Ok(v) => v,
Err(e) => bail!(
"run #{} job {} unavailable — the run/job may not exist, or its logs were \
garbage-collected (act_runner prunes completed-run task records; ci-log \
is reliable on live + recently-finished runs). Underlying: {e}",
args.run,
args.job
),
};
let view = client.post_json_web(path, &json!({ "logCursors": [] }))?;
let steps = steps_of(&view);
if steps.is_empty() {
bail!(
"run #{} job {} has no steps — run/job not found, or logs were \
garbage-collected (act_runner prunes completed-run logs). Try a \
live or recently-finished run.",
args.run,
args.job
);
return Ok(false);
}
// 2. Request the chosen step(s) from cursor 0, fully expanded.
@ -114,13 +104,8 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
.iter()
.map(|&s| json!({ "step": s, "cursor": 0, "expanded": true }))
.collect();
let resp = client.post_json_web(&path, &json!({ "logCursors": cursors }))?;
let resp = client.post_json_web(path, &json!({ "logCursors": cursors }))?;
if client.json_mode() {
return crate::verbs::print_json(&resp);
}
// 3. Human output: a header per step, then its lines in order.
let steps_log = resp
.get("logs")
.and_then(|l| l.get("stepsLog"))
@ -128,14 +113,15 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
.cloned()
.unwrap_or_default();
if steps_log.is_empty() {
bail!(
"no log lines returned for run #{} job {} — logs were likely \
garbage-collected (act_runner prunes completed-run logs). ci-log \
is reliable on live and recently-finished runs.",
args.run,
args.job
);
return Ok(false);
}
if client.json_mode() {
crate::verbs::print_json(&resp)?;
return Ok(true);
}
// Human output: a header per step, then its lines in order.
for sl in &steps_log {
let idx = sl
.get("step")
@ -152,9 +138,74 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
}
}
}
Ok(true)
}
/// Durable fallback: download the persisted whole-job log via the web route
/// the run page's "view raw logs" link uses,
/// `<base>/<owner>/<repo>/actions/runs/<run>/jobs/<job>/attempt/<n>/logs`
/// (keyed by the per-repo run NUMBER, with the attempt segment). Prints the
/// text (or wraps it in JSON under `--json`). `--step` is not honored here —
/// the persisted log is flat.
fn persisted_logs(client: &Client, repo: &str, args: &Args) -> Result<()> {
let url = client.web_url(&format!(
"/{repo}/actions/runs/{}/jobs/{}/attempt/{}/logs",
args.run, args.job, args.attempt
));
let bytes = client.get_bytes_raw(&url)?;
if bytes.is_empty() {
bail!(
"run #{} job {} returned an empty log from both the live streamer \
and the persisted-log download the run may not exist or its logs \
were fully purged.",
args.run,
args.job
);
}
let text = String::from_utf8_lossy(&bytes);
if client.json_mode() {
return crate::verbs::print_json(&json!({
"run": args.run,
"job": args.job,
"source": "persisted",
"log": text,
}));
}
print!("{text}");
if !text.ends_with('\n') {
println!();
}
Ok(())
}
/// # Errors
///
/// Returns an error if the run/job can't be fetched from either source
/// (network, an unknown run number, or a non-2xx), if a requested `--step`
/// is out of range, or if both the live streamer and the persisted-log
/// download yield no log content.
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo();
let stream_path = format!("/{repo}/actions/runs/{}/jobs/{}", args.run, args.job);
// 1. Live run-view streamer first — rich per-step framing for in-progress
// and recently-finished runs. A pruned run errors or returns no lines;
// both cases fall through to the durable download below. (An
// out-of-range `--step` is a real usage error and propagates.)
match streamer_logs(client, &stream_path, &args) {
Ok(true) => return Ok(()),
// An out-of-range `--step` is a real usage error, not a pruned run —
// surface it instead of masking it with the fallback.
Err(e) if e.to_string().contains("out of range") => return Err(e),
// Pruned task (Err) or reachable-but-no-lines (Ok(false)) → fall back.
Ok(false) | Err(_) => {}
}
// 2. Durable persisted-log download (survives the act_runner task prune
// that 500s the streamer — the #1781 case).
persisted_logs(client, repo, &args)
}
#[cfg(test)]
mod tests {
use super::*;