engineering

How we run GitHub with our own CI and no GitHub Actions

Scenema Team ·
Contents: Why we left GitLab.com, and why GitHub Actions was no answer

Until this October our code, our merge requests and our pipelines all lived on GitLab.com, and for a small team that combination worked well: one place to review code, one YAML file that described how to test and deploy it, and a runner on our own server that did the actual work. Today our code and pull requests live on GitHub, while every test and every deploy still runs on our own hardware, under a coordinator we wrote ourselves, from the same YAML file we had before. GitHub Actions plays no part in it. This piece explains why we made that move, how the pieces fit together, and what went wrong on the day we switched.

Why we left GitLab.com, and why GitHub Actions was no answer

On October 19, GitLab.com began enforcing per-user rate limits on its Free plan: 100 requests a minute in bursts and 5,000 an hour sustained, counted across the API, the web interface and Git over HTTPS together. A person clicking through merge requests stays far below those numbers. Our engineering, however, increasingly runs through coding agents that open merge requests, poll pipelines and read job logs on our behalf, and every one of those agents spends the same allowance as the person who owns its token. Within days we were getting rate limited while simply reading our own merge requests in a browser, because an agent somewhere was watching a pipeline.

Moving the code itself was the easy part of the decision. The hard part was CI, because the obvious destination carries its own risk. In December 2025 GitHub announced a charge of $0.002 per minute for jobs on self-hosted runners in private repositories, to start in March 2026, and it postponed that charge two days later after a loud response from its users. It has not taken effect, and GitHub has not said when it might. Nevertheless, the announcement told us something useful: when you bring your own hardware, the part of GitHub Actions that GitHub considers worth charging for is the coordination, meaning the queue of jobs, the order they run in, and the status that comes back to the pull request. That coordination is a small amount of software, and we already had a description of our pipelines that we liked.

We looked at the alternatives before writing anything. Self-hosting GitLab would have removed the rate limits while keeping every other cost of running GitLab. Gitea and Forgejo are light, but we did not want to move our code review to them. OneDev is impressive and young. Woodpecker comes closest to what we wanted, although it lacks manual jobs, and our deploys of GPU services and our image builds are manual by design. What we wanted was narrower than any of these: a CI that agents can drive from a command line as comfortably as people can from a browser, that runs on servers we already pay for, and that does not care where the Git repository lives.

Step one: the pipeline logic moves out of YAML

Before changing platforms we changed the shape of our pipelines. Our GitLab YAML had grown to hold real logic in its script: blocks: shell that built images, deployed to our cluster, synced files to our GPU machines and backed up repositories. That logic was hard to test and tied to GitLab’s runner. We moved all of it into TypeScript under devops/ci, one command per job, run as node devops/ci/run.ts <group> <command>. The YAML kept only orchestration: which job runs when, in which stage, after which other jobs, and with which cache.

That step changed nothing for anyone using the pipelines, which was the point. It meant the job logic could run anywhere Node runs, including a laptop when every CI system is down, and it meant that whatever replaced GitLab CI would only need to understand the orchestration. Today the job logic is about 1,300 lines of TypeScript, and the YAML that orchestrates it is about 1,800 lines across a dozen files.

The pieces: a coordinator, an agent and a CLI

The YAML still describes our pipelines, and something has to read it. gitlab-ci-local is an open source tool that runs a .gitlab-ci.yml on one machine with Docker: it evaluates rules: and changes:, honors needs: and stages, passes artifacts between jobs, copies caches in and out, and runs manual jobs on request. It was built for people who want to test their pipelines before pushing them, and it turned out to be a complete pipeline executor for our purposes. As such, the work left for us was everything around it.

GitHub
pushes, pull requests
Coordinator
ci.scenema.ai, SQLite
Agent
on our CI runner
CLI
people and coding agents
gitlab-ci-local
runs .gitlab-ci.yml in Docker
Commit statuses
back on the pull request
Job logs
object storage
A push to a pull request, from GitHub to our runner and back.

