From 4c377e0fab3c55e2c072e703dc91a0e46f00548a Mon Sep 17 00:00:00 2001 From: Sina Meraji Date: Fri, 17 Jul 2026 02:40:35 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20v0.3=20core=20CI=20(M8)=20=E2=80=94?= =?UTF-8?q?=20.gitflare/ci.yml=20jobs=20on=20Cloudflare=20Sandboxes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ci.yml format: jobs/needs/run steps (multi-line via new block-scalar support in yaml.ts), per-job env + timeout_minutes; parse-time cycle and unknown-needs validation, Kahn topological order - CiDO: detached runs (202 + pending-work), one sandbox per run cloned from the Artifacts mirror with env-scoped git auth (token never in argv/.git/config/stderr; all output scrubbed), streamed live logs over hibernatable WS, alarm watchdog, cancel, push-storm supersede - deploy jobs delegate to DeployDO (mode "ci", strict at-sha ref-aware clone) keeping one deploy history + cross-format rollback; CI-built worker entries ship via entryOverrides instead of stale committed files - fail-closed: committed ci.yml with CI disabled blocks ungated deploy.yml - GitHub commit statuses (legacy Status API, soft-fail), runs UI at /r/:name/ci, /control/ci/* endpoints - CLI: gitflare ci enable/disable/run/list/cancel; containers block with public docker.io/cloudflare/sandbox image (no local Docker), migrations v3/v4 unconditional, symmetric CONTROL_SECRET reuse - 51 new unit tests (107 total) Co-Authored-By: Claude Fable 5 --- PLAN.md | 5 +- README.md | 31 +- packages/cli/src/commands/ci.ts | 325 +++++++++ packages/cli/src/commands/deploy.ts | 16 +- packages/cli/src/config.ts | 11 + packages/cli/src/index.ts | 32 + packages/cli/src/redeploy.ts | 3 + packages/cli/src/wrangler.ts | 44 +- packages/worker/package.json | 1 + packages/worker/src/artifacts/content.ts | 66 ++ packages/worker/src/ci/github-status.ts | 67 ++ packages/worker/src/ci/sandbox-runner.ts | 298 +++++++++ packages/worker/src/ci/workflow.ts | 227 +++++++ packages/worker/src/deploy/workflow.ts | 102 +-- packages/worker/src/deploy/yaml.ts | 80 ++- packages/worker/src/durable-objects/ci.ts | 627 ++++++++++++++++++ packages/worker/src/durable-objects/deploy.ts | 176 ++++- packages/worker/src/env.ts | 9 + packages/worker/src/index.tsx | 153 ++++- packages/worker/src/ui/deployments.tsx | 3 +- packages/worker/src/ui/runs.tsx | 197 ++++++ packages/worker/test/ci-workflow.test.ts | 260 ++++++++ packages/worker/test/github-status.test.ts | 65 ++ packages/worker/test/sandbox-runner.test.ts | 313 +++++++++ packages/worker/test/yaml.test.ts | 49 ++ packages/worker/wrangler.toml | 25 + pnpm-lock.yaml | 45 ++ 27 files changed, 3146 insertions(+), 84 deletions(-) create mode 100644 packages/cli/src/commands/ci.ts create mode 100644 packages/worker/src/ci/github-status.ts create mode 100644 packages/worker/src/ci/sandbox-runner.ts create mode 100644 packages/worker/src/ci/workflow.ts create mode 100644 packages/worker/src/durable-objects/ci.ts create mode 100644 packages/worker/src/ui/runs.tsx create mode 100644 packages/worker/test/ci-workflow.test.ts create mode 100644 packages/worker/test/github-status.test.ts create mode 100644 packages/worker/test/sandbox-runner.test.ts diff --git a/PLAN.md b/PLAN.md index bf8966d..3f87cea 100644 --- a/PLAN.md +++ b/PLAN.md @@ -478,7 +478,7 @@ That's it for v0.1 — three permissions. *Workers Routes* is a Zone-level permi **Three scopes for v0.1. Every one named. No "Edit all of Cloudflare" hand-grenade tokens.** Each later version adds scopes; never replaces: - v0.2 adds: `Workers KV Storage: Edit` (for cached deploy state) -- v0.3 adds: Sandbox-related scopes when those stabilize; `R2: Edit` (specifically *Workers R2 Storage*, for build cache); `D1: Edit` (for CI run history) +- v0.3 adds: the account-level **Containers** permission (needed the moment `gitflare ci enable` puts a `[[containers]]` block in the Worker config — exact permission name to be confirmed on the first live run; `ci enable` warns about this up front). Later in v0.3: `R2: Edit` (specifically *Workers R2 Storage*, for build cache); `D1: Edit` (for CI run history) - v0.4 adds: `Cloudflare Access: Apps and Policies: Edit` - v0.5 adds: `Cloudflare One Connector: Edit`, `Cloudflare Tunnel: Edit` - Custom domains (any version): `Zone → Workers Routes: Edit`, `Zone → DNS: Edit` @@ -553,7 +553,8 @@ The roadmap in §4 is what we're shipping. This section is *where we are right n | M5 | Privacy via Cloudflare Access | 🧪 implemented, not yet live-validated | Worker: `accessGuard` middleware verifies the `Cf-Access-Jwt-Assertion` JWT (RS256 via WebCrypto + JWKS cache, no `jose`) and gates `/` + `/r/*` + `/api/*`; enforces only when `ACCESS_AUD` var is set, so public mirrors stay open. `/webhooks/github` + `/health` left unauth. 8 unit tests. CLI: opt-in `gitflare access enable/disable` creates/deletes a self-hosted Access app + allow-list policy and redeploys with `ACCESS_AUD`/`ACCESS_TEAM_DOMAIN` vars. **Caveat:** this gates the dashboard/API only — `git clone` hits Artifacts directly (`*.artifacts.cloudflare.net`), so it is NOT Access-gated; private-clone is a later (v0.4+) item per §6/§11. **TODO before ✅:** verify the live Access apps/policies API shapes + `aud` field, and that `*.workers.dev` hosts are accepted, against a real account. | | M5.5 | v0.1 polish | 🧪 implemented | Syntax highlighting in the blob viewer (highlight.js core + ~20 curated grammars, server-side; 512 KB cap with `
` fallback; bundle 681→901 KB). README image proxy: new `GET /r/:name/raw/*` serves blob bytes from the Artifacts mirror so images render for private repos and survive GitHub outages — README rewriting now points there instead of `raw.githubusercontent.com`. Styled empty/error states (`ui/states.tsx`): home shows the `npx gitflare init` command, browse 404/500 render through the layout with a way back. |
 | M6 | v0.2 CD (MVP slice) | 🧪 implemented | **Self-deploy model (user chose Worker-Secret path).** On push, after sync, the webhook fires `DeployDO` (`waitUntil`), which clones the repo, parses `.gitflare/deploy.yml`, and uploads the pre-built `cloudflare/deploy { kind: worker }` entry via the Workers Scripts multipart API. History in `DeployDO`; Deployments UI; CLI `gitflare deploy enable/disable` stores `CF_DEPLOY_TOKEN` + `CD_ENABLED`. Superseded by M7. |
-| M7 | v0.2 CD — complete (per §4) | 🧪 implemented, not yet live-validated | Everything in §4 v0.2: **(1)** real YAML-subset parser (`deploy/yaml.ts`, no heavyweight dep) → **worker bindings** (vars/KV/R2/D1/DO/services) in the Scripts metadata; **(2)** **Pages** deploys via Direct Upload (upload-token → check-missing → upload → create-deployment), with per-branch **previews** (`production_branch`); **(3)** **D1 migrations** applied in order via the D1 query API, opt-in (`apply: true`), idempotent (applied set tracked per-DB in the DO); **(4)** **live deploy-log streaming** over a hibernatable WebSocket (`/r/:name/deployments/stream`) with the Deployments page subscribing; **(5)** **manual / GitHub-down trigger** `gitflare deploy run` + `/control/deploy/run` (auth'd by a `CONTROL_SECRET`, outside Access); **(6)** `gitflare deploy list` + `gitflare deploy rollback [--to ]` (rollback redeploys a previous successful commit via a full clone; migrations are forward-only and skipped). 56 unit tests across yaml/workflow/cf-deploy/highlight/access. **TODO before ✅:** one live run against a real Cloudflare account to confirm the Scripts multipart, Pages Direct Upload, and D1 query wire shapes. |
+| M7 | v0.2 CD — complete (per §4) | 🧪 implemented, not yet live-validated | Everything in §4 v0.2: **(1)** real YAML-subset parser (`deploy/yaml.ts`, no heavyweight dep) → **worker bindings** (vars/KV/R2/D1/DO/services) in the Scripts metadata; **(2)** **Pages** deploys via Direct Upload (upload-token → check-missing → upload → create-deployment), with per-branch **previews** (`production_branch`); **(3)** **D1 migrations** applied in order via the D1 query API, opt-in (`apply: true`), idempotent (applied set tracked per-DB in the DO); **(4)** **live deploy-log streaming** over a hibernatable WebSocket (`/r/:name/deployments/stream`) with the Deployments page subscribing; **(5)** **manual / GitHub-down trigger** `gitflare deploy run` + `/control/deploy/run` (auth'd by a `CONTROL_SECRET`, outside Access); **(6)** `gitflare deploy list` + `gitflare deploy rollback [--to ]` (rollback redeploys a previous successful commit via a full clone; migrations are forward-only and skipped). 56 unit tests across yaml/workflow/cf-deploy/highlight/access. **TODO before ✅:** one live run against a real Cloudflare account to confirm the Scripts multipart, Pages Direct Upload, and D1 query wire shapes. Known quirk (kept in M8): the v0.2 push path reads workflow/content at the clone's HEAD while recording the webhook sha — same-branch seconds-apart race only; the v0.3 CI path reads strictly at the pushed sha by construction. |
+| M8 | v0.3 core CI — sandbox jobs, needs-gated deploys | 🧪 implemented, not yet live-validated | **Format** (`.gitflare/ci.yml`, parsed by `src/ci/workflow.ts` reusing the yaml.ts subset + the shared `cloudflare/deploy` step validator): `on`/`branches`, `jobs:` with `needs:` (parse-time unknown/cycle validation, Kahn topo order), `run:` steps (multi-line via new `\|`/`\|-` block-scalar support in yaml.ts), per-job `env:` + `timeout_minutes` (default 15, max 60); a job is either a run job or a deploy job (mixing = parse error). **Execution**: new `CiDO` (binding `CI`, migration v3) — POST `/run` answers 202 and the pipeline runs as detached DO work; ONE sandbox per run (`@cloudflare/sandbox@0.12.3`, image pinned to match) cloned once from the Artifacts mirror via env-scoped `http.extraHeader` auth (token never in argv/.git/config/stderr; every log line scrubbed for the secret + its base64), then jobs execute in topo order with streamed line-buffered logs (hibernatable WS at `/r/:name/ci/stream`, cap 1000 lines/job). **Deploy jobs delegate to DeployDO** (`mode: "ci"`, strict at-sha ref-aware clone via new `cloneRepoAtRef`, feature branches included) so deploy history/rollback stay in one place; `DeployRecord` gains `workflow`/`job` for cross-format rollback. **Artifact handover**: worker entries built by run jobs are read out of the sandbox (5 MB cap) and shipped via `entryOverrides` — deploys ship what CI built, not the stale committed file. **Safety**: alarm watchdog fails interrupted/over-budget runs + destroys orphaned sandboxes; cancel (CLI `gitflare ci cancel`, `/control/ci/cancel`, runs-page button); push-storm supersede; fail-closed — committed ci.yml + CI disabled means deploy.yml does NOT run ungated; delegated deploys count as green ONLY on `status: "success"` (a "CD not enabled" skip fails the job loudly). **Status postback** via the legacy Commit Status API (context `gitflare/ci`; classic PAT can't use Checks), soft-fail. **CLI**: `gitflare ci enable/disable/run/list/cancel`; enable provisions `[[containers]]` (public `docker.io/cloudflare/sandbox` image — no local Docker; `--instance-type`, default `standard`); migrations v3+v4 are emitted UNconditionally so cross-machine redeploys never miss applied tags; CONTROL_SECRET reuse is symmetric with `deploy enable`; disable drops only `CI_ENABLED` (idle containers cost $0). 51 new unit tests (97 total worker-side). Worker bundle 901 KB → 1557 KB (sandbox SDK +433 KB, capnweb +104 KB, containers +75 KB); ~400 KB gzipped, fine for Workers limits. **Deliberate deviations from §4 v0.3**: `runtime: worker` rejected with a clear error (Dynamic Workers can't run npm — no Linux userland); per-job `image:` rejected (containers pin the image at Worker-deploy time); ArtifactFS mount not used (sandbox `git clone`; cold container start pulls the image from Docker Hub — minutes, not the §4 \<500ms); jobs in one run share a sandbox/workspace (GHA users expect isolation — M9); Pages artifact handover, R2 build cache, Browser Run, Actions importer, per-job status contexts, `gitflare sandbox` verb → M9. **TODO before ✅:** live run to validate: containers provisioning via wrangler TOML on a real Workers Paid account (incl. exact API-token permission name for Containers), migration v4 with an unbound Sandbox class, Sandbox `exec`/streaming/`readFile` wire behavior, that a long exec counts as container activity for `sleepAfter`, and detached-DO-work longevity for hour-scale runs. |
 
 ### What's in the repo right now (as of M0)
 
diff --git a/README.md b/README.md
index b755287..2ccd135 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ GitFlare ships in versions. Each one stands alone — if the next one never gets
 |---|---|---|
 | **v0.1** | ✅ **shipping — you are here** | **Read replica.** One command mirrors a GitHub repo into your Cloudflare account: Artifacts for git storage, a Worker that takes GitHub webhooks + serves a dashboard, file browsing with syntax highlighting, README rendering (images proxied through your Worker), sync status. Optional Cloudflare Access gates the dashboard for private repos. If GitHub is down, reads + clones still work. |
 | v0.2 | 🧪 code-complete (pending a live run) | **CD that doesn't depend on GitHub.** Push → your Worker deploys to your own account: Workers + Pages (with preview deploys), bindings (vars/KV/R2/D1/DO/services), opt-in D1 migrations, live deploy logs over WebSocket, plus `deploy run` (the GitHub-down escape hatch), `deploy list`, and `deploy rollback`. Deploys **pre-built** artifacts via `.gitflare/deploy.yml`; arbitrary build steps arrive with v0.3 CI. |
-| v0.3 | 📋 planned | **Generic CI.** A small declarative workflow format that runs tests on Cloudflare Sandboxes (full Linux) or Dynamic Workers (fast JS path). Build cache in R2. Browser Run for E2E. |
+| v0.3 | 🚧 in progress (core CI code-complete) | **Generic CI.** `.gitflare/ci.yml` with jobs / `needs:` / `run:` steps, executed on Cloudflare Sandboxes (full Linux containers on your account). Deploy jobs gate on test jobs, and a Worker deploy ships **what CI just built** (not the stale committed file). Live logs, cancel, run history, GitHub commit statuses. Still to come in v0.3: R2 build cache, Browser Run for E2E, a GitHub Actions importer, Pages build artifacts. |
 | v0.4 | 📋 planned | **Multi-user teams.** PRs, reviews, comments — native to GitFlare, bidirectionally mirrored to GitHub. Stacked diffs. "Open PR in sandbox" one-click ephemeral env. |
 | v0.5 | 📋 planned | **Cross-tenant collaboration via Cloudflare Mesh.** Alice and Bob on separate Cloudflare accounts; private repos served Mesh-only with per-identity policies instead of SSH keys. |
 | v0.6 | 📋 planned | **Public repos + discovery.** A real code browser for the public web, search, forks across accounts. |
@@ -83,12 +83,35 @@ GitFlare never sees your code, your token, or your traffic. It's an MIT-licensed
 
   Deploys and their **live logs** show up at `/r//deployments`.
 
+- `gitflare ci enable` — turn on generic CI (v0.3, requires the Workers Paid plan for Containers). Commit a `.gitflare/ci.yml` and every push runs your jobs in a **Cloudflare Sandbox on your own account** — full Linux, Node 20 + Python 3.11 — no GitHub Actions involved:
+
+  ```yaml
+  on: push
+  branches: [main]
+  jobs:
+    test:
+      steps:
+        - run: npm ci
+        - run: npm test
+    deploy:
+      needs: [test]          # deploys only if tests pass
+      steps:
+        - cloudflare/deploy:
+            project: my-worker
+            kind: worker
+            entry: dist/worker.js
+  ```
+
+  If a job builds `entry` (e.g. `npm run build`), the deploy ships the freshly built file from the CI workspace, not the committed copy. When `ci.yml` exists, it owns the pipeline — `deploy.yml` no longer runs ungated. Deploy jobs also need `gitflare deploy enable` (that's where the deploy token lives).
+
+- `gitflare ci run` / `list` / `cancel` — trigger the pipeline for the current Artifacts HEAD (GitHub-down escape hatch), review runs, or stop a runaway one. Runs + **live logs** stream at `/r//ci`, and results post back to GitHub as commit statuses (`gitflare/ci`) when GitHub is reachable.
+
 ## Contributing
 
-Pre-alpha, built in the open, and there's a lot of obvious next work. Cloudflare Access (M5), syntax highlighting, the image proxy, and the full v0.2 CD feature set have landed — see [PLAN.md §12](./PLAN.md#12-milestones-and-development-log) for current status. PRs and issues are welcome — particularly on:
+Pre-alpha, built in the open, and there's a lot of obvious next work. Cloudflare Access (M5), the full v0.2 CD feature set, and the v0.3 core CI (M8: sandbox jobs, needs-gated deploys, artifact handover) have landed — see [PLAN.md §12](./PLAN.md#12-milestones-and-development-log) for current status. PRs and issues are welcome — particularly on:
 
-- **Live-validating M5 + v0.2** against a real Cloudflare account. The Access apps/policies API, the Workers Scripts upload, Pages Direct Upload, and the D1 query path are coded to spec but need an end-to-end run.
-- **v0.2 build steps.** CD currently deploys pre-built artifacts; running `npm run build` needs a Linux runtime (Sandboxes) — that's [v0.3](./PLAN.md#v03--generic-ci-tests-lint-build).
+- **Live-validating M5 + v0.2 + M8** against a real Cloudflare account. The Access apps/policies API, the Workers Scripts upload, Pages Direct Upload, the D1 query path, and now the Containers provisioning + Sandbox exec paths are coded to spec but need an end-to-end run.
+- **v0.3 remainder.** R2 build cache keyed on lockfile hash, Browser Run for E2E, the GitHub Actions importer, and Pages build-artifact handover — see [PLAN.md §12 M8 notes](./PLAN.md#12-milestones-and-development-log).
 - **Private `git clone`.** Access gates the dashboard, but clone still hits Artifacts directly. Closing that needs an Access service token / Mesh path (v0.4+).
 - **Custom domains** in front of the Worker, and **better empty states / error messages** anywhere in the CLI or dashboard.
 - **Anything in [PLAN.md §8 Open Questions](./PLAN.md#8-open-questions-to-resolve-before-v01-starts)** you have a strong opinion on.
diff --git a/packages/cli/src/commands/ci.ts b/packages/cli/src/commands/ci.ts
new file mode 100644
index 0000000..0f57268
--- /dev/null
+++ b/packages/cli/src/commands/ci.ts
@@ -0,0 +1,325 @@
+import * as p from "@clack/prompts";
+import kleur from "kleur";
+import { CloudflareClient } from "../cloudflare.js";
+import { loadConfig, saveConfig } from "../config.js";
+import { orange, randomHex } from "../util.js";
+import { redeployWorker } from "../redeploy.js";
+import { wranglerSecret } from "../wrangler.js";
+import { pickRepo, getCfToken, type RepoEntry } from "../repo-select.js";
+
+const TOKEN_URL = "https://dash.cloudflare.com/profile/api-tokens";
+
+const INSTANCE_TYPES = ["dev", "basic", "standard"];
+
+async function fetchRemote(
+  cf: CloudflareClient,
+  entry: Awaited>,
+): Promise {
+  if (!entry) return undefined;
+  try {
+    const r = await cf.getRepo(
+      entry.cloudflareAccountId,
+      entry.artifactsNamespace,
+      entry.artifactsRepoName,
+    );
+    return r.remote;
+  } catch (e) {
+    p.log.error(`Artifacts remote lookup failed: ${(e as Error).message}`);
+    return undefined;
+  }
+}
+
+export async function runCiEnable(
+  repoArg: string | undefined,
+  opts: { instanceType?: string },
+): Promise {
+  p.intro(kleur.bold(orange("GitFlare ci enable")));
+  const cfg = await loadConfig();
+  const entry = await pickRepo(cfg, repoArg);
+  if (!entry) return;
+
+  p.log.message(
+    [
+      kleur.bold("CI runs your .gitflare/ci.yml on push inside a Cloudflare Sandbox"),
+      "container on your own account — even when GitHub Actions is down. The",
+      `container image is ${kleur.cyan("docker.io/cloudflare/sandbox")} (Node 20 + Python 3.11),`,
+      "referenced straight from Docker Hub — no local Docker needed.",
+      "",
+      `Containers require the ${kleur.cyan("Workers Paid")} plan. The Cloudflare API token`,
+      `used for the redeploy may additionally need the ${kleur.cyan("Containers")} account`,
+      "permission — if the redeploy fails with a permissions error, re-issue the",
+      `token with that scope added at ${kleur.gray(TOKEN_URL)}.`,
+    ].join("\n"),
+  );
+
+  const instanceType = opts.instanceType ?? "standard";
+  if (!INSTANCE_TYPES.includes(instanceType)) {
+    p.log.error(
+      `--instance-type must be one of ${INSTANCE_TYPES.join(" | ")}, got "${instanceType}".`,
+    );
+    return;
+  }
+
+  const cfToken = await getCfToken(cfg);
+  if (!cfToken) return p.cancel("Cancelled."), undefined;
+  const cf = new CloudflareClient(cfToken);
+
+  const remote = await fetchRemote(cf, entry);
+  if (!remote) return;
+
+  // The control secret is shared with deploy (one CONTROL_SECRET on the
+  // Worker), so reuse whichever copy already exists — enabling one feature
+  // must never strand the other's.
+  const controlSecret =
+    entry.deploy?.controlSecret ?? entry.ci?.controlSecret ?? randomHex(32);
+  const freshControlSecret =
+    !entry.deploy?.controlSecret && !entry.ci?.controlSecret;
+
+  // Set before redeploy — redeployWorker reads entry.ci to emit the SANDBOX
+  // binding, containers block, and CI_ENABLED var.
+  entry.ci = {
+    enabledAt: new Date().toISOString(),
+    enabled: true,
+    controlSecret,
+    instanceType,
+  };
+
+  const sp = p.spinner();
+  sp.start("Redeploying Worker with CI enabled");
+  try {
+    const res = await redeployWorker(entry, cfToken, remote);
+    if (freshControlSecret) {
+      sp.message("Setting control secret");
+      await wranglerSecret(res.workDir, cfToken, "CONTROL_SECRET", controlSecret);
+    }
+    sp.stop("Worker redeployed with CI enabled");
+  } catch (e) {
+    sp.stop("Redeploy failed");
+    const msg = (e as Error).message;
+    if (/container|plan|payment|not.?entitled|unauthorized|permission|10403|CODE.?100/i.test(msg)) {
+      p.log.error(
+        [
+          "This looks like a Containers entitlement problem. Two likely causes:",
+          `  • Containers require the ${kleur.cyan("Workers Paid")} plan — upgrade in the Cloudflare dashboard.`,
+          `  • The API token is missing the ${kleur.cyan("Containers")} account permission —`,
+          `    re-issue it at ${kleur.gray(TOKEN_URL)} with that scope added, then re-run \`gitflare ci enable\`.`,
+          "",
+          msg,
+        ].join("\n"),
+      );
+    } else {
+      p.log.error(msg);
+    }
+    // Config must only persist on success; the in-memory entry.ci is discarded.
+    return;
+  }
+
+  cfg.cloudflare = { token: cfToken };
+  await saveConfig(cfg);
+
+  p.outro(
+    [
+      kleur.bold(orange("CI enabled.")),
+      "",
+      "  Commit a .gitflare/ci.yml like:",
+      kleur.gray("    on: push"),
+      kleur.gray("    branches: [main]"),
+      kleur.gray("    jobs:"),
+      kleur.gray("      test:"),
+      kleur.gray("        steps:"),
+      kleur.gray("          - run: npm ci"),
+      kleur.gray("          - run: npm test"),
+      kleur.gray("      deploy:"),
+      kleur.gray("        needs: [test]"),
+      kleur.gray("        steps:"),
+      kleur.gray("          - cloudflare/deploy:"),
+      kleur.gray("              project: my-worker"),
+      kleur.gray("              kind: worker"),
+      kleur.gray("              entry: dist/worker.js"),
+      "",
+      `  Runs appear at ${kleur.cyan(`${entry.workerUrl}/r/${entry.artifactsRepoName}/ci`)}`,
+      kleur.gray("  deploy jobs additionally need `gitflare deploy enable`."),
+    ].join("\n"),
+  );
+}
+
+export async function runCiDisable(repoArg: string | undefined): Promise {
+  p.intro(kleur.bold(orange("GitFlare ci disable")));
+  const cfg = await loadConfig();
+  const entry = await pickRepo(cfg, repoArg);
+  if (!entry) return;
+  if (!entry.ci || !entry.ci.enabled) {
+    p.log.warn(`CI is not enabled for ${kleur.cyan(entry.githubFullName)}.`);
+    p.outro("");
+    return;
+  }
+
+  const cfToken = await getCfToken(cfg);
+  if (!cfToken) return p.cancel("Cancelled."), undefined;
+  const cf = new CloudflareClient(cfToken);
+
+  const remote = await fetchRemote(cf, entry);
+  if (!remote) return;
+
+  // Flip enabled off but do NOT delete entry.ci — the Sandbox container stays
+  // provisioned on the Worker so re-enable is a var-only redeploy (no
+  // deleted_classes migration needed).
+  entry.ci.enabled = false;
+  const sp = p.spinner();
+  sp.start("Redeploying Worker with CI disabled");
+  try {
+    await redeployWorker(entry, cfToken, remote);
+    sp.stop("CI disabled");
+  } catch (e) {
+    sp.stop("Disable failed");
+    p.log.error((e as Error).message);
+    return;
+  }
+
+  await saveConfig(cfg);
+  p.log.info(
+    "CI_ENABLED was dropped — the Worker now ignores pushes. The Sandbox container config remains on the Worker (idle containers cost nothing); `gitflare ci enable` turns it back on.",
+  );
+  p.outro(kleur.bold(orange("CI disabled.")));
+}
+
+// --- control-plane commands (talk to the Worker's /control/* endpoints) ---
+
+function ciSecret(entry: RepoEntry): string | undefined {
+  if (!entry.ci || !entry.ci.enabled) {
+    p.log.warn(`CI isn't enabled for ${kleur.cyan(entry.githubFullName)}. Run \`gitflare ci enable\`.`);
+    return undefined;
+  }
+  return entry.ci?.controlSecret ?? entry.deploy?.controlSecret;
+}
+
+async function controlFetch(
+  entry: RepoEntry,
+  secret: string,
+  path: string,
+  init?: { method?: string; body?: unknown },
+): Promise {
+  return fetch(`${entry.workerUrl}${path}`, {
+    method: init?.method ?? "GET",
+    headers: {
+      Authorization: `Bearer ${secret}`,
+      ...(init?.body ? { "Content-Type": "application/json" } : {}),
+    },
+    ...(init?.body ? { body: JSON.stringify(init.body) } : {}),
+  });
+}
+
+export async function runCiRun(repoArg: string | undefined): Promise {
+  p.intro(kleur.bold(orange("GitFlare ci run")));
+  const cfg = await loadConfig();
+  const entry = await pickRepo(cfg, repoArg);
+  if (!entry) return;
+  const secret = ciSecret(entry);
+  if (!secret) return;
+
+  const sp = p.spinner();
+  sp.start("Triggering a CI run of the current Artifacts HEAD");
+  try {
+    const res = await controlFetch(entry, secret, "/control/ci/run", {
+      method: "POST",
+      body: { repo: entry.artifactsRepoName },
+    });
+    if (res.status !== 202) {
+      sp.stop("Trigger failed");
+      p.log.error(`${res.status}: ${await res.text()}`);
+      return;
+    }
+    sp.stop("CI run triggered (runs in your Worker's sandbox — works even when GitHub is down)");
+  } catch (e) {
+    sp.stop("Trigger failed");
+    p.log.error((e as Error).message);
+    return;
+  }
+  p.outro(`Watch it at ${kleur.cyan(`${entry.workerUrl}/r/${entry.artifactsRepoName}/ci`)}`);
+}
+
+interface CiRunRow {
+  id: number;
+  branch: string;
+  sha: string;
+  mode: string;
+  status: "running" | "success" | "failed" | "skipped";
+  message?: string;
+  jobs: Array<{ name: string; status: string }>;
+}
+
+function jobGlyph(status: string): string {
+  if (status === "success") return "✓";
+  if (status === "failed") return "✗";
+  if (status === "skipped") return "○";
+  return "●"; // running/pending
+}
+
+export async function runCiList(repoArg: string | undefined): Promise {
+  p.intro(kleur.bold(orange("GitFlare ci list")));
+  const cfg = await loadConfig();
+  const entry = await pickRepo(cfg, repoArg);
+  if (!entry) return;
+  const secret = ciSecret(entry);
+  if (!secret) return;
+
+  const sp = p.spinner();
+  sp.start("Fetching CI run history");
+  let runs: CiRunRow[] = [];
+  try {
+    const res = await controlFetch(entry, secret, `/control/ci/runs?repo=${encodeURIComponent(entry.artifactsRepoName)}`);
+    if (!res.ok) {
+      sp.stop("Fetch failed");
+      p.log.error(`${res.status}: ${await res.text()}`);
+      return;
+    }
+    ({ runs } = (await res.json()) as { runs: CiRunRow[] });
+    sp.stop(`${runs.length} run(s)`);
+  } catch (e) {
+    sp.stop("Fetch failed");
+    p.log.error((e as Error).message);
+    return;
+  }
+
+  if (runs.length === 0) {
+    p.outro("No CI runs yet.");
+    return;
+  }
+  for (const r of runs.slice(0, 20)) {
+    const dot = r.status === "success" ? kleur.green("●") : r.status === "failed" ? kleur.red("●") : kleur.yellow("●");
+    const jobs = r.jobs.map((j) => `${j.name}${jobGlyph(j.status)}`).join(" ") || "—";
+    p.log.message(
+      `${dot} #${r.id} ${kleur.cyan(r.branch)} ${kleur.gray(r.sha.slice(0, 8))} ${r.mode}  ${jobs}  ${r.status}${r.message ? kleur.gray(` — ${r.message}`) : ""}`,
+    );
+  }
+  p.outro(`Full view: ${kleur.cyan(`${entry.workerUrl}/r/${entry.artifactsRepoName}/ci`)}`);
+}
+
+export async function runCiCancel(repoArg: string | undefined): Promise {
+  p.intro(kleur.bold(orange("GitFlare ci cancel")));
+  const cfg = await loadConfig();
+  const entry = await pickRepo(cfg, repoArg);
+  if (!entry) return;
+  const secret = ciSecret(entry);
+  if (!secret) return;
+
+  const sp = p.spinner();
+  sp.start("Requesting cancellation of the in-flight CI run");
+  try {
+    const res = await controlFetch(entry, secret, "/control/ci/cancel", {
+      method: "POST",
+      body: { repo: entry.artifactsRepoName },
+    });
+    if (res.status !== 202) {
+      sp.stop("Cancel failed");
+      p.log.error(`${res.status}: ${await res.text()}`);
+      return;
+    }
+    sp.stop("Cancel requested — the current step's sandbox is being torn down");
+  } catch (e) {
+    sp.stop("Cancel failed");
+    p.log.error((e as Error).message);
+    return;
+  }
+  p.outro(`Watch it at ${kleur.cyan(`${entry.workerUrl}/r/${entry.artifactsRepoName}/ci`)}`);
+}
diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts
index 4585aa4..53145b9 100644
--- a/packages/cli/src/commands/deploy.ts
+++ b/packages/cli/src/commands/deploy.ts
@@ -83,15 +83,25 @@ export async function runDeployEnable(repoArg: string | undefined): Promise;
   // Tokens — kept local, never sent to gitflare servers.
   github?: { token: string };
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 86a6ebc..e572842 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -11,6 +11,13 @@ import {
   runDeploysList,
   runDeployRollback,
 } from "./commands/deploy.js";
+import {
+  runCiEnable,
+  runCiDisable,
+  runCiRun,
+  runCiList,
+  runCiCancel,
+} from "./commands/ci.js";
 
 const require = createRequire(import.meta.url);
 const pkg = require("../package.json") as { version?: string };
@@ -78,4 +85,29 @@ deploy
   .option("--to ", "deploy id to roll back to")
   .action(runDeployRollback);
 
+const ci = program
+  .command("ci")
+  .description("Generic CI: run .gitflare/ci.yml jobs on Cloudflare Sandboxes on push");
+ci.command("enable")
+  .description("Enable CI — run .gitflare/ci.yml on push in a Cloudflare Sandbox container")
+  .argument("[repo]", "github full name or artifacts repo name; prompts if omitted")
+  .option("--instance-type ", "sandbox container size: dev | basic | standard (default standard)")
+  .action(runCiEnable);
+ci.command("disable")
+  .description("Disable CI for a repo (the container config stays provisioned)")
+  .argument("[repo]", "github full name or artifacts repo name; prompts if omitted")
+  .action(runCiDisable);
+ci.command("run")
+  .description("Run the CI pipeline for the current Artifacts HEAD now (the GitHub-down escape hatch)")
+  .argument("[repo]", "github full name or artifacts repo name; prompts if omitted")
+  .action(runCiRun);
+ci.command("list")
+  .description("List recent CI runs for a repo")
+  .argument("[repo]", "github full name or artifacts repo name; prompts if omitted")
+  .action(runCiList);
+ci.command("cancel")
+  .description("Cancel the in-flight CI run for a repo")
+  .argument("[repo]", "github full name or artifacts repo name; prompts if omitted")
+  .action(runCiCancel);
+
 program.parseAsync(process.argv);
diff --git a/packages/cli/src/redeploy.ts b/packages/cli/src/redeploy.ts
index e7da429..24f231e 100644
--- a/packages/cli/src/redeploy.ts
+++ b/packages/cli/src/redeploy.ts
@@ -26,5 +26,8 @@ export async function redeployWorker(
       ? { accessAud: entry.access.aud, accessTeamDomain: entry.access.teamDomain }
       : {}),
     ...(entry.deploy ? { cdEnabled: true } : {}),
+    ...(entry.ci
+      ? { ci: { provisioned: true, enabled: entry.ci.enabled, instanceType: entry.ci.instanceType } }
+      : {}),
   });
 }
diff --git a/packages/cli/src/wrangler.ts b/packages/cli/src/wrangler.ts
index d689f79..dc6b6a4 100644
--- a/packages/cli/src/wrangler.ts
+++ b/packages/cli/src/wrangler.ts
@@ -8,6 +8,10 @@ import { fileURLToPath } from "node:url";
 const HERE = dirname(fileURLToPath(import.meta.url));
 const requireFromHere = createRequire(import.meta.url);
 
+// The sandbox container image the CI feature runs jobs in. The tag must match
+// the worker's @cloudflare/sandbox dependency version.
+const SANDBOX_IMAGE = "docker.io/cloudflare/sandbox:0.12.3";
+
 export interface RepoMapEntry {
   name: string;
   remote: string;
@@ -25,6 +29,9 @@ export interface DeployParams {
   accessTeamDomain?: string;
   // Continuous deploy (set by `gitflare deploy enable`). Emits CD_ENABLED="1".
   cdEnabled?: boolean;
+  // CI on Cloudflare Sandboxes (set by `gitflare ci enable`). `provisioned`
+  // emits the SANDBOX binding + containers block; `enabled` emits CI_ENABLED.
+  ci?: { provisioned: boolean; enabled: boolean; instanceType: string };
 }
 
 export interface DeployResult {
@@ -88,12 +95,35 @@ ACCESS_TEAM_DOMAIN = ${JSON.stringify(p.accessTeamDomain)}
   }
   if (p.cdEnabled) {
     out += `CD_ENABLED = "1"
+`;
+  }
+  if (p.ci?.enabled) {
+    out += `CI_ENABLED = "1"
 `;
   }
   return out;
 }
 
 function tomlFor(main: string, p: DeployParams, version: string): string {
+  // The SANDBOX binding + containers block are only emitted once CI has been
+  // provisioned (paid-plan Containers). The v3/v4 migrations below are always
+  // emitted regardless.
+  const sandbox = p.ci?.provisioned
+    ? `[[durable_objects.bindings]]
+name = "SANDBOX"
+class_name = "Sandbox"
+
+[[containers]]
+class_name = "Sandbox"
+image = "${SANDBOX_IMAGE}"
+instance_type = "${p.ci.instanceType}"
+max_instances = 5
+
+`
+    : "";
+  // v3/v4 are unconditional ON PURPOSE: migrations are free-plan-safe, and
+  // gating them on local config would hard-fail redeploys from a second
+  // machine once they've been applied remotely.
   return `name = "${p.workerName}"
 main = "${main}"
 compatibility_date = "2026-05-01"
@@ -112,7 +142,11 @@ class_name = "RepoDO"
 name = "DEPLOY"
 class_name = "DeployDO"
 
-[[migrations]]
+[[durable_objects.bindings]]
+name = "CI"
+class_name = "CiDO"
+
+${sandbox}[[migrations]]
 tag = "v1"
 new_sqlite_classes = ["RepoDO"]
 
@@ -120,6 +154,14 @@ new_sqlite_classes = ["RepoDO"]
 tag = "v2"
 new_sqlite_classes = ["DeployDO"]
 
+[[migrations]]
+tag = "v3"
+new_sqlite_classes = ["CiDO"]
+
+[[migrations]]
+tag = "v4"
+new_sqlite_classes = ["Sandbox"]
+
 ${varsBlock(p, version)}`;
 }
 
diff --git a/packages/worker/package.json b/packages/worker/package.json
index 709ffd5..609096d 100644
--- a/packages/worker/package.json
+++ b/packages/worker/package.json
@@ -11,6 +11,7 @@
     "test": "vitest run"
   },
   "dependencies": {
+    "@cloudflare/sandbox": "0.12.3",
     "@gitflare/shared": "workspace:*",
     "highlight.js": "^11.11.1",
     "hono": "^4.6.0",
diff --git a/packages/worker/src/artifacts/content.ts b/packages/worker/src/artifacts/content.ts
index c0e2dc2..172a2a1 100644
--- a/packages/worker/src/artifacts/content.ts
+++ b/packages/worker/src/artifacts/content.ts
@@ -260,6 +260,72 @@ export async function readBlobAtCommit(
   };
 }
 
+/**
+ * Mint a short-TTL read token and return the git basic-auth password. Also
+ * used by the CI DO to hand a sandbox a token for cloning the mirror.
+ */
+export async function mintReadPassword(repo: ArtifactsRepo, ttlSeconds: number): Promise {
+  const tokenResult = (await repo.createToken("read", ttlSeconds)) as {
+    plaintext?: string;
+    token?: string;
+  };
+  const rawToken = tokenResult.plaintext ?? tokenResult.token;
+  if (!rawToken) throw new Error("createToken returned no token");
+  return tokenSecret(rawToken);
+}
+
+/**
+ * Clone a specific branch and make a specific commit readable, deepening as
+ * needed (a push can race HEAD past a fixed depth, and feature branches aren't
+ * covered by the default-branch clone helpers at all). Used by the v0.3 CI
+ * path so workflow + entry files are read strictly AT the delegated sha —
+ * never at whatever HEAD happens to be by the time we clone.
+ */
+export async function cloneRepoAtRef(
+  repo: ArtifactsRepo,
+  remote: string,
+  branch: string,
+  sha: string,
+): Promise {
+  const password = await mintReadPassword(repo, 180);
+  const fs = new MemFs();
+  const dir = "/repo";
+  const onAuth = (): { username: string; password: string } => ({ username: "x", password });
+
+  await git.clone({
+    fs,
+    http,
+    dir,
+    url: remote,
+    ref: branch,
+    singleBranch: true,
+    depth: 50,
+    noCheckout: true,
+    noTags: true,
+    onAuth,
+  });
+
+  const hasCommit = async (): Promise => {
+    try {
+      await git.readCommit({ fs, dir, oid: sha });
+      return true;
+    } catch {
+      return false;
+    }
+  };
+
+  if (!(await hasCommit())) {
+    for (const depth of [500, 100000]) {
+      await git.fetch({ fs, http, dir, url: remote, ref: branch, depth, singleBranch: true, onAuth });
+      if (await hasCommit()) break;
+    }
+    if (!(await hasCommit())) {
+      throw new Error(`commit ${sha.slice(0, 8)} not reachable on ${branch}`);
+    }
+  }
+  return { fs, dir, headSha: sha, branchName: branch };
+}
+
 /**
  * Full clone (no depth limit) of the default branch, so any historical commit
  * is reachable. Heavier than cloneRepoShallow — used only for rollback.
diff --git a/packages/worker/src/ci/github-status.ts b/packages/worker/src/ci/github-status.ts
new file mode 100644
index 0000000..ab77a38
--- /dev/null
+++ b/packages/worker/src/ci/github-status.ts
@@ -0,0 +1,67 @@
+// Posts CI results to GitHub's legacy Commit Status API. The Worker holds a
+// classic PAT with `repo` scope (set by `gitflare init`), which Status accepts;
+// the newer Checks API needs a GitHub App and is out of reach — fine, since
+// statuses render on commits/PRs all the same. Callers soft-fail on errors:
+// GitHub being down is exactly the scenario gitflare exists for.
+
+export type CommitState = "pending" | "success" | "failure" | "error";
+
+export interface CommitStatusParams {
+  githubFullName: string; // "owner/repo"
+  sha: string;
+  state: CommitState;
+  description: string;
+  targetUrl?: string;
+  token: string;
+  fetchImpl?: typeof fetch;
+}
+
+export interface CommitStatusResult {
+  ok: boolean;
+  status: number;
+  detail?: string;
+}
+
+const CONTEXT = "gitflare/ci";
+const MAX_DESCRIPTION = 140; // GitHub caps description length
+
+export function buildStatusRequest(p: CommitStatusParams): { url: string; init: RequestInit } {
+  const body: Record = {
+    state: p.state,
+    context: CONTEXT,
+    description:
+      p.description.length > MAX_DESCRIPTION
+        ? `${p.description.slice(0, MAX_DESCRIPTION - 1)}…`
+        : p.description,
+  };
+  if (p.targetUrl) body.target_url = p.targetUrl;
+  return {
+    url: `https://api.github.com/repos/${p.githubFullName}/statuses/${p.sha}`,
+    init: {
+      method: "POST",
+      headers: {
+        Authorization: `Bearer ${p.token}`,
+        Accept: "application/vnd.github+json",
+        "Content-Type": "application/json",
+        // GitHub rejects requests without a User-Agent.
+        "User-Agent": "gitflare-worker",
+      },
+      body: JSON.stringify(body),
+    },
+  };
+}
+
+export async function postCommitStatus(p: CommitStatusParams): Promise {
+  const fetchImpl = p.fetchImpl ?? fetch;
+  const { url, init } = buildStatusRequest(p);
+  try {
+    const resp = await fetchImpl(url, init);
+    if (!resp.ok) {
+      const text = await resp.text().catch(() => "");
+      return { ok: false, status: resp.status, detail: text.slice(0, 200) };
+    }
+    return { ok: true, status: resp.status };
+  } catch (e) {
+    return { ok: false, status: 0, detail: (e as Error).message };
+  }
+}
diff --git a/packages/worker/src/ci/sandbox-runner.ts b/packages/worker/src/ci/sandbox-runner.ts
new file mode 100644
index 0000000..50f5a11
--- /dev/null
+++ b/packages/worker/src/ci/sandbox-runner.ts
@@ -0,0 +1,298 @@
+// Runs CI job steps inside a Cloudflare Sandbox. Kept free of the Sandbox SDK:
+// everything is written against the minimal structural SandboxHandle below and
+// unit-tested with fakes (the DO passes the real sandbox from getSandbox()).
+//
+// Token hygiene (the sandbox runs UNTRUSTED code — npm postinstall scripts et
+// al): the Artifacts read token never appears in argv, in .git/config, or in
+// git's stderr. Auth rides in env-scoped git config (GIT_CONFIG_* variables,
+// visible only to the git commands we run), and every captured output line is
+// scrubbed for the secret and its Basic-auth encoding before it reaches logs.
+
+import type { CiJob } from "./workflow";
+
+export interface ExecResultLike {
+  stdout: string;
+  stderr: string;
+  exitCode: number;
+  success: boolean;
+}
+
+export interface ExecOptionsLike {
+  cwd?: string;
+  env?: Record;
+  timeout?: number;
+  stream?: boolean;
+  onOutput?: (stream: "stdout" | "stderr", data: string) => void;
+}
+
+/** Structural subset of @cloudflare/sandbox's Sandbox used by the CI runner. */
+export interface SandboxHandle {
+  exec(command: string, options?: ExecOptionsLike): Promise;
+  readFile(path: string): Promise<{ content: string }>;
+  destroy(): Promise;
+}
+
+export const REPO_DIR = "/workspace/repo";
+const CLONE_TIMEOUT_MS = 5 * 60_000;
+const CHECKOUT_TIMEOUT_MS = 60_000;
+/** Per-job cap on captured output lines — the DO ring buffer holds 500 anyway. */
+export const MAX_JOB_LOG_LINES = 1000;
+
+// -- validation ---------------------------------------------------------------
+// sha/branch/remote come from webhook JSON and are interpolated into shell
+// commands run in the user's own sandbox. Injection there would "only" run in
+// an environment that already executes the repo's arbitrary code, but a forged
+// branch name must never be able to smuggle flags or shell syntax regardless.
+
+export function validateCloneInputs(p: {
+  remote: string;
+  branch: string;
+  sha: string;
+}): string | null {
+  if (!/^[0-9a-f]{40}$/.test(p.sha)) return `invalid sha "${p.sha.slice(0, 60)}"`;
+  if (!/^[A-Za-z0-9._/-]+$/.test(p.branch) || p.branch.startsWith("-")) {
+    return `invalid branch name "${p.branch.slice(0, 60)}"`;
+  }
+  if (!/^https:\/\/[A-Za-z0-9._~:/?#@!$&'()*+,;=%[\]-]+$/.test(p.remote) || /["'`\\\s]/.test(p.remote)) {
+    return `invalid remote URL`;
+  }
+  return null;
+}
+
+/** Env-scoped git auth: applies only to commands we pass it to, persists nowhere. */
+export function gitAuthEnv(tokenSecret: string): Record {
+  return {
+    GIT_CONFIG_COUNT: "1",
+    GIT_CONFIG_KEY_0: "http.extraHeader",
+    GIT_CONFIG_VALUE_0: `Authorization: Basic ${btoa(`x:${tokenSecret}`)}`,
+  };
+}
+
+/**
+ * Returns a scrubber that blanks each secret — and its Basic-auth base64 form —
+ * from a log line. Belt-and-suspenders: the secrets shouldn't reach output in
+ * the first place, but git error paths are creative.
+ */
+export function makeScrubber(secrets: string[]): (line: string) => string {
+  const needles = secrets
+    .filter((s) => s.length > 0)
+    .flatMap((s) => [s, btoa(`x:${s}`)]);
+  return (line: string): string => {
+    let out = line;
+    for (const n of needles) out = out.split(n).join("***");
+    return out;
+  };
+}
+
+/** Split streamed chunks into lines; flush() emits any unterminated remainder. */
+export function makeLineBuffer(onLine: (line: string) => void): {
+  push: (data: string) => void;
+  flush: () => void;
+} {
+  let rest = "";
+  return {
+    push(data: string): void {
+      rest += data;
+      let idx: number;
+      while ((idx = rest.indexOf("\n")) !== -1) {
+        onLine(rest.slice(0, idx).replace(/\r$/, ""));
+        rest = rest.slice(idx + 1);
+      }
+    },
+    flush(): void {
+      if (rest.trim() !== "") onLine(rest.replace(/\r$/, ""));
+      rest = "";
+    },
+  };
+}
+
+// -- clone --------------------------------------------------------------------
+
+export interface CloneParams {
+  remote: string;
+  branch: string;
+  sha: string;
+  tokenSecret: string;
+  log: (line: string) => void;
+}
+
+export interface CloneResult {
+  ok: boolean;
+  message?: string;
+}
+
+/**
+ * Clone the Artifacts mirror into the sandbox at REPO_DIR and check out the
+ * exact sha (deepening once if the push raced HEAD past our depth).
+ */
+export async function cloneIntoSandbox(
+  sandbox: SandboxHandle,
+  p: CloneParams,
+): Promise {
+  const invalid = validateCloneInputs(p);
+  if (invalid) return { ok: false, message: invalid };
+  const auth = gitAuthEnv(p.tokenSecret);
+
+  p.log(`cloning ${p.remote} (${p.branch}) — auth via short-lived read token`);
+  const clone = await sandbox.exec(
+    `git clone --quiet --depth 50 --single-branch --branch ${p.branch} -- ${p.remote} ${REPO_DIR}`,
+    { cwd: "/workspace", env: auth, timeout: CLONE_TIMEOUT_MS },
+  );
+  if (clone.exitCode !== 0) {
+    return { ok: false, message: `git clone failed: ${tail(clone.stderr)}` };
+  }
+
+  const checkoutCmd = `git -c advice.detachedHead=false checkout --quiet ${p.sha}`;
+  let checkout = await sandbox.exec(checkoutCmd, { cwd: REPO_DIR, timeout: CHECKOUT_TIMEOUT_MS });
+  if (checkout.exitCode !== 0) {
+    p.log(`sha ${p.sha.slice(0, 8)} not in shallow clone — deepening`);
+    const deepen = await sandbox.exec(`git fetch --quiet --depth 500 origin ${p.branch}`, {
+      cwd: REPO_DIR,
+      env: auth,
+      timeout: CLONE_TIMEOUT_MS,
+    });
+    if (deepen.exitCode !== 0) {
+      return { ok: false, message: `git fetch (deepen) failed: ${tail(deepen.stderr)}` };
+    }
+    checkout = await sandbox.exec(checkoutCmd, { cwd: REPO_DIR, timeout: CHECKOUT_TIMEOUT_MS });
+    if (checkout.exitCode !== 0) {
+      return {
+        ok: false,
+        message: `commit ${p.sha.slice(0, 8)} not reachable on ${p.branch} (checked 500 commits deep)`,
+      };
+    }
+  }
+  p.log(`checked out ${p.sha.slice(0, 8)}`);
+  return { ok: true };
+}
+
+// -- job steps ----------------------------------------------------------------
+
+export interface JobStepOutcome {
+  command: string;
+  ok: boolean;
+  exitCode?: number;
+  detail?: string;
+}
+
+export interface RunJobResult {
+  ok: boolean;
+  steps: JobStepOutcome[];
+  message?: string;
+}
+
+export interface RunJobParams {
+  job: CiJob; // kind "run"
+  meta: { repo: string; branch: string; sha: string };
+  log: (line: string) => void;
+  now?: () => number;
+}
+
+/** First line of a (possibly multi-line) command, for step labels. */
+export function commandLabel(command: string): string {
+  const first = command.split("\n", 1)[0]!.trim();
+  return command.includes("\n") ? `${first} …` : first;
+}
+
+function tail(s: string, n = 300): string {
+  const t = s.trim();
+  return t.length > n ? `…${t.slice(-n)}` : t;
+}
+
+/**
+ * Run one CI job's `run:` steps sequentially in the (already cloned) sandbox.
+ * timeout_minutes is a wall-clock budget across ALL of the job's steps.
+ * Note: on a step timeout the underlying process may keep running until the
+ * run's sandbox is destroyed — logged so the user isn't surprised.
+ */
+export async function runJobSteps(
+  sandbox: SandboxHandle,
+  p: RunJobParams,
+): Promise {
+  const now = p.now ?? Date.now;
+  const deadline = now() + p.job.timeoutMinutes * 60_000;
+  const steps: JobStepOutcome[] = [];
+
+  const env: Record = {
+    CI: "true",
+    GITFLARE: "1",
+    GITFLARE_REPO: p.meta.repo,
+    GITFLARE_BRANCH: p.meta.branch,
+    GITFLARE_SHA: p.meta.sha,
+    ...p.job.env,
+  };
+
+  let lines = 0;
+  const emit = (line: string): void => {
+    lines++;
+    if (lines === MAX_JOB_LOG_LINES) {
+      p.log(`  … output truncated at ${MAX_JOB_LOG_LINES} lines`);
+      return;
+    }
+    if (lines > MAX_JOB_LOG_LINES) return;
+    p.log(`  ${line}`);
+  };
+
+  for (const step of p.job.steps) {
+    if (step.type !== "run") continue; // parser guarantees run-only; defensive
+    const label = commandLabel(step.command);
+    const remaining = deadline - now();
+    if (remaining <= 0) {
+      steps.push({ command: label, ok: false, detail: `job timeout (${p.job.timeoutMinutes}m)` });
+      return { ok: false, steps, message: `timed out after ${p.job.timeoutMinutes}m` };
+    }
+
+    p.log(`$ ${label}`);
+    const buf = makeLineBuffer(emit);
+    try {
+      const res = await sandbox.exec(step.command, {
+        cwd: REPO_DIR,
+        env,
+        timeout: remaining,
+        stream: true,
+        onOutput: (_stream, data) => buf.push(data),
+      });
+      buf.flush();
+      if (res.exitCode !== 0) {
+        // Non-streaming fallbacks put output on the result instead.
+        if (lines === 0 && (res.stdout || res.stderr)) emit(tail(res.stderr || res.stdout));
+        steps.push({ command: label, ok: false, exitCode: res.exitCode });
+        return { ok: false, steps, message: `"${label}" exited ${res.exitCode}` };
+      }
+      steps.push({ command: label, ok: true, exitCode: 0 });
+    } catch (e) {
+      buf.flush();
+      const msg = (e as Error).message;
+      const timedOut = /timed?\s?out|timeout/i.test(msg);
+      if (timedOut) {
+        p.log(`  step hit the job's ${p.job.timeoutMinutes}m budget — its process may keep running until the sandbox is torn down`);
+      }
+      steps.push({ command: label, ok: false, detail: timedOut ? "timed out" : msg });
+      return { ok: false, steps, message: timedOut ? `"${label}" timed out` : `"${label}": ${msg}` };
+    }
+  }
+  return { ok: true, steps };
+}
+
+/**
+ * Read a build artifact (a worker entry file) out of the sandbox so a deploy
+ * job can ship what CI just built instead of a stale committed file. Returns
+ * undefined when the file doesn't exist in the sandbox workspace.
+ */
+export const MAX_ARTIFACT_BYTES = 5 * 1024 * 1024;
+
+export async function readArtifact(
+  sandbox: SandboxHandle,
+  path: string,
+): Promise<{ content?: string; error?: string }> {
+  if (path.includes("..") || path.startsWith("/")) return { error: `invalid entry path: ${path}` };
+  try {
+    const { content } = await sandbox.readFile(`${REPO_DIR}/${path}`);
+    if (content.length > MAX_ARTIFACT_BYTES) {
+      return { error: `built ${path} exceeds ${MAX_ARTIFACT_BYTES / 1024 / 1024}MB` };
+    }
+    return { content };
+  } catch {
+    return {}; // not built in this run — deploy falls back to the committed file
+  }
+}
diff --git a/packages/worker/src/ci/workflow.ts b/packages/worker/src/ci/workflow.ts
new file mode 100644
index 0000000..36a69cd
--- /dev/null
+++ b/packages/worker/src/ci/workflow.ts
@@ -0,0 +1,227 @@
+// Parses + validates `.gitflare/ci.yml` into a typed CI workflow (v0.3).
+// Reuses the YAML-subset parser and the cloudflare/deploy step validation
+// from ../deploy/workflow so both formats behave identically.
+//
+//   on: push
+//   branches: [main]
+//   jobs:
+//     test:
+//       steps:                    # runtime: sandbox is the default (and only
+//         - run: npm ci           # supported) runtime in v0.3 M8
+//         - run: npm test
+//     deploy:
+//       needs: [test]
+//       steps:
+//         - cloudflare/deploy:
+//             project: my-worker
+//             kind: worker
+//             entry: dist/worker.js
+//
+// A job is EITHER a run job (only `run:` steps, executed in a Cloudflare
+// Sandbox) or a deploy job (only `cloudflare/deploy:` steps, delegated to the
+// DeployDO) — mixing the two in one job is a parse error, matching the GitHub
+// Actions mental model of jobs as isolated machines.
+
+import { parseYaml, type YamlValue } from "../deploy/yaml";
+import {
+  asArray,
+  asString,
+  isObj,
+  parseDeployStepConfig,
+  parseTriggers,
+  branchOf,
+  type DeployStep,
+} from "../deploy/workflow";
+
+export interface CiRunStep {
+  type: "run";
+  command: string;
+}
+
+export interface CiDeployStep {
+  type: "cloudflare/deploy";
+  step: DeployStep;
+}
+
+export interface CiJob {
+  name: string;
+  kind: "run" | "deploy";
+  needs: string[];
+  steps: Array;
+  env: Record;
+  /** Wall-clock budget for the whole job (all steps cumulative). */
+  timeoutMinutes: number;
+}
+
+export interface CiWorkflow {
+  on: string[];
+  branches: string[]; // empty = every branch
+  /** In topological order (needs before dependents), ties by declaration order. */
+  jobs: CiJob[];
+}
+
+export interface CiParseResult {
+  workflow?: CiWorkflow;
+  error?: string;
+}
+
+export const CI_WORKFLOW_PATH = ".gitflare/ci.yml";
+
+export const DEFAULT_JOB_TIMEOUT_MINUTES = 15;
+export const MAX_JOB_TIMEOUT_MINUTES = 60;
+
+function parseJob(name: string, cfg: { [k: string]: YamlValue }): { job?: CiJob; error?: string } {
+  const runtime = asString(cfg.runtime);
+  if (runtime === "worker") {
+    return {
+      error: `job "${name}": runtime "worker" (Dynamic Workers) isn't available yet — omit it or use "sandbox"`,
+    };
+  }
+  if (runtime !== undefined && runtime !== "sandbox") {
+    return { error: `job "${name}": unsupported runtime "${runtime}" (sandbox)` };
+  }
+  if (cfg.image !== undefined) {
+    return {
+      error:
+        `job "${name}": per-job images aren't supported — jobs run on the gitflare ` +
+        `sandbox base image (Node 20 + Python 3.11)`,
+    };
+  }
+
+  const needs = asArray(cfg.needs)
+    .map(asString)
+    .filter((s): s is string => !!s);
+
+  const env: Record = {};
+  if (isObj(cfg.env)) {
+    for (const [k, v] of Object.entries(cfg.env)) {
+      const s = asString(v);
+      if (s !== undefined) env[k] = s;
+    }
+  }
+
+  let timeoutMinutes = DEFAULT_JOB_TIMEOUT_MINUTES;
+  if (cfg.timeout_minutes !== undefined) {
+    const t = Number(cfg.timeout_minutes);
+    if (!Number.isFinite(t) || t <= 0 || t > MAX_JOB_TIMEOUT_MINUTES) {
+      return {
+        error: `job "${name}": timeout_minutes must be 1–${MAX_JOB_TIMEOUT_MINUTES}`,
+      };
+    }
+    timeoutMinutes = Math.ceil(t);
+  }
+
+  const steps: Array = [];
+  for (const raw of asArray(cfg.steps)) {
+    if (!isObj(raw)) return { error: `job "${name}": each step must be a mapping` };
+    if (raw.run !== undefined) {
+      const command = asString(raw.run)?.trim();
+      if (!command) return { error: `job "${name}": empty run step` };
+      steps.push({ type: "run", command });
+      continue;
+    }
+    const dcfg = raw["cloudflare/deploy"];
+    if (isObj(dcfg)) {
+      const parsed = parseDeployStepConfig(dcfg);
+      if (parsed.error || !parsed.step) {
+        return { error: `job "${name}": ${parsed.error ?? "invalid cloudflare/deploy step"}` };
+      }
+      steps.push({ type: "cloudflare/deploy", step: parsed.step });
+      continue;
+    }
+    const key = Object.keys(raw)[0] ?? "?";
+    return { error: `job "${name}": unsupported step "${key}" (run | cloudflare/deploy)` };
+  }
+  if (steps.length === 0) return { error: `job "${name}": no steps` };
+
+  const hasRun = steps.some((s) => s.type === "run");
+  const hasDeploy = steps.some((s) => s.type === "cloudflare/deploy");
+  if (hasRun && hasDeploy) {
+    return {
+      error:
+        `job "${name}": mixes run and cloudflare/deploy steps — put deploys in ` +
+        `their own job gated with needs:`,
+    };
+  }
+
+  return {
+    job: { name, kind: hasDeploy ? "deploy" : "run", needs, steps, env, timeoutMinutes },
+  };
+}
+
+/** Kahn topological sort; declaration order breaks ties. */
+function orderJobs(jobs: CiJob[]): { order?: CiJob[]; error?: string } {
+  const byName = new Map(jobs.map((j) => [j.name, j]));
+  for (const j of jobs) {
+    for (const n of j.needs) {
+      if (!byName.has(n)) return { error: `job "${j.name}": needs unknown job "${n}"` };
+      if (n === j.name) return { error: `job "${j.name}": needs itself` };
+    }
+  }
+  const remaining = new Map(jobs.map((j) => [j.name, new Set(j.needs)]));
+  const order: CiJob[] = [];
+  while (order.length < jobs.length) {
+    const ready = jobs.find(
+      (j) => remaining.has(j.name) && remaining.get(j.name)!.size === 0,
+    );
+    if (!ready) {
+      const stuck = [...remaining.keys()].join(", ");
+      return { error: `dependency cycle among jobs: ${stuck}` };
+    }
+    order.push(ready);
+    remaining.delete(ready.name);
+    for (const deps of remaining.values()) deps.delete(ready.name);
+  }
+  return { order };
+}
+
+export function parseCiWorkflow(src: string): CiParseResult {
+  let root: YamlValue;
+  try {
+    root = parseYaml(src);
+  } catch (e) {
+    return { error: `YAML parse error: ${(e as Error).message}` };
+  }
+  if (!isObj(root)) return { error: "ci.yml must be a mapping" };
+
+  const triggers = parseTriggers(root);
+  if (triggers.error || !triggers.on) return { error: triggers.error ?? "missing `on:`" };
+
+  if (!isObj(root.jobs)) return { error: "missing `jobs:`" };
+  const jobs: CiJob[] = [];
+  for (const [name, cfg] of Object.entries(root.jobs)) {
+    if (!isObj(cfg)) return { error: `job "${name}" must be a mapping` };
+    const parsed = parseJob(name, cfg);
+    if (parsed.error || !parsed.job) return { error: parsed.error ?? `invalid job "${name}"` };
+    jobs.push(parsed.job);
+  }
+  if (jobs.length === 0) return { error: "no jobs defined" };
+
+  const ordered = orderJobs(jobs);
+  if (ordered.error || !ordered.order) return { error: ordered.error ?? "invalid job graph" };
+
+  return { workflow: { on: triggers.on, branches: triggers.branches ?? [], jobs: ordered.order } };
+}
+
+/** Does this workflow run for a push to `ref` (e.g. "refs/heads/main")? */
+export function ciMatchesPush(wf: CiWorkflow, ref: string): boolean {
+  if (!wf.on.includes("push")) return false;
+  if (wf.branches.length === 0) return true;
+  return wf.branches.includes(branchOf(ref));
+}
+
+/** Names of deploy jobs, used by DeployDO manual mode ("deploy now"). */
+export function deployJobsOf(wf: CiWorkflow): CiJob[] {
+  return wf.jobs.filter((j) => j.kind === "deploy");
+}
+
+/** All cloudflare/deploy steps of one job (or of all deploy jobs when job is undefined). */
+export function deployStepsOf(wf: CiWorkflow, job?: string): { steps?: DeployStep[]; error?: string } {
+  const jobs = job === undefined ? deployJobsOf(wf) : wf.jobs.filter((j) => j.name === job);
+  if (job !== undefined && jobs.length === 0) return { error: `no job named "${job}" in ci.yml` };
+  const steps = jobs.flatMap((j) =>
+    j.steps.filter((s): s is CiDeployStep => s.type === "cloudflare/deploy").map((s) => s.step),
+  );
+  if (steps.length === 0) return { error: "no cloudflare/deploy steps" };
+  return { steps };
+}
diff --git a/packages/worker/src/deploy/workflow.ts b/packages/worker/src/deploy/workflow.ts
index 4960fef..64d5c77 100644
--- a/packages/worker/src/deploy/workflow.ts
+++ b/packages/worker/src/deploy/workflow.ts
@@ -63,19 +63,19 @@ export interface ParseResult {
   error?: string;
 }
 
-function asArray(v: YamlValue | undefined): YamlValue[] {
+export function asArray(v: YamlValue | undefined): YamlValue[] {
   if (v == null) return [];
   return Array.isArray(v) ? v : [v];
 }
 
-function asString(v: YamlValue | undefined): string | undefined {
+export function asString(v: YamlValue | undefined): string | undefined {
   if (v == null) return undefined;
   if (typeof v === "string") return v;
   if (typeof v === "number" || typeof v === "boolean") return String(v);
   return undefined;
 }
 
-function isObj(v: YamlValue | undefined): v is { [k: string]: YamlValue } {
+export function isObj(v: YamlValue | undefined): v is { [k: string]: YamlValue } {
   return typeof v === "object" && v !== null && !Array.isArray(v);
 }
 
@@ -128,6 +128,58 @@ function parseBindings(step: { [k: string]: YamlValue }): WorkerBindings {
   return b;
 }
 
+/**
+ * Parse the body of one `cloudflare/deploy:` step. Shared with the v0.3 CI
+ * workflow parser (src/ci/workflow.ts) so both formats validate identically.
+ */
+export function parseDeployStepConfig(cfg: {
+  [k: string]: YamlValue;
+}): { step?: DeployStep; error?: string } {
+  const project = asString(cfg.project);
+  const entry = asString(cfg.entry);
+  const kind = (asString(cfg.kind) ?? "worker") as DeployStep["kind"];
+  if (!project) return { error: "step missing `project`" };
+  if (!entry) return { error: "step missing `entry`" };
+  if (kind !== "worker" && kind !== "pages") {
+    return { error: `unsupported kind "${kind}" (worker | pages)` };
+  }
+
+  const step: DeployStep = {
+    type: "cloudflare/deploy",
+    project,
+    kind,
+    entry,
+    bindings: parseBindings(cfg),
+  };
+  const compat = asString(cfg.compatibility_date);
+  if (compat) step.compatibility_date = compat;
+  const prodBranch = asString(cfg.production_branch);
+  if (prodBranch) step.production_branch = prodBranch;
+
+  if (isObj(cfg.migrations)) {
+    const dir = asString(cfg.migrations.dir);
+    const databaseId = asString(cfg.migrations.database_id);
+    if (dir && databaseId) {
+      step.migrations = {
+        dir,
+        database_id: databaseId,
+        apply: cfg.migrations.apply === true,
+      };
+    }
+  }
+  return { step };
+}
+
+/** Parse `on:` + `branches:` — shared by both workflow formats. */
+export function parseTriggers(root: {
+  [k: string]: YamlValue;
+}): { on?: string[]; branches?: string[]; error?: string } {
+  const on = asArray(root.on).map(asString).filter((s): s is string => !!s);
+  if (on.length === 0) return { error: "missing `on:`" };
+  const branches = asArray(root.branches).map(asString).filter((s): s is string => !!s);
+  return { on, branches };
+}
+
 export function parseDeployWorkflow(src: string): ParseResult {
   let root: YamlValue;
   try {
@@ -137,10 +189,8 @@ export function parseDeployWorkflow(src: string): ParseResult {
   }
   if (!isObj(root)) return { error: "deploy.yml must be a mapping" };
 
-  const on = asArray(root.on).map(asString).filter((s): s is string => !!s);
-  if (on.length === 0) return { error: "missing `on:`" };
-
-  const branches = asArray(root.branches).map(asString).filter((s): s is string => !!s);
+  const triggers = parseTriggers(root);
+  if (triggers.error || !triggers.on) return { error: triggers.error ?? "missing `on:`" };
 
   const steps: DeployStep[] = [];
   for (const raw of asArray(root.steps)) {
@@ -150,43 +200,13 @@ export function parseDeployWorkflow(src: string): ParseResult {
       const key = Object.keys(raw)[0] ?? "?";
       return { error: `unsupported step "${key}" (only cloudflare/deploy in v0.2)` };
     }
-    const project = asString(cfg.project);
-    const entry = asString(cfg.entry);
-    const kind = (asString(cfg.kind) ?? "worker") as DeployStep["kind"];
-    if (!project) return { error: "step missing `project`" };
-    if (!entry) return { error: "step missing `entry`" };
-    if (kind !== "worker" && kind !== "pages") {
-      return { error: `unsupported kind "${kind}" (worker | pages)` };
-    }
-
-    const step: DeployStep = {
-      type: "cloudflare/deploy",
-      project,
-      kind,
-      entry,
-      bindings: parseBindings(cfg),
-    };
-    const compat = asString(cfg.compatibility_date);
-    if (compat) step.compatibility_date = compat;
-    const prodBranch = asString(cfg.production_branch);
-    if (prodBranch) step.production_branch = prodBranch;
-
-    if (isObj(cfg.migrations)) {
-      const dir = asString(cfg.migrations.dir);
-      const databaseId = asString(cfg.migrations.database_id);
-      if (dir && databaseId) {
-        step.migrations = {
-          dir,
-          database_id: databaseId,
-          apply: cfg.migrations.apply === true,
-        };
-      }
-    }
-    steps.push(step);
+    const parsed = parseDeployStepConfig(cfg);
+    if (parsed.error || !parsed.step) return { error: parsed.error ?? "invalid step" };
+    steps.push(parsed.step);
   }
 
   if (steps.length === 0) return { error: "no steps defined" };
-  return { workflow: { on, branches, steps } };
+  return { workflow: { on: triggers.on, branches: triggers.branches ?? [], steps } };
 }
 
 /** Does this workflow run for a push to `ref` (e.g. "refs/heads/main")? */
diff --git a/packages/worker/src/deploy/yaml.ts b/packages/worker/src/deploy/yaml.ts
index cd3a6d8..9cdf987 100644
--- a/packages/worker/src/deploy/yaml.ts
+++ b/packages/worker/src/deploy/yaml.ts
@@ -1,12 +1,14 @@
-// A tiny YAML-subset parser — just enough for `.gitflare/deploy.yml`. We avoid
+// A tiny YAML-subset parser — just enough for `.gitflare/*.yml`. We avoid
 // a full YAML library (~200KB) to keep the worker bundle lean. Supports:
 //   - nested maps (`key:` then indented children)
 //   - block lists (`- item`), including lists of maps
 //   - scalars: strings, numbers, booleans, null
 //   - inline lists: `[a, b, c]`
+//   - literal block scalars (`key: |` / `key: |-`) — used by multi-line `run:`
 //   - quoted strings, `#` comments, blank lines
-// NOT supported (and not needed here): anchors, multi-line scalars, flow maps,
-// multiple documents. Anything outside the subset parses on a best-effort basis.
+// NOT supported (and not needed here): anchors, folded scalars (`>`), flow
+// maps spanning lines, multiple documents. Anything outside the subset parses
+// on a best-effort basis.
 
 export type YamlValue =
   | string
@@ -22,8 +24,9 @@ interface Line {
 }
 
 export function parseYaml(src: string): YamlValue {
+  const { text, blocks } = extractBlockScalars(src);
   const lines: Line[] = [];
-  for (const raw of src.split(/\r?\n/)) {
+  for (const raw of text.split(/\r?\n/)) {
     if (raw.trim().startsWith("#")) continue;
     const noComment = stripComment(raw);
     if (!noComment.trim()) continue;
@@ -34,6 +37,75 @@ export function parseYaml(src: string): YamlValue {
   }
   if (lines.length === 0) return null;
   const [value] = parseBlock(lines, 0, lines[0]!.indent);
+  return blocks.length > 0 ? resolveBlocks(value, blocks) : value;
+}
+
+// -- literal block scalars ----------------------------------------------------
+// `key: |` swallows the following more-indented raw lines (comments and blanks
+// preserved verbatim) before structural parsing. The content is stashed and a
+// NUL-delimited placeholder — impossible in real YAML scalars — takes its
+// place, resolved back after the tree is built.
+
+const BLOCK_TOKEN = (i: number): string => `\u0000block${i}\u0000`;
+
+function extractBlockScalars(src: string): { text: string; blocks: string[] } {
+  const raw = src.split(/\r?\n/);
+  const out: string[] = [];
+  const blocks: string[] = [];
+  let i = 0;
+  while (i < raw.length) {
+    const line = raw[i]!;
+    const stripped = stripComment(line).trimEnd();
+    const m = stripped.match(/^(\s*)(- )?([A-Za-z0-9_./-]+):\s*([|>])([+-]?)$/);
+    if (!m) {
+      out.push(line);
+      i++;
+      continue;
+    }
+    if (m[4] === ">") {
+      throw new Error("folded block scalars (>) aren't supported — use | instead");
+    }
+    // Content must be indented past the key (for `- key: |`, past the dash slot).
+    const keyIndent = m[1]!.length + (m[2] ? 2 : 0);
+    const content: string[] = [];
+    let j = i + 1;
+    while (j < raw.length) {
+      const l = raw[j]!;
+      if (l.trim() === "") {
+        content.push("");
+        j++;
+        continue;
+      }
+      const ind = l.length - l.trimStart().length;
+      if (ind <= keyIndent) break;
+      content.push(l);
+      j++;
+    }
+    // Drop trailing blank lines before measuring the base indent.
+    while (content.length > 0 && content[content.length - 1] === "") content.pop();
+    const base = Math.min(
+      ...content.filter((l) => l !== "").map((l) => l.length - l.trimStart().length),
+    );
+    let text = content.map((l) => (l === "" ? "" : l.slice(base))).join("\n");
+    if (m[5] !== "-" && text !== "") text += "\n"; // `|` keeps one trailing newline; `|-` chomps
+    blocks.push(text);
+    out.push(`${m[1]}${m[2] ?? ""}${m[3]}: ${BLOCK_TOKEN(blocks.length - 1)}`);
+    i = j;
+  }
+  return { text: out.join("\n"), blocks };
+}
+
+function resolveBlocks(value: YamlValue, blocks: string[]): YamlValue {
+  if (typeof value === "string") {
+    const m = value.match(/^\u0000block(\d+)\u0000$/);
+    return m ? blocks[Number(m[1])]! : value;
+  }
+  if (Array.isArray(value)) return value.map((v) => resolveBlocks(v, blocks));
+  if (typeof value === "object" && value !== null) {
+    const out: { [k: string]: YamlValue } = {};
+    for (const [k, v] of Object.entries(value)) out[k] = resolveBlocks(v, blocks);
+    return out;
+  }
   return value;
 }
 
diff --git a/packages/worker/src/durable-objects/ci.ts b/packages/worker/src/durable-objects/ci.ts
new file mode 100644
index 0000000..ceaf1bb
--- /dev/null
+++ b/packages/worker/src/durable-objects/ci.ts
@@ -0,0 +1,627 @@
+import { getSandbox } from "@cloudflare/sandbox";
+import type { Env } from "../env";
+import {
+  cloneRepoAtRef,
+  cloneRepoShallow,
+  mintReadPassword,
+  readBlobAtCommit,
+  type ShallowRepo,
+} from "../artifacts/content";
+import { branchOf } from "../deploy/workflow";
+import {
+  parseCiWorkflow,
+  ciMatchesPush,
+  CI_WORKFLOW_PATH,
+  type CiJob,
+  type CiWorkflow,
+} from "../ci/workflow";
+import {
+  cloneIntoSandbox,
+  runJobSteps,
+  readArtifact,
+  makeScrubber,
+  type SandboxHandle,
+} from "../ci/sandbox-runner";
+import { postCommitStatus, type CommitState } from "../ci/github-status";
+import { deployStubFor, type DeployRecord } from "./deploy";
+
+const MAX_LOG_LINES = 500;
+/** Slack added on top of the summed job budgets before the watchdog fires. */
+const WATCHDOG_SLACK_MS = 10 * 60_000;
+const SANDBOX_READ_TOKEN_TTL_S = 600;
+
+export interface CiJobRecord {
+  name: string;
+  kind: "run" | "deploy";
+  status: "pending" | "running" | "success" | "failed" | "skipped";
+  steps: Array<{ label: string; ok: boolean; detail?: string }>;
+  message?: string;
+}
+
+export interface CiRunRecord {
+  id: number;
+  ref: string;
+  branch: string;
+  sha: string;
+  mode: "push" | "manual";
+  startedAt: number;
+  finishedAt?: number;
+  status: "running" | "success" | "failed" | "skipped";
+  jobs: CiJobRecord[];
+  logs: string[];
+  message?: string;
+  // For the watchdog + late status posts (records outlive in-memory state).
+  deadlineAt?: number;
+  githubFullName?: string;
+  statusTargetUrl?: string;
+}
+
+export interface CiRunRequest {
+  artifactsRepoName: string;
+  remote: string;
+  githubFullName: string;
+  ref: string; // "" for manual (learned from the mirror's HEAD)
+  sha: string; // "" for manual
+  mode?: "push" | "manual";
+  statusTargetUrl?: string;
+}
+
+/**
+ * Per-repo CI pipeline runner (v0.3). Executes `.gitflare/ci.yml` jobs in a
+ * Cloudflare Sandbox on the user's own account, serialized per repo like
+ * DeployDO — but detached: POST /run answers 202 immediately and the pipeline
+ * (minutes, not seconds) continues as pending DO work, guarded by an alarm
+ * watchdog that fails interrupted runs and tears down orphaned sandboxes.
+ */
+export class CiDO {
+  private state: DurableObjectState;
+  private env: Env;
+  private inFlight: Promise | null = null;
+  private current: CiRunRecord | null = null;
+  private cancelRequested = false;
+  // Latest queued push sha per branch — a queued run superseded by a newer
+  // push records "skipped" instead of burning a sandbox. In-memory only:
+  // best-effort, and an evicted queue has nothing left to supersede anyway.
+  private latestPushSha = new Map();
+
+  constructor(state: DurableObjectState, env: Env) {
+    this.state = state;
+    this.env = env;
+  }
+
+  async fetch(request: Request): Promise {
+    const url = new URL(request.url);
+
+    if (url.pathname === "/stream") {
+      const pair = new WebSocketPair();
+      const [client, server] = [pair[0], pair[1]];
+      this.state.acceptWebSocket(server);
+      if (this.current) {
+        server.send(JSON.stringify({ type: "snapshot", record: this.current }));
+      }
+      return new Response(null, { status: 101, webSocket: client });
+    }
+
+    if (request.method === "POST" && url.pathname === "/run") {
+      const body = (await request.json()) as CiRunRequest;
+      if ((body.mode ?? "push") === "push" && body.ref) {
+        this.latestPushSha.set(branchOf(body.ref), body.sha);
+      }
+      // Chain behind any in-flight run, but do NOT hold this request open for
+      // the pipeline — pending work keeps the DO alive.
+      const prior = this.inFlight ?? Promise.resolve();
+      const next = prior.then(() => this.executeRun(body));
+      this.inFlight = next.catch(() => undefined);
+      return Response.json({ accepted: true }, { status: 202 });
+    }
+
+    if (request.method === "POST" && url.pathname === "/cancel") {
+      if (!this.current) {
+        return Response.json({ accepted: false, message: "no run in progress" });
+      }
+      this.cancelRequested = true;
+      this.log("cancel requested — tearing down the sandbox");
+      await this.destroySandbox(this.current.id).catch(() => undefined);
+      return Response.json({ accepted: true, runId: this.current.id }, { status: 202 });
+    }
+
+    if (request.method === "GET" && url.pathname === "/state") {
+      return Response.json({ runs: await this.history() });
+    }
+    return new Response("not found", { status: 404 });
+  }
+
+  webSocketMessage(): void {}
+  webSocketClose(): void {}
+  webSocketError(): void {}
+
+  /**
+   * Watchdog: fires past the current run's deadline (or after an eviction).
+   * Any record still "running" whose deadline passed — or that no longer
+   * matches in-memory state (the DO restarted mid-run) — is failed loudly and
+   * its sandbox torn down, so the UI never shows a zombie "running" forever
+   * and no orphaned container keeps billing.
+   */
+  async alarm(): Promise {
+    const runs = await this.history();
+    const now = Date.now();
+    for (const run of runs) {
+      if (run.status !== "running") continue;
+      const isCurrent = this.current?.id === run.id;
+      const pastDeadline = run.deadlineAt !== undefined && now > run.deadlineAt;
+      if (isCurrent && !pastDeadline) {
+        // Still legitimately running — re-arm.
+        if (run.deadlineAt) await this.state.storage.setAlarm(run.deadlineAt + 60_000);
+        continue;
+      }
+      run.status = "failed";
+      run.finishedAt = now;
+      run.message = pastDeadline
+        ? "watchdog: run exceeded its wall-clock budget"
+        : "interrupted (worker restarted mid-run)";
+      for (const j of run.jobs) {
+        if (j.status === "running" || j.status === "pending") j.status = "skipped";
+      }
+      await this.record(run);
+      this.broadcast({ type: "done", record: run });
+      await this.destroySandbox(run.id).catch(() => undefined);
+      await this.postStatus(run, "failure", run.message);
+      if (isCurrent) {
+        this.current = null;
+        this.cancelRequested = false;
+      }
+    }
+  }
+
+  // -- plumbing (mirrors DeployDO) -------------------------------------------
+
+  private broadcast(msg: unknown): void {
+    const data = JSON.stringify(msg);
+    for (const ws of this.state.getWebSockets()) {
+      try {
+        ws.send(data);
+      } catch {
+        // socket gone — ignore
+      }
+    }
+  }
+
+  private log(line: string): void {
+    if (!this.current) return;
+    const stamped = `${new Date().toISOString()}  ${line}`;
+    this.current.logs.push(stamped);
+    if (this.current.logs.length > MAX_LOG_LINES) this.current.logs.shift();
+    this.broadcast({ type: "log", line: stamped });
+  }
+
+  private async nextId(): Promise {
+    const last = (await this.state.storage.get("lastId")) ?? 0;
+    const id = last + 1;
+    await this.state.storage.put("lastId", id);
+    return id;
+  }
+
+  private async record(r: CiRunRecord): Promise {
+    await this.state.storage.put(`run:${String(r.id).padStart(10, "0")}`, r);
+  }
+
+  private async history(): Promise {
+    const map = await this.state.storage.list({
+      prefix: "run:",
+      reverse: true,
+      limit: 50,
+    });
+    return [...map.values()];
+  }
+
+  private async begin(
+    req: CiRunRequest,
+    ref: string,
+    sha: string,
+    mode: CiRunRecord["mode"],
+    jobs: CiJobRecord[],
+  ): Promise {
+    const id = await this.nextId();
+    const rec: CiRunRecord = {
+      id,
+      ref,
+      branch: branchOf(ref),
+      sha,
+      mode,
+      startedAt: Date.now(),
+      status: "running",
+      jobs,
+      logs: [],
+      ...(req.githubFullName ? { githubFullName: req.githubFullName } : {}),
+      ...(req.statusTargetUrl ? { statusTargetUrl: req.statusTargetUrl } : {}),
+    };
+    this.current = rec;
+    this.cancelRequested = false;
+    await this.record(rec);
+    this.broadcast({ type: "start", record: rec });
+    this.log(`ci run #${id} started (${mode}, ${rec.branch} @ ${sha.slice(0, 8)})`);
+    return rec;
+  }
+
+  private async finish(
+    rec: CiRunRecord,
+    status: CiRunRecord["status"],
+    message?: string,
+  ): Promise {
+    rec.status = status;
+    rec.finishedAt = Date.now();
+    if (message) rec.message = message;
+    this.log(`ci run #${rec.id} ${status}${message ? `: ${message}` : ""}`);
+    await this.record(rec);
+    this.broadcast({ type: "done", record: rec });
+    this.current = null;
+    this.cancelRequested = false;
+    return rec;
+  }
+
+  private async postStatus(
+    rec: CiRunRecord,
+    state: CommitState,
+    description: string,
+  ): Promise {
+    // Soft-fail by construction: GitHub being down is gitflare's raison d'être.
+    if (!rec.githubFullName || !/^[0-9a-f]{40}$/.test(rec.sha)) return;
+    await postCommitStatus({
+      githubFullName: rec.githubFullName,
+      sha: rec.sha,
+      state,
+      description,
+      token: this.env.GITHUB_TOKEN,
+      ...(rec.statusTargetUrl ? { targetUrl: rec.statusTargetUrl } : {}),
+    }).catch(() => undefined);
+  }
+
+  private sandboxName(runId: number): string {
+    return `ci-${this.state.id.toString().slice(0, 16)}-r${runId}`;
+  }
+
+  private sandbox(runId: number): SandboxHandle {
+    if (!this.env.SANDBOX) throw new Error("SANDBOX binding missing");
+    return getSandbox(
+      this.env.SANDBOX as never,
+      this.sandboxName(runId),
+    ) as unknown as SandboxHandle;
+  }
+
+  private async destroySandbox(runId: number): Promise {
+    if (!this.env.SANDBOX) return;
+    await this.sandbox(runId).destroy();
+  }
+
+  // -- pipeline ---------------------------------------------------------------
+
+  private async executeRun(req: CiRunRequest): Promise {
+    try {
+      await this.executeRunInner(req);
+    } catch (e) {
+      // Never let one run poison the serialize chain.
+      if (this.current) {
+        await this.finish(this.current, "failed", (e as Error).message).catch(() => undefined);
+      }
+    }
+  }
+
+  private async executeRunInner(req: CiRunRequest): Promise {
+    const mode = req.mode ?? "push";
+
+    // A queued push superseded by a newer push to the same branch is skipped
+    // before it costs a clone or a sandbox.
+    if (mode === "push" && req.ref) {
+      const latest = this.latestPushSha.get(branchOf(req.ref));
+      if (latest !== undefined && latest !== req.sha) {
+        const rec = await this.begin(req, req.ref, req.sha, mode, []);
+        await this.finish(rec, "skipped", `superseded by a newer push (${latest.slice(0, 8)})`);
+        return;
+      }
+    }
+
+    // Clone the mirror to learn/verify what we're running.
+    const handle = await this.env.ARTIFACTS.get(req.artifactsRepoName);
+    let shallow: ShallowRepo;
+    let ref = req.ref;
+    let sha = req.sha;
+    try {
+      if (mode === "push" && ref && /^[0-9a-f]{40}$/.test(sha)) {
+        shallow = await cloneRepoAtRef(handle, req.remote, branchOf(ref), sha);
+      } else {
+        shallow = await cloneRepoShallow(handle, req.remote);
+        ref = `refs/heads/${shallow.branchName}`;
+        sha = shallow.headSha;
+      }
+    } catch (e) {
+      const rec = await this.begin(req, ref || "refs/heads/?", sha || "0".repeat(40), mode, []);
+      await this.finish(rec, "failed", `clone failed: ${(e as Error).message}`);
+      return;
+    }
+
+    // No ci.yml → this push belongs to the v0.2 deploy path. Pass it through
+    // to DeployDO (the webhook only fires one DO — us — when CI is enabled)
+    // without minting a CI run record.
+    const blob = await readBlobAtCommit(shallow, sha, CI_WORKFLOW_PATH).catch(() => null);
+    if (!blob || blob.isBinary || !blob.text) {
+      if (mode === "push") {
+        await this.delegatePlainDeploy(req);
+      } else {
+        const rec = await this.begin(req, ref, sha, mode, []);
+        await this.finish(rec, "skipped", `no ${CI_WORKFLOW_PATH} at ${sha.slice(0, 8)}`);
+      }
+      return;
+    }
+
+    const parsed = parseCiWorkflow(blob.text);
+    if (parsed.error || !parsed.workflow) {
+      const rec = await this.begin(req, ref, sha, mode, []);
+      await this.finish(rec, "failed", `invalid ${CI_WORKFLOW_PATH}: ${parsed.error}`);
+      await this.postStatus(rec, "error", `invalid ci.yml: ${parsed.error ?? ""}`);
+      return;
+    }
+    const wf = parsed.workflow;
+
+    if (mode !== "manual" && !ciMatchesPush(wf, ref)) {
+      const rec = await this.begin(req, ref, sha, mode, []);
+      await this.finish(rec, "skipped", `${branchOf(ref)} not matched by workflow branches`);
+      return;
+    }
+
+    if (this.env.CI_ENABLED !== "1") {
+      const rec = await this.begin(req, ref, sha, mode, []);
+      await this.finish(rec, "skipped", "CI not enabled — run `gitflare ci enable`");
+      return;
+    }
+    if (!this.env.SANDBOX) {
+      // Config drift (e.g. hand-edited wrangler config): don't silently stop
+      // shipping — fail the run loudly AND let the v0.2 deploy path proceed.
+      const rec = await this.begin(req, ref, sha, mode, []);
+      await this.finish(
+        rec,
+        "failed",
+        "SANDBOX binding missing — rerun `gitflare ci enable`; falling back to deploy.yml",
+      );
+      await this.postStatus(rec, "error", "CI sandbox missing — rerun gitflare ci enable");
+      if (mode === "push") await this.delegatePlainDeploy(req);
+      return;
+    }
+
+    await this.runPipeline(req, wf, ref, sha, mode);
+  }
+
+  private async delegatePlainDeploy(req: CiRunRequest): Promise {
+    const stub = deployStubFor(this.env, req.artifactsRepoName);
+    await stub
+      .fetch("https://deploy-do/deploy", {
+        method: "POST",
+        body: JSON.stringify({
+          artifactsRepoName: req.artifactsRepoName,
+          remote: req.remote,
+          ref: req.ref,
+          sha: req.sha,
+        }),
+      })
+      .catch(() => undefined);
+  }
+
+  private async runPipeline(
+    req: CiRunRequest,
+    wf: CiWorkflow,
+    ref: string,
+    sha: string,
+    mode: CiRunRecord["mode"],
+  ): Promise {
+    const jobRecords: CiJobRecord[] = wf.jobs.map((j) => ({
+      name: j.name,
+      kind: j.kind,
+      status: "pending",
+      steps: [],
+    }));
+    const rec = await this.begin(req, ref, sha, mode, jobRecords);
+
+    // Watchdog: sum of job budgets + slack. Survives eviction via storage.
+    const budgetMs =
+      wf.jobs.reduce((ms, j) => ms + j.timeoutMinutes * 60_000, 0) + WATCHDOG_SLACK_MS;
+    rec.deadlineAt = rec.startedAt + budgetMs;
+    await this.record(rec);
+    await this.state.storage.setAlarm(rec.deadlineAt + 60_000);
+
+    await this.postStatus(rec, "pending", `run #${rec.id} started`);
+
+    const branch = branchOf(ref);
+    let sandboxCreated = false;
+    // Mutated inside closures — TS control flow can't see that, so expose the
+    // bits the loop body needs as thunks instead of comparing the `let` direct.
+    let cloneState: "pending" | "ok" | "failed" = "pending";
+    const cloneOk = (): boolean => cloneState === "ok";
+    let scrub: (line: string) => string = (l) => l;
+    let failedJob: string | undefined;
+
+    const statusOf = (name: string): CiJobRecord["status"] =>
+      rec.jobs.find((j) => j.name === name)?.status ?? "pending";
+
+    try {
+      for (const job of wf.jobs) {
+        const jr = rec.jobs.find((j) => j.name === job.name)!;
+
+        if (this.cancelRequested) {
+          jr.status = "skipped";
+          jr.message = "cancelled";
+          continue;
+        }
+        const blocked = job.needs.find((n) => statusOf(n) !== "success");
+        if (blocked) {
+          jr.status = "skipped";
+          jr.message = `dependency "${blocked}" ${statusOf(blocked)}`;
+          this.log(`[${job.name}] skipped — ${jr.message}`);
+          await this.record(rec);
+          continue;
+        }
+
+        jr.status = "running";
+        await this.record(rec);
+        this.log(`[${job.name}] started (${job.kind} job)`);
+
+        const outcome =
+          job.kind === "deploy"
+            ? await this.runDeployJob(req, job, rec, sandboxCreated && cloneOk())
+            : await this.runSandboxJob(req, job, rec, branch, sha, {
+                ensureClone: async () => {
+                  if (cloneState !== "pending") return cloneState === "ok";
+                  const repoHandle = await this.env.ARTIFACTS.get(req.artifactsRepoName);
+                  const tokenSecret = await mintReadPassword(repoHandle, SANDBOX_READ_TOKEN_TTL_S);
+                  scrub = makeScrubber([tokenSecret]);
+                  sandboxCreated = true;
+                  const res = await cloneIntoSandbox(this.sandbox(rec.id), {
+                    remote: req.remote,
+                    branch,
+                    sha,
+                    tokenSecret,
+                    log: (l) => this.log(`[${job.name}] ${scrub(l)}`),
+                  });
+                  cloneState = res.ok ? "ok" : "failed";
+                  if (!res.ok) this.log(`[${job.name}] ${scrub(res.message ?? "clone failed")}`);
+                  return res.ok;
+                },
+                scrub: (l) => scrub(l),
+              });
+
+        jr.status = outcome.ok ? "success" : "failed";
+        if (outcome.message) jr.message = outcome.message;
+        jr.steps = outcome.steps;
+        if (!outcome.ok && !failedJob) failedJob = job.name;
+        this.log(`[${job.name}] ${outcome.ok ? "✓ success" : `✗ failed${outcome.message ? ` — ${outcome.message}` : ""}`}`);
+        await this.record(rec);
+      }
+    } finally {
+      if (sandboxCreated) {
+        await this.destroySandbox(rec.id).catch((e) =>
+          this.log(`sandbox teardown failed (it will idle out): ${(e as Error).message}`),
+        );
+      }
+    }
+
+    if (this.cancelRequested) {
+      await this.finish(rec, "failed", "cancelled");
+      await this.postStatus(rec, "failure", "run cancelled");
+      return;
+    }
+    const anyFailed = rec.jobs.some((j) => j.status === "failed");
+    await this.finish(rec, anyFailed ? "failed" : "success");
+    await this.postStatus(
+      rec,
+      anyFailed ? "failure" : "success",
+      anyFailed ? `job "${failedJob}" failed` : `${rec.jobs.length} job(s) passed`,
+    );
+  }
+
+  private async runSandboxJob(
+    req: CiRunRequest,
+    job: CiJob,
+    rec: CiRunRecord,
+    branch: string,
+    sha: string,
+    io: { ensureClone: () => Promise; scrub: (l: string) => string },
+  ): Promise<{ ok: boolean; steps: Array<{ label: string; ok: boolean; detail?: string }>; message?: string }> {
+    const cloned = await io.ensureClone().catch((e) => {
+      this.log(`[${job.name}] ${io.scrub((e as Error).message)}`);
+      return false;
+    });
+    if (!cloned) {
+      return { ok: false, steps: [], message: "workspace clone failed" };
+    }
+    try {
+      const result = await runJobSteps(this.sandbox(rec.id), {
+        job,
+        meta: { repo: req.artifactsRepoName, branch, sha },
+        log: (l) => this.log(`[${job.name}] ${io.scrub(l)}`),
+      });
+      return {
+        ok: result.ok,
+        steps: result.steps.map((s) => ({
+          label: s.command,
+          ok: s.ok,
+          ...(s.detail ? { detail: s.detail } : {}),
+        })),
+        ...(result.message ? { message: result.message } : {}),
+      };
+    } catch (e) {
+      return { ok: false, steps: [], message: io.scrub((e as Error).message) };
+    }
+  }
+
+  private async runDeployJob(
+    req: CiRunRequest,
+    job: CiJob,
+    rec: CiRunRecord,
+    sandboxUsable: boolean,
+  ): Promise<{ ok: boolean; steps: Array<{ label: string; ok: boolean; detail?: string }>; message?: string }> {
+    // Ship what CI just built (worker entries only in M8): read built entry
+    // files out of the run's sandbox and pass them as overrides — otherwise
+    // the deploy would ship the stale COMMITTED file, which is exactly the
+    // trap users fall into with "build then deploy" pipelines.
+    const entryOverrides: Record = {};
+    if (sandboxUsable) {
+      for (const step of job.steps) {
+        if (step.type !== "cloudflare/deploy" || step.step.kind !== "worker") continue;
+        const art = await readArtifact(this.sandbox(rec.id), step.step.entry);
+        if (art.error) this.log(`[${job.name}] ${art.error}`);
+        if (art.content !== undefined) {
+          entryOverrides[step.step.entry] = art.content;
+          this.log(`[${job.name}] using CI-built ${step.step.entry} (${art.content.length} bytes)`);
+        } else if (!art.error) {
+          this.log(`[${job.name}] ${step.step.entry} not built in this run — deploying the committed file`);
+        }
+      }
+    }
+
+    const stub = deployStubFor(this.env, req.artifactsRepoName);
+    let resp: Response;
+    try {
+      resp = await stub.fetch("https://deploy-do/deploy", {
+        method: "POST",
+        body: JSON.stringify({
+          artifactsRepoName: req.artifactsRepoName,
+          remote: req.remote,
+          ref: rec.ref,
+          sha: rec.sha,
+          mode: "ci",
+          workflow: CI_WORKFLOW_PATH,
+          job: job.name,
+          ...(Object.keys(entryOverrides).length > 0 ? { entryOverrides } : {}),
+        }),
+      });
+    } catch (e) {
+      return { ok: false, steps: [], message: `deploy delegation failed: ${(e as Error).message}` };
+    }
+
+    if (!resp.ok) {
+      const body = (await resp.json().catch(() => ({}))) as { error?: string };
+      return { ok: false, steps: [], message: `deploy failed: ${body.error ?? resp.status}` };
+    }
+    const record = (await resp.json()) as DeployRecord;
+    const steps = record.steps.map((s) => ({
+      label: `${s.kind}: ${s.project}`,
+      ok: s.ok,
+      ...(s.detail ? { detail: s.detail } : {}),
+    }));
+    this.log(`[${job.name}] deploy #${record.id} ${record.status} — see the deployments page for logs`);
+    if (record.status === "success") return { ok: true, steps };
+    // "skipped" (e.g. CD not enabled) must NOT read as green — the whole point
+    // of a needs-gated deploy job is that it actually shipped.
+    return {
+      ok: false,
+      steps,
+      message:
+        record.status === "skipped"
+          ? `deploy skipped: ${record.message ?? "CD not enabled — run \`gitflare deploy enable\`"}`
+          : `deploy #${record.id} ${record.status}${record.message ? `: ${record.message}` : ""}`,
+    };
+  }
+}
+
+export function ciStubFor(env: Env, artifactsRepoName: string): DurableObjectStub {
+  const id = env.CI.idFromName(artifactsRepoName);
+  return env.CI.get(id);
+}
diff --git a/packages/worker/src/durable-objects/deploy.ts b/packages/worker/src/durable-objects/deploy.ts
index 5a4f3c7..8da63f0 100644
--- a/packages/worker/src/durable-objects/deploy.ts
+++ b/packages/worker/src/durable-objects/deploy.ts
@@ -2,6 +2,7 @@ import type { Env } from "../env";
 import {
   cloneRepoShallow,
   cloneRepoFull,
+  cloneRepoAtRef,
   readBlobAtCommit,
   listFilesUnder,
 } from "../artifacts/content";
@@ -12,6 +13,7 @@ import {
   type DeployStep,
   type DeployWorkflow,
 } from "../deploy/workflow";
+import { parseCiWorkflow, deployStepsOf, CI_WORKFLOW_PATH } from "../ci/workflow";
 import {
   uploadWorkerScript,
   deployPages,
@@ -34,13 +36,18 @@ export interface DeployRecord {
   ref: string;
   branch: string;
   sha: string;
-  mode: "push" | "manual" | "rollback";
+  mode: "push" | "manual" | "rollback" | "ci";
   startedAt: number;
   finishedAt?: number;
   status: "running" | "success" | "failed" | "skipped";
   steps: DeployStepResult[];
   logs: string[];
   message?: string;
+  // v0.3: which workflow file/job produced this deploy, so rollback re-reads
+  // the right steps at the target sha. Absent = the v0.2 deploy.yml path
+  // (all pre-v0.3 records). job absent with workflow set = all deploy jobs.
+  workflow?: string;
+  job?: string;
 }
 
 interface DeployRequest {
@@ -48,7 +55,13 @@ interface DeployRequest {
   remote: string;
   ref: string;
   sha: string;
-  mode?: "push" | "manual";
+  mode?: "push" | "manual" | "ci";
+  // mode "ci" only: the CI pipeline names the workflow file + deploy job it is
+  // delegating, and may override entry files with sandbox-built artifacts so
+  // the deploy ships what CI just built instead of a stale committed file.
+  workflow?: string;
+  job?: string;
+  entryOverrides?: Record;
 }
 
 interface RollbackRequest {
@@ -173,6 +186,7 @@ export class DeployDO {
     ref: string,
     sha: string,
     mode: DeployRecord["mode"],
+    source?: { workflow?: string; job?: string },
   ): Promise {
     const id = await this.nextId();
     const rec: DeployRecord = {
@@ -185,6 +199,8 @@ export class DeployDO {
       status: "running",
       steps: [],
       logs: [],
+      ...(source?.workflow ? { workflow: source.workflow } : {}),
+      ...(source?.job ? { job: source.job } : {}),
     };
     this.current = rec;
     await this.record(rec);
@@ -226,10 +242,33 @@ export class DeployDO {
     return parsed.workflow;
   }
 
-  // -- push / manual deploy -------------------------------------------------
+  /** Read `.gitflare/ci.yml` at a commit and extract deploy steps (v0.3). */
+  private async loadCiDeploySteps(
+    shallow: ShallowRepo,
+    commitOid: string,
+    job?: string,
+  ): Promise<{ steps: DeployStep[] } | { error: string }> {
+    const blob = await readBlobAtCommit(shallow, commitOid, CI_WORKFLOW_PATH).catch(() => null);
+    if (!blob || blob.isBinary || !blob.text) return { error: `no ${CI_WORKFLOW_PATH}` };
+    const parsed = parseCiWorkflow(blob.text);
+    if (parsed.error || !parsed.workflow)
+      return { error: `invalid ${CI_WORKFLOW_PATH}: ${parsed.error}` };
+    const extracted = deployStepsOf(parsed.workflow, job);
+    if (extracted.error || !extracted.steps) return { error: extracted.error ?? "no deploy steps" };
+    return { steps: extracted.steps };
+  }
+
+  /** Does `.gitflare/ci.yml` exist at this commit (any validity)? */
+  private async ciFileExists(shallow: ShallowRepo, commitOid: string): Promise {
+    const blob = await readBlobAtCommit(shallow, commitOid, CI_WORKFLOW_PATH).catch(() => null);
+    return !!blob && !blob.isBinary && !!blob.text;
+  }
+
+  // -- push / manual / CI-delegated deploy ------------------------------------
 
   private async runDeploy(req: DeployRequest): Promise {
     const mode = req.mode ?? "push";
+    if (mode === "ci") return this.runCiDelegatedDeploy(req);
 
     // Clone first so a manual run (empty ref/sha — the GitHub-down escape hatch)
     // can learn the current default branch + tip from Artifacts directly.
@@ -243,6 +282,36 @@ export class DeployDO {
 
     const ref = req.ref || `refs/heads/${shallow.branchName}`;
     const sha = req.sha || shallow.headSha;
+
+    // v0.3: when a ci.yml is committed, the CI pipeline owns pushes. Fail
+    // CLOSED rather than run deploy.yml ungated — the user added `needs:`
+    // gating on purpose, and deploying around it would ship untested code.
+    if (mode === "push" && (await this.ciFileExists(shallow, shallow.headSha))) {
+      const rec = await this.begin(ref, sha, mode);
+      return this.finish(
+        rec,
+        "skipped",
+        this.env.CI_ENABLED === "1"
+          ? `${CI_WORKFLOW_PATH} owns this push — the CI pipeline deploys (see the CI runs page)`
+          : `${CI_WORKFLOW_PATH} present but CI not enabled — run \`gitflare ci enable\` (deploy.yml is not run ungated)`,
+      );
+    }
+
+    // v0.3: a manual "deploy now" prefers ci.yml's deploy jobs when CI is
+    // enabled and ci.yml actually has deploy jobs — the GitHub-down escape
+    // hatch deploys immediately, intentionally skipping test jobs.
+    if (mode === "manual" && this.env.CI_ENABLED === "1") {
+      const ci = await this.loadCiDeploySteps(shallow, shallow.headSha);
+      if ("steps" in ci) {
+        const rec = await this.begin(ref, sha, mode, { workflow: CI_WORKFLOW_PATH });
+        const creds = this.creds();
+        if (!creds) return this.finish(rec, "skipped", "CD not enabled — run `gitflare deploy enable`");
+        this.log(`manual deploy from ${CI_WORKFLOW_PATH} deploy jobs (test jobs intentionally skipped)`);
+        const anyFailed = await this.runSteps(rec, ci.steps, shallow, shallow.headSha, creds, rec.branch);
+        return this.finish(rec, anyFailed ? "failed" : "success");
+      }
+    }
+
     const rec = await this.begin(ref, sha, mode);
 
     const creds = this.creds();
@@ -259,6 +328,52 @@ export class DeployDO {
     return this.finish(rec, anyFailed ? "failed" : "success");
   }
 
+  // -- CI-delegated deploy (v0.3) ---------------------------------------------
+  // The CI pipeline already matched branches and gated on its `needs:` graph;
+  // here we read the named job's deploy steps STRICTLY at the delegated sha
+  // (ref-aware clone — feature branches and raced HEADs included) and ship
+  // them, preferring sandbox-built entry files handed over by the CI run.
+
+  private async runCiDelegatedDeploy(req: DeployRequest): Promise {
+    const workflow = req.workflow ?? CI_WORKFLOW_PATH;
+    let shallow: ShallowRepo;
+    try {
+      shallow = await cloneRepoAtRef(
+        await this.env.ARTIFACTS.get(req.artifactsRepoName),
+        req.remote,
+        branchOf(req.ref),
+        req.sha,
+      );
+    } catch (e) {
+      const rec = await this.begin(req.ref, req.sha, "ci", {
+        workflow,
+        ...(req.job ? { job: req.job } : {}),
+      });
+      return this.finish(rec, "failed", `clone failed: ${(e as Error).message}`);
+    }
+
+    const rec = await this.begin(req.ref, req.sha, "ci", {
+      workflow,
+      ...(req.job ? { job: req.job } : {}),
+    });
+    const creds = this.creds();
+    if (!creds) return this.finish(rec, "skipped", "CD not enabled — run `gitflare deploy enable`");
+
+    const ci = await this.loadCiDeploySteps(shallow, req.sha, req.job);
+    if ("error" in ci) return this.finish(rec, "failed", ci.error);
+
+    const anyFailed = await this.runSteps(
+      rec,
+      ci.steps,
+      shallow,
+      req.sha,
+      creds,
+      rec.branch,
+      req.entryOverrides,
+    );
+    return this.finish(rec, anyFailed ? "failed" : "success");
+  }
+
   // -- rollback -------------------------------------------------------------
 
   private async runRollback(req: RollbackRequest): Promise {
@@ -280,7 +395,12 @@ export class DeployDO {
         req.toDeployId ? `deploy #${req.toDeployId} not found` : "no prior successful deploy to roll back to",
       );
     }
-    const rec = await this.begin(target.ref, target.sha, "rollback");
+    // Carry the source workflow/job so a rollback-of-a-rollback re-resolves.
+    const source = {
+      ...(target.workflow ? { workflow: target.workflow } : {}),
+      ...(target.job ? { job: target.job } : {}),
+    };
+    const rec = await this.begin(target.ref, target.sha, "rollback", source);
     const creds = this.creds();
     if (!creds) return this.finish(rec, "skipped", "CD not enabled");
 
@@ -292,11 +412,28 @@ export class DeployDO {
       return this.finish(rec, "failed", `full clone failed: ${(e as Error).message}`);
     }
 
-    const wf = await this.loadWorkflow(full, target.sha);
-    if ("error" in wf) return this.finish(rec, "failed", `cannot read workflow at target: ${wf.error}`);
+    // Re-read the steps from whichever workflow file produced the target
+    // deploy (absent workflow field = pre-v0.3 record = deploy.yml).
+    let steps: DeployStep[];
+    if (target.workflow === CI_WORKFLOW_PATH) {
+      const ci = await this.loadCiDeploySteps(full, target.sha, target.job);
+      if ("error" in ci) {
+        return this.finish(rec, "failed", `cannot read workflow at target: ${ci.error}`);
+      }
+      steps = ci.steps;
+      this.log("note: CI-built artifacts aren't stored — rolling back to the committed entry at the target sha");
+    } else {
+      const wf = await this.loadWorkflow(full, target.sha);
+      if ("error" in wf) {
+        return this.finish(rec, "failed", `cannot read workflow at target: ${wf.error}`);
+      }
+      steps = wf.steps;
+    }
 
-    // Rollback never re-runs migrations (they're forward-only).
-    const steps = wf.steps.map((s) => {
+    // Rollback never re-runs migrations (they're forward-only). Note: a CI
+    // deploy that shipped a sandbox-built entry rolls back to the COMMITTED
+    // file at the target sha — build outputs aren't stored anywhere to replay.
+    steps = steps.map((s) => {
       const { migrations, ...rest } = s;
       void migrations;
       return rest as DeployStep;
@@ -314,6 +451,7 @@ export class DeployDO {
     commitOid: string,
     creds: { token: string; accountId: string },
     branch: string,
+    entryOverrides?: Record,
   ): Promise {
     let anyFailed = false;
     for (const step of steps) {
@@ -334,7 +472,7 @@ export class DeployDO {
         const result =
           step.kind === "pages"
             ? await this.deployPagesStep(step, shallow, commitOid, creds, branch)
-            : await this.deployWorkerStep(step, shallow, commitOid, creds);
+            : await this.deployWorkerStep(step, shallow, commitOid, creds, entryOverrides?.[step.entry]);
 
         const sr: DeployStepResult = {
           project: step.project,
@@ -361,19 +499,29 @@ export class DeployDO {
     shallow: ShallowRepo,
     commitOid: string,
     creds: { token: string; accountId: string },
+    entryOverride?: string,
   ): Promise {
-    const entry = await readBlobAtCommit(shallow, commitOid, step.entry).catch(() => null);
-    if (!entry || entry.isBinary || !entry.text) {
-      return { ok: false, status: 0, detail: `entry not found or not text: ${step.entry}` };
+    // A CI run hands over the entry it just built in the sandbox; the commit's
+    // copy (often a stale checked-in artifact) is only the fallback.
+    let code: string;
+    if (entryOverride !== undefined) {
+      code = entryOverride;
+      this.log(`  uploading CI-built ${step.entry} (${code.length} bytes, ${countBindings(step)} bindings)`);
+    } else {
+      const entry = await readBlobAtCommit(shallow, commitOid, step.entry).catch(() => null);
+      if (!entry || entry.isBinary || !entry.text) {
+        return { ok: false, status: 0, detail: `entry not found or not text: ${step.entry}` };
+      }
+      code = entry.text;
+      this.log(`  uploading ${step.entry} (${entry.size} bytes, ${countBindings(step)} bindings)`);
     }
-    this.log(`  uploading ${step.entry} (${entry.size} bytes, ${countBindings(step)} bindings)`);
     return uploadWorkerScript({
       accountId: creds.accountId,
       apiToken: creds.token,
       upload: {
         scriptName: step.project,
         moduleFileName: "worker.js",
-        code: entry.text,
+        code,
         bindings: step.bindings,
         ...(step.compatibility_date ? { compatibilityDate: step.compatibility_date } : {}),
       },
diff --git a/packages/worker/src/env.ts b/packages/worker/src/env.ts
index b019cc4..c9ca2c8 100644
--- a/packages/worker/src/env.ts
+++ b/packages/worker/src/env.ts
@@ -35,10 +35,19 @@ export interface Env {
   ACCESS_AUD?: string;
   ACCESS_TEAM_DOMAIN?: string; // e.g. "myteam.cloudflareaccess.com"
 
+  // "1" when `gitflare ci enable` is active (v0.3). Gates the CI pipeline the
+  // same way CD_ENABLED gates deploys; `ci disable` drops the var but keeps
+  // the Sandbox container provisioned so re-enable never needs a migration.
+  CI_ENABLED?: string;
+
   // Bindings
   ARTIFACTS: ArtifactsNamespace;
   REPO: DurableObjectNamespace;
   DEPLOY: DurableObjectNamespace;
+  CI: DurableObjectNamespace;
+  // The Cloudflare Sandbox container namespace — present only after
+  // `gitflare ci enable` adds the [[containers]] block to the Worker config.
+  SANDBOX?: DurableObjectNamespace;
 }
 
 export function parseRepoMap(env: Env): RepoMap {
diff --git a/packages/worker/src/index.tsx b/packages/worker/src/index.tsx
index f3b4051..32c99ad 100644
--- a/packages/worker/src/index.tsx
+++ b/packages/worker/src/index.tsx
@@ -7,12 +7,19 @@ import { cloneRepoShallow, getRepoContent, listTreeAt, readBlobAt } from "./arti
 import { Browse } from "./ui/browse";
 import { Home, type HomeRepo } from "./ui/home";
 import { Deployments } from "./ui/deployments";
+import { Runs } from "./ui/runs";
 import { NotFound, ErrorView } from "./ui/states";
 import { accessGuard, type AccessVariables } from "./access/middleware";
 import { deployStubFor, type DeployRecord } from "./durable-objects/deploy";
+import { ciStubFor, type CiRunRecord } from "./durable-objects/ci";
 
 export { RepoDO } from "./durable-objects/repo";
 export { DeployDO } from "./durable-objects/deploy";
+export { CiDO } from "./durable-objects/ci";
+// The Sandbox container class must be exported for the (optional) SANDBOX
+// binding + [[containers]] block that `gitflare ci enable` adds; inert unless
+// the deployed config binds it.
+export { Sandbox } from "@cloudflare/sandbox";
 
 const app = new Hono<{ Bindings: Env; Variables: AccessVariables }>();
 
@@ -261,6 +268,56 @@ app.get("/r/:name/deployments/stream", async (c) => {
   return stub.fetch(new Request("https://deploy-do/stream", c.req.raw));
 });
 
+// ---- CI runs (v0.3) --------------------------------------------------------
+
+app.get("/r/:name/ci", async (c) => {
+  const name = c.req.param("name");
+  const repo = findRepoByArtifactsName(c.env, name);
+  if (!repo)
+    return c.html(
+      ,
+      404,
+    );
+  let runs: CiRunRecord[] = [];
+  try {
+    const stub = ciStubFor(c.env, name);
+    const resp = await stub.fetch("https://ci-do/state");
+    if (resp.ok) ({ runs } = (await resp.json()) as { runs: CiRunRecord[] });
+  } catch {
+    // Soft fail — show an empty list.
+  }
+  return c.html(
+    ,
+  );
+});
+
+// Live CI-log WebSocket — forwarded to the CiDO, Access-guarded via /r/*.
+app.get("/r/:name/ci/stream", async (c) => {
+  const name = c.req.param("name");
+  const repo = findRepoByArtifactsName(c.env, name);
+  if (!repo) return c.text("unknown repo", 404);
+  if (c.req.header("Upgrade") !== "websocket") return c.text("expected websocket", 426);
+  const stub = ciStubFor(c.env, name);
+  return stub.fetch(new Request("https://ci-do/stream", c.req.raw));
+});
+
+// Cancel button on the runs page. POST from the page itself, so the Access
+// guard (when enabled) already authenticated the human clicking it.
+app.post("/r/:name/ci/cancel", async (c) => {
+  const name = c.req.param("name");
+  const repo = findRepoByArtifactsName(c.env, name);
+  if (!repo) return c.json({ error: "unknown repo" }, 404);
+  const stub = ciStubFor(c.env, name);
+  const resp = await stub.fetch("https://ci-do/cancel", { method: "POST" });
+  return c.json((await resp.json()) as object, resp.status === 202 ? 202 : 200);
+});
+
 // ---- Control plane (CLI → Worker), authed by CONTROL_SECRET, not Access ----
 // Mirrors how /webhooks/github sits outside Access with its own auth.
 
@@ -322,6 +379,52 @@ app.get("/control/deployments", async (c) => {
   return c.json(resp.ok ? ((await resp.json()) as object) : { deploys: [] });
 });
 
+// ---- CI control plane (v0.3) — same CONTROL_SECRET bearer as deploys ------
+
+app.post("/control/ci/run", async (c) => {
+  if (!controlAuthorized(c)) return c.json({ error: "unauthorized" }, 401);
+  const { repo } = (await c.req.json().catch(() => ({}))) as { repo?: string };
+  const entry = repo ? findRepoByArtifactsName(c.env, repo) : undefined;
+  if (!entry) return c.json({ error: "unknown repo" }, 404);
+  const stub = ciStubFor(c.env, entry.name);
+  // CiDO answers 202 immediately and runs the pipeline detached.
+  c.executionCtx.waitUntil(
+    stub.fetch("https://ci-do/run", {
+      method: "POST",
+      body: JSON.stringify({
+        artifactsRepoName: entry.name,
+        remote: entry.remote,
+        githubFullName: entry.githubFullName,
+        ref: "",
+        sha: "",
+        mode: "manual",
+        statusTargetUrl: `${new URL(c.req.url).origin}/r/${entry.name}/ci`,
+      }),
+    }),
+  );
+  return c.json({ accepted: true }, 202);
+});
+
+app.get("/control/ci/runs", async (c) => {
+  if (!controlAuthorized(c)) return c.json({ error: "unauthorized" }, 401);
+  const repo = c.req.query("repo");
+  const entry = repo ? findRepoByArtifactsName(c.env, repo) : undefined;
+  if (!entry) return c.json({ error: "unknown repo" }, 404);
+  const stub = ciStubFor(c.env, entry.name);
+  const resp = await stub.fetch("https://ci-do/state");
+  return c.json(resp.ok ? ((await resp.json()) as object) : { runs: [] });
+});
+
+app.post("/control/ci/cancel", async (c) => {
+  if (!controlAuthorized(c)) return c.json({ error: "unauthorized" }, 401);
+  const { repo } = (await c.req.json().catch(() => ({}))) as { repo?: string };
+  const entry = repo ? findRepoByArtifactsName(c.env, repo) : undefined;
+  if (!entry) return c.json({ error: "unknown repo" }, 404);
+  const stub = ciStubFor(c.env, entry.name);
+  const resp = await stub.fetch("https://ci-do/cancel", { method: "POST" });
+  return c.json((await resp.json()) as object, resp.status === 202 ? 202 : 200);
+});
+
 app.post("/webhooks/github", async (c) => {
   const signature = c.req.header("x-hub-signature-256");
   const event = c.req.header("x-github-event");
@@ -377,22 +480,44 @@ app.post("/webhooks/github", async (c) => {
     });
     const json = await resp.json();
 
-    // CD (v0.2): once the sync landed, kick off a deploy. DeployDO no-ops if
-    // there's no .gitflare/deploy.yml or CD isn't enabled. Runs after the
-    // response so the webhook returns fast.
+    // Once the sync landed, exactly ONE pipeline owns the push (a single
+    // decision point — no races between two DOs deciding independently):
+    //  - CI enabled (v0.3): CiDO. It runs .gitflare/ci.yml when present and
+    //    passes plain v0.2 pushes through to DeployDO itself when it isn't.
+    //  - otherwise (v0.2): DeployDO, which no-ops without a deploy.yml and
+    //    fails closed if a ci.yml is committed but CI was never enabled.
+    // Runs after the response so the webhook returns fast.
     if (resp.ok) {
-      const deploy = deployStubFor(c.env, entry.name);
-      c.executionCtx.waitUntil(
-        deploy.fetch("https://deploy-do/deploy", {
-          method: "POST",
-          body: JSON.stringify({
-            artifactsRepoName: entry.name,
-            remote: entry.remote,
-            ref: payload.ref,
-            sha: payload.after,
+      if (c.env.CI_ENABLED === "1") {
+        const ci = ciStubFor(c.env, entry.name);
+        c.executionCtx.waitUntil(
+          ci.fetch("https://ci-do/run", {
+            method: "POST",
+            body: JSON.stringify({
+              artifactsRepoName: entry.name,
+              remote: entry.remote,
+              githubFullName: payload.repository.full_name,
+              ref: payload.ref,
+              sha: payload.after,
+              mode: "push",
+              statusTargetUrl: `${new URL(c.req.url).origin}/r/${entry.name}/ci`,
+            }),
           }),
-        }),
-      );
+        );
+      } else {
+        const deploy = deployStubFor(c.env, entry.name);
+        c.executionCtx.waitUntil(
+          deploy.fetch("https://deploy-do/deploy", {
+            method: "POST",
+            body: JSON.stringify({
+              artifactsRepoName: entry.name,
+              remote: entry.remote,
+              ref: payload.ref,
+              sha: payload.after,
+            }),
+          }),
+        );
+      }
     }
     return c.json({ accepted: true, result: json }, resp.ok ? 202 : 500);
   }
diff --git a/packages/worker/src/ui/deployments.tsx b/packages/worker/src/ui/deployments.tsx
index 3a98534..fdc8df9 100644
--- a/packages/worker/src/ui/deployments.tsx
+++ b/packages/worker/src/ui/deployments.tsx
@@ -72,7 +72,8 @@ export const Deployments: FC = (p) => (
 
       

Deployments

- {p.githubFullName} · browse code + {p.githubFullName} · browse code ·{" "} + CI runs

{!p.cdEnabled ? ( diff --git a/packages/worker/src/ui/runs.tsx b/packages/worker/src/ui/runs.tsx new file mode 100644 index 0000000..bcc7908 --- /dev/null +++ b/packages/worker/src/ui/runs.tsx @@ -0,0 +1,197 @@ +import type { FC } from "hono/jsx"; +import { Layout } from "./layout"; +import { LOGO_PNG_DATA_URL } from "./logo-data"; +import type { CiRunRecord } from "../durable-objects/ci"; + +interface Props { + githubFullName: string; + artifactsRepoName: string; + runs: CiRunRecord[]; + ciEnabled: boolean; + version: string; +} + +const PILL: Record = { + success: "ok", + failed: "err", + running: "warn", + skipped: "warn", +}; + +const JOB_GLYPH: Record = { + success: "✓", + failed: "✗", + skipped: "○", + running: "●", + pending: "·", +}; + +function rel(ts: number): string { + const s = Math.max(0, Math.round((Date.now() - ts) / 1000)); + if (s < 60) return `${s}s ago`; + if (s < 3600) return `${Math.round(s / 60)}m ago`; + if (s < 86400) return `${Math.round(s / 3600)}h ago`; + return `${Math.round(s / 86400)}d ago`; +} + +function dur(r: CiRunRecord): string { + if (!r.finishedAt) return "…"; + const s = Math.round((r.finishedAt - r.startedAt) / 1000); + if (s < 90) return `${s}s`; + return `${Math.round(s / 60)}m`; +} + +// Client-side: stream live CI logs over a WebSocket, offer cancel, and reload +// when the run finishes so the history table refreshes. +function streamScript(name: string): string { + return ` +(function () { + var box = document.getElementById('live-logs'); + if (!box || !('WebSocket' in window)) return; + var proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + var ws = new WebSocket(proto + '//' + location.host + '/r/${name}/ci/stream'); + function show() { document.getElementById('live-wrap').style.display = 'block'; } + function append(line) { + box.textContent += line + '\\n'; + box.scrollTop = box.scrollHeight; + show(); + } + ws.onmessage = function (ev) { + try { + var m = JSON.parse(ev.data); + if (m.type === 'snapshot' || m.type === 'start') { + if (m.record && m.record.logs) { box.textContent = m.record.logs.join('\\n') + '\\n'; show(); } + } else if (m.type === 'log') { + append(m.line); + } else if (m.type === 'done') { + append('— run finished: ' + (m.record ? m.record.status : '') + ' —'); + setTimeout(function () { location.reload(); }, 1500); + } + } catch (e) {} + }; + var btn = document.getElementById('cancel-run'); + if (btn) btn.addEventListener('click', function () { + btn.disabled = true; + fetch('/r/${name}/ci/cancel', { method: 'POST' }).catch(function () {}); + }); +})(); +`; +} + +export const Runs: FC = (p) => ( + +
+
+ +
v{p.version}
+
+ +

CI runs

+

+ {p.githubFullName} · browse code ·{" "} + deployments +

+ + {!p.ciEnabled ? ( +
+
CI isn't enabled for this repo.
+
gitflare ci enable
+
+ Then commit a .gitflare/ci.yml. On push, GitFlare runs your jobs in a + Cloudflare Sandbox on your own account — even when GitHub Actions is down. +
+
+ ) : ( + <> + + + {p.runs.length === 0 ? ( +
+ No runs yet. Push to a branch matched by .gitflare/ci.yml, or run{" "} + gitflare ci run. +
+ ) : ( +
+ + + + + + + + + + + + + + + {p.runs.map((r) => ( + + + + + + + + + + + ))} + +
#BranchCommitModeJobsStatusTookWhen
{r.id}{r.branch}{r.sha.slice(0, 8)}{r.mode} + {r.jobs.length === 0 + ? "—" + : r.jobs.map((j) => ( + + {j.name} {JOB_GLYPH[j.status] ?? "?"} + + ))} + + {r.status} + {r.message ? ( + {r.message} + ) : null} + {dur(r)}{rel(r.startedAt)}
+
+ )} + + {p.runs[0] && p.runs[0].logs.length > 0 ? ( + <> +

Latest log (#{p.runs[0].id})

+
+                {p.runs[0].logs.join("\n")}
+              
+ + ) : null} + +