Skip to content

Gitea and Forgejo ​

How kinhin talks to Gitea Actions and Forgejo Actions, and how that compares with their current API specs. Forgejo is served by the same client (GiteaClient); this page notes where the two diverge.

← Home · Forge reference index · Setup guide

Overview ​

Forge idsgitea, forgejo (GiteaForge.swift:36 only switches the id and display name)
HostingSelf-hosted (Gitea, Forgejo) or hosted (gitea.com, codeberg.org). instance is required.
Scopeowner/name or an org. actionsBase is /api/v1/repos/{scope}/actions or /api/v1/orgs/{scope}/actions (GiteaClient.swift:32-36).
SourceSources/KinhinKit/Forges/GiteaClient.swift, GiteaForge.swift, GiteaModels.swift
Live-testedGitea 1.24 and 1.26, Forgejo 11, on every runtime, by the end-to-end suite. Unit tests stub HTTP (StubProtocol); FakeGitea in KinhinTestSupport is pinned to GiteaForge by contract tests.

Auth ​

Authorization: token <T> (GiteaClient.swift:262), plus Accept: application/json and User-Agent: Kinhin/1.0. Required permissions are in TokenRequirements.gitea(scope:instance:): the token's user must own the repository or organization (or be an administrator), and the token needs the repository or organization scopes listed there.

API version and base URL ​

We sendDocumented (verified 2026-10-01)
Base{instance}/api/v1/api/v1 on both Gitea and Forgejo
Version header or parameterNoneNone. The API is versioned by the server release, not by the request.

Because the request carries no version, the server release decides what exists. The next sections compare releases.

Endpoints used ​

MethodPathPurposeCode
GET, then POST{actionsBase}/runners/registration-tokenMint a registration token; doubles as the permission probe. Gitea 1.26+ only accepts POST, so a 404 or 405 is retried with POSTGiteaClient.fetchRegistrationToken()
GET{actionsBase}/runnersList runnersGiteaClient.swift:169
DELETE{actionsBase}/runners/{id}Remove a runnerGiteaClient.swift:184
GET{actionsBase}/jobs?status=queued&page=1&limit=50Queued demand (Gitea 1.25+); the status of an unclaimed job is queuedGiteaClient.jobs(runnerLabels:)
GET{actionsBase}/runners/jobs?labels=a,bQueued demand (Forgejo 11+); labels is the runner's label set, returns a bare array or nullGiteaClient.jobs(runnerLabels:)
GET{actionsBase}/tasks?status=waiting&page=1&limit=50Queued demand fallback for older serversGiteaClient.swift:191
GET{actionsBase}/tasks?status=running&page=1&limit=50Running tasks, attributed by runner_nameGiteaClient.swift:218
GET/api/v1/repos/search?limit=50&private=true&sorted=updated&page=NRepository pickerGiteaClient.swift:91

Pipeline view ​

The running task (/actions/tasks?status=running, matched by runner_name) is shown as one job with its run link, title and start time where the server reports them. Gitea and Forgejo have no stable public step list, so the view is job-level.

No output is shown: there is no stable public log endpoint across Gitea and Forgejo releases.

Registration flow ​

  • Registration mode sharedLongLived, exit semantics idleGrace (GiteaForge.swift).
  • The token is fetched once in GiteaForge.make and reused by every runner.
  • Runner start: gitea-runner register --no-interactive --token --labels, then gitea-runner daemon (RunnerBootstrap+Linux.swift:235-240). The binary comes from gitea.com releases (RunnerBootstrap+Verification.swift:62).

Demand and label matching ​

  • Gitea 1.25+ (/actions/jobs?status=queued) and Forgejo 11+ (/actions/runners/jobs?labels=) report real queued jobs. Forgejo is asked about every label the fleet's pools register, plus the account's labels and kinhin's pool markers; it returns only the jobs whose runs-on fits.
  • Older servers fall back to the task queue, where a min_runners floor (demandFloor) applies when the endpoint answers with zero entries. A failed request keeps the previous scaling state.
  • A runner is busy when the runner list reports busy: true (Gitea reports a working runner as status: online); where the server has no runner list, see below.
  • Jobs opt in to a pool through runs-on; the pool's labels are used at register time.

Pagination, rate limits, retries ​

limit=50 with one page for tasks and a short-page stop for repositories. No rate-limit handling beyond withTransientRetry (2 retries, backoff 1 s then 2 s, on network errors, 429 and 5xx).

What each server release offers ​

Built by diffing the swagger specs shipped in each release (templates/swagger/v1_json.tmpl at the release tag; Forgejo from codeberg.org). "Reg-token" is …/actions/runners/registration-token.

Gitea ​

