CI/CD: implement Forgejo/Gitea runner protocol (runner.v1) so stock runners work against git-shark #2
Summary
As an admin, I can set up CI/CD runners in git-shark: register runner instances, and have workflows in repositories executed on push, with live logs and results visible in the UI.
This is an epic. Strategy decision up front: do not write our own runner. Reuse the existing Forgejo Runner / Gitea act_runner by implementing their server-side protocol in git-shark. Stock runner binaries then work against git-shark unchanged.
Why the Forgejo/Gitea runner path (research summary)
- Wire protocol is small and open. Runners talk Connect RPC (buf) over plain HTTP — no extra port, reuses the normal HTTP listener. One service
runner.v1.RunnerServicewith 5 methods (Register,Declare,FetchTask,UpdateTask,UpdateLog) plus aPingServicehealth check. Proto definitions are MIT-licensed (gitea.com/gitea/actions-proto-def) — we can generate Java stubs directly from the.protofiles. Forgejo confirmed it still shares Gitea's proto contract (forgejo/runner#525), so bothforgejo-runnerandact_runnerare compatible clients. - Workflows are GitHub-Actions-compatible YAML in
.forgejo/workflows//.gitea/workflows/— familiar format, existing ecosystem of actions. - GitLab Runner (fallback) is strictly worse for us: its server side must fully compile
.gitlab-ci.yml(includinginclude,extends,rules, YAML anchors,!reference, DAG, matrix) into pre-resolved job rows, and several endpoints (trace PATCH, status PUT) are only documented in the runner's source, not as a stable public contract. - Writing our own containerized runner: worst case only; not needed given the above.
- The real cost is server-side, not the protocol: the runner intentionally receives a pre-resolved single-job payload and does no trigger/DAG/matrix work itself. Gitea's server does this with the Go
actlibrary; there is no Java port, so git-shark must implement workflow parsing, trigger evaluation,needsresolution and matrix expansion itself. This is the main implementation gap and the reason for phasing.
Architecture (target state)
push → detect .forgejo/workflows/*.yml at new head
→ evaluate `on:` triggers → create run + job rows (DAG, matrix-expanded)
→ runner long-polls FetchTask → gets Task { expanded single-job YAML,
github context, secrets, vars, needs-outputs }
→ runner executes steps locally (its bundled act engine)
→ UpdateLog streams log rows (offset + ack_index resume protocol)
→ UpdateTask reports step/task state → run UI updates
Key protocol facts to honor:
FetchTaskcarriestasks_versionso idle polling is cheap (no DB scan when nothing changed).UpdateLog: runner sends rows fromindex, server acks durable offset (ack_index); reconnect-tolerant.UpdateTask: step states carrylog_index/log_lengthpointers into the log stream — server maps steps to log ranges via these, no log parsing.- Secrets/variables are delivered inline in
FetchTaskResponse(plaintext over TLS — same trust model as GitHub self-hosted runners).
Phases
Phase 1 — MVP: runner registration + basic run loop
- Connect RPC endpoint in Quarkus serving
runner.v1(generate Java stubs from the MIT-licensed protos; Connect protocol with binary protobuf codec over HTTP). - New tables:
runner(uuid, token hash, name, labels, status, last_seen),action_run,action_task(job),action_log(or log blob storage), plus registration tokens. - Admin UI: generate registration token, list runners with status (idle/active/offline from Declare/FetchTask heartbeats), delete runner. Instance-scope only for v1 (repo/org scopes later).
- Trigger:
on: pushonly. Single-job workflows, noneeds, no matrix. Parse workflow YAML from.forgejo/workflows/at the pushed commit. - Run UI per repository: run list, run detail with per-step status + logs.
- Task state machine incl. timeout/zombie handling (runner disappears mid-task → fail after deadline).
Phase 2 — real workflow semantics
- Trigger evaluation: branch/tag/path filters, more events (tag push, merge-request events mapped to
pull_request). needsDAG resolution + passingTaskNeedoutputs;matrixexpansion (one Task per cell).- Secrets & variables: repo-level CRUD UI (owner-only), delivered via
Task.Secrets/Task.Vars. - Concurrency/cancellation: cancel run from UI, re-run, cancel superseded runs on force-push.
- Label-based task-to-runner matching.
Phase 3 — ecosystem compatibility
- Artifacts: implement the GitHub Actions "Results" artifact service (Twirp,
github.actions.results.api.v1.ArtifactService—CreateArtifact/FinalizeArtifact/ListArtifacts/…) soactions/upload-artifact@v4works; expose viaACTIONS_RESULTS_URL. Artifact download from run UI. - Cache: none server-side initially — act_runner ships its own cache server (
ACTIONS_CACHE_URL, runner-local or sharedexternal_server); document that in admin docs. (Gitea's server-side cache v2 is itself still an open proposal, gitea#33393.) - Repo/org-scoped runners; ephemeral runners (
--ephemeral, server must enforce single-job-then-delete). - Commit/MR status integration (run result shown on commits and merge requests).
Out of scope
- Writing our own runner binary.
- GitLab runner compatibility.
- Windows/macOS runner concerns (runner's problem, not ours).
- Full GitHub Actions parity (reusable workflows, environments, deployments, OIDC token issuance).
Security notes
- Registration tokens and runner secrets: store hashed, admin-only UI.
- Secrets go to any runner that picks up the task — document clearly that runner hosts are trusted infrastructure; no fork-PR workflows with secrets in v1 (skip secret delivery for tasks triggered by non-writers when MR triggers arrive in Phase 2).
- Workflow YAML is untrusted input: hard limits on size, job count, matrix size, log volume per task.
Acceptance criteria (Phase 1 = definition of done for this issue; 2/3 split into follow-ups)
- [ ] Admin can create a registration token in the admin UI; stock
forgejo-runner registeragainst git-shark succeeds. - [ ] Registered runners appear in admin UI with status;
Declareupdates version/labels. - [ ] Push with
.forgejo/workflows/*.yml(on: push, single job) creates a run; runner fetches, executes, streams logs; UI shows live per-step status and logs; final state (success/failure) correct. - [ ] Log resume after runner reconnect works (ack_index honored).
- [ ] Task fails cleanly when runner vanishes (timeout).
- [ ] Failing tests first: protocol round-trip against real
forgejo-runnerin an integration test (container), plus unit tests for trigger eval and task state machine. - [ ] Docs:
docs/admins/(runner setup, endpoints, new tables, reverse-proxy notes for Connect RPC),docs/users/(writing workflows),docs/maintainers/(protocol implementation decisions, phase gap list), rootREADME.mdfeature list.
References
- Proto: https://gitea.com/gitea/actions-proto-def (MIT), Go stubs incl. Connect handlers: https://gitea.com/gitea/actions-proto-go
- Gitea Actions design (server/runner split): https://docs.gitea.com/usage/actions/design
- Forgejo ↔ Gitea shared protocol confirmation: https://code.forgejo.org/forgejo/runner/issues/525
- Forgejo runner registration flows: https://forgejo.org/docs/latest/admin/actions/registration/
- Prior art (client-side protocol reimplementation): https://github.com/ChristopherHX/gitea-actions-runner
Migrated from https://github.com/workaround-org/git-shark/issues/13 Migrated from https://gitshark.ha1nz.de/repos/miggi/GitShark/issues/2 (original author: miggi, created 2026-07-09)