1 CI and Builds
mitosis edited this page 2026-09-02 08:56:51 +00:00

Every build on this platform runs on one CI pool. The pool is ephemeral, scales from zero, and is non-privileged by construction. This page covers what runs-on: isolated gives you, how the platform builds images without a Docker daemon, and the one bootstrap step a fresh platform needs.

One pool: runs-on: isolated

Declare runs-on: isolated in a workflow. You get an ephemeral runner with a full toolchain, a rootless image builder, and a smoke Postgres — and no path to the node or other tenants.

Note: isolated is the only pool. No privileged pool exists, so a runs-on: docker job is never claimed; it waits forever.

Each runner pod is shaped for isolation:

  • automountServiceAccountToken: false — tenant code gets no kube API token.
  • The main container is the open-platform/ci-runner toolchain image (bun, node, git, curl baked in). It runs forgejo-runner one-job: one job, then gone. The pool registers ephemeral runners from a global instance-scoped secret, so every repo can target isolated.
  • The execution label is isolated:host — steps run directly in the toolchain container. There is no job container and no implicit checkout. Each job clones its exact commit itself with the ephemeral, repo-scoped github.token.
  • Sidecars: a rootless BuildKit builder (moby/buildkit rootless, uid 1000, not privileged, reached over a UNIX socket), a postgres:16 smoke database at 127.0.0.1:5432 (creds ci/ci, db ci), and a CA-bundle init container so pushes to the platform's HTTPS registry endpoint verify.
  • An egress-only NetworkPolicy allows exactly: DNS, the Forgejo namespace (clone + registry pull), the edge (HTTPS push), and the MCP provisioning endpoints (/ci/preview, /ci/sync-env). The endpoints authorize each call by a check of the caller's Actions token for push rights on that repo, and they fail closed. Public internet egress is open except RFC1918 ranges and the cloud-metadata address 169.254.169.254.

Scale-from-zero

The pool is a KEDA ScaledJob (0 → 6 replicas, 15s polling, a 40-minute job deadline). The trigger is a postgresql query against Forgejo's own database: SELECT COUNT(*) FROM action_run_job WHERE status = 5 AND runs_on::text LIKE '%isolated%'. Waiting isolated jobs spawn runner pods. No waiting jobs, no pods, no idle cost.

The build pipeline

App workflows (see the five that ship in app-template: check, guard-ppt, preview, release, sync-env) build and ship in three steps:

  1. Build. buildctl --addr "$BUILDKIT_HOST" drives the rootless BuildKit sidecar. Images push to the platform's public HTTPS registry endpoint. buildctl refuses a plain-HTTP push — that is what the CA bundle is for.
  2. Sign. cosign sign signs by digest with the platform key (--tlog-upload=false, self-hosted). When no key is configured, the step skips gracefully — the Kyverno verifyImages policy is Audit-first, so a keyless fresh platform still deploys.
  3. Chart. helm package charts/app --version "$V" --app-version "$V" (the chart is renamed to the app), then helm push to oci://<registry>/<org>/charts.

Previews use the tag 0.0.0-pr.<N>.<sha7> — sha-suffixed, so every push makes a distinct chart+image version that Flux rolls on. Releases are v* git-tag pushes; the bare semver becomes the image tag, the chart version, and APP_VERSION. From there Flux takes over — see Preview Environments and Platform Internals.

The credentials split by design. github.token (ephemeral, scoped to this repo) handles clone, API reads, and provisioning calls. REGISTRY_TOKEN is registry auth only — a per-org write:package-only credential, never an admin token.

Writing workflows: there is no Docker daemon

No node on the platform runs a Docker daemon for CI. For a workflow author that means:

  • No docker run, no docker build, no service containers. Your tools are bun (install, test, build), buildctl (images), helm (charts), cosign (signing), plus git and curl.
  • Need a database for tests? Use the Postgres sidecar at 127.0.0.1:5432. The template's check workflow uses it to lint, typecheck, and build, then boots the real server and smoke-asserts its endpoints — all before any image ships.
  • Need to reach the platform API? The in-cluster registry is forgejo-http.forgejo.svc.cluster.local:3000; the provisioning API is http://mcp.mcp.svc.cluster.local.

guard-ppt

guard-ppt.yml runs on every push and PR. It fails when any workflow file uses the pull_request_target trigger — the one Actions trigger that hands base-repo secrets to untrusted PR code. It is a required status check on main for open-platform/mitosis and open-platform/app-template, so a reintroduction cannot merge there. Making it required on every app repo is a known open follow-up. The rest of the workflow surface and its controls are covered in Security Model.

The bootstrap chicken-and-egg

The runner pool's own container image is open-platform/ci-runner. On a fresh platform, nothing can build it, because every build needs a runner. Germination — the run that creates a new platform — breaks the cycle off-cluster.

Warning: Do not skip this step. Without it the platform stands up, but no CI ever runs.

On a k3d host, germinate runs docker build for the seeded ci-runner repo as git.<domain>/open-platform/ci-runner:<tag>, then imports it with k3d image import. Germinate reads the tag from the gitops pin, so the tag cannot skew. On a bare VPS with no Docker on the box, run a one-time docker build + docker save | ssh ... ctr images import from any Docker machine. The tag must match the gitops pin; the exact block is in Grow Your Own Platform.

Once the pool is alive, the ci-runner repo's own release workflow rebuilds the image through normal CI, and the loop is closed.