ReleaseReg-tokenList and delete runners/actions/tasks/actions/jobs?status=
1.22GETnonono
1.23GETnoyes (page, limit)no
1.24GET and POSTyesyesno
1.25GET and POSTyesyesyes (/actions/jobs?status=)
1.26 and trunkPOST onlyyes (adds disabled, PATCH)yesyes (/actions/jobs?status=)

Forgejo ​

ReleaseReg-tokenList and delete runners/actions/tasks/actions/runners/jobs?labels=
7.0GETnonono
11.0GETnoyes (page, limit)yes (/actions/runners/jobs?labels=)
12.0, 14.0GETnoyesyes (/actions/runners/jobs?labels=)
trunk (16 dev)GETyes (GET/DELETE …/actions/runners, POST to create)yes (adds status)yes (/actions/runners/jobs?labels=)

Version sensitivity ​

ItemAffectsDetail
Registration-token methodGitea ≥ 1.26Only POST exists there; kinhin sends GET first and falls back to POST on a 404 or 405. Forgejo and Gitea ≤ 1.25 answer GET.
Runner list and deleteGitea < 1.24, Forgejo through at least v14Endpoints absent, so listRunners and deleteRunner get a 404. kinhin watches running tasks instead; see Forgejo has no runner list.
/actions/tasksGitea < 1.23, Forgejo < 11Endpoint absent.
Demand sourceGitea ≥ 1.25, Forgejo ≥ 11Better endpoints are used: Gitea /actions/jobs?status=queued, Forgejo /actions/runners/jobs?labels= (the account's labels plus every label the fleet's pools register, and kinhin's pool markers). They report real queued jobs before any runner exists, so min_runners: 0 works. Verified live on Gitea 1.26.4 and Forgejo 11; Gitea 1.24 and older still need min_runners: 1.
Job repository, branch, commitGitea ≥ 1.25, Forgejo ≥ 11Neither queue names the repository: Gitea 1.26 puts it only in the job's url (read from there), Forgejo not at all (a repo-scoped account's jobs are its repository; an org-scoped Forgejo account's are unattributed, so repo routes do not apply to them). Branch and commit are head_branch/head_sha.
Workflow routesGitea, ForgejoNot supported. Neither the job nor the run names the workflow (name:), only the workflow file, so owner/repo@Workflow routes never match; repo routes and labels do.
Runner binaryallact_runner and gitea-runner naming. kinhin installs gitea-runner; confirm Forgejo's own runner (forgejo-runner) is acceptable before relying on the gitea.com download for Forgejo.

Forgejo has no runner list ​

Probed live on Forgejo 11 (codeberg.org/forgejo/forgejo:11) with a registered gitea-runner v5 and a running job:

EndpointAnswer
GET …/actions/runners, …/runners/{id}, org, user and admin variants404
GET …/actions/runs, …/actions/jobs, …/actions/workflows404
GET …/actions/tasks (any ?status=)200 {"workflow_runs": [...], "total_count": n}; the filter is ignored and finished runs are included; newest-updated first; each entry has id, name (the job), status (running, success, failure, ...), run_number, head_branch, head_sha, display_title, url, timestamps and no runner name. A job no runner has fetched yet is not listed.
GET …/actions/runners/jobs?labels=a200, the waiting jobs for those labels (id, name, runs_on, task_id, status: "waiting"), null without a label match

So there is no way to ask the server which runner is busy. The exit watch (idleGrace, which every Gitea-family runner needs because gitea-runner daemons never deregister) therefore changes rule when the runner list answers 404 (the 404 is remembered for ten minutes, not asked on every poll):

  • Registered: the registration script succeeded (it runs gitea-runner register synchronously) before the watch starts, so every expected runner counts as registered at once.
  • Busy: any task of the scope that is not finished (running, or a status kinhin does not recognize, or none) makes every runner of the account busy. Tasks are read from the first pages of /actions/tasks (up to 200 newest-updated entries; a running task is touched by its runner every few seconds, so it stays near the top).
  • Done: no unfinished task for the idle grace (30 s), then a fresh read confirms it before the instance is deleted. A runner that never gets a job is reclaimed the same way once the scope is quiet.

The trade-off is deliberate: an instance whose own job ended stays until the rest of the scope's jobs end too, and an idle instance in a busy scope stays until it quiets (or the 6 h watch deadline). A leaked instance is cheaper than one deleted mid-job. The server keeps a stale offline runner record per finished instance because there is no delete endpoint (whether the server prunes them was not checked).

The task list is read as workflow_runs (or entries, the older shape), filtered to running/waiting client-side, since the server ignores ?status=.

Open notes ​

  1. Workflow routes are not supported (see Version sensitivity).
  2. Runner list and delete need newer servers: Gitea 1.24+; Forgejo only on trunk. kinhin works without them as described above.
  3. Rate limits: no handling beyond the transient-retry policy.

Verified ​

2026-10-01, against:

Specs show what each server documents. They do not prove runtime behavior.