Skip to main content
PProfileMatch

HardProblem 36 of 36 in the system design series

Design GitHub Actions: A CI/CD Platform for Untrusted Code

Cover illustration: Design GitHub Actions: A CI/CD Platform for Untrusted Code

A CI/CD platform watches a code repository and reacts to what happens there. Push a commit or open a pull request, and within seconds a fleet of machines checks out the code, compiles it, runs the test suite and perhaps deploys the result, while the developer watches the output scroll by and waits for a green tick on the pull request. This article designs a GitHub Actions-like service that offers that experience to millions of repositories.

Under the hood the problem is a distributed job scheduler with some sharp edges. Events must turn into the right set of jobs exactly once. Those jobs execute arbitrary code written by people we do not know, so a careless design could let one customer read another's secrets. Machines are the main cost, which pushes us to recycle them, while security pushes us to throw them away. And every running job produces logs that people want to see live. Interviewers use it to probe scheduling, isolation, fairness and streaming in one question.

This is an interview practice design for a GitHub Actions-like service, built from first principles. It is not a description of how GitHub Actions is actually built.

Functional Requirements

Workflows start from events: a push, a pull request, a cron schedule or a manual dispatch from the UI. Each workflow is defined in a YAML file stored in the repository, made of jobs that contain ordered steps. Jobs can declare needs on other jobs to form a dependency chain, and a matrix expands one job definition into many variants, such as testing on three operating systems and four language versions.

Jobs execute on runners, which are either machines we host or machines the customer registers and operates inside their own network. While a job runs, users watch its logs live; afterwards they can download artifacts like compiled binaries or test reports. The overall result is posted back as a pass or fail check on the pull request, which is what developers look at most.

Trigger on events

Push, pull request, schedule, or a manual run.

on: pushon: pull_request

Run workflows

Jobs from a YAML file, with dependencies and matrices.

needs · matrix

Runners

Run jobs on hosted machines or on the user's own.

hosted · self-hosted

Logs, artifacts & status

Live logs, saved build outputs, and a check on the PR.

✓ or ✗ on the PR

Non-Functional Requirements

Isolation is the first requirement because the platform's entire purpose is to run code from strangers. A job must have no way to observe another tenant's processes, files or secrets. Reliability comes next: each qualifying event should produce exactly one run, and if a runner crashes mid-job the system must detect it instead of leaving a check stuck in the pending state forever.

Start latency shapes the developer experience. Someone waiting on a pull request expects work to begin within about 30 seconds of queueing. Fairness protects the shared fleet: an enterprise that pushes a thousand jobs at once should be capped by per-organisation limits so that a small team's single build is not stuck behind it.

Strong isolation

Untrusted code must never see other customers' jobs or secrets.

security first

No lost runs

Every matching event runs once; a crashed job is detected.

reliable

Fast start

A queued job should start on a runner within seconds.

< 30 s to start

Fair at scale

One huge organisation can't starve everyone else.

per-org limits

Scale Estimation

Assume around 100 million pushes and pull requests per day, which is about 1.2K events per second. A share of them match workflows and produce roughly 20 million jobs daily, or about 230 per second on average and near 1K at peak. If an average job runs for about five minutes, then 230 per second times 300 seconds means roughly 70K jobs are executing at any instant, each needing a dedicated machine.

Logs add up quickly as well. At about 1 MB per job, the platform writes around 20 TB of log data every day, before counting artifacts. These figures make the cost centre obvious: tens of thousands of concurrently running machines. The design must schedule them efficiently, scale the pool up and down quickly, and recycle capacity without ever letting one tenant's leftovers reach the next.

1.2K / sec

Repo events

100M pushes & PRs / day

20million

Jobs per day

≈ 230 / sec · 1K at peak

70K

Jobs running at once

230 / sec × 5 min

20TB

Logs per day

≈ 1 MB per job

Runners are the cost → schedule well, scale fast, recycle safely

Core Entities & API

Four entities structure the system. A Workflow is the YAML definition, a Run is one execution of it triggered by an event, a Job is a unit of work inside a run, and a Runner is the machine that executes jobs. Runs and jobs carry state such as queued, in progress, succeeded or failed.

