Early Alpha — written by a coding agent, has not been used yet. YOU HAVE BEEN WARNED.
A Kubernetes-native runner for Gitea Actions and Forgejo Actions. Executes workflow jobs as unprivileged Kubernetes pods — no Docker-in-Docker required.
The existing runner (act_runner) requires Docker-in-Docker to run on Kubernetes. DinD means privileged containers, broken resource accounting, overlay-on-overlay filesystem overhead, and no k8s scheduling benefits.
drawbar replaces DinD with native Kubernetes primitives:
- Each workflow job becomes a Kubernetes Job
- Each step runs sequentially inside a single container via an entrypoint shim
- Service containers (postgres, redis, BuildKit) run as native k8s sidecars
- Image builds use a BuildKit sidecar — no Docker socket needed
- The workspace is a shared emptyDir volume
- Zero privileged containers, ever
- Single container per job — entrypoint shim orchestrates steps sequentially
$GITHUB_ENV/$GITHUB_PATH/$GITHUB_OUTPUT— full inter-step state propagation- Service containers — postgres, redis, etc. as k8s native sidecars (k8s 1.28+)
- Expression evaluation —
${{ github.* }},${{ env.* }},${{ secrets.* }}, stepif:conditions - Action support — Node.js, composite, Go, and shell actions via direct exec
actions/checkout@v4— works natively (loaded as a Node.js action, auth viaGITHUB_TOKEN)- Action cache server — GitHub Actions cache protocol with BoltDB + PVC storage
- Action repo caching — actions cloned once to PVC, mounted via k8s subPath (zero copy per build)
- BuildKit sidecar — auto-detected, rootless, unprivileged container builds
- ZFS snapshot cache — O(1) workspace restore via VolumeSnapshot (optional)
- Artifact upload/download — via
actions/upload-artifact@v3/actions/download-artifact@v3 - OIDC tokens — cloud auth actions (
aws-actions,google-github-actions) supported - Helm chart — RBAC, ServiceAccount, PVC, health probes, cache Service
- Log masking — secret values replaced with
***in streamed logs - Crash recovery — orphaned jobs cleaned up on controller restart
- Docker actions (
runs.using: docker) are not supported. Use Node.js, composite, Go, or shell actions instead. This is by design — the single-container architecture is incompatible with per-step image switching. actions/upload-artifact@v4andactions/download-artifact@v4do not work with Gitea/Forgejo — they throwGHESNotSupportedErrorfor non-github.com servers. Use v3 instead.GITHUB_STEP_SUMMARY— the file is created for each step but the content is not sent to the server (Gitea's runner protocol has no summary field).
helm install runner oci://fj.monoloco.net/chaos-inc/drawbar \
--namespace drawbar --create-namespace \
--set server.url=https://gitea.example.com \
--set server.registrationToken=<token> \
--set runner.gitCloneUrl=http://gitea.gitea.svc:80Get a registration token: Site Administration > Runners > Create Registration Token.
drawbar needs two URLs:
-
server.url— Your Gitea external hostname (e.g.,https://gitea.example.com). Used for runner registration, API calls, and asGITHUB_SERVER_URLin workflows. Gitea'sROOT_URLshould match this — it's used to generate artifact signed upload URLs that job pods must be able to reach. -
runner.gitCloneUrl(optional) — Internal k8s service URL for git clone (e.g.,http://gitea.gitea.svc:80). Job pods use this instead of going through the external ingress, which is faster and avoids TLS/DNS issues. If omitted,server.urlis used for cloning too.
Controller Pod
+-- Poller: FetchTask from Gitea/Forgejo via Connect RPC
+-- Cache server: GitHub Actions cache protocol on PVC
+-- Health server: /healthz + /readyz on :8081
+-- Task handler:
+-- Parse workflow YAML
+-- Evaluate expressions
+-- Resolve actions (clone to PVC cache)
+-- Build step manifest JSON
+-- Create k8s Job:
initContainers:
- svc-* (sidecars, restartPolicy: Always)
- wait-for-services (TCP readiness probes)
- setup-shim (copies entrypoint binary + manifest)
containers:
- runner (entrypoint executes all steps sequentially)
The entrypoint binary runs inside the job container, executing each step as a child process. Between steps it reads $GITHUB_ENV / $GITHUB_PATH / $GITHUB_OUTPUT files and propagates state. Step lifecycle events are written to /shim/state.jsonl (not parseable from stdout — prevents spoofing by user steps).
drawbar has no Docker daemon. For container image builds, use a BuildKit sidecar:
services:
buildkit:
image: moby/buildkit:rootless
ports:
- 1234
steps:
- uses: actions/checkout@v4
- run: |
buildctl --addr tcp://localhost:1234 build \
--frontend dockerfile.v0 \
--local context=. \
--local dockerfile=. \
--output type=image,name=registry.example.com/myapp:latest,push=trueHow it works: drawbar auto-detects moby/buildkit images in service containers and configures them for rootless operation:
- Sets
seccompProfile: Unconfined(required for user namespace creation) - Adds
SETUID+SETGIDcapabilities (required bynewuidmap/newgidmap) - Injects
--oci-worker-no-process-sandbox(avoids needingSYS_ADMIN) - Injects
--addr=tcp://0.0.0.0:{port}for each declared port (BuildKit defaults to Unix socket only)
No workflow-level annotations needed — the auto-detection keeps your YAML clean.
Note: The job container image must include buildctl. Either use an image that has it pre-installed, or download it in a prior step.
Pass credentials via workflow secrets and create a docker config inline:
- run: |
mkdir -p $HOME/.docker
AUTH=$(printf '%s:%s' "${{ secrets.REGISTRY_USER }}" "${{ secrets.REGISTRY_PASS }}" | base64)
printf '{"auths":{"%s":{"auth":"%s"}}}\n' "registry.example.com" "$AUTH" > $HOME/.docker/config.json
buildctl --addr tcp://localhost:1234 build ...Optional feature that restores workspace dependencies (e.g., target/, node_modules/) from a ZFS VolumeSnapshot in O(1) time — compared to minutes for tar.gz over HTTP.
- Workflow declares cache paths via the
drawbar/cacheaction - Controller looks up an existing VolumeSnapshot matching the cache key
- If found: creates a PVC from the snapshot (ZFS clone — instant, copy-on-write)
- Bind-mounts the cached paths into
/workspacebefore the job starts - On job success: snapshots the workspace PVC for future reuse
- Snapshots older than
retentionDaysare garbage-collected hourly
- A CSI driver with snapshot support. Recommended: OpenEBS ZFS LocalPV
- A
VolumeSnapshotClassandStorageClassconfigured for the driver
# Helm values
snapshot:
enabled: true
class: openebs-zfs-snapshot # VolumeSnapshotClass name
storageClass: openebs-zfs # StorageClass for PVCs
size: 10Gi # Workspace PVC size
retentionDays: 7 # GC snapshots older than thissteps:
- uses: drawbar/cache@v1
with:
key: rust-${{ hashFiles('Cargo.lock') }}
path: target
restore-keys: |
rust-
- run: cargo buildThe drawbar/cache action is a no-op at runtime — the controller reads key, path, and restore-keys from the step environment at job-build time and sets up bind mounts before the pod starts.
Use actions/upload-artifact@v3 and actions/download-artifact@v3 for passing data between jobs:
jobs:
build:
steps:
- run: echo "result" > output.txt
- uses: actions/upload-artifact@v3
with:
name: my-output
path: output.txt
test:
needs: build
steps:
- uses: actions/download-artifact@v3
with:
name: my-outputImportant: @v4 does NOT work with Gitea/Forgejo — it throws GHESNotSupportedError. Use @v3.
Gitea configuration: The server's ROOT_URL must be set to a URL reachable from job pods (e.g., http://gitea.gitea.svc:80/). Signed artifact upload URLs are derived from ROOT_URL.
| Value | Description | Default |
|---|---|---|
image.repository |
Controller image | fj.monoloco.net/chaos-inc/drawbar |
image.tag |
Image tag | latest |
image.pullPolicy |
Image pull policy | IfNotPresent |
server.url |
Gitea/Forgejo instance URL | (required) |
server.registrationToken |
Runner registration token | (required for first install) |
server.insecure |
Skip TLS certificate verification | false |
runner.name |
Runner name | k8s-runner |
runner.labels |
Runner labels (image mapping) | ["ubuntu-latest:docker://node:24-trixie"] |
runner.capacity |
Max concurrent jobs | 1 |
runner.timeout |
Job timeout | 30m |
runner.fetchInterval |
Task poll interval | 2s |
runner.fetchTimeout |
FetchTask RPC timeout | 30s |
runner.gitCloneUrl |
Override URL for git clone | (uses server.url) |
runner.jobNamespace |
Namespace for job pods | (release namespace) |
runner.jobSecrets |
K8s Secrets to mount into job pods | [] |
cache.enabled |
Enable action cache server | true |
cache.storage |
PVC size for cache | 5Gi |
cache.port |
Cache proxy port | 9300 |
snapshot.enabled |
Enable ZFS snapshot cache | false |
snapshot.class |
VolumeSnapshotClass name | "" |
snapshot.storageClass |
StorageClass for snapshot PVCs | "" |
snapshot.size |
Workspace PVC size | 10Gi |
snapshot.retentionDays |
GC snapshots older than this | 7 |
log.level |
Log level (debug, info, warn, error) | info |
resources |
Controller pod resource requests/limits | 100m CPU, 128Mi-256Mi memory |
serviceAccount.create |
Create a ServiceAccount | true |
rbac.create |
Create RBAC roles | true |
Mount Kubernetes Secrets into job pods (e.g., registry credentials, TLS certs):
runner:
jobSecrets:
- name: registry-creds # inject as env vars (no mountPath)
- name: buildkit-client-certs # mount as files
mountPath: /certsAll config values can be overridden with environment variables:
| Env Var | Config Field |
|---|---|
SERVER_URL |
server.url |
RUNNER_NAME |
runner.name |
RUNNER_LABELS |
runner.labels (comma-separated) |
RUNNER_CAPACITY |
runner.capacity |
RUNNER_EPHEMERAL |
runner.ephemeral |
RUNNER_GIT_CLONE_URL |
runner.git_clone_url |
RUNNER_ACTIONS_URL |
runner.actions_url |
CONTROLLER_IMAGE |
runner.controller_image |
CACHE_ENABLED |
cache.enabled |
CACHE_DIR |
cache.dir |
CACHE_PORT |
cache.port |
CACHE_SERVICE_NAME |
cache.service_name |
CACHE_PVC_NAME |
cache.pvc_name |
SNAPSHOT_ENABLED |
snapshot.enabled |
SNAPSHOT_CLASS |
snapshot.class |
SNAPSHOT_STORAGE_CLASS |
snapshot.storage_class |
SNAPSHOT_SIZE |
snapshot.size |
SNAPSHOT_RETENTION_DAYS |
snapshot.retention_days |
LOG_LEVEL |
log.level |
drawbar exposes a /metrics/active-jobs JSON endpoint on port 8081 that reports active job count and capacity:
{"active": 1, "capacity": 1}KEDA can use this to scale the drawbar deployment based on load.
- Install KEDA:
kubectl apply --server-side -f https://github.com/kedacore/keda/releases/download/v2.19.0/keda-2.19.0.yaml- Create a Service for the metrics endpoint:
apiVersion: v1
kind: Service
metadata:
name: runner-metrics
namespace: drawbar
spec:
selector:
app: drawbar # match your deployment pod labels
ports:
- port: 8081
targetPort: 8081- Create a ScaledObject:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: drawbar-scaler
namespace: drawbar
spec:
scaleTargetRef:
name: runner-runner # your drawbar Deployment name
minReplicaCount: 1
maxReplicaCount: 5
pollingInterval: 5 # check every 5 seconds
cooldownPeriod: 30 # scale down 30s after jobs finish
triggers:
- type: metrics-api
metadata:
targetValue: "1"
url: "http://runner-metrics.drawbar.svc:8081/metrics/active-jobs"
valueLocation: "active"When active jobs reach the threshold, KEDA scales up additional replicas. When jobs finish, it scales back down after the cooldown period. Multiple replicas can share the same cache PVC (SQLite WAL mode allows concurrent access).
Note: If using Forgejo, you can alternatively use KEDA's native forgejo-runner scaler which polls the Forgejo API directly for pending jobs. This provides better scaling signals (pending queue depth vs active count) but requires Forgejo-specific API endpoints that Gitea doesn't have.
# Check the controller is registered and polling
kubectl logs -n drawbar -l app=drawbar --tail=20
# Look for: "runner is online, polling for tasks"
# If missing: check server.url, registration token, network connectivityIf the runner polls but never receives tasks:
- Verify the runner appears in Gitea's admin UI under Runners
- Check the runner labels match your workflow's
runs-on: - If using Gitea (not Forgejo): see GITEA_FETCHTASK_BUG.md for a known issue with lost tasks under concurrent load
# Check which init container is failing
kubectl describe pod -n drawbar <pod-name>
# Check service sidecar logs (e.g., BuildKit, postgres)
kubectl logs -n drawbar <pod-name> -c svc-buildkit
# Check the setup-shim init container
kubectl logs -n drawbar <pod-name> -c setup-shimCommon causes:
- Image pull failure: job image or service image can't be pulled (check registry access, image name)
- BuildKit sidecar crash: if
moby/buildkit:rootlessfails withnewuidmap: operation not permitted, the drawbar image may be outdated (auto-detection of seccomp/caps was added recently) - wait-for-services timeout: service container is listening on a different port than declared
# Get the runner container logs (all step output goes here)
kubectl logs -n drawbar <pod-name> -c runnerThe runner container's stdout/stderr contains all step output, including ::error:: and ::debug:: workflow commands. Each step failure prints Step N (name) failed with exit code X.
This usually means the action caught an error internally. Common causes:
actions/upload-artifact@v4: throwsGHESNotSupportedErroron non-github.com servers. Use@v3instead.- Missing env vars: check that
GITHUB_RUN_ID,GITHUB_REPOSITORY,ACTIONS_RUNTIME_TOKENare set. Run arun: env | sortstep to inspect. RUNNER_TEMPnot set: some actions require this (drawbar sets it to/tmp).
# Check cache server is running
kubectl logs -n drawbar -l app=drawbar | grep "cache server"
# Test the cache endpoint from inside the cluster
kubectl run test --rm -i --restart=Never --image=alpine -- \
wget -qO- http://runner-runner-cache.drawbar.svc:9300/_apis/artifactcache/cache?keys=test&version=v1The cache uses SQLite WAL (cache.db), not BoltDB. If upgrading from an older version, the old bolt.db is ignored — cache entries repopulate on next CI run.
- Use
actions/upload-artifact@v3(v4 doesn't work with Gitea/Forgejo) - Gitea's
ROOT_URLmust be reachable from job pods — if it's set tolocalhost, signed upload URLs won't work. Set it to your external hostname (see URL Configuration) - Check that
ACTIONS_RUNTIME_TOKENis set (requires cache to be enabled, orgitea_runtime_tokenin task context)
Set log.level: debug in the Helm values (or LOG_LEVEL=debug env var) for verbose output from the controller, including expression evaluation, action loading, and cache operations.
See BUGS.md for known bugs and GITEA_FETCHTASK_BUG.md for a Gitea server-side issue affecting task reliability under concurrent load (fixed in Forgejo, not yet in Gitea).
The project includes a fully automated dev environment:
# Full setup: k3d cluster + Gitea + runner
./hack/dev-env.sh up
# Fast iteration: rebuild + redeploy runner only
./hack/dev-env.sh rebuild
# Other commands
./hack/dev-env.sh status # show pod status
./hack/dev-env.sh logs # tail runner logs
./hack/dev-env.sh token # print registration token
./hack/dev-env.sh down # tear down cluster
# Use Forgejo instead of Gitea
SERVER=forgejo ./hack/dev-env.sh upmake build # controller + entrypoint binaries
make test # run all tests
make image # build Docker image
make push # push to registryMIT