# Recipe: read a Woodpecker pipeline's per-step logs

> **Audience:** any agent session or operator asking *"why did that step fail?"*
> **Cost of not having this:** an entire two-session incident (F-0005). Per-step logs
> were treated as unreachable, diagnosis ran by elimination at ~10× cost, and the
> conclusion — *"blocked on `woodpecker_token`"* — was false the whole time. One
> clone-step log, read in under a minute, settled what eight red pipelines could not
> (the counterfeit-`master` diagnosis, 2026-08-01).

## 0. The token — it exists; do not mint one

`[woodpecker_token]` in [`SECRETS.toml`](../../SECRETS.toml) declares it, and its
`consumers` field names agent sessions explicitly. Resolution order:

```sh
export WP=https://195.15.241.48.sslip.io                  # MGF_INFRA §1 — ours, unmetered
export WPT="${WOODPECKER_TOKEN:-$(cat ~/PycharmProjects/mgf-standard/my_stuff/woodpecker_token.txt)}"
```

**Mint a new one only on a 401**, per [MGF_INFRA §2](../../MGF_INFRA.md). A 401 is the
*only* evidence this token has stopped working: it carries **no `exp` claim**, so it
cannot expire on a clock — it dies when a Woodpecker DB user-hash rotation invalidates
it. "I could not find it" is not a 401, and neither is "the doctor said SKIPPED".

**AG-18:** never `cat`/`echo` the token itself. Command substitution keeps the value
out of your transcript; `mgf-secrets exec --with <id> -- <cmd>` is better still where
the command shape allows it.

## 1. Repo id from its slug

```sh
RID=$(curl -fsS -H "Authorization: Bearer $WPT" "$WP/api/repos/lookup/magogi-admin/<repo>" | jq -r .id)
```

## 2. Recent pipelines

```sh
curl -fsS -H "Authorization: Bearer $WPT" "$WP/api/repos/$RID/pipelines?perPage=5" \
  | jq -r '.[] | "\(.number)\t\(.status)\t\(.event)\t\(.branch)"'
```

`status` is `success` · `failure` · `running` · `error`. **`error` is not `failure`:**
it means Woodpecker could not even start the run — most often *"could not load config
from forge"*, i.e. that ref has no `.woodpecker.yml`. Tagging a commit that predates
the pipeline produces `error`, and that is history, not a defect.

## 3. Steps, with their ids

```sh
curl -fsS -H "Authorization: Bearer $WPT" "$WP/api/repos/$RID/pipelines/<number>" \
  | jq -r '.workflows[].children[] | "\(.id)\t\(.name)\t\(.state)\t\(.exit_code)"'
```

The `id` here is the **step id** the logs endpoint wants — not the `pid`, not the step
name. Picking the wrong one is the usual reason this recipe "doesn't work".

## 4. The log

Each array entry's `data` is **one base64-encoded line** — and the decoded fragments
carry **no trailing newline**. Decoding the stream in one shot therefore concatenates
the whole log into a single line, which is fine for eyeballing and useless for
filtering (`grep` then matches the entire blob). Decode per entry and re-add the
newline:

```sh
curl -fsS -H "Authorization: Bearer $WPT" "$WP/api/repos/$RID/logs/<number>/<stepID>" \
  | jq -r '.[].data' | while IFS= read -r l; do printf '%s\n' "$l" | base64 -d; printf '\n'; done
```

Steps that `apt-get install` bury their real output under hundreds of package lines, so
filter rather than scroll — and note that a gate's verdict is usually the *last* thing
it prints:

```sh
  … | grep -Ei 'error|failed|AG-03|check-release-map'
```

## Worked example — the run that proved the counterfeit-master fix

```sh
RID=54                                    # figrecovery
# pipeline 86 = the push that landed PR #33 on master; 67078 = its commit-trailers step
curl -fsS -H "Authorization: Bearer $WPT" "$WP/api/repos/54/logs/86/67078" \
  | jq -r '.[].data' | while IFS= read -r l; do printf '%s\n' "$l" | base64 -d; printf '\n'; done \
  | grep -Ei 'AG-03|check-release-map'
```

```
AG-03: all 0 commit(s) on this branch carry both trailers.
check-release-map: 15 of 15 declared version(s) are placeable on origin/master; RELEASES.md matches the derivation.
```

Read that against the same step on the **PR** run (#81), which reported
`all 8 commit(s)` and declined to derive the release map at all. Both are correct, and
the difference is the whole point: on a `pull_request` event Woodpecker names the PR
checkout after the default branch, so a gate that asks *"what is on master?"* by name
gets a counterfeit answer. The gates now decide by **provenance** — a fetched
`refs/remotes/origin/<branch>` — so they enforce in full on the landing push and state
a reduced scope, out loud, on PRs.

## When it 401s

Then, and only then, mint per [MGF_INFRA §2](../../MGF_INFRA.md) — it is an on-host
agent task, not a founder gate — and write the new value to the declared path above.
Update `rotation.last` in `SECRETS.toml`, and run `mgf-secrets doctor --surface local`
to confirm the entry probes clean.

## See also

- [`activate-ci.md`](activate-ci.md) — turning a repo's CI on in the first place.
- [MGF_INFRA §2](../../MGF_INFRA.md) — access & recovery for every estate surface.
- `mgf-secrets probe woodpecker_token` — the registry's own verdict on this credential.