The Git service posts every repository event to POST /events with the repo, event type, ref and commit SHA. Users can trigger a run manually through POST /workflows/{id}/dispatches with a branch and inputs. Runners fetch work through GET /runner/jobs?wait=50s, a long poll that holds the request open until a job is available and returns the job definition plus a short-lived job_token. Browsers call GET /jobs/{id}/logs to follow output as it is produced.

Core entities

WorkflowRunJobRunner

API

  • POST /events (from Git) { repo, type, ref, sha }→ runs created
  • POST /workflows/{id}/dispatches { ref, inputs }→ 204 run queued
  • GET /runner/jobs?wait=50s→ { job, steps, job_token }
  • GET /jobs/{id}/logs→ live log stream

High-Level Design

Each push or pull request in the Git service is published to Kafka, which acts as a durable event bus so nothing is dropped if a downstream component restarts. The orchestrator consumes these events, locates the workflow files whose triggers match, and expands them into a run containing jobs. All run and job state is persisted in the run database.

Jobs whose dependencies are satisfied are placed in a job queue partitioned by runner label, for example ubuntu, windows or a customer's self-hosted pool. Runners long-poll the queue for their label, take a job, execute its steps in order and report the outcome. An autoscaler monitors queue depth, booting more runner VMs when jobs start to wait and shrinking the pool when demand falls.

During execution, the runner sends log lines to the log service, which streams them to anyone watching and eventually persists them, together with uploaded artifacts, in blob storage. Keeping this traffic on its own path means a burst of noisy builds cannot slow down scheduling.

eventcreate run + jobsready jobslong polldepthboot VMslog linesGit servicepush · PR eventsEvent busKafkaOrchestratorYAML → job graphRun DBruns · jobs stateJob queueby runner labelRunner poolephemeral VMsAutoscalerwatches queue depthLog servicelive streamBlob storagelogs · artifacts
High-level design. Dashed arrows are taken only on a cache miss or as a fallback. Scroll sideways on small screens.

Deep Dive 1 — Events to Jobs

Kafka may deliver an event more than once, so the orchestrator first deduplicates on the event's delivery ID; a single push must yield a single run. It then reads the workflow files exactly as they exist at the triggering commit, not at the branch head, and evaluates their trigger filters such as branch patterns and changed file paths.

Matching workflows are expanded into a job graph. A matrix of three operating systems by four versions becomes twelve independent jobs, while a job declaring needs stays blocked until its prerequisites succeed. Only jobs with no unfinished dependencies are enqueued, and each organisation's concurrency limit is applied at this point. When a newer push lands on the same branch, the older run can be cancelled to avoid wasting runners on stale code.

As jobs complete, the orchestrator unblocks and enqueues their dependents. Every running job holds a lease that the runner renews with heartbeats; if the heartbeats stop, the lease expires and the job is retried or marked failed, so a crashed machine never leaves a run hanging.

  1. 1Receive the eventDurable in Kafka; dedupe by delivery ID
  2. 2Match workflowsRead YAML at that commit; check branch and path filters
  3. 3Build the job graphExpand matrices; jobs wait on their 'needs'
  4. 4Enqueue fairlyPer-org concurrency limits; cancel outdated runs
  5. 5Advance on finishStart dependents; leases catch dead runners

Deep Dive 2 — Runners & Isolation

A small fleet of long-lived machines that run job after job gives instant starts, but files, caches and stray processes from one customer can be found by the next, which is unacceptable for untrusted code. Containers on a shared host are fast and pack densely, yet every container shares the host kernel, so a single kernel exploit could cross tenant boundaries.

A fresh virtual machine for every job, destroyed when the job ends, provides hardware-level isolation and a guaranteed clean environment, so it is the chosen option. Its weakness is boot time. The autoscaler counters that with a warm pool of pre-booted VMs per runner label, sized from recent demand, so a queued job is assigned to an already running machine in seconds.

Each secret is delivered solely to the job that references it, and any occurrence of its value in log output is replaced with asterisks. For deployments to cloud providers, jobs exchange a signed identity for short-lived OIDC credentials, which removes the need to store long-lived cloud keys in the platform at all.

Shared long-lived box

  • + Instant start
  • − Files leak between jobs
  • − Unsafe for strangers

Containers, one host

  • + Fast and dense
  • − Shared kernel
  • − Weaker isolation

