| 1 | --- |
| 2 | title: Actions, runners and sandboxes |
| 3 | description: How a workflow job is planned and run, on g1t's runners or yours, what walls each job in, how secrets reach a job without the job holding them, and what else runs in a sandbox. |
| 4 | --- |
| 5 | |
| 6 | import Aside from "../../../components/Aside.astro"; |
| 7 | import Diagram from "../../../components/Diagram.astro"; |
| 8 | |
| 9 | Everything that runs code you did not write runs in a sandbox: workflow |
| 10 | jobs, merge checks, deployments, dependency updates, backups. Three pieces |
| 11 | share the work. |
| 12 | |
| 13 | | Piece | Is | Does | |
| 14 | | ------------------ | -------------------------------------------- | ------------------------------------------------------------------------------------- | |
| 15 | | `services/actions` | A Rust Worker, D1 `g1t-actions` | Reads workflows, starts runs, plans jobs, keeps logs, secrets and self-hosted runners | |
| 16 | | `services/runner` | A TypeScript Worker on Cloudflare Containers | Starts a sandbox per job, and owns what leaves it | |
| 17 | | `crates/runner` | The `g1t-runner` binary | Runs inside every sandbox and on every self-hosted runner, does one job, and exits | |
| 18 | |
| 19 | `crates/actions` holds the workflow language itself, with no I/O: |
| 20 | syntax, `${{ }}` expressions, filters, matrices and cron. The Worker plans |
| 21 | with it and the sandbox runs with it, so the two cannot read a workflow |
| 22 | differently. |
| 23 | |
| 24 | ## A workflow job |
| 25 | |
| 26 | <Diagram name="actions-job" /> |
| 27 | |
| 28 | 1. **Trigger.** `actions` reads its event queue, `g1t-events-actions`, ten |
| 29 | at a time (step 1), and matches each event to the workflows in |
| 30 | `.g1t/workflows` at the commit the event is about (the default branch |
| 31 | for schedules, work items and manual runs; `services/actions/src/sync.rs`). |
| 32 | g1t never reads `.github/workflows`. The files are GitHub Actions |
| 33 | syntax, unchanged; [GitHub |
| 34 | Actions](/guides/actions/) lists what is supported. |
| 35 | 2. **Plan.** It expands matrices, orders jobs by `needs`, and reports the |
| 36 | run as pending on the commit (steps 2 and 3). |
| 37 | 3. **Start.** A hosted job is taken by flipping its row from `queued` to |
| 38 | `in_progress` in one statement, so two starters can never both take it. |
| 39 | `actions` asks `runner` for a sandbox (step 4), and `runner` starts a |
| 40 | Container for that job alone (step 5). |
| 41 | 4. **Run.** The sandbox reports its steps and logs to the API, with the |
| 42 | job's own token (step 6), which passes them to `actions` (step 7). The |
| 43 | answer can tell the job to stop, which is how **Cancel** reaches a |
| 44 | running job (step 8). |
| 45 | 5. **Finish.** The run's conclusion goes onto the commit as a status |
| 46 | (step 9), where [required checks](/architecture/work/#checks-and-statuses) |
| 47 | find it, and `workflow.completed` is published (step 10). |
| 48 | |
| 49 | A job that says nothing for 10 minutes is taken as lost. A cron in |
| 50 | `actions` runs every minute for schedules, jobs waiting for room, and |
| 51 | jobs whose runner went quiet. |
| 52 | |
| 53 | ### Limits |
| 54 | |
| 55 | | | | |
| 56 | | --------------------------- | ------------------------------------------------------------------------------------------------- | |
| 57 | | Hosted jobs running at once | 4 per workspace always, up to 12 while the pool of 40 sandboxes keeps 8 free for other workspaces | |
| 58 | | A hosted job's timeout | 60 minutes at most | |
| 59 | | A self-hosted job's timeout | 24 hours at most | |
| 60 | | Logs | 4 MiB a job, kept 90 days | |
| 61 | | Workflows per repository | 50 | |
| 62 | | Schedules | Paused after 60 days without a push | |
| 63 | |
| 64 | ### Logs, caches and artifacts |
| 65 | |
| 66 | Logs are kept in D1, in chunks, deduplicated so a report sent twice is |
| 67 | stored once. Cache entries and artifacts go to the R2 bucket |
| 68 | `g1t-actions-cache`, uploaded through the API: cache entries under `c/` |
| 69 | for 30 days, artifacts under `a/` for 91. `actions` lists them and decides |
| 70 | what is kept. |
| 71 | |
| 72 | ## Sandboxes on g1t's runners |
| 73 | |
| 74 | `runner` defines three Container classes, picked by `runs-on`, each a |
| 75 | named Cloudflare instance type: |
| 76 | |
| 77 | | `runs-on` | Class | Size | |
| 78 | | ---------------------------------------------------------------------------- | ---------------- | ----------------------------------------------- | |
| 79 | | Any other hosted label in a private repository, and every other kind of work | `AttemptSandbox` | `standard-3`: 2 vCPU, 8 GiB memory, 16 GB disk | |
| 80 | | `g1t-2core` | `Sandbox2Core` | `standard-3`: 2 vCPU, 8 GiB memory, 16 GB disk | |
| 81 | | `g1t-4core`, and any other hosted label in a public repository | `Sandbox4Core` | `standard-4`: 4 vCPU, 12 GiB memory, 20 GB disk | |
| 82 | |
| 83 | `ubuntu-latest` follows GitHub's hosted runners: the actions service |
| 84 | resolves it to `g1t-4core` when the run's repository is public (its |
| 85 | event's `repository.private`), and the job is billed as that machine. |
| 86 | `g1t-2core` is kept as a name of the standard machine, so workflows that |
| 87 | ask for it keep working; it keeps its class and its own `max_instances` |
| 88 | in `services/runner/wrangler.jsonc`. The contracts' instance table |
| 89 | (`INSTANCE_TYPES`) and billing's price keeper say the same sizes, and a |
| 90 | test holds them to `wrangler.jsonc`. |
| 91 | |
| 92 | Every sandbox starts from one image: `services/runner/base/Dockerfile`, |
| 93 | which has Node, Python, Go, Rust, Java, .NET, Ruby and Docker, with the |
| 94 | `g1t-runner` binary on top. Hosted runners are Linux only. |
| 95 | |
| 96 | <Diagram name="sandboxes" /> |
| 97 | |
| 98 | ### Egress |
| 99 | |
| 100 | A sandbox starts with no internet. Every HTTP and HTTPS request it makes |
| 101 | goes through the runner Worker's outbound handler (`src/egress.ts`, |
| 102 | `src/guard.ts`), which decides what may leave. Other protocols have no |
| 103 | route out at all. |
| 104 | |
| 105 | ### Secrets a job never holds |
| 106 | |
| 107 | A secret can be **brokered**: the job sees a stand-in, `g1tsec_…`, and |
| 108 | the egress handler swaps in the real value only on a request to a host |
| 109 | the secret allows (`src/broker.ts`). On a self-hosted runner, the job sends |
| 110 | the request to `POST /actions/jobs/{job}/broker` instead, and g1t makes |
| 111 | it, so the value never reaches your machine at all. |
| 112 | |
| 113 | ## Runners of your own |
| 114 | |
| 115 | A [self-hosted runner](/guides/self-hosted-runners/) only ever calls out, |
| 116 | over HTTPS, so it needs no open port: |
| 117 | |
| 118 | | Call | What it does | |
| 119 | | ----------------------------- | -------------------------------------------------------------------------------------------- | |
| 120 | | `POST /runners/register` | Trades a registration token, `g1trt_…`, valid for an hour, for a runner credential, `g1tr_…` | |
| 121 | | `POST /runners/{id}/poll` | Waits up to 20 seconds for a job; doubles as the heartbeat | |
| 122 | | `POST /runners/{id}/finished` | Says the job is over | |
| 123 | | `POST /runners/{id}/remove` | Signs the runner out | |
| 124 | |
| 125 | The runner credential is stored only as a hash and rotates every day on a |
| 126 | poll. A runner that has not polled for 90 seconds is offline; one offline |
| 127 | for 14 days is removed. A job goes to a runner that has every one of its |
| 128 | `runs-on` labels and whose group allows the repository, and waits up to 24 |
| 129 | hours for one. |
| 130 | |
| 131 | ### Three walls |
| 132 | |
| 133 | On your machine, `g1t-runner` puts three walls between a job and the |
| 134 | host: |
| 135 | |
| 136 | 1. **A VM.** WSL2 on Windows, with drives, interop and the Windows `PATH` |
| 137 | turned off, vfkit on macOS, or QEMU on Linux (`deploy/sandbox`). |
| 138 | 2. **A Sysbox container**, the runner itself, with its own dockerd. |
| 139 | It refuses to start if the host's `docker.sock` is mounted |
| 140 | (`deploy/runner`). |
| 141 | 3. **A container per job**, new each time. |
| 142 | |
| 143 | Native Windows jobs run in Windows Sandbox instead. Each runner reports |
| 144 | its isolation, `nested-container`, `vm`, `container` or `host`, and a |
| 145 | `host` runner never gets a public repository's jobs. The [desktop |
| 146 | app](/guides/desktop/) runs the sandboxed runner by default while it is |
| 147 | open; Docker or direct execution happen only if you choose them. |
| 148 | |
| 149 | ## Other work in sandboxes |
| 150 | |
| 151 | `g1t-runner` takes a `MODE`, so the same image does more than workflows: |
| 152 | |
| 153 | | Mode | Started by | Does | |
| 154 | | ------------ | ------------------------------------- | ------------------------------------------------------------ | |
| 155 | | `actions` | `actions` | A workflow job | |
| 156 | | `mergecheck` | `runner`, when a pull request changes | Checks whether a pull request merges cleanly | |
| 157 | | `deploy` | `deployments` | Builds an app ([Deployments](/architecture/deployments/)) | |
| 158 | | `bump` | `security` | Makes a dependency update | |
| 159 | | `backup` | `runner`'s sweep, every 5 minutes | Cuts a repository backup ([Git](/architecture/git/#backups)) | |
| 160 | |
| 161 | The merge queue needs no sandbox of its own: `work` builds each queued |
| 162 | state in the repository itself ([Work](/architecture/work/#the-merge-queue)), |
| 163 | and only the workflows that run on `merge_group` run in sandboxes, as |
| 164 | workflow jobs. |
| 165 | |
| 166 | Compute is admitted through billing: a sandbox starts only after |
| 167 | `billing` holds its estimated cost (`reserve`), and the hold is settled |
| 168 | with the real cost when it ends (`settle`). |
| 169 | |
| 170 | <Aside type="caution" title="Self-hosted installations"> |
| 171 | A [self-hosted g1t](/guides/self-hosting-architecture/) binds `runner` to a Worker that answers that it is off, so workflows, the merge queue and deployments |
| 172 | do not run there yet. |
| 173 | </Aside> |