Inspect Forgejo repositories through the REST API and web endpoints, especially Actions CI, task/run metadata, logs, package registry tags, haku-state CI, and Flux/image rollout debugging. Use when diagnosing Forgejo Actions failures, missing logs, branch badge state, package/image publish gaps, or when endpoint shape is unclear.
Scanned 9/9/2026
Install to Claude Code
npx -y skills add agentydragon/ducktape --skill forgejo --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Forgejo?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/agentydragon-forgejo)More formats (shields.io, HTML) on the badges page.
---
name: forgejo
description: Inspect Forgejo repositories through the REST API and web endpoints, especially Actions CI, task/run metadata, logs, package registry tags, haku-state CI, and Flux/image rollout debugging. Use when diagnosing Forgejo Actions failures, missing logs, branch badge state, package/image publish gaps, or when endpoint shape is unclear.
---
# Forgejo
Use live Forgejo evidence before inferring from manifests. For private repos, get a real
repo credential first and keep it in shell variables; never print tokens or passwords.
## Endpoint Discovery
Check the deployment's OpenAPI before guessing:
```bash
curl -fsS "$FORGEJO_URL/swagger.v1.json" \
| jq -r '.paths | keys[] | select(test("actions|packages|repos"))'
```
This Forgejo deployment exposes Actions metadata under `/api/v1/repos/.../actions/...`.
It does not expose GitHub-compatible REST endpoints for log download or rerun/retry; the
one Actions write route is `workflow_dispatch` (§ Re-running).
Gotchas that look like a wrong route:
- An unauthenticated call for a private repo returns the generic 404
`{"message":"The target couldn't be found."}`, identical to a bad path. Authenticate
first: `curl -n` reads `~/.netrc` and Basic auth is enough for `/api/v1/...`.
- `/actions/runs` returns the whole run history unless paginated. On `haku/haku-state`
the unbounded response was over 9 MB and outran a 90 s timeout; `?limit=15&page=1` is
140 KB and answers in about a second.
## Actions Metadata
List runs:
```bash
curl -fsS -u "$USER:$PASS" \
"$FORGEJO_URL/api/v1/repos/$OWNER/$REPO/actions/runs" \
| jq -r '.workflow_runs[] |
[.index_in_repo, .id, .workflow_id, .status, .prettyref, .commit_sha[0:9], .title] | @tsv'
```
Important ID namespaces:
- `index_in_repo` is the UI/display run number, e.g. `/actions/runs/396`.
- `id` is the internal REST run id for `GET /api/v1/repos/$OWNER/$REPO/actions/runs/$id`.
- `actions/tasks` returns job/task rows; its `id` is a task id, not the run id.
- A job re-run keeps the run's `index_in_repo`. It shows up as a **new task row** with
the same `run_number` and a later `run_started_at`, and the run's `status` flips back
to `running`; watching the run list for a higher index misses it.
List task rows:
```bash
curl -fsS -u "$USER:$PASS" \
"$FORGEJO_URL/api/v1/repos/$OWNER/$REPO/actions/tasks" \
| jq -r '.workflow_runs[] |
[.run_number, .id, .name, .workflow_id, .status, .head_branch, .head_sha[0:9], .display_title] | @tsv'
```
Always inspect `event_payload` for the actual ref and changed paths:
```bash
curl -fsS -u "$USER:$PASS" \
"$FORGEJO_URL/api/v1/repos/$OWNER/$REPO/actions/runs/$INTERNAL_RUN_ID" \
| jq -r '.prettyref, .event_payload | fromjson? | {ref, before, after, commits}'
```
A green run on a non-main branch does not prove a main-gated publish step ran. A green
`workflow_dispatch` run also does not prove the push-triggered path is healthy; compare
the `event` field before using one run to explain another.
## Actions CI Timing
The `logs` subcommand (below) answers "why did this run fail?"; `timing` answers "why is CI
slow?" — a per-job duration distribution over recent tasks. The script is self-contained
(PEP 723 inline deps), so `uv run` fetches `httpx` + `pydantic` on the fly; in-repo,
`bb run //skills/forgejo/scripts:cli -- timing …` works too:
```bash
uv run skills/forgejo/scripts/forgejo.py timing --owner "$OWNER" --repo "$REPO"
# --list also prints individual recent tasks
# --limit N analyze the N most-recent finished tasks (default 200)
# --max-seconds S drop longer rows as outliers (default 1800, the runner job timeout)
```
It reads `FORGEJO_URL` (defaults to this deployment) and authenticates via `~/.netrc` (or
`FORGEJO_USER` / `FORGEJO_PASSWORD`). Sample (`haku/haku-state`):
```text
job n min p50 p90 max
bazel 37 494s 538s 593s 659s
validate 51 161s 223s 247s 276s
linkcheck 92 20s 61s 72s 131s
```
A flat `min ≈ p50` on every run (no run ever incremental) is the signature of a build with no
persistent cache.
Manual equivalent — duration is `updated_at - run_started_at`, per finished task:
```bash
curl -fsS -u "$USER:$PASS" "$FORGEJO_URL/api/v1/repos/$OWNER/$REPO/actions/tasks" \
| jq -r '.workflow_runs[]
| select(.status=="success" or .status=="failure")
| [.name, ((.updated_at|fromdateiso8601) - (.run_started_at|fromdateiso8601) | floor)]
| @tsv'
```
Timing-field gotchas on this deployment (they bite a naive reading):
- **No `conclusion`, no `stopped_at`.** `status` carries
`success`/`failure`/`cancelled`/`running`; `updated_at` is the completion time of a finished
task.
- **The start field is `run_started_at`, not `started_at`** — there is no `started_at`, so
reading it silently yields `null` and drops every row.
- **Duration is run + queue wall time.** The runner is capacity-limited, so a row can sit
queued before it runs; treat a long tail as queue wait and filter outliers (the helper's
`--max-seconds`).
- **`limit` is ignored** — the endpoint returns the whole task list under `workflow_runs`;
slice client-side. That list is the repo's entire task history (3.4 MB in 80–90 s on
`haku/haku-state`, 2026-09), so give the read minutes: a default 30 s client times out.
## Logs
On this deployment, logs are web UI endpoints, not documented REST routes. Treat these
routes as Forgejo UI implementation details: discover the current URL shape from the run
page each time, prefer page-provided attributes over hardcoded IDs, and expect this recipe
to need adjustment after Forgejo upgrades. REST Basic auth is enough for `/api/v1/...`, but
not for the web log endpoints. Start a temporary web session with the same credential:
Prefer the bundled helper when you need logs:
```bash
# List step indexes from the run page.
uv run skills/forgejo/scripts/forgejo.py logs \
--owner "$OWNER" --repo "$REPO" --run "$RUN_NUMBER" --list-steps
# Fetch one expanded step's log.
uv run skills/forgejo/scripts/forgejo.py logs \
--owner "$OWNER" --repo "$REPO" --run "$RUN_NUMBER" --step "$STEP_INDEX"
```
The helper logs in, fetches the run page, parses the page-provided `data-*` attributes, and
posts the UI's JSON cursor payload. If `uv run` picks a stripped system interpreter (the
symptom is `ModuleNotFoundError: No module named 'math'` from inside the stdlib, seen in
the Claude Code web container), point it at the resolved path of a full one:
`uv run --python "$(readlink -f /usr/local/bin/python3.13)" …`. The symlink path itself
does not help: a venv whose `home` is `/usr/local/bin` falls back to the same stripped
`/usr/lib/python3.13`.
The credential comes from the `~/.netrc` entry for the Forgejo host (mode `600`, or
Python's `netrc` refuses it) unless `--user`/`--password` or
`FORGEJO_USER`/`FORGEJO_PASSWORD` are set, so no password has to pass through a command
line; do not print them.
Manual equivalent. The login form carries no `_csrf` field on this deployment, so the login
is one POST; a wrong password answers 200 with the form again, so check that the final URL
left `/user/login`:
```bash
cookie=$(mktemp)
curl -fsS -L -b "$cookie" -c "$cookie" -o /dev/null -w '%{url_effective}\n' \
--data-urlencode "user_name=$USER" \
--data-urlencode "password=$PASS" \
"$FORGEJO_URL/user/login"
```
Fetch the run page using the UI/display run number, then read the attributes the Vue app
uses:
```bash
curl -fsS -L -b "$cookie" \
"$FORGEJO_URL/$OWNER/$REPO/actions/runs/$RUN_NUMBER" \
-o /tmp/forgejo-run.html
```
On this deployment the relevant HTML attributes are:
- `data-actions-url`, e.g. `/haku/haku-state/actions`
- `data-run-index`, the UI run number
- `data-job-index`, zero-based index in the run's job list
- `data-attempt-number`
- `data-initial-post-response`, JSON-escaped initial job state containing step indexes,
statuses, and the UI job ids
To pull one step's log, POST JSON to the same endpoint the UI uses:
```bash
endpoint="$FORGEJO_URL${ACTIONS_URL}/runs/${RUN_INDEX}/jobs/${JOB_INDEX}/attempt/${ATTEMPT}"
curl -fsS -L -b "$cookie" -H 'Content-Type: application/json' \
--data '{"logCursors":[{"step":STEP_INDEX,"cursor":null,"expanded":true}]}' \
"$endpoint" \
| jq -r '.logs.stepsLog[] | .lines[] | [.timestamp, .message] | @tsv'
```
Notes:
- `jobs/$JOB_INDEX` is the zero-based UI job index, not the REST task id and not the UI job
id embedded in `data-initial-post-response`.
- There is no REST rerun or retry; § Re-running covers what exists.
- The download link in the gear menu is
`$ACTIONS_URL/runs/$RUN_INDEX/jobs/$JOB_INDEX/attempt/$ATTEMPT/logs`, but the JSON POST is
better for targeted diagnostics and works with expanded-step cursors.
- If the POST returns an empty `stepsLog`, expand the failing step by index. The initial
page state lists each step summary/status under `state.currentJob.steps`.
- Some repos also publish fallback logs, e.g. a `ci-logs` branch or workflow artifact; check
workflow comments before assuming web logs are the only channel.
## Re-running
There is no REST rerun or retry on this deployment (15.0.3+gitea-1.22.0; `swagger.v1.json`
has no path matching `rerun|retry|cancel`). The UI's re-run buttons post to web routes,
which the helper drives over the § Logs session; a `workflow_dispatch` is the REST-only
alternative, and it does less.
**The UI re-run, the web route — what repaints a PR's checks.** The helper logs in
(`~/.netrc`, § Logs), reads the run page's job list and `canRerun` flags, and posts where
the UI's buttons post:
```bash
# One job (by name, or by zero-based index in the run's job list) plus the jobs that need it.
uv run skills/forgejo/scripts/forgejo.py rerun \
--owner "$OWNER" --repo "$REPO" --run "$RUN_NUMBER" --job image
# Every job of the run.
uv run skills/forgejo/scripts/forgejo.py rerun --owner "$OWNER" --repo "$REPO" --run "$RUN_NUMBER"
```
Manual equivalent over the cookie jar from § Logs. `$RUN_LINK` is the page state's
`state.run.link` (`/haku/haku-state/actions/runs/7502`); `$JOB_INDEX` is the job's position
in `state.run.jobs`, which follows the workflow file's job order — not the task id, not the
UI job id:
```bash
curl -fsS -b "$cookie" -X POST "$FORGEJO_URL$RUN_LINK/jobs/$JOB_INDEX/rerun" # -> {}
curl -fsS -b "$cookie" -X POST "$FORGEJO_URL$RUN_LINK/rerun" # every job
```
Observed on a job re-run (`haku/haku-state` run 7502, `validate`, 2026-09-08): the run keeps
its index and goes back to `running`; the job gets a new task row in `/actions/tasks` with a
later `run_started_at` (poll that, not the run list, whose `updated_at` stays blank); and
the job's commit-status context on the PR head is re-posted — `pending` within seconds, the
final state when the job ends. So a job re-run repaints the PR's check, which the dispatch
below cannot. `canRerun` is false while the run is still running or when the session cannot
write Actions; the helper stops on it before posting.
**`workflow_dispatch`, the REST route.** Works only for a workflow that declares
`on: workflow_dispatch`; `haku/haku-state`'s `bazel-ci.yaml` does, as its documented manual
re-run, and its gate runs the `image` job for every non-push event:
```bash
curl -fsS -n -X POST -H 'Content-Type: application/json' \
--data '{"ref":"my-branch"}' \
"$FORGEJO_URL/api/v1/repos/$OWNER/$REPO/actions/workflows/bazel-ci.yaml/dispatches"
# 204 No Content; the run appears in /actions/runs a few seconds later.
```
How the dispatched run reads back, and what it does not do:
- In the run list its `prettyref` is the bare branch name (a PR run shows `#N`) and its
`event` is null, so a filter on `.event == "workflow_dispatch"` finds nothing.
- It posts **no commit status** on the commit, not even its own context: after a green
dispatch of `bazel-ci.yaml`, `/commits/{sha}/statuses` still listed only the
`(pull_request)` contexts, with the failed `image` one untouched. So a dispatch proves
the commit builds; the PR's checks go green only through the UI re-run or the next push.
## Actions Artifacts
Verified on this deployment (Forgejo 15.0.3+gitea-1.22.0, probe 2026-07-12; byte-identical
sha256 round-trip).
Upload, in workflows: `actions/upload-artifact@v4` fails with `GHESNotSupportedError` —
the GitHub action refuses any non-github.com host. Use
`https://code.forgejo.org/forgejo/upload-artifact@v4` (preferred, v4 semantics) or
`actions/upload-artifact@v3`.
Download: no REST endpoints (`/api/v1/.../actions/artifacts` and
`.../actions/runs/$ID/artifacts` both 404, and Basic auth on the web routes also 404s) —
web session only (login recipe under Logs above). Gotcha: the two web routes key on
different run identifiers, both available from `GET
/api/v1/repos/$OWNER/$REPO/actions/runs` (`id` = DB id, `index_in_repo` = UI/display run
number; the tasks endpoint's `url` field also embeds the display number):
```bash
# List (display run number): -> {"artifacts":[{name,size,status}]}
curl -fsS -b "$cookie" "$FORGEJO_URL/$OWNER/$REPO/actions/runs/$RUN_INDEX/artifacts"
# Download (DB id! the display number here returns 404 "no such run").
# The response is always a ZIP wrapping the uploaded file(s).
curl -fsS -b "$cookie" -o artifact.zip \
"$FORGEJO_URL/$OWNER/$REPO/actions/runs/$RUN_DB_ID/artifacts/$ARTIFACT_NAME"
unzip artifact.zip
```
## Actions Secrets And Registry Auth
Forgejo Actions secret metadata is deliberately opaque. `GET
/api/v1/repos/$OWNER/$REPO/actions/secrets` proves a secret exists, but it does not reveal
the value and may not include a useful `updated_at`. Do not conclude a registry credential
was refreshed from that listing alone. Verify with behavior: an authenticated registry probe
such as `/v2/`, a workflow preflight step, or a real image push.
When diagnosing image publishes, compare registry responses:
```bash
curl -sS -o /tmp/registry-probe.json -w '%{http_code}\n' \
-u "$REGISTRY_USER:$REGISTRY_PASSWORD" \
"$FORGEJO_URL/v2/"
```
On this deployment, valid haku credentials return `200` for `/v2/`; empty or wrong
passwords return `401`. A workflow-only `403` points at the exact Actions context or
generated Docker auth config, not at the registry being globally down.
## haku-state CI And UI Rollout
For `haku/haku-state`, the useful read credential is `haku-forgejo-git` from
`haku-sandbox`. Use it for API reads; do not use scratch tokens.
`bazel-ci.yaml` publishes the failing job's Bazel output to the `ci-logs` branch
(`tools/ci/publish_bazel_log.sh`), and only on failure, so a green run leaves no log there.
The branch holds one commit that each publish replaces, and a job re-run republishes under
the **same** subject, `ci log: run N (sha)`. Poll for a new hash or commit date, never for
a new subject:
```bash
git fetch origin ci-logs && git log -1 --format='%h %ci %s' FETCH_HEAD
git show FETCH_HEAD:bazel-ci.log
```
Distinguish the two CI surfaces:
- `validate-state.yaml` green means the current `main` data contract is valid. It does not
build or publish `haku-ui`.
- `bazel-ci.yaml` on `main` is the image publish path. Path filters mean data-only commits
do not run it.
To answer "is a UI change live?", verify the whole chain:
1. `bazel-ci.yaml` ran on `main` for the UI commit, not only on a PR or `wip/*`.
2. The registry has a matching `git.allegedly.works/haku/ui:main-<utc>-<sha7>` tag.
3. `ImageRepository/haku-ui` scanned that tag.
4. `ImagePolicy/haku-ui` selected it.
5. `ImageUpdateAutomation/haku-ui` committed the tag into `haku-state`.
6. `Kustomization/haku-state-workloads` applied that revision.
7. `Deployment/haku-ui` is running the selected image and the served bundle contains the
expected UI strings/routes.
If there is no registry tag for the UI commit, this is a CI/publish problem, not Flux lag.
If the latest green run is only `validate-state.yaml`, the branch badge can be green while
the UI remains stale.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!