The coordinator is a small Node service, about 3,000 lines with no npm dependencies, that runs as one pod in our production cluster and keeps its state in a single SQLite file. It receives GitHub’s webhooks for pushes and pull requests, records a pipeline, and puts a unit of work in a queue. When the agent reports back, the coordinator posts one commit status per job to GitHub with a readable name, such as “Server / Test unit”, plus one overall “Scenema CI” status. Each deploy job is also recorded as a GitHub Deployment, so the repository page lists every production environment, the commit live there and its history. The “Details” link next to each status opens our own pages, which sit behind a GitHub sign-in that only admits active members of our GitHub organization. The home page lists every pipeline with who merged or pushed it, filters by person, state, text and date, a panel of what is live on production and a panel of what runs and waits right now. A pipeline’s page draws its jobs as a graph of stages and dependencies with their states and running times, and the selected job’s log sits beside the graph, streaming while the job runs, a few seconds behind. Manual jobs, such as GPU deploys, have Run buttons that ask for confirmation first. We chose SQLite because the coordinator has one writer and modest traffic, and because a single file means the whole service can move to another machine with one copy.

The agent is a container on the same runner host that used to run GitLab’s jobs, about 1,000 lines of TypeScript. It claims work from the coordinator, keeps a mirror of the repository, clones a fresh workspace for each pipeline at the exact commit, merges a pull request into the current main so that its pipeline tests what the merge will produce, points origin/main at the right base so that changes: rules see exactly the pull request’s changes, and starts gitlab-ci-local with access to the host’s Docker. It reports every job as it starts and finishes, uploads each log to object storage, and sends a heartbeat every five seconds that doubles as the channel for cancel requests. It runs up to three pipelines at once with three jobs each, while work on main and on tags runs strictly one at a time, in merge order, so two deploys can never overlap or arrive out of order; the deploys that wait stay in the coordinator’s queue, which leaves the agent’s slots to pull requests.

The CLI is the reason the whole thing exists. ci status --pr 42 shows a pull request’s latest pipeline with every job; ci failed 57 prints the end of every failed job’s log; ci rerun 57 reruns the failed jobs; ci run 57 gpu-vm:deploy runs a manual job; and ci start creates a pipeline by hand. Our coding agents use exactly these commands, and the plain text output is designed for them: no pagination, no browser, no rate limit we did not set ourselves. A person reading the same failure gets the same log on the job page, in color and split into steps.

Secrets, and keeping main safe without branch protection

GitLab’s CI variables had two kinds of secret: ordinary ones every pipeline gets, and protected ones only pipelines on protected branches get. Our production SSH key, our Cloudflare token and the coordinator’s own secrets are protected, so a pull request from a branch can never deploy anything, and we wanted to keep that guarantee exactly. The agent therefore keeps two files on the runner host: variables every pipeline receives, and protected variables it copies into a workspace only for pipelines on main or on a tag, and only after checking that the commit really is on that branch.

The harder problem was making sure main itself only changes through reviewed pull requests. On GitHub’s Free plan, branch protection is unavailable for private repositories, so nothing on GitHub’s side stops a direct push to main. We closed that gap in two places. A Git pre-push hook in the repository refuses any push to main from a developer’s machine, which stops mistakes. For anything that gets past it, the coordinator asks GitHub, on every push to main, whether the commit came from a merged pull request; if it did not, the pipeline is marked as a direct push, its check on GitHub fails, and the agent withholds every protected variable from it, so nothing deploys.

Running CI when GitHub is down

Moving to one hosted Git provider should not make us depend on it for every deploy, and the coordinator only needs GitHub for two things: webhooks to start pipelines and the API to post statuses. Everything else works without it. GitLab now serves as a mirror, which the agent keeps current by pushing every branch and tag to it after each fetch from GitHub, through a deploy key that may only push. If GitHub stops answering, the agent fetches from the GitLab mirror instead, and a person or an agent can start a pipeline from the mirror directly with ci start --git gitlab. We tested that path before we switched, by running a full pipeline from the mirror alone.

The switch, and what it taught us

We did not switch in one step. For several days GitLab mirrored every push to GitHub, GitLab CI kept deploying, and our own CI ran every pipeline in parallel in a dry-run mode, in which deploy jobs printed their commands instead of running them. A comparison script matched each of our pipelines against GitLab’s for the same commit, job by job. That phase found a real bug: gitlab-ci-local’s option to mount caches as shared volumes let two jobs install into the same node_modules at the same time and break each other, so we switched to copying caches in and out of each job, as GitLab’s runner does. Once the pipelines matched for a while, we waited for GitLab’s last deploy to finish, turned off GitLab CI and the mirror, moved the CI variables onto the runner, and pointed our checkouts at GitHub.

