diff --git a/PLAN.md b/PLAN.md index bf8966d..3b3047c 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 | ✅ core live-validated (2026-07-17, `sinameraji/kimiflare`) | **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-1`); 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. **Post-review hardening** (a 4-lens adversarial review of the diff, 18 findings, all verifier-confirmed): FIXED — watchdog/pipeline verdict races (a `finalizedRunIds` fence stops a lagging coroutine resurrecting a run the watchdog failed, and the watchdog no longer erases a concurrent cancel); a fallback alarm armed in `begin()` so eviction before the deadline alarm can't leave a zombie "running" record; `ARTIFACTS.get` moved inside the try (+ a last-resort commit status) so a push can't vanish traceless; tag/non-branch pushes skipped at the webhook (were minting failed runs + red statuses); artifact handover gated on the whole `needs` closure of run jobs succeeding (was shipping a failed build's output); the dead "fall back to deploy.yml" path on missing SANDBOX replaced with an honest fail-closed error; delegated-deploy response mapping extracted to a tested pure fn (`ci/delegation.ts`, all four shapes incl. skipped-is-not-green); the remote-URL allowlist tightened to reject shell metacharacters; duplicate webhook deliveries de-duped by branch+sha; dashboard cancel gated behind Access (was open on public mirrors — CLI `gitflare ci cancel` uses CONTROL_SECRET); CONTROL_SECRET now always (re)written on enable and config persisted the moment the Worker goes live (was strandable across machines / on a partial enable). RESIDUAL (documented, not yet fixed — acceptable for a not-live-validated milestone): a run still queued in-memory when the DO is evicted (e.g. by a redeploy) is dropped with no record; webhook delivery order isn't guaranteed by GitHub, so the supersede optimization is best-effort (out-of-order delivery can run the older sha last); cancel is a no-op during a run's brief pre-clone window and cannot un-ship a deploy job that already returned success (the run message says so). **Live-validated end-to-end** (2026-07-17, `sinameraji/kimiflare` on a real Workers Paid account): `gitflare ci enable` provisions the `[[containers]]` app from wrangler TOML; a push → webhook → sync → CI run #1 succeeded in 12s — the Sandbox booted (`node v22`), cloned the mirror at the pushed sha with a short-lived read token (URL logged WITHOUT credentials — env-scoped auth + scrubbing confirmed), and ran all 4 `run:` steps with streamed line-buffered logs, resolved env vars, and captured exit codes. Findings fixed along the way (all on this branch): (1) container deploy needs BOTH **Account → Cloudchamber:Edit** and **Account → Containers:Edit** token permissions (Containers alone → `Forbidden`); (2) the container app name can't contain consecutive dashes, so we emit an explicit sanitized `name` (the `owner--repo` worker name has `--`); (3) a latent M1 sync bug — `artifactsRepo.remote` on the current Artifacts beta is a lazy RPC proxy that stringifies to `[object JsRpcProperty]`; now threads the REPO_MAP remote string (would have broken any live v0.2 deploy too); (4) the push webhook awaited the full sync and blew past GitHub's 10s delivery timeout on a real repo (504 + redeliveries) — now responds 202 immediately, sync + dispatch in `waitUntil`; (5) commit-status postback soft-fails observably (the run logs *why* — here, an expired GITHUB_TOKEN). Config also cross-checked via `wrangler deploy --dry-run` (both CI-provisioned and default TOML resolve all four DO exports incl. unbound-Sandbox migration v4). **Not yet exercised live** (deferred soak): the deploy-job → DeployDO delegation, the alarm watchdog firing, cancel mid-run, hour-scale runs / `sleepAfter` container-activity behavior, and commit statuses actually rendering (needs a fresh GITHUB_TOKEN). |
### What's in the repo right now (as of M0)
diff --git a/README.md b/README.md
index b755287..08f9d7b 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 live-validated) | **Generic CI.** `.gitflare/ci.yml` with jobs / `needs:` / `run:` steps, executed on Cloudflare Sandboxes (full Linux containers on your account) — validated end-to-end: push → sandbox boots → clones → runs your steps with live logs. Deploy jobs gate on test jobs, and a Worker deploy ships **what CI just built** (not the stale committed file). 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..59c8a67
--- /dev/null
+++ b/packages/cli/src/commands/ci.ts
@@ -0,0 +1,346 @@
+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";
+
+// Cloudflare Containers instance types. "standard" was renamed to the numbered
+// "standard-N" tiers; we default to standard-1 (real test suites OOM on basic).
+const INSTANCE_TYPES = ["dev", "basic", "standard-1", "standard-2", "standard-3", "standard-4"];
+const DEFAULT_INSTANCE_TYPE = "standard-1";
+
+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 ?? DEFAULT_INSTANCE_TYPE;
+ 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);
+
+ // 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");
+ let redeployed = false;
+ try {
+ const res = await redeployWorker(entry, cfToken, remote);
+ redeployed = true;
+ // The Worker is now live with CI_ENABLED + the containers block — persist
+ // that BEFORE the secret step so a secret failure can't leave the Worker
+ // enabled while local config still says disabled (which would make
+ // `gitflare ci disable` refuse to turn it back off).
+ cfg.cloudflare = { token: cfToken };
+ await saveConfig(cfg);
+ sp.message("Setting control secret");
+ // Always (re)write CONTROL_SECRET — idempotent, and it re-establishes the
+ // secret if the Worker was recreated (dashboard delete + re-provision).
+ await wranglerSecret(res.workDir, cfToken, "CONTROL_SECRET", controlSecret);
+ sp.stop("Worker redeployed with CI enabled");
+ } catch (e) {
+ const msg = (e as Error).message;
+ if (redeployed) {
+ // Redeploy succeeded; only the secret step failed. Config is already
+ // saved (CI is genuinely live), so tell the user how to finish.
+ sp.stop("CI is live, but setting the control secret failed");
+ p.log.warn(
+ "`gitflare ci run/list/cancel` will 401 until the control secret is set. " +
+ "Re-run `gitflare ci enable` to retry (your config is already saved).",
+ );
+ p.log.error(msg);
+ return;
+ }
+ sp.stop("Redeploy failed");
+ 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 container permissions. Deploying a`,
+ ` container needs BOTH ${kleur.cyan("Account → Cloudchamber → Edit")} and`,
+ ` ${kleur.cyan("Account → Containers → Edit")} (the worker script uploads without`,
+ ` them, but the container rollout returns Forbidden). Edit the token`,
+ ` in place at ${kleur.gray(TOKEN_URL)} — the secret stays the same — then`,
+ " re-run `gitflare ci enable`.",
+ "",
+ msg,
+ ].join("\n"),
+ );
+ } else {
+ p.log.error(msg);
+ }
+ // Redeploy never happened; the in-memory entry.ci is discarded (not saved).
+ return;
+ }
+
+ 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..2a9f997 100644
--- a/packages/cli/src/commands/deploy.ts
+++ b/packages/cli/src/commands/deploy.ts
@@ -83,25 +83,35 @@ 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..48c55d1 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-1..4 (default standard-1)")
+ .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..ff393a0 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,42 @@ 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.
+ // The container application name must be a valid Cloudflare namespace label:
+ // no consecutive dashes. wrangler otherwise derives it as `${workerName}-sandbox`,
+ // and workerName carries `--` from the `owner--repo` Artifacts convention, so
+ // we set an explicit collapsed name (stable across redeploys — it identifies
+ // the container application; don't change it once created).
+ const containerName = `${p.workerName}-sandbox`.replace(/-+/g, "-");
+ const sandbox = p.ci?.provisioned
+ ? `[[durable_objects.bindings]]
+name = "SANDBOX"
+class_name = "Sandbox"
+
+[[containers]]
+name = "${containerName}"
+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 +149,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 +161,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..68c19c6 100644
--- a/packages/worker/package.json
+++ b/packages/worker/package.json
@@ -11,9 +11,10 @@
"test": "vitest run"
},
"dependencies": {
+ "@cloudflare/sandbox": "0.12.3",
"@gitflare/shared": "workspace:*",
"highlight.js": "^11.11.1",
- "hono": "^4.6.0",
+ "hono": "^4.12.26",
"isomorphic-git": "^1.27.1",
"marked": "^14.1.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/delegation.ts b/packages/worker/src/ci/delegation.ts
new file mode 100644
index 0000000..db86a05
--- /dev/null
+++ b/packages/worker/src/ci/delegation.ts
@@ -0,0 +1,45 @@
+// Interprets the DeployDO response a CI deploy job receives when it delegates
+// to the deploy pipeline. Extracted as a pure function so the four response
+// shapes are exhaustively unit-testable — the important one being that a
+// "skipped" deploy (e.g. CD not enabled) must NOT read as a green job.
+
+import type { DeployRecord } from "../durable-objects/deploy";
+
+export interface DeployJobOutcome {
+ ok: boolean;
+ steps: Array<{ label: string; ok: boolean; detail?: string }>;
+ message?: string;
+}
+
+/**
+ * @param httpOk resp.ok
+ * @param httpStatus resp.status
+ * @param body parsed JSON: a DeployRecord on 200, else `{ error? }`
+ */
+export function interpretDeployResponse(
+ httpOk: boolean,
+ httpStatus: number,
+ body: unknown,
+): DeployJobOutcome {
+ if (!httpOk) {
+ const err = (body as { error?: string } | null)?.error;
+ return { ok: false, steps: [], message: `deploy failed: ${err ?? httpStatus}` };
+ }
+ const record = body as DeployRecord;
+ const steps = (record.steps ?? []).map((s) => ({
+ label: `${s.kind}: ${s.project}`,
+ ok: s.ok,
+ ...(s.detail ? { detail: s.detail } : {}),
+ }));
+ 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}` : ""}`,
+ };
+}
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..7c56b59
--- /dev/null
+++ b/packages/worker/src/ci/sandbox-runner.ts
@@ -0,0 +1,303 @@
+// 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)}"`;
+ }
+ // The remote is interpolated (unquoted) into a shell `git clone` command, so
+ // the allowlist is deliberately tight — an Artifacts mirror URL only ever
+ // needs alphanumerics, `.-_~%`, `/`, and `:`. Shell-active characters
+ // ($ ( ) ; & ' " ` \ space | < > etc.) are NOT in the set, so no crafted
+ // REPO_MAP remote can smuggle command substitution through this boundary.
+ if (!/^https:\/\/[A-Za-z0-9._~%:/-]+$/.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..4e0166f
--- /dev/null
+++ b/packages/worker/src/ci/workflow.ts
@@ -0,0 +1,252 @@
+// 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");
+}
+
+/**
+ * Run-job names in a job's transitive `needs` closure. These are the jobs that
+ * may have written build artifacts into the shared workspace; a deploy job may
+ * only ship those artifacts if all of these succeeded (an empty result means
+ * the deploy job built nothing and must deploy the committed file).
+ */
+export function runJobsInNeedsClosure(wf: CiWorkflow, jobName: string): string[] {
+ const byName = new Map(wf.jobs.map((j) => [j.name, j]));
+ const start = byName.get(jobName);
+ if (!start) return [];
+ const seen = new Set();
+ const out: string[] = [];
+ const stack = [...start.needs];
+ while (stack.length > 0) {
+ const name = stack.pop()!;
+ if (seen.has(name)) continue;
+ seen.add(name);
+ const dep = byName.get(name);
+ if (!dep) continue;
+ if (dep.kind === "run") out.push(name);
+ stack.push(...dep.needs);
+ }
+ return out;
+}
+
+/** 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..79782aa
--- /dev/null
+++ b/packages/worker/src/durable-objects/ci.ts
@@ -0,0 +1,707 @@
+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,
+ runJobsInNeedsClosure,
+ 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 { interpretDeployResponse } from "../ci/delegation";
+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;
+ // Runs the watchdog (or a cancel) has already written a terminal state for.
+ // A still-executing pipeline coroutine must not resurrect these back to
+ // "running" or post a contradictory second GitHub status.
+ private finalizedRunIds = new Set();
+ // 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") {
+ // Cancel applies to a run that has begun (has jobs + a sandbox). A run
+ // still in its pre-clone window (this.current not yet set) can't be
+ // cancelled yet — the clone is seconds and the CLI can retry.
+ 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";
+ }
+ // Write the terminal state BEFORE marking it finalized (record() no-ops
+ // for finalized ids), then fence off the live pipeline so it can't
+ // resurrect this run or post a contradictory status.
+ await this.record(run);
+ this.finalizedRunIds.add(run.id);
+ 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;
+ }
+ }
+
+ // -- 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 {
+ // Once the watchdog/cancel finalized a run, never write over its terminal
+ // record (a lagging pipeline coroutine would otherwise flip it back to
+ // "running" and then "success").
+ if (this.finalizedRunIds.has(r.id)) return;
+ 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);
+ // Arm a short fallback alarm if none is set, so an eviction between here
+ // and runPipeline's real deadline alarm still gets this run failed by the
+ // watchdog instead of leaving a zombie "running" record forever.
+ if ((await this.state.storage.getAlarm()) === null) {
+ await this.state.storage.setAlarm(Date.now() + 60_000);
+ }
+ 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 {
+ // The watchdog already wrote a terminal state — don't overwrite it or emit
+ // a second, contradictory "done"/commit status.
+ if (this.finalizedRunIds.has(rec.id)) {
+ this.finalizedRunIds.delete(rec.id);
+ if (this.current === rec) this.current = null;
+ return rec;
+ }
+ 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.
+ // But surface WHY a status didn't post (e.g. an expired GITHUB_TOKEN) in
+ // the run log instead of vanishing silently.
+ if (this.finalizedRunIds.has(rec.id)) return;
+ if (!rec.githubFullName || !/^[0-9a-f]{40}$/.test(rec.sha)) return;
+ const res = await postCommitStatus({
+ githubFullName: rec.githubFullName,
+ sha: rec.sha,
+ state,
+ description,
+ token: this.env.GITHUB_TOKEN,
+ ...(rec.statusTargetUrl ? { targetUrl: rec.statusTargetUrl } : {}),
+ }).catch((e) => ({ ok: false, status: 0, detail: (e as Error).message }));
+ if (!res.ok) {
+ this.log(`github commit status (${state}) not posted: ${res.status} ${res.detail ?? ""} — check GITHUB_TOKEN`.trimEnd());
+ }
+ }
+
+ 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.
+ const msg = (e as Error).message;
+ if (this.current) {
+ await this.finish(this.current, "failed", msg).catch(() => undefined);
+ } else {
+ // Failed before a run record existed — leave a trace instead of a
+ // silently-vanished push: a red commit status (if we know the sha).
+ console.error(`CI run failed before begin(): ${msg}`);
+ if (req.githubFullName && /^[0-9a-f]{40}$/.test(req.sha)) {
+ await postCommitStatus({
+ githubFullName: req.githubFullName,
+ sha: req.sha,
+ state: "error",
+ description: `CI failed to start: ${msg}`,
+ token: this.env.GITHUB_TOKEN,
+ ...(req.statusTargetUrl ? { targetUrl: req.statusTargetUrl } : {}),
+ }).catch(() => undefined);
+ }
+ }
+ }
+ }
+
+ private async executeRunInner(req: CiRunRequest): Promise {
+ const mode = req.mode ?? "push";
+
+ if (mode === "push" && req.ref && /^[0-9a-f]{40}$/.test(req.sha)) {
+ const branch = branchOf(req.ref);
+ // A queued push superseded by a newer push to the same branch is skipped
+ // before it costs a clone or a sandbox.
+ const latest = this.latestPushSha.get(branch);
+ 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;
+ }
+ // Duplicate webhook delivery (GitHub auto-retry / manual redelivery):
+ // runs are serialized, so an earlier delivery for this exact branch+sha
+ // has already completed — don't run the whole pipeline (incl. deploys)
+ // again. A genuine new push carries a new sha and passes this check.
+ const done = (await this.history()).some(
+ (r) =>
+ r.mode === "push" &&
+ r.sha === req.sha &&
+ r.branch === branch &&
+ (r.status === "success" || r.status === "failed"),
+ );
+ if (done) {
+ const rec = await this.begin(req, req.ref, req.sha, mode, []);
+ await this.finish(rec, "skipped", `duplicate delivery for ${req.sha.slice(0, 8)} — already ran`);
+ return;
+ }
+ }
+
+ // Clone the mirror to learn/verify what we're running. ARTIFACTS.get is
+ // inside the try so a transient namespace error still mints a failed
+ // record (never a vanished push with no trace).
+ let shallow: ShallowRepo;
+ let ref = req.ref;
+ let sha = req.sha;
+ try {
+ const handle = await this.env.ARTIFACTS.get(req.artifactsRepoName);
+ 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): CI is enabled and a
+ // ci.yml is committed, but the Sandbox container isn't provisioned. Fail
+ // closed and loudly — we do NOT silently fall back to deploy.yml, since
+ // the ci.yml may gate deploys behind tests we can't run.
+ const rec = await this.begin(req, ref, sha, mode, []);
+ await this.finish(
+ rec,
+ "failed",
+ "SANDBOX container missing — rerun `gitflare ci enable` (its `[[containers]]` config is absent)",
+ );
+ await this.postStatus(rec, "error", "CI sandbox missing — rerun gitflare ci enable");
+ return;
+ }
+
+ await this.runPipeline(req, wf, ref, sha, mode);
+ }
+
+ /** True iff every run job in this job's transitive `needs` closure succeeded. */
+ private neededRunJobsSucceeded(
+ wf: CiWorkflow,
+ job: CiJob,
+ statusOf: (name: string) => CiJobRecord["status"],
+ ): boolean {
+ const runJobs = runJobsInNeedsClosure(wf, job.name);
+ // No run job in the closure → nothing was built for us to ship.
+ return runJobs.length > 0 && runJobs.every((n) => statusOf(n) === "success");
+ }
+
+ 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";
+
+ let shippedDeploy = false;
+ try {
+ for (const job of wf.jobs) {
+ // The watchdog finalized this run out from under us — stop working.
+ if (this.finalizedRunIds.has(rec.id)) break;
+ 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)`);
+
+ // Only hand a deploy job the sandbox-built artifact when every run job
+ // it transitively `needs` actually succeeded — otherwise the workspace
+ // holds output from a job that FAILED after writing files, and shipping
+ // it would deploy a broken build. A deploy job with no run-job in its
+ // needs closure deploys the committed file (sandboxUsable = false).
+ const artifactsUsable =
+ sandboxCreated && cloneOk() && this.neededRunJobsSucceeded(wf, job, statusOf);
+ const outcome =
+ job.kind === "deploy"
+ ? await this.runDeployJob(req, job, rec, artifactsUsable)
+ : 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 (job.kind === "deploy" && outcome.ok) shippedDeploy = true;
+ 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) {
+ // A delegated deploy that already returned success has shipped — cancel
+ // can't unship it (it ran in a separate DO). Be honest about that.
+ const note = shippedDeploy
+ ? "cancelled — note: a deploy job had already shipped; roll back from the deployments page if needed"
+ : "cancelled";
+ await this.finish(rec, "failed", note);
+ await this.postStatus(rec, "failure", note);
+ 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}` };
+ }
+
+ const body = await resp.json().catch(() => ({}));
+ const outcome = interpretDeployResponse(resp.ok, resp.status, body);
+ if (resp.ok) {
+ const record = body as DeployRecord;
+ this.log(`[${job.name}] deploy #${record.id} ${record.status} — see the deployments page for logs`);
+ }
+ return outcome;
+ }
+}
+
+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/durable-objects/repo.ts b/packages/worker/src/durable-objects/repo.ts
index cd6420c..f81c8c8 100644
--- a/packages/worker/src/durable-objects/repo.ts
+++ b/packages/worker/src/durable-objects/repo.ts
@@ -10,6 +10,7 @@ interface RefState {
interface SyncRequest {
githubFullName: string;
artifactsRepoName: string;
+ remote: string;
ref: string;
beforeSha: string;
afterSha: string;
@@ -65,6 +66,7 @@ export class RepoDO {
githubToken: this.env.GITHUB_TOKEN,
ref: req.ref,
artifactsRepo,
+ remote: req.remote,
beforeSha: req.beforeSha,
afterSha: req.afterSha,
});
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..0ae4d30 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,67 @@ 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. This is a state-changing action, so it must
+// only be reachable by an authenticated principal. The Access guard on /r/*
+// authenticates the human ONLY when Access is enabled; on a public mirror it
+// no-ops, which would let anyone kill in-flight runs. So we refuse the
+// page-initiated cancel when Access is off and point users at the
+// CONTROL_SECRET-gated CLI (`gitflare ci cancel`) instead.
+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);
+ if (!c.env.ACCESS_AUD) {
+ return c.json(
+ { error: "cancel from the dashboard requires Cloudflare Access; use `gitflare ci cancel`" },
+ 403,
+ );
+ }
+ 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 +390,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");
@@ -353,6 +467,13 @@ app.post("/webhooks/github", async (c) => {
return c.json({ accepted: true, skipped: "branch-delete" }, 202);
}
+ // Only branch pushes drive sync + CD/CI. Tag pushes (refs/tags/*) and other
+ // non-branch refs would otherwise mint failed CI runs and red commit
+ // statuses (branchOf() leaves them unmatched by any workflow branch list).
+ if (!payload.ref.startsWith("refs/heads/")) {
+ return c.json({ accepted: true, skipped: "non-branch-ref" }, 202);
+ }
+
const entry = lookupArtifactsRepoEntry(
c.env,
payload.repository.full_name,
@@ -364,37 +485,62 @@ app.post("/webhooks/github", async (c) => {
);
}
- const stub = repoStubFor(c.env, entry.name);
- const resp = await stub.fetch("https://repo-do/sync", {
- method: "POST",
- body: JSON.stringify({
- githubFullName: payload.repository.full_name,
- artifactsRepoName: entry.name,
- ref: payload.ref,
- beforeSha: payload.before,
- afterSha: payload.after,
- }),
- });
- 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.
- if (resp.ok) {
- const deploy = deployStubFor(c.env, entry.name);
- c.executionCtx.waitUntil(
- deploy.fetch("https://deploy-do/deploy", {
+ // Respond to GitHub immediately and do the (potentially multi-second) sync
+ // + dispatch in the background. GitHub times out webhook deliveries at 10s;
+ // an in-worker isomorphic-git sync of a real repo can exceed that, so
+ // awaiting it inline makes every push show as a failed delivery (and
+ // triggers redeliveries). Fire-and-forget instead — sync errors surface in
+ // the RepoDO/dashboard, not GitHub's delivery UI.
+ const origin = new URL(c.req.url).origin;
+ c.executionCtx.waitUntil(
+ (async () => {
+ const stub = repoStubFor(c.env, entry.name);
+ const resp = await stub.fetch("https://repo-do/sync", {
method: "POST",
body: JSON.stringify({
+ githubFullName: payload.repository.full_name,
artifactsRepoName: entry.name,
remote: entry.remote,
ref: payload.ref,
- sha: payload.after,
+ beforeSha: payload.before,
+ afterSha: payload.after,
}),
- }),
- );
- }
- return c.json({ accepted: true, result: json }, resp.ok ? 202 : 500);
+ });
+ if (!resp.ok) return;
+
+ // 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.
+ if (c.env.CI_ENABLED === "1") {
+ await ciStubFor(c.env, entry.name).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: `${origin}/r/${entry.name}/ci`,
+ }),
+ });
+ } else {
+ await deployStubFor(c.env, entry.name).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 }, 202);
}
return c.json({ accepted: true, skipped: event }, 202);
diff --git a/packages/worker/src/sync/git-sync.ts b/packages/worker/src/sync/git-sync.ts
index 8c59ac5..1ccfcb6 100644
--- a/packages/worker/src/sync/git-sync.ts
+++ b/packages/worker/src/sync/git-sync.ts
@@ -11,7 +11,8 @@ export interface SyncParams {
githubFullName: string; // "owner/repo"
githubToken: string; // for private repos + rate limit
ref: string; // "refs/heads/main"
- artifactsRepo: ArtifactsRepo; // the destination handle
+ artifactsRepo: ArtifactsRepo; // the destination handle (for createToken)
+ remote: string; // the Artifacts clone URL, from REPO_MAP
beforeSha: string; // SHA before the push (00...0 if new branch)
afterSha: string; // SHA after the push
}
@@ -83,7 +84,12 @@ export async function syncGithubToArtifacts(
fs,
http,
dir,
- url: params.artifactsRepo.remote,
+ // Use the REPO_MAP remote string, NOT artifactsRepo.remote: on the live
+ // Artifacts beta, property access on the binding returns a lazy RPC proxy
+ // (JsRpcProperty) that stringifies to "[object JsRpcProperty]" — only RPC
+ // *methods* like createToken() resolve. The REPO_MAP string is what every
+ // browse path already uses successfully.
+ url: params.remote,
ref: params.ref,
remoteRef: params.ref,
force: false,
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..5099139
--- /dev/null
+++ b/packages/worker/src/ui/runs.tsx
@@ -0,0 +1,202 @@
+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;
+ // Cancel from the dashboard is only offered when Cloudflare Access gates the
+ // page (otherwise anyone could kill runs on a public mirror — use the CLI).
+ canCancel: 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) => (
+
+
+
+
+
+
+ GitFlare
+
+
+ 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[0] && p.runs[0].logs.length > 0 ? (
+ <>
+ Latest log (#{p.runs[0].id})
+
+ {p.runs[0].logs.join("\n")}
+
+ >
+ ) : null}
+
+
+ >
+ )}
+
+
+);
diff --git a/packages/worker/test/ci-workflow.test.ts b/packages/worker/test/ci-workflow.test.ts
new file mode 100644
index 0000000..d9f9360
--- /dev/null
+++ b/packages/worker/test/ci-workflow.test.ts
@@ -0,0 +1,300 @@
+import { describe, it, expect } from "vitest";
+import {
+ parseCiWorkflow,
+ ciMatchesPush,
+ deployStepsOf,
+ deployJobsOf,
+ runJobsInNeedsClosure,
+} from "../src/ci/workflow";
+
+const CANONICAL = `
+on: push
+branches: [main]
+jobs:
+ test:
+ steps:
+ - run: npm ci
+ - run: npm test
+ deploy:
+ needs: [test]
+ steps:
+ - cloudflare/deploy:
+ project: my-worker
+ kind: worker
+ entry: dist/worker.js
+`;
+
+describe("parseCiWorkflow", () => {
+ it("parses the canonical example", () => {
+ const { workflow, error } = parseCiWorkflow(CANONICAL);
+ expect(error).toBeUndefined();
+ expect(workflow!.on).toEqual(["push"]);
+ expect(workflow!.branches).toEqual(["main"]);
+ expect(workflow!.jobs.map((j) => j.name)).toEqual(["test", "deploy"]);
+ const test = workflow!.jobs[0]!;
+ expect(test.kind).toBe("run");
+ expect(test.steps).toEqual([
+ { type: "run", command: "npm ci" },
+ { type: "run", command: "npm test" },
+ ]);
+ const deploy = workflow!.jobs[1]!;
+ expect(deploy.kind).toBe("deploy");
+ expect(deploy.needs).toEqual(["test"]);
+ expect(deploy.steps[0]).toMatchObject({
+ type: "cloudflare/deploy",
+ step: { project: "my-worker", kind: "worker", entry: "dist/worker.js" },
+ });
+ });
+
+ it("topologically orders jobs (declaration order breaks ties)", () => {
+ const { workflow } = parseCiWorkflow(`
+on: push
+jobs:
+ deploy:
+ needs: [b, a]
+ steps:
+ - cloudflare/deploy: { project: p, entry: e }
+ a:
+ steps:
+ - run: echo a
+ b:
+ needs: [a]
+ steps:
+ - run: echo b
+`);
+ expect(workflow!.jobs.map((j) => j.name)).toEqual(["a", "b", "deploy"]);
+ });
+
+ it("rejects unknown needs", () => {
+ const { error } = parseCiWorkflow(`
+on: push
+jobs:
+ test:
+ needs: [nope]
+ steps:
+ - run: npm test
+`);
+ expect(error).toMatch(/unknown job "nope"/);
+ });
+
+ it("rejects dependency cycles", () => {
+ const { error } = parseCiWorkflow(`
+on: push
+jobs:
+ a:
+ needs: [b]
+ steps:
+ - run: echo a
+ b:
+ needs: [a]
+ steps:
+ - run: echo b
+`);
+ expect(error).toMatch(/cycle/);
+ });
+
+ it("rejects a self-need", () => {
+ const { error } = parseCiWorkflow(`
+on: push
+jobs:
+ a:
+ needs: [a]
+ steps:
+ - run: echo a
+`);
+ expect(error).toMatch(/needs itself/);
+ });
+
+ it("rejects mixing run and deploy steps in one job", () => {
+ const { error } = parseCiWorkflow(`
+on: push
+jobs:
+ all:
+ steps:
+ - run: npm run build
+ - cloudflare/deploy: { project: p, entry: e }
+`);
+ expect(error).toMatch(/mixes run and cloudflare\/deploy/);
+ });
+
+ it("rejects runtime: worker with an actionable message", () => {
+ const { error } = parseCiWorkflow(`
+on: push
+jobs:
+ test:
+ runtime: worker
+ steps:
+ - run: npm test
+`);
+ expect(error).toMatch(/runtime "worker".*isn't available/);
+ });
+
+ it("accepts runtime: sandbox explicitly", () => {
+ const { workflow, error } = parseCiWorkflow(`
+on: push
+jobs:
+ test:
+ runtime: sandbox
+ steps:
+ - run: npm test
+`);
+ expect(error).toBeUndefined();
+ expect(workflow!.jobs[0]!.kind).toBe("run");
+ });
+
+ it("rejects per-job images", () => {
+ const { error } = parseCiWorkflow(`
+on: push
+jobs:
+ e2e:
+ image: mcr.microsoft.com/playwright:latest
+ steps:
+ - run: npm run e2e
+`);
+ expect(error).toMatch(/per-job images aren't supported/);
+ });
+
+ it("parses env and timeout_minutes", () => {
+ const { workflow } = parseCiWorkflow(`
+on: push
+jobs:
+ test:
+ timeout_minutes: 30
+ env:
+ NODE_ENV: test
+ API_BASE: https://example.com
+ steps:
+ - run: npm test
+`);
+ const job = workflow!.jobs[0]!;
+ expect(job.timeoutMinutes).toBe(30);
+ expect(job.env).toEqual({ NODE_ENV: "test", API_BASE: "https://example.com" });
+ });
+
+ it("defaults timeout to 15m and rejects out-of-range values", () => {
+ const { workflow } = parseCiWorkflow(`
+on: push
+jobs:
+ a:
+ steps:
+ - run: x
+`);
+ expect(workflow!.jobs[0]!.timeoutMinutes).toBe(15);
+ const { error } = parseCiWorkflow(`
+on: push
+jobs:
+ a:
+ timeout_minutes: 90
+ steps:
+ - run: x
+`);
+ expect(error).toMatch(/timeout_minutes must be 1–60/);
+ });
+
+ it("parses multi-line run commands via block scalars", () => {
+ const { workflow, error } = parseCiWorkflow(`
+on: push
+jobs:
+ test:
+ steps:
+ - run: |
+ npm ci
+ npm test -- --reporter=dot
+`);
+ expect(error).toBeUndefined();
+ expect(workflow!.jobs[0]!.steps[0]).toEqual({
+ type: "run",
+ command: "npm ci\nnpm test -- --reporter=dot",
+ });
+ });
+
+ it("rejects unsupported step keys, empty jobs, and missing jobs", () => {
+ expect(parseCiWorkflow(`
+on: push
+jobs:
+ a:
+ steps:
+ - uses: actions/checkout@v4
+`).error).toMatch(/unsupported step "uses"/);
+ expect(parseCiWorkflow("on: push\njobs:\n a:\n").error).toMatch(/must be a mapping|no steps/);
+ expect(parseCiWorkflow("on: push\n").error).toMatch(/missing `jobs:`/);
+ expect(parseCiWorkflow("jobs:\n").error).toMatch(/missing `on:`/);
+ });
+
+ it("validates deploy steps with the shared parser (missing entry)", () => {
+ const { error } = parseCiWorkflow(`
+on: push
+jobs:
+ deploy:
+ steps:
+ - cloudflare/deploy:
+ project: my-worker
+`);
+ expect(error).toMatch(/missing `entry`/);
+ });
+});
+
+describe("ciMatchesPush", () => {
+ const wf = parseCiWorkflow(CANONICAL).workflow!;
+ it("matches configured branches only", () => {
+ expect(ciMatchesPush(wf, "refs/heads/main")).toBe(true);
+ expect(ciMatchesPush(wf, "refs/heads/feature")).toBe(false);
+ });
+ it("matches every branch when branches is empty", () => {
+ const open = parseCiWorkflow("on: push\njobs:\n a:\n steps:\n - run: x\n").workflow!;
+ expect(ciMatchesPush(open, "refs/heads/anything")).toBe(true);
+ });
+});
+
+describe("deployStepsOf", () => {
+ const wf = parseCiWorkflow(CANONICAL).workflow!;
+ it("extracts a named job's deploy steps", () => {
+ const { steps } = deployStepsOf(wf, "deploy");
+ expect(steps).toHaveLength(1);
+ expect(steps![0]!.project).toBe("my-worker");
+ });
+ it("extracts all deploy jobs when job is omitted", () => {
+ const { steps } = deployStepsOf(wf);
+ expect(steps).toHaveLength(1);
+ });
+ it("errors on unknown job and on run-only workflows", () => {
+ expect(deployStepsOf(wf, "nope").error).toMatch(/no job named "nope"/);
+ const runOnly = parseCiWorkflow("on: push\njobs:\n a:\n steps:\n - run: x\n").workflow!;
+ expect(deployStepsOf(runOnly).error).toMatch(/no cloudflare\/deploy steps/);
+ expect(deployJobsOf(runOnly)).toEqual([]);
+ });
+});
+
+describe("runJobsInNeedsClosure — gates artifact handover on the builder succeeding", () => {
+ const wf = parseCiWorkflow(`
+on: push
+jobs:
+ lint:
+ steps:
+ - run: npm run lint
+ build:
+ needs: [lint]
+ steps:
+ - run: npm run build
+ deploy:
+ needs: [build]
+ steps:
+ - cloudflare/deploy: { project: p, entry: dist/w.js }
+ deploy_nobuild:
+ steps:
+ - cloudflare/deploy: { project: p2, entry: committed.js }
+`).workflow!;
+
+ it("returns all transitively-needed run jobs", () => {
+ expect(new Set(runJobsInNeedsClosure(wf, "deploy"))).toEqual(new Set(["build", "lint"]));
+ });
+
+ it("returns empty for a deploy job with no needs (ships the committed file)", () => {
+ expect(runJobsInNeedsClosure(wf, "deploy_nobuild")).toEqual([]);
+ });
+
+ it("returns empty for a run job with no needs and for unknown jobs", () => {
+ expect(runJobsInNeedsClosure(wf, "lint")).toEqual([]);
+ expect(runJobsInNeedsClosure(wf, "ghost")).toEqual([]);
+ });
+});
diff --git a/packages/worker/test/delegation.test.ts b/packages/worker/test/delegation.test.ts
new file mode 100644
index 0000000..f3716d5
--- /dev/null
+++ b/packages/worker/test/delegation.test.ts
@@ -0,0 +1,55 @@
+import { describe, it, expect } from "vitest";
+import { interpretDeployResponse } from "../src/ci/delegation";
+
+const record = (over: Record = {}) => ({
+ id: 7,
+ ref: "refs/heads/main",
+ branch: "main",
+ sha: "a".repeat(40),
+ mode: "ci",
+ startedAt: 0,
+ status: "success",
+ steps: [{ project: "my-worker", kind: "worker", ok: true }],
+ logs: [],
+ ...over,
+});
+
+describe("interpretDeployResponse — the four shapes a CI deploy job sees", () => {
+ it("200 + status success → green job", () => {
+ const out = interpretDeployResponse(true, 200, record());
+ expect(out.ok).toBe(true);
+ expect(out.steps).toEqual([{ label: "worker: my-worker", ok: true }]);
+ expect(out.message).toBeUndefined();
+ });
+
+ it('200 + status "skipped" → RED (a needs-gated deploy must actually ship)', () => {
+ const out = interpretDeployResponse(true, 200, record({ status: "skipped", message: "CD not enabled — run `gitflare deploy enable`" }));
+ expect(out.ok).toBe(false);
+ expect(out.message).toMatch(/deploy skipped: .*CD not enabled/);
+ // Steps still surface for context.
+ expect(out.steps).toHaveLength(1);
+ });
+
+ it("200 + status failed → red with the record message", () => {
+ const out = interpretDeployResponse(
+ true,
+ 200,
+ record({ status: "failed", message: "entry not found", steps: [{ project: "my-worker", kind: "worker", ok: false, detail: "entry not found" }] }),
+ );
+ expect(out.ok).toBe(false);
+ expect(out.message).toMatch(/deploy #7 failed: entry not found/);
+ expect(out.steps[0]).toEqual({ label: "worker: my-worker", ok: false, detail: "entry not found" });
+ });
+
+ it("non-200 (DeployDO threw) → red with the error string, no steps", () => {
+ const out = interpretDeployResponse(false, 500, { ok: false, error: "boom" });
+ expect(out.ok).toBe(false);
+ expect(out.message).toBe("deploy failed: boom");
+ expect(out.steps).toEqual([]);
+ });
+
+ it("non-200 with no error body falls back to the status code", () => {
+ const out = interpretDeployResponse(false, 502, {});
+ expect(out.message).toBe("deploy failed: 502");
+ });
+});
diff --git a/packages/worker/test/github-status.test.ts b/packages/worker/test/github-status.test.ts
new file mode 100644
index 0000000..9cfd9fd
--- /dev/null
+++ b/packages/worker/test/github-status.test.ts
@@ -0,0 +1,65 @@
+import { describe, it, expect } from "vitest";
+import { buildStatusRequest, postCommitStatus } from "../src/ci/github-status";
+
+const BASE = {
+ githubFullName: "owner/repo",
+ sha: "a".repeat(40),
+ state: "success" as const,
+ description: "2 job(s) passed",
+ token: "ghp_test",
+};
+
+describe("buildStatusRequest", () => {
+ it("shapes the legacy Status API request", () => {
+ const { url, init } = buildStatusRequest({ ...BASE, targetUrl: "https://x.dev/r/repo/ci" });
+ expect(url).toBe(`https://api.github.com/repos/owner/repo/statuses/${"a".repeat(40)}`);
+ expect(init.method).toBe("POST");
+ const headers = init.headers as Record;
+ expect(headers.Authorization).toBe("Bearer ghp_test");
+ expect(headers["User-Agent"]).toBe("gitflare-worker"); // GitHub rejects UA-less requests
+ expect(JSON.parse(init.body as string)).toEqual({
+ state: "success",
+ context: "gitflare/ci",
+ description: "2 job(s) passed",
+ target_url: "https://x.dev/r/repo/ci",
+ });
+ });
+
+ it("omits target_url when absent and truncates long descriptions", () => {
+ const { init } = buildStatusRequest({ ...BASE, description: "x".repeat(200) });
+ const body = JSON.parse(init.body as string) as { description: string; target_url?: string };
+ expect(body.target_url).toBeUndefined();
+ expect(body.description.length).toBeLessThanOrEqual(140);
+ expect(body.description.endsWith("…")).toBe(true);
+ });
+});
+
+describe("postCommitStatus", () => {
+ it("returns ok on 201", async () => {
+ const res = await postCommitStatus({
+ ...BASE,
+ fetchImpl: async () => new Response("{}", { status: 201 }),
+ });
+ expect(res).toEqual({ ok: true, status: 201 });
+ });
+
+ it("soft-fails with detail on API errors", async () => {
+ const res = await postCommitStatus({
+ ...BASE,
+ fetchImpl: async () => new Response('{"message":"Bad credentials"}', { status: 401 }),
+ });
+ expect(res.ok).toBe(false);
+ expect(res.status).toBe(401);
+ expect(res.detail).toMatch(/Bad credentials/);
+ });
+
+ it("soft-fails on network errors (GitHub down is the whole point)", async () => {
+ const res = await postCommitStatus({
+ ...BASE,
+ fetchImpl: async () => {
+ throw new Error("connect ETIMEDOUT");
+ },
+ });
+ expect(res).toEqual({ ok: false, status: 0, detail: "connect ETIMEDOUT" });
+ });
+});
diff --git a/packages/worker/test/sandbox-runner.test.ts b/packages/worker/test/sandbox-runner.test.ts
new file mode 100644
index 0000000..b75a3f9
--- /dev/null
+++ b/packages/worker/test/sandbox-runner.test.ts
@@ -0,0 +1,318 @@
+import { describe, it, expect } from "vitest";
+import {
+ cloneIntoSandbox,
+ runJobSteps,
+ readArtifact,
+ makeScrubber,
+ makeLineBuffer,
+ gitAuthEnv,
+ validateCloneInputs,
+ commandLabel,
+ REPO_DIR,
+ MAX_ARTIFACT_BYTES,
+ type SandboxHandle,
+ type ExecResultLike,
+ type ExecOptionsLike,
+} from "../src/ci/sandbox-runner";
+import type { CiJob } from "../src/ci/workflow";
+
+const SECRET = "s3cr3t-artifacts-token";
+const REMOTE = "https://acct.artifacts.cloudflare.net/git/gitflare/owner--repo.git";
+const SHA = "a".repeat(40);
+
+interface Call {
+ command: string;
+ options?: ExecOptionsLike;
+}
+
+/** Scriptable fake sandbox: match commands by substring, in order of rules. */
+function fakeSandbox(rules: Array<{
+ match: string | RegExp;
+ result?: Partial;
+ output?: string[];
+ throwError?: string;
+}> = []): { sandbox: SandboxHandle; calls: Call[]; destroyed: () => boolean; files: Map } {
+ const calls: Call[] = [];
+ const files = new Map();
+ let destroyed = false;
+ const sandbox: SandboxHandle = {
+ async exec(command, options) {
+ calls.push({ command, ...(options ? { options } : {}) });
+ const rule = rules.find((r) =>
+ typeof r.match === "string" ? command.includes(r.match) : r.match.test(command),
+ );
+ if (rule?.throwError) throw new Error(rule.throwError);
+ for (const line of rule?.output ?? []) options?.onOutput?.("stdout", line);
+ return {
+ stdout: "",
+ stderr: "",
+ exitCode: 0,
+ success: true,
+ ...rule?.result,
+ };
+ },
+ async readFile(path) {
+ const content = files.get(path);
+ if (content === undefined) throw new Error(`ENOENT: ${path}`);
+ return { content };
+ },
+ async destroy() {
+ destroyed = true;
+ },
+ };
+ return { sandbox, calls, destroyed: () => destroyed, files };
+}
+
+function runJob(steps: string[], overrides: Partial = {}): CiJob {
+ return {
+ name: "test",
+ kind: "run",
+ needs: [],
+ steps: steps.map((command) => ({ type: "run" as const, command })),
+ env: {},
+ timeoutMinutes: 15,
+ ...overrides,
+ };
+}
+
+const META = { repo: "owner--repo", branch: "main", sha: SHA };
+
+describe("validateCloneInputs", () => {
+ it("accepts sane inputs", () => {
+ expect(validateCloneInputs({ remote: REMOTE, branch: "main", sha: SHA })).toBeNull();
+ expect(validateCloneInputs({ remote: REMOTE, branch: "feat/x_1.2-y", sha: SHA })).toBeNull();
+ });
+ it("rejects malformed shas", () => {
+ expect(validateCloneInputs({ remote: REMOTE, branch: "main", sha: "HEAD" })).toMatch(/invalid sha/);
+ expect(validateCloneInputs({ remote: REMOTE, branch: "main", sha: SHA.slice(1) })).toMatch(/invalid sha/);
+ });
+ it("rejects shell metacharacters and flag-shaped branches", () => {
+ expect(validateCloneInputs({ remote: REMOTE, branch: "x;rm -rf /", sha: SHA })).toMatch(/invalid branch/);
+ expect(validateCloneInputs({ remote: REMOTE, branch: "$(id)", sha: SHA })).toMatch(/invalid branch/);
+ expect(validateCloneInputs({ remote: REMOTE, branch: "-upload-pack=x", sha: SHA })).toMatch(/invalid branch/);
+ });
+ it("rejects non-https schemes and any shell-active character", () => {
+ expect(validateCloneInputs({ remote: "http://x/y.git", branch: "main", sha: SHA })).toMatch(/invalid remote/);
+ expect(validateCloneInputs({ remote: "https://x/`id`.git", branch: "main", sha: SHA })).toMatch(/invalid remote/);
+ expect(validateCloneInputs({ remote: "https://x/a b.git", branch: "main", sha: SHA })).toMatch(/invalid remote/);
+ // Command-substitution chars must be rejected even without quotes/spaces —
+ // the remote is interpolated unquoted into the git clone command line.
+ expect(validateCloneInputs({ remote: "https://host/$(reboot).git", branch: "main", sha: SHA })).toMatch(/invalid remote/);
+ expect(validateCloneInputs({ remote: "https://host/a;b.git", branch: "main", sha: SHA })).toMatch(/invalid remote/);
+ expect(validateCloneInputs({ remote: "https://host/a&b.git", branch: "main", sha: SHA })).toMatch(/invalid remote/);
+ });
+});
+
+describe("gitAuthEnv", () => {
+ it("carries the token only in env-scoped git config, base64-encoded", () => {
+ const env = gitAuthEnv(SECRET);
+ expect(env.GIT_CONFIG_COUNT).toBe("1");
+ expect(env.GIT_CONFIG_KEY_0).toBe("http.extraHeader");
+ expect(env.GIT_CONFIG_VALUE_0).toBe(`Authorization: Basic ${btoa(`x:${SECRET}`)}`);
+ });
+});
+
+describe("cloneIntoSandbox", () => {
+ const params = { remote: REMOTE, branch: "main", sha: SHA, tokenSecret: SECRET };
+
+ it("clones with auth env and checks out the sha", async () => {
+ const { sandbox, calls } = fakeSandbox();
+ const logs: string[] = [];
+ const res = await cloneIntoSandbox(sandbox, { ...params, log: (l) => logs.push(l) });
+ expect(res.ok).toBe(true);
+ expect(calls[0]!.command).toBe(
+ `git clone --quiet --depth 50 --single-branch --branch main -- ${REMOTE} ${REPO_DIR}`,
+ );
+ expect(calls[0]!.options?.env?.GIT_CONFIG_VALUE_0).toContain("Basic");
+ expect(calls[1]!.command).toContain(`checkout --quiet ${SHA}`);
+ // The checkout gets NO auth env — it's a local operation.
+ expect(calls[1]!.options?.env).toBeUndefined();
+ // The token never appears in any command line.
+ for (const c of calls) {
+ expect(c.command).not.toContain(SECRET);
+ expect(c.command).not.toContain(btoa(`x:${SECRET}`));
+ }
+ });
+
+ it("deepens once when the sha is missing, then succeeds", async () => {
+ let checkouts = 0;
+ const { sandbox, calls } = fakeSandbox([
+ {
+ match: /checkout/,
+ result: { exitCode: 0 },
+ },
+ ]);
+ // First checkout fails, second (after deepen) succeeds.
+ const orig = sandbox.exec.bind(sandbox);
+ sandbox.exec = async (cmd, opts) => {
+ if (/checkout/.test(cmd) && checkouts++ === 0) {
+ await orig(cmd, opts);
+ return { stdout: "", stderr: "fatal: reference is not a tree", exitCode: 128, success: false };
+ }
+ return orig(cmd, opts);
+ };
+ const logs: string[] = [];
+ const res = await cloneIntoSandbox(sandbox, { ...params, log: (l) => logs.push(l) });
+ expect(res.ok).toBe(true);
+ expect(calls.some((c) => c.command.includes("fetch --quiet --depth 500 origin main"))).toBe(true);
+ expect(logs.some((l) => l.includes("deepening"))).toBe(true);
+ });
+
+ it("fails with the git stderr tail when the clone fails", async () => {
+ const { sandbox } = fakeSandbox([
+ { match: "clone", result: { exitCode: 128, stderr: "fatal: could not read Username" } },
+ ]);
+ const res = await cloneIntoSandbox(sandbox, { ...params, log: () => {} });
+ expect(res.ok).toBe(false);
+ expect(res.message).toMatch(/git clone failed: .*could not read Username/);
+ });
+
+ it("refuses invalid inputs before touching the sandbox", async () => {
+ const { sandbox, calls } = fakeSandbox();
+ const res = await cloneIntoSandbox(sandbox, {
+ ...params,
+ branch: "pwn`ed`",
+ log: () => {},
+ });
+ expect(res.ok).toBe(false);
+ expect(calls).toHaveLength(0);
+ });
+});
+
+describe("runJobSteps", () => {
+ it("runs steps in order with CI env, cwd, and job env merged", async () => {
+ const { sandbox, calls } = fakeSandbox();
+ const result = await runJobSteps(sandbox, {
+ job: runJob(["npm ci", "npm test"], { env: { NODE_ENV: "test" } }),
+ meta: META,
+ log: () => {},
+ });
+ expect(result.ok).toBe(true);
+ expect(result.steps).toEqual([
+ { command: "npm ci", ok: true, exitCode: 0 },
+ { command: "npm test", ok: true, exitCode: 0 },
+ ]);
+ const opts = calls[0]!.options!;
+ expect(opts.cwd).toBe(REPO_DIR);
+ expect(opts.env).toMatchObject({
+ CI: "true",
+ GITFLARE_REPO: "owner--repo",
+ GITFLARE_BRANCH: "main",
+ GITFLARE_SHA: SHA,
+ NODE_ENV: "test",
+ });
+ expect(opts.stream).toBe(true);
+ expect(typeof opts.timeout).toBe("number");
+ });
+
+ it("streams output lines through the log", async () => {
+ const { sandbox } = fakeSandbox([
+ { match: "npm test", output: ["> vitest run\n", "42 passed\npartial"] },
+ ]);
+ const logs: string[] = [];
+ await runJobSteps(sandbox, { job: runJob(["npm test"]), meta: META, log: (l) => logs.push(l) });
+ expect(logs).toContain("$ npm test");
+ expect(logs).toContain(" > vitest run");
+ expect(logs).toContain(" 42 passed");
+ expect(logs).toContain(" partial"); // flushed remainder
+ });
+
+ it("stops at the first failing step and reports the exit code", async () => {
+ const { sandbox, calls } = fakeSandbox([
+ { match: "npm test", result: { exitCode: 1, success: false } },
+ ]);
+ const result = await runJobSteps(sandbox, {
+ job: runJob(["npm ci", "npm test", "npm run e2e"]),
+ meta: META,
+ log: () => {},
+ });
+ expect(result.ok).toBe(false);
+ expect(result.message).toBe('"npm test" exited 1');
+ expect(result.steps).toHaveLength(2);
+ expect(calls.map((c) => c.command)).toEqual(["npm ci", "npm test"]);
+ });
+
+ it("fails the job when the wall-clock budget is exhausted before a step", async () => {
+ let t = 0;
+ const { sandbox } = fakeSandbox();
+ const result = await runJobSteps(sandbox, {
+ job: runJob(["sleep", "never-runs"], { timeoutMinutes: 1 }),
+ meta: META,
+ log: () => {},
+ now: () => {
+ // First step consumes the whole budget.
+ t += 61_000;
+ return t;
+ },
+ });
+ expect(result.ok).toBe(false);
+ expect(result.steps.at(-1)!.detail).toMatch(/job timeout/);
+ });
+
+ it("maps an exec timeout error to a friendly outcome and warns about the leaked process", async () => {
+ const { sandbox } = fakeSandbox([{ match: "hang", throwError: "Command timed out" }]);
+ const logs: string[] = [];
+ const result = await runJobSteps(sandbox, {
+ job: runJob(["hang"]),
+ meta: META,
+ log: (l) => logs.push(l),
+ });
+ expect(result.ok).toBe(false);
+ expect(result.message).toBe('"hang" timed out');
+ expect(logs.some((l) => l.includes("may keep running"))).toBe(true);
+ });
+
+ it("labels multi-line commands by their first line", () => {
+ expect(commandLabel("npm ci\nnpm test")).toBe("npm ci …");
+ expect(commandLabel("npm test")).toBe("npm test");
+ });
+});
+
+describe("makeScrubber", () => {
+ it("scrubs the raw secret and its Basic-auth form from any line", () => {
+ const scrub = makeScrubber([SECRET]);
+ const b64 = btoa(`x:${SECRET}`);
+ // The classic leak: git echoes the failing URL (had it held credentials)
+ // or the header value into stderr.
+ expect(scrub(`fatal: unable to access 'https://x:${SECRET}@host/repo.git'`)).not.toContain(SECRET);
+ expect(scrub(`http.extraHeader: Authorization: Basic ${b64}`)).not.toContain(b64);
+ expect(scrub("plain line")).toBe("plain line");
+ });
+ it("ignores empty secrets", () => {
+ expect(makeScrubber([""])("anything")).toBe("anything");
+ });
+});
+
+describe("makeLineBuffer", () => {
+ it("splits chunks into lines, handles CRLF, flushes remainders", () => {
+ const lines: string[] = [];
+ const buf = makeLineBuffer((l) => lines.push(l));
+ buf.push("a\r\nb");
+ buf.push("c\nd");
+ buf.flush();
+ expect(lines).toEqual(["a", "bc", "d"]);
+ });
+});
+
+describe("readArtifact", () => {
+ it("reads a built entry from the workspace", async () => {
+ const { sandbox, files } = fakeSandbox();
+ files.set(`${REPO_DIR}/dist/worker.js`, "export default {}");
+ const res = await readArtifact(sandbox, "dist/worker.js");
+ expect(res).toEqual({ content: "export default {}" });
+ });
+ it("returns empty (not an error) when the file wasn't built", async () => {
+ const { sandbox } = fakeSandbox();
+ expect(await readArtifact(sandbox, "dist/worker.js")).toEqual({});
+ });
+ it("rejects path traversal and absolute paths", async () => {
+ const { sandbox } = fakeSandbox();
+ expect((await readArtifact(sandbox, "../../etc/passwd")).error).toMatch(/invalid entry path/);
+ expect((await readArtifact(sandbox, "/etc/passwd")).error).toMatch(/invalid entry path/);
+ });
+ it("caps artifact size", async () => {
+ const { sandbox, files } = fakeSandbox();
+ files.set(`${REPO_DIR}/dist/worker.js`, "x".repeat(MAX_ARTIFACT_BYTES + 1));
+ expect((await readArtifact(sandbox, "dist/worker.js")).error).toMatch(/exceeds/);
+ });
+});
diff --git a/packages/worker/test/yaml.test.ts b/packages/worker/test/yaml.test.ts
index ee9e255..ab55815 100644
--- a/packages/worker/test/yaml.test.ts
+++ b/packages/worker/test/yaml.test.ts
@@ -59,3 +59,52 @@ steps:
expect(v.color).toBe("#F38020");
});
});
+
+describe("parseYaml block scalars", () => {
+ it("parses `key: |` with a trailing newline kept", () => {
+ const v = parseYaml(`
+script: |
+ npm ci
+ npm test
+after: done
+`) as Record;
+ expect(v.script).toBe("npm ci\nnpm test\n");
+ expect(v.after).toBe("done");
+ });
+
+ it("parses `key: |-` chomping trailing newlines", () => {
+ const v = parseYaml(`script: |-\n echo hi\n\n\nafter: 1\n`) as Record;
+ expect(v.script).toBe("echo hi");
+ expect(v.after).toBe(1);
+ });
+
+ it("works on list-item maps (`- run: |`) and stops at sibling keys", () => {
+ const v = parseYaml(`
+steps:
+ - run: |
+ npm ci
+ npm test
+ name: tests
+ - run: echo ok
+`) as { steps: Array> };
+ expect(v.steps[0]!.run).toBe("npm ci\nnpm test\n");
+ expect(v.steps[0]!.name).toBe("tests");
+ expect(v.steps[1]!.run).toBe("echo ok");
+ });
+
+ it("preserves inner indentation, blank lines, and # characters verbatim", () => {
+ const v = parseYaml(`
+script: |
+ if true; then
+ echo "#not a comment"
+ fi
+
+ echo after-blank
+`) as Record;
+ expect(v.script).toBe('if true; then\n echo "#not a comment"\nfi\n\necho after-blank\n');
+ });
+
+ it("rejects folded scalars with a hint", () => {
+ expect(() => parseYaml("script: >\n a\n b\n")).toThrow(/folded.*use \|/);
+ });
+});
diff --git a/packages/worker/wrangler.toml b/packages/worker/wrangler.toml
index 5ecf56c..a908f50 100644
--- a/packages/worker/wrangler.toml
+++ b/packages/worker/wrangler.toml
@@ -12,10 +12,35 @@ namespace = "gitflare"
name = "REPO"
class_name = "RepoDO"
+[[durable_objects.bindings]]
+name = "DEPLOY"
+class_name = "DeployDO"
+
+[[durable_objects.bindings]]
+name = "CI"
+class_name = "CiDO"
+
+# NOTE: this file is a dev artifact — the deployed config is generated by
+# packages/cli/src/wrangler.ts (tomlFor). Keep bindings/migrations in sync.
+# `gitflare ci enable` additionally emits a SANDBOX binding + [[containers]]
+# block (image docker.io/cloudflare/sandbox, pinned to the SDK version).
+
[[migrations]]
tag = "v1"
new_sqlite_classes = ["RepoDO"]
+[[migrations]]
+tag = "v2"
+new_sqlite_classes = ["DeployDO"]
+
+[[migrations]]
+tag = "v3"
+new_sqlite_classes = ["CiDO"]
+
+[[migrations]]
+tag = "v4"
+new_sqlite_classes = ["Sandbox"]
+
[vars]
GITFLARE_VERSION = "0.0.0"
REPO_MAP = "{\"sinameraji/kimiflare\":{\"name\":\"sinameraji--kimiflare\",\"remote\":\"https://b35e975c549e4e6b888ed6a6d436d89f.artifacts.cloudflare.net/git/gitflare/sinameraji--kimiflare.git\"}}"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7f6fb23..8f1f435 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -56,6 +56,9 @@ importers:
packages/worker:
dependencies:
+ '@cloudflare/sandbox':
+ specifier: 0.12.3
+ version: 0.12.3
'@gitflare/shared':
specifier: workspace:*
version: link:../shared
@@ -63,8 +66,8 @@ importers:
specifier: ^11.11.1
version: 11.11.1
hono:
- specifier: ^4.6.0
- version: 4.12.23
+ specifier: ^4.12.26
+ version: 4.12.30
isomorphic-git:
specifier: ^1.27.1
version: 1.38.2
@@ -92,10 +95,27 @@ packages:
bundledDependencies:
- is-unicode-supported
+ '@cloudflare/containers@0.3.7':
+ resolution: {integrity: sha512-DM9dm3FnIBSyiSJ1FLavKwl/lk3oAmTaynCzZQ9pZR0ncRPquSxkxd8Nu2MFILxmDDsPkxKsSNEh9mHHMty4Fw==}
+
'@cloudflare/kv-asset-handler@0.5.0':
resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==}
engines: {node: '>=22.0.0'}
+ '@cloudflare/sandbox@0.12.3':
+ resolution: {integrity: sha512-4yx+PtBCZBrS3eZteefwBfGOm+8MTZahLH8/fG/qsvqoNflzno9780FsM6HZf02gGuoDy+s9jQrXbg/h5gEvgw==}
+ peerDependencies:
+ '@openai/agents': ^0.3.3
+ '@opencode-ai/sdk': ^1.1.40
+ '@xterm/xterm': '>=5.0.0'
+ peerDependenciesMeta:
+ '@openai/agents':
+ optional: true
+ '@opencode-ai/sdk':
+ optional: true
+ '@xterm/xterm':
+ optional: true
+
'@cloudflare/unenv-preset@2.16.1':
resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==}
peerDependencies:
@@ -1086,6 +1106,9 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
+ aws4fetch@1.0.20:
+ resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==}
+
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -1111,6 +1134,9 @@ packages:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
+ capnweb@0.8.0:
+ resolution: {integrity: sha512-BK/TuXUiyfLSKsmjojn70yN7oYG/JJzoURZ3tckjg5Zj2KcygPm0A5jyOlswK7SYB4f0Gh9tt+RZ132b80iLfA==}
+
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
@@ -1267,6 +1293,10 @@ packages:
resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==}
engines: {node: '>=16.9.0'}
+ hono@4.12.30:
+ resolution: {integrity: sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog==}
+ engines: {node: '>=16.9.0'}
+
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@@ -1620,8 +1650,17 @@ snapshots:
picocolors: 1.1.1
sisteransi: 1.0.5
+ '@cloudflare/containers@0.3.7': {}
+
'@cloudflare/kv-asset-handler@0.5.0': {}
+ '@cloudflare/sandbox@0.12.3':
+ dependencies:
+ '@cloudflare/containers': 0.3.7
+ aws4fetch: 1.0.20
+ capnweb: 0.8.0
+ hono: 4.12.30
+
'@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260521.1)':
dependencies:
unenv: 2.0.0-rc.24
@@ -2210,6 +2249,8 @@ snapshots:
dependencies:
possible-typed-array-names: 1.1.0
+ aws4fetch@1.0.20: {}
+
base64-js@1.5.1: {}
blake3-wasm@2.1.5: {}
@@ -2238,6 +2279,8 @@ snapshots:
call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0
+ capnweb@0.8.0: {}
+
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
@@ -2463,6 +2506,8 @@ snapshots:
hono@4.12.23: {}
+ hono@4.12.30: {}
+
ieee754@1.2.1: {}
ignore@5.3.2: {}