Fresh VM per jobChosen

  • + Strong isolation
  • + Clean machine every time
  • − Slow boot → warm pool
  • Warm pool of pre-booted VMs per label → jobs start in seconds
  • Secrets: injected per job, masked in logs; short-lived OIDC tokens for clouds

Deep Dive 3 — Logs & Artifacts

Runners batch log output into small chunks and send one roughly every second. The log service fans each chunk out to anyone currently viewing that job, giving the browser a near-live stream. When the job finishes, the chunks are assembled into one file per step and written to blob storage, after which the in-memory live buffer can be released.

Artifacts can reach hundreds of megabytes, so runners request a signed URL and upload directly to blob storage rather than streaming bytes through the API tier. A dependency cache whose key is derived from hashing the lock file lets later builds restore packages instead of downloading them again, which shortens jobs and reduces runner minutes. Retention rules delete logs and artifacts after a period such as 90 days; without them, 20 TB per day would grow without limit.

  • Stream logs in small chunks

    The runner sends batches every second or so; viewers get them live.

  • Store finished logs in blob storage

    Chunks are combined into one file per step when the job ends.

  • Upload artifacts with signed URLs

    Big files go straight to blob storage, not through our API.

  • Cache dependencies

    Keyed by a hash of the lock file, so builds skip re-downloading.

  • Retention limits

    Delete logs and artifacts after, say, ninety days.

Trade-offs

Fresh VMs are far safer than containers, at the price of higher cost per job and slower cold starts. The size of the warm pool trades start latency against paying for idle machines. Long polling lets self-hosted runners behind corporate firewalls work with only outbound connections, though it adds a little delay compared with pushing work to them.

Retention length balances debugging value against steadily rising storage bills. The overall philosophy: keep events durable, apply fairness in the queues, treat machines as disposable, and move log handling off the critical path of job execution.

  • Fresh VMs vs containers

    Much safer, but each job costs more and boots slower.

  • Warm pool size

    Bigger pool means faster starts, but we pay for idle machines.

  • Long poll vs push to runners

    Works behind firewalls for self-hosted runners, but adds some delay.

  • Retention period

    Longer history helps debugging, but storage costs keep growing.

Durable events. Fair queues. Throwaway machines. Logs off the hot path.

Key takeaways

  • A CI/CD platform is a distributed job scheduler that runs code from strangers.
  • Dedupe events by delivery ID, read workflow YAML at the commit, and build a job graph.
  • Enforce per-organisation concurrency limits and cancel outdated runs on the same branch.
  • Use a fresh VM per job for isolation and a warm pool per label for fast starts.
  • Stream logs in chunks, store them in blob storage, and upload artifacts via signed URLs.

Interview questions

How does a CI/CD system like GitHub Actions turn a push into jobs?

In this practice design the push becomes an event on a durable bus such as Kafka. An orchestrator deduplicates it, reads workflow YAML at that commit, checks trigger filters, expands matrices into individual jobs and builds a dependency graph. Jobs with no pending dependencies go into a queue for their runner label, and later jobs are released as earlier ones finish.

How do CI platforms run untrusted code safely?

Each job runs in a fresh virtual machine that is destroyed afterwards, so no files or processes survive between tenants and there is no shared kernel to attack. Secrets reach only the job that references them and are redacted from log output, and cloud access uses short-lived OIDC tokens instead of stored long-lived keys.

Why do CI runners poll for jobs instead of receiving pushed work?

Self-hosted runners often sit behind corporate firewalls or NAT that block inbound connections. With long polling, the runner opens an outbound HTTPS request that the server holds until a job is ready, which works almost anywhere. The cost is a small amount of extra latency compared with pushing jobs directly to a reachable machine.

How can jobs start quickly if each one gets a new VM?

The autoscaler maintains a reserve of already-booted virtual machines for every runner label, sized from recent demand and queue depth. A newly queued job is handed to an already running VM, so it starts within seconds. After the job, that VM is destroyed and a replacement boots in the background to refill the pool.

How are live build logs streamed to the browser?

The runner sends log output in small chunks about once per second to a log service, which forwards each chunk to everyone watching that job. When the job completes, chunks are merged per step into a single file that is written to blob storage, and the live buffer is freed. Retention rules later delete old logs to control storage cost.

Keep practising