The first real day still found five bugs that the dry runs could not reach, and they are worth listing because each one is a plausible mistake for anyone building something similar.

  • The backup job failed on its first real run with “bad object refs/heads/main”. The agent cloned workspaces with git clone --shared, which borrows objects from the mirror through an absolute path that does not exist inside a job’s container. A plain local clone hard-links the same objects without that path, and costs nothing extra on the same disk.
  • The first pull request merged on GitHub was marked as a direct push. The webhook arrived five seconds after the merge, before GitHub listed the commit’s pull request, and our check treated an empty answer as final. It now asks again for about fifteen seconds.
  • The agent ran one pipeline at a time, so a long deploy on main held every pull request’s tests behind it. When we let it run several, a single slow cache copy in one pipeline froze the heartbeats of the others for two minutes, because a few steps still ran synchronously in Node’s one thread. Every command now runs as a child process without blocking, with a lock around each shared resource.
  • A pipeline canceled while it was being planned flipped back to “running” and then to “success”, with every job canceled: a green check on GitHub for tests that never ran. A canceled pipeline now stays canceled.
  • The routine cleanup that runs after every pipeline had been written for a runner that ran one job at a time. With several pipelines sharing one Docker daemon, it could delete a container whose artifacts another job was still copying out, an image that a parallel job had just pulled, or content that a pull was still writing. It now removes only what no running job can need.

None of these was hard to fix once seen, and all of them were found within hours, because the CLI made every failure one command away. Several were found by the coding agents that motivated the project in the first place.

The day after, with the whole team merging through it, taught us three more lessons, and they are the ones we would pass on first.

  • main stopped compiling even though every pipeline had passed. One pull request removed a function while another, written against an older main, added a call to it; each compiled on its own branch, and nothing tested the two together. Pull request pipelines now test the pull request merged into the current main, the agent making the merge itself because GitHub’s ready-made merge refs are recomputed lazily and lag behind.
  • Two merges deployed nothing, and every job showed as skipped. The agent had no login for our own image registry, and pulls had only worked while the images happened to be on the host; once a cleanup removed one, the executor stopped before its first job without an error code. The agent now carries the registry login, and a run that starts no job fails its pipeline with the executor’s own last lines as the reason.
  • Pull requests waited on an idle machine. Deploys waiting their turn held the agent’s slots, so with one deploy running and two queued, nothing else could start. Waiting deploys now stay in the coordinator’s queue.

We also learned that a restart should never cost a pipeline: the agent now hands its work back to the coordinator when it stops, and the new agent picks it up within seconds.

What it costs, and what we gave up

The running cost is close to nothing beyond what we already paid: the runner host is the same machine that ran GitLab’s jobs, the coordinator is one small pod in a cluster we already operate, and job logs sit in object storage we already use. There is no per-minute charge for anything, and the only rate limits are the ones we chose. In engineering time, the coordinator, agent and CLI took a few days to build and move onto, and they come to about 4,400 lines of TypeScript.

We gave up things too, and they matter to some teams more than to us. GitHub Actions’ marketplace of ready-made steps is unavailable, although our jobs were already plain scripts. GitHub’s richer checks interface, with annotations on lines of code, is replaced by plain commit statuses that link to our own pages. Branch protection would still be better than our two checks, and we would turn it on if we moved to a paid plan. Finally, we now maintain a small piece of infrastructure ourselves, which is the trade any self-hosted system asks for.

Even so, the result fits how we work now. Our pipelines are described in one YAML file that any of three systems could run, our job logic runs anywhere Node runs, our Git host can change without touching CI, and the people and agents who depend on CI can ask it anything, as often as they like, from a terminal. Following a deploy in a browser now feels as direct as following it from the command line, and the next steps are trends over time (which jobs are slow, which fail, which are flaky) and retesting open pull requests whenever main moves. We also plan to publish the coordinator, agent and CLI as open source, for any team that wants its pipelines on its own hardware without giving up GitHub.