g1t-runner 0.1.0 is released: signed binaries for five platforms at g1t.sh/downloads/runner; the Artifacts checks' results
- Runner release: the public key is the variable RUNNER_RELEASE_PUBLIC_KEY, since names starting G1T_ are reserved; the workflow hands it to the build as G1T_RUNNER_RELEASE_KEY. 0.1.0 is built, signed, verified and published (linux, macOS and Windows; x64 and arm64). - The self-hosted runners guide no longer says the runner is not available. The container image and an agent image are not published yet, and it says so where they come up. - scripts/ops: both Artifacts scripts take a global API key (CLOUDFLARE_API_KEY with CLOUDFLARE_EMAIL) as well as a token. - docs/ARTIFACTS.md: what the usage check and the fork storage test found on 2026-10-06. Cloudflare counts binding reads (read, token_create); pull looks like fetch + ls-refs + info/refs; forks are stored and billed at their source's full size, so fork retention should drop to a day; the store's early answer to a negotiating fetch.
| 48 | 48 | key: runner-release-${{ hashFiles('Cargo.lock') }} | |
| 49 | 49 | - name: Build every platform | |
| 50 | 50 | env: | |
| 51 | − | G1T_RUNNER_RELEASE_KEY: ${{ vars.G1T_RUNNER_RELEASE_KEY }} | |
| 51 | + | G1T_RUNNER_RELEASE_KEY: ${{ vars.RUNNER_RELEASE_PUBLIC_KEY }} | |
| 52 | 52 | RUNNER_AGENT_IMAGE: ${{ vars.RUNNER_AGENT_IMAGE }} | |
| 53 | 53 | run: node scripts/runner-release.mjs build | |
| 54 | 54 | - name: Sign | |
| 55 | 55 | env: | |
| 56 | 56 | RUNNER_RELEASE_KEY: ${{ secrets.RUNNER_RELEASE_KEY }} | |
| 57 | − | G1T_RUNNER_RELEASE_KEY: ${{ vars.G1T_RUNNER_RELEASE_KEY }} | |
| 57 | + | G1T_RUNNER_RELEASE_KEY: ${{ vars.RUNNER_RELEASE_PUBLIC_KEY }} | |
| 58 | 58 | run: | | |
| 59 | 59 | node scripts/runner-release.mjs sign | |
| 60 | 60 | node scripts/runner-release.mjs verify |
| 9 | 9 | on it, register it once, and it picks up jobs that ask for it with | |
| 10 | 10 | `runs-on: self-hosted`. | |
| 11 | 11 | ||
| 12 | − | **Not available yet:** the first release of `g1t-runner` has not been | |
| 13 | − | published, so the downloads below and the `flagonio/g1t-runner` image do | |
| 14 | − | not exist yet. g1t's side (**Settings → Runners**, registration tokens, | |
| 15 | − | groups and the API) is live. This page says how the runner works once it | |
| 16 | − | is released. | |
| 17 | − | ||
| 18 | 12 | - **Time on your runners is $0**, on every plan, including the free one. | |
| 19 | 13 | It shows on [usage](/guides/usage-and-billing/) as self-hosted minutes. | |
| 20 | 14 | - **It only connects out.** The runner polls `api.g1t.sh` over HTTPS for | |
| 181 | 175 | your own [model provider](/guides/models/), g1t charges nothing for the | |
| 182 | 176 | run; otherwise the model is paid as usual and the machine is free. | |
| 183 | 177 | - Agent work needs an image with git, Node and the agent's CLI: register | |
| 184 | − | the runner with `--agent-image`, which the release names, or run it with | |
| 185 | − | `--no-docker` on a Linux machine set aside for it, where `/work` can be | |
| 186 | − | written. | |
| 178 | + | the runner with `--agent-image` and an image of yours that has them, or | |
| 179 | + | run it with `--no-docker` on a Linux machine set aside for it, where | |
| 180 | + | `/work` can be written. g1t does not publish an agent image yet. | |
| 187 | 181 | - [Guardrails](/guides/guardrails/) still apply to what the agent does, but | |
| 188 | 182 | their network list cannot be enforced on your machine. The run's session | |
| 189 | 183 | says so when it starts. | |
| 232 | 226 | ||
| 233 | 227 | ## Docker and Kubernetes | |
| 234 | 228 | ||
| 229 | + | **The `flagonio/g1t-runner` image is not published yet.** Until it is, run | |
| 230 | + | the binary on the machine, or build the image from | |
| 231 | + | [`deploy/runner/Dockerfile`](https://g1t.sh/flagon-io/g1t/blob/main/deploy/runner/Dockerfile) | |
| 232 | + | with the Linux binary beside it. | |
| 233 | + | ||
| 235 | 234 | The runner's image runs `register-and-run`, which registers once and then | |
| 236 | 235 | runs. Each option can also come from `G1T_RUNNER_<OPTION>` in the | |
| 237 | 236 | environment, such as `G1T_RUNNER_TOKEN` and `G1T_RUNNER_LABELS`, so a |
| 387 | 387 | Ratios over 1: something reaches Artifacts that g1t does not meter, such as sandboxes pushing | |
| 388 | 388 | directly with handed-out credentials. | |
| 389 | 389 | - Only days after the meters were deployed compare; before that only `git_operations` exists. | |
| 390 | − | - Binding calls (`binding.*`) never appear among Cloudflare's event types; if Cloudflare says | |
| 391 | − | they are billed, map them in `operation_mapping`. | |
| 390 | + | - Binding calls do appear: Cloudflare's events include `read` and `token_create` actions (and | |
| 391 | + | `namespace_*`) besides the five documented ones. If Cloudflare says they are billed, map the | |
| 392 | + | `binding.*` meters in `operation_mapping`. | |
| 393 | + | ||
| 394 | + | A global API key works in place of the token: `CLOUDFLARE_API_KEY` with `CLOUDFLARE_EMAIL` | |
| 395 | + | (both scripts). | |
| 396 | + | ||
| 397 | + | **Result, 2026-10-06** (31 days; g1t's meters cover only 2026-10-06, the day they shipped): | |
| 398 | + | ||
| 399 | + | | Cloudflare event | 31 days | 2026-10-06 | g1t's meters, 2026-10-06 | | |
| 400 | + | | --- | --- | --- | --- | | |
| 401 | + | | `read` | 137,225 | 107,616 | `binding.get` 85,206, `read_file` 49,846, `read_tree` 9,208, `read_blob` 4,964, `log` 2,798 | | |
| 402 | + | | `pull` | 646 | 101 | `git.fetch` 29, `git.ls_refs` 26, `git.info_refs` 426 (+ 129 internal) | | |
| 403 | + | | `push` | 385 | 10 | `git.receive_pack` 3 | | |
| 404 | + | | `token_create` | 2,721 | 338 | `binding.create_token` 34 | | |
| 405 | + | | `fork` / `create` / `delete` | 96 / 14 / 8 | 2 / 0 / 0 | | | |
| 406 | + | | errors | 699 (688 client) | 476 client | | | |
| 407 | + | ||
| 408 | + | - `pull` is not one per upload-pack fetch: 101 pulls against 29 fetches on the one day both | |
| 409 | + | exist. Over 31 days `pull` ≈ fetch + ls-refs + info/refs (ratio 1.06), so listing refs likely | |
| 410 | + | counts as a pull. Not yet changed in `operation_mapping`: one day of meters is too little, and | |
| 411 | + | re-check after a week before setting `cost_operations` for `git.info_refs` and `git.ls_refs`. | |
| 412 | + | - `read` is the open question that matters. If reads are billed as operations at $0.15 per | |
| 413 | + | 1,000, today's demo-scale traffic alone is about 3.2 million a month (~$480). Ask Cloudflare | |
| 414 | + | (Q1) before 2026-10-14. Either way the volume is mostly waste: every repos call opens a handle | |
| 415 | + | with `get` even when the answer is cached, and the object cache may not be hitting (no hit/miss | |
| 416 | + | meter yet). Fixes, ranked: lazy `get`; a real cache for objects named by hash (KV or an | |
| 417 | + | in-isolate LRU, with hit/miss meters); Actions reading workflows from the synced table instead | |
| 418 | + | of the store on every event; caller attribution in the meters. | |
| 419 | + | - 476 client errors on 2026-10-06 are unexplained; the fetch fix below accounts for some (every | |
| 420 | + | failed negotiation was one). | |
| 392 | 421 | ||
| 393 | 422 | ### R2: running and reading `scripts/ops/fork-storage-test.mjs` | |
| 394 | 423 | ||
| 413 | 442 | Either way R2 retires forks; the answer decides `FORK_RETENTION_DAYS` (shared: a week is fine; | |
| 414 | 443 | copied: shorten it to 1 or 2 days and ask Cloudflare to raise the 1 TB account limit). | |
| 415 | 444 | ||
| 445 | + | **Result, 2026-10-06: forks are stored and billed as copies.** A 100 MB source took 57 s to | |
| 446 | + | push; each of 5 forks took 4–6 s. The storage dataset (`artifactsStorageAdaptiveGroups`, | |
| 447 | + | `max.repositorySizeBytes`) gave every fork the source's full 105,582,592 bytes: about 633 MB for | |
| 448 | + | the six, not about 106 MB. Whatever Artifacts shares underneath, storage billing and the 1 TB | |
| 449 | + | account limit see full copies. So: | |
| 450 | + | ||
| 451 | + | - `FORK_RETENTION_DAYS` should drop from 7 to 1 (not yet changed). | |
| 452 | + | - g1t meters a workspace's storage once per repository (`stored_bytes`), so an open pull | |
| 453 | + | request's working copy is Cloudflare cost g1t absorbs: about $0.05 a month per 100 MB per open | |
| 454 | + | pull request. Small now; decide whether open working copies count toward a workspace's | |
| 455 | + | storage before agent pull requests reach thousands. | |
| 456 | + | - Ask Cloudflare whether forks share objects physically, and for a higher account limit. | |
| 457 | + | ||
| 458 | + | Also found while testing (fixed in `git_http.rs`): the store answers a protocol v2 fetch that is | |
| 459 | + | still negotiating, and whose `have`s it does not know, with `acknowledgments`, `NAK`, then a pack. | |
| 460 | + | git refuses that ("expected no other sections to be sent after no 'ready'"). g1t now ends such | |
| 461 | + | an answer after the acknowledgments with a flush, and the client negotiates again. Report it to | |
| 462 | + | Cloudflare. | |
| 463 | + | ||
| 416 | 464 | ### R7: making more namespaces (yours to run, when needed) | |
| 417 | 465 | ||
| 418 | 466 | ```sh |
| 562 | 562 | ||
| 563 | 563 | 1. `node scripts/runner-release.mjs keygen`. Put `RUNNER_RELEASE_KEY` in the | |
| 564 | 564 | repository's secrets (production environment) and keep a copy offline; | |
| 565 | − | put `G1T_RUNNER_RELEASE_KEY` in its variables. A build made without the | |
| 566 | − | public key never updates itself. | |
| 565 | + | put the public key in its variables as `RUNNER_RELEASE_PUBLIC_KEY` (names | |
| 566 | + | starting `G1T_` are reserved; the workflow hands it to the build as | |
| 567 | + | `G1T_RUNNER_RELEASE_KEY`). A build made without the public key never | |
| 568 | + | updates itself. | |
| 567 | 569 | 2. `npx wrangler r2 bucket create g1t-downloads`, and deploy the site so it | |
| 568 | 570 | has the `DOWNLOADS` binding. | |
| 569 | 571 | 3. Set the variables `RUNNER_IMAGE` (the image's name in a public registry), |
| 11 | 11 | const WRANGLER = join(ROOT, "node_modules/wrangler/bin/wrangler.js"); | |
| 12 | 12 | export const ACCOUNT_ID = "1e6f2cffa3f445920836e8ebe446bb58"; | |
| 13 | 13 | ||
| 14 | + | /** | |
| 15 | + | * Headers for Cloudflare's REST and GraphQL APIs, for the ops scripts: | |
| 16 | + | * CLOUDFLARE_API_TOKEN as a bearer token, or else a global API key | |
| 17 | + | * (CLOUDFLARE_API_KEY with CLOUDFLARE_EMAIL). Null when neither is set. | |
| 18 | + | */ | |
| 19 | + | export function cloudflareAuth(env = process.env) { | |
| 20 | + | if (env.CLOUDFLARE_API_TOKEN) return { authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}` }; | |
| 21 | + | if (env.CLOUDFLARE_API_KEY && env.CLOUDFLARE_EMAIL) { | |
| 22 | + | return { "x-auth-key": env.CLOUDFLARE_API_KEY, "x-auth-email": env.CLOUDFLARE_EMAIL }; | |
| 23 | + | } | |
| 24 | + | return null; | |
| 25 | + | } | |
| 26 | + | ||
| 14 | 27 | /** What a deploy's version message starts with, followed by the commit. */ | |
| 15 | 28 | export const MESSAGE_PREFIX = "g1t-deploy"; | |
| 16 | 29 |
| 16 | 16 | // token needs D1: Read too), or with CLOUDFLARE_D1_TOKEN when that is set, | |
| 17 | 17 | // or as you are logged in (`npx wrangler login`) when neither has it. | |
| 18 | 18 | ||
| 19 | − | import { ACCOUNT_ID, exec, jsonFrom, wranglerEnv } from "../deploy/cloudflare.mjs"; | |
| 19 | + | import { ACCOUNT_ID, cloudflareAuth, exec, jsonFrom, wranglerEnv } from "../deploy/cloudflare.mjs"; | |
| 20 | 20 | import { ROOT } from "../deploy/stack.mjs"; | |
| 21 | 21 | import { join } from "node:path"; | |
| 22 | 22 | ||
| 33 | 33 | const days = Math.min(31, Math.max(1, Number(option("--days", "31")) || 31)); | |
| 34 | 34 | const asJson = flag("--json"); | |
| 35 | 35 | ||
| 36 | − | const token = process.env.CLOUDFLARE_API_TOKEN; | |
| 37 | − | if (!token) { | |
| 38 | − | console.error("Set CLOUDFLARE_API_TOKEN to a token with Account Analytics: Read on account " + ACCOUNT_ID + "."); | |
| 36 | + | const auth = cloudflareAuth(); | |
| 37 | + | if (!auth) { | |
| 38 | + | console.error( | |
| 39 | + | "Set CLOUDFLARE_API_TOKEN to a token with Account Analytics: Read on account " + ACCOUNT_ID + | |
| 40 | + | ", or CLOUDFLARE_API_KEY and CLOUDFLARE_EMAIL.", | |
| 41 | + | ); | |
| 39 | 42 | process.exit(2); | |
| 40 | 43 | } | |
| 41 | 44 | ||
| 62 | 65 | }`; | |
| 63 | 66 | const response = await fetch("https://api.cloudflare.com/client/v4/graphql", { | |
| 64 | 67 | method: "POST", | |
| 65 | − | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 68 | + | headers: { ...auth, "content-type": "application/json" }, | |
| 66 | 69 | body: JSON.stringify({ query, variables: { accountTag: ACCOUNT_ID, start: start.toISOString(), end: end.toISOString() } }), | |
| 67 | 70 | }); | |
| 68 | 71 | const body = await response.json(); |
| 10 | 10 | // against Cloudflare's API, never through g1t.sh. | |
| 11 | 11 | // | |
| 12 | 12 | // export CLOUDFLARE_API_TOKEN=<token: Artifacts edit, Account Analytics read> | |
| 13 | + | // (or a global API key: CLOUDFLARE_API_KEY with CLOUDFLARE_EMAIL) | |
| 13 | 14 | // node scripts/ops/fork-storage-test.mjs run # make, fork 5x, measure for 20 min, delete | |
| 14 | 15 | // node scripts/ops/fork-storage-test.mjs run --keep # ... and keep it, to measure again tomorrow | |
| 15 | 16 | // node scripts/ops/fork-storage-test.mjs measure # read the figures again (e.g. the next day) | |
| 28 | 29 | import { tmpdir } from "node:os"; | |
| 29 | 30 | import { join } from "node:path"; | |
| 30 | 31 | ||
| 32 | + | import { cloudflareAuth } from "../deploy/cloudflare.mjs"; | |
| 33 | + | ||
| 31 | 34 | const ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID || "1e6f2cffa3f445920836e8ebe446bb58"; | |
| 32 | 35 | const API = `https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}`; | |
| 33 | 36 | const args = process.argv.slice(2); | |
| 44 | 47 | const SOURCE = "fork-test-source"; | |
| 45 | 48 | const forkName = (n) => `fork-test-copy-${n}`; | |
| 46 | 49 | ||
| 47 | − | const token = process.env.CLOUDFLARE_API_TOKEN; | |
| 48 | − | if (!token) { | |
| 49 | − | console.error("Set CLOUDFLARE_API_TOKEN (Artifacts edit, Account Analytics read)."); | |
| 50 | + | const auth = cloudflareAuth(); | |
| 51 | + | if (!auth) { | |
| 52 | + | console.error("Set CLOUDFLARE_API_TOKEN (Artifacts edit, Account Analytics read), or CLOUDFLARE_API_KEY and CLOUDFLARE_EMAIL."); | |
| 50 | 53 | process.exit(2); | |
| 51 | 54 | } | |
| 52 | 55 | ||
| 53 | 56 | async function api(method, path, body) { | |
| 54 | 57 | const response = await fetch(`${API}${path}`, { | |
| 55 | 58 | method, | |
| 56 | − | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 59 | + | headers: { ...auth, "content-type": "application/json" }, | |
| 57 | 60 | body: body ? JSON.stringify(body) : undefined, | |
| 58 | 61 | }); | |
| 59 | 62 | const json = await response.json().catch(() => ({})); | |
| 63 | 66 | async function graphql(query, variables = {}) { | |
| 64 | 67 | const response = await fetch("https://api.cloudflare.com/client/v4/graphql", { | |
| 65 | 68 | method: "POST", | |
| 66 | − | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 69 | + | headers: { ...auth, "content-type": "application/json" }, | |
| 67 | 70 | body: JSON.stringify({ query, variables }), | |
| 68 | 71 | }); | |
| 69 | 72 | const json = await response.json(); |