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:
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 -nreads~/.netrcand Basic auth is enough for/api/v1/.... /actions/runsreturns the whole run history unless paginated. Onhaku/haku-statethe unbounded response was over 9 MB and outran a 90 s timeout;?limit=15&page=1is 140 KB and answers in about a second.
Actions Metadata
List runs:
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_repois the UI/display run number, e.g./actions/runs/396.idis the internal REST run id forGET /api/v1/repos/$OWNER/$REPO/actions/runs/$id.actions/tasksreturns job/task rows; itsidis 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 samerun_numberand a laterrun_started_at, and the run'sstatusflips back torunning; watching the run list for a higher index misses it.
List task rows:
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:
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:
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):
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:
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, nostopped_at.statuscarriessuccess/failure/cancelled/running;updated_atis the completion time of a finished task. - The start field is
run_started_at, notstarted_at— there is nostarted_at, so reading it silently yieldsnulland 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). limitis ignored — the endpoint returns the whole task list underworkflow_runs; slice client-side. That list is the repo's entire task history (3.4 MB in 80–90 s onhaku/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:
# List one job's step indexes (--job: name, or zero-based index in the run's job list).
uv run skills/forgejo/scripts/forgejo.py logs \
--owner "$OWNER" --repo "$REPO" --run "$RUN_NUMBER" --job "$JOB" --list-steps
# Fetch one expanded step's log.
uv run skills/forgejo/scripts/forgejo.py logs \
--owner "$OWNER" --repo "$REPO" --run "$RUN_NUMBER" --job "$JOB" --step "$STEP_INDEX"
Without --job, a run with more than one job is refused with its job list (index, status,
name).
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.14)" …. The symlink path itself
does not help: a venv whose home is /usr/local/bin falls back to the same stripped
/usr/lib/python3.14.
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:
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:
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/actionsdata-run-index, the UI run numberdata-job-index, zero-based index in the run's job listdata-attempt-numberdata-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:
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_INDEXis the zero-based UI job index, not the REST task id and not the UI job id embedded indata-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 understate.currentJob.steps. - Some repos also publish fallback logs, e.g. a
ci-logsbranch 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:
# 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:
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:
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
prettyrefis the bare branch name (a PR run shows#N) and itseventis 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}/statusesstill listed only the(pull_request)contexts, with the failedimageone 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):
# 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:
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:
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.yamlgreen means the currentmaindata contract is valid. It does not build or publishhaku-ui.bazel-ci.yamlonmainis 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:
bazel-ci.yamlran onmainfor the UI commit, not only on a PR orwip/*.- The registry has a matching
git.allegedly.works/haku/ui:main-<utc>-<sha7>tag. ImageRepository/haku-uiscanned that tag.ImagePolicy/haku-uiselected it.ImageUpdateAutomation/haku-uicommitted the tag intohaku-state.Kustomization/haku-state-workloadsapplied that revision.Deployment/haku-uiis 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.