CI (Buildfyio Checks)
Every push and pull request checks out that exact commit into a fresh, isolated container, runs the steps in .buildfyio/ci.yml, and reports one GitHub commit status per job. Logs and artifacts stay on the run page.
Checks are separate from deployments: they never run in your production container, never see your project's environment variables, and never deploy anything. A project without .buildfyio/ci.yml runs no checks. Runs show up on the project's CI tab.
version: 1
jobs:
check:
cache: [pnpm]
steps:
- run: pnpm install --frozen-lockfile # pnpm version from "packageManager"
- run: pnpm testConfig format
The file lives at .buildfyio/ci.yml in the repository root and is read at the commit being checked, so a branch can change its own checks. Unknown keys are errors (a typo fails the run with a clear message instead of being ignored); validate locally with buildfy ci validate. A complete example: a security/quality job and a Playwright job.
# .buildfyio/ci.yml
version: 1
on:
push:
branches: ["**"] # globs; "!" excludes. Default: every branch, production included
pull_request: true # opened, synchronize, reopened (default true)
jobs:
# ── becomes the GitHub status "buildfyio/check" ──────────────────────────
check:
# image omitted → node:<major from .nvmrc>-bookworm (22 when there is no .nvmrc)
timeout: 30m # default 30m, max 60m
cache: [pnpm] # PNPM_STORE_DIR → a per-project cache volume
env:
APP_ENV: test
NODE_ENV: test
NEXT_TELEMETRY_DISABLED: "1"
ASTRO_TELEMETRY_DISABLED: "1"
# secrets: [NPM_TOKEN] # only if a private registry needs it (never for fork PRs)
steps:
- name: Secret scan (gitleaks 8.30.1, checksum-verified)
run: |
V=8.30.1
BASE=https://github.com/gitleaks/gitleaks/releases/download/v${V}
cd /tmp
curl -sSfLO "${BASE}/gitleaks_${V}_linux_x64.tar.gz"
curl -sSfLO "${BASE}/gitleaks_${V}_checksums.txt"
grep " gitleaks_${V}_linux_x64.tar.gz$" "gitleaks_${V}_checksums.txt" | sha256sum -c -
tar -xzf "gitleaks_${V}_linux_x64.tar.gz" gitleaks
cd /workspace
/tmp/gitleaks git --redact --exit-code 1 .
- name: Node matches .nvmrc
run: |
want="v$(tr -d 'v \n' < .nvmrc | cut -d. -f1)"
have="$(node -v | cut -d. -f1)"
echo "node $(node -v), .nvmrc wants ${want}"
test "${have}" = "${want}"
# pnpm: nothing to install. When the image has corepack but no pnpm, the
# runner enables corepack (version from "packageManager") and puts it on PATH.
- name: Install
run: pnpm install --frozen-lockfile
- name: Audit
run: pnpm audit --prod --audit-level high
- name: Lint
run: pnpm lint
- name: Typecheck
run: pnpm typecheck
- name: Unit tests
run: pnpm test
- name: Build
run: pnpm build
- name: Env check
run: pnpm env:check
# ── becomes "buildfyio/e2e"; runs in parallel with "check" ───────────────
e2e:
image: mcr.microsoft.com/playwright:v1.49.0-noble
timeout: 40m
cache: [pnpm, playwright] # PLAYWRIGHT_BROWSERS_PATH is cached too
env:
APP_ENV: test
NODE_ENV: test
steps:
- name: Install
run: pnpm install --frozen-lockfile
- name: Build what the web app needs
run: pnpm turbo run build --filter=./apps/web...
- name: Browsers
run: pnpm --filter ./apps/web exec playwright install chromium
- name: E2E
run: pnpm --filter ./apps/web test:e2e
artifacts:
- path: apps/web/test-results # relative to the repository root
when: failure # always | failure | success (default always)
retention: 7d # 1d … 30d (default 7d)| Key | Default | Notes |
|---|---|---|
version | — | Must be 1. |
on.push.branches | every branch, the production branch included | Globs; ** matches any branch, a leading ! excludes. |
on.pull_request | true | Opened, synchronize (new commits) and reopened. |
jobs.<name> | — | Name: lowercase letters, digits and -, up to 31 characters. Becomes the status context buildfyio/<name>. |
image | node:<.nvmrc major>-bookworm (22 without .nvmrc) | Any public image. |
timeout | 30m | Max 60m. The job is killed and reported timed out. |
env | — | Plain values for this job. Not secret, visible in the config. |
secrets | — | Names of project CI secrets to expose as environment variables (see Secrets). |
cache | — | Named caches or {name, path} (see Caches). |
steps[].run | — | Shell, run with bash -eo pipefail; multi-line with |. The first failing step fails the job. |
steps[].name | step N | Shown on the run page and in the log. |
artifacts[] | — | path (relative to the repo root), when (always | failure | success, default always), retention (1d–30d, default 7d). |
Every job gets CI=true, BUILDFYIO_CI=1, BUILDFYIO_RUN_ID, BUILDFYIO_JOB, BUILDFYIO_SHA and BUILDFYIO_REF, plus its own env and listed secrets. Nothing else. Jobs run as a non-root user (uid 1000), so install tools into the workspace or $HOME (/workspace/.bfy/home), not into /usr/local. A PATH in env is merged with the image's PATH (use $PATH to place it); each step runs in its own shell, so cd and export do not carry over between steps.
Triggers and concurrency
- Push to a branch that matches
on.push.branches(deleted branches are ignored). The production branch is included by default, and a push to it starts CI before its deploy, so the deploy gate sees the run (see Deploy gate). - Pull request opened, updated or reopened, checked at the PR head commit.
- Manual: Run on branch… on the CI tab,
buildfy ci run --ref, or therun_ciMCP tool. Rerun repeats a finished run at the same commit.
A newer run on the same branch cancels older queued or running runs of that branch: the old run is marked cancelled and links to the one that replaced it. Jobs of one run start in parallel, up to your plan's concurrency; the rest wait in the queue.
Isolation
Each job runs in its own Kubernetes Job and is deleted after it finishes.
- A fresh container per job: nothing carries over from previous runs except the caches you name.
- No production environment variables. Project env vars (production, preview or any other environment) are never passed to a run. Only the job's
envand the CI secrets it lists. - No access to the platform. The cluster network and private address ranges are blocked:
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16(link-local, including cloud metadata),100.64.0.0/10, and the platform's own public addresses. A run cannot reach your deployments' pods, databases, or internal services. DNS works. - Outbound internet is allowed (package registries, GitHub, your public staging URL). A per-project egress allow-list is planned.
- Non-root (uid 1000), no privilege escalation, all Linux capabilities dropped, default seccomp profile. No inbound connections.
Secrets
CI secrets are set under Project → Settings → CI (owners and admins) or with buildfy ci secrets set. They follow stricter rules than environment variables:
- Separate from env vars. A production
DATABASE_URLis not a CI secret; add a test value under its own key if a job needs one. - Never automatic. A job sees a secret only when it names it under
secrets:. - Fork pull requests get none, whatever the config says; the run page marks those runs as fork. Code from a fork cannot read your secrets.
- Masked in logs. Every secret value, and the clone token, is replaced by
***before a log line is stored. - Write-only: values are never shown again, in the dashboard, API, CLI or MCP.
Repository access
Before the job starts, a separate clone container checks the commit out with full history into /workspace. It authenticates with the GitHub App installation token of the account that owns the repository (valid for about an hour, scoped to every repository that installation can see, with the App's permissions, not read-only and not limited to this repository). Without an App installation on that account it uses the workspace's GitHub OAuth token (scope repo). The token is removed from .git/config (the remote is reset to the plain URL) before your steps run, is never in the step container's environment or arguments, and is masked if it ever appears in output. CI does not push or write back to the repository; the only thing it writes to GitHub is commit statuses.
Reporting to GitHub
Each job posts a commit status with the context buildfyio/<job>: pending when the run is created, then success or failure/error. “Details” links to the run page on Buildfyio.
Required checks in branch protection
In GitHub, Settings → Branches → Branch protection rules (or a ruleset) → Require status checks to pass, search for buildfyio/check, buildfyio/e2e, … and add them. A context appears in that search after it has run once. Pull requests then cannot be merged until those jobs pass.
Deploy gate
Turn on Require passing checks before production in Project → Settings → Workflow. A production deploy then waits for every check run and commit status on its commit, buildfyio/* statuses included, and is refused when one fails. This applies to pushes, manual deploys, promoting a preview and “Redeploy to production”. While a Buildfyio Checks job on that commit is still queued or running the deploy waits (up to 90 minutes); a failed, timed-out, errored or cancelled job blocks it. If the commit's .buildfyio/ci.yml runs on that push but its run does not exist yet, the deploy waits up to 2 minutes for it, then is refused.
What still reaches production without a green Buildfyio Checks run: a rollback to a release production already ran (exempt by design, so a bad release can always be undone), a commit whose .buildfyio/ci.yml does not run on pushes to that branch (see below) and that has no other checks, and every deploy while the setting is off. Uploaded source trees and prebuilt images have no commit to check and are not gated.
main waits for that run when the gate is on. To skip CI there, exclude it: branches: ["**", "!main"].Logs, artifacts and retention
- Logs stream live on the run page (each step is a section) and stay downloadable after the job finishes (
buildfy ci logs, Download on the run page). - Artifacts: each
artifacts[].paththat exists when the job ends is packed as a.tgz, subject towhen. Retention is1dto30d, default7d; expired artifacts are deleted and listed as expired.
Caches
Caches live on a per-project volume and are keyed by your lockfile (pnpm-lock.yaml, package-lock.json or yarn.lock): a lockfile change starts a fresh cache.
Caches are separated by trust, so a run can never plant files that a more trusted run later executes. Runs on your production branch read and write the project's main cache. Runs on any other branch get their own cache for that branch, which starts empty and cannot see the main one. Fork pull requests get a throwaway cache that is deleted with the job and never read by another run.
| Name | Sets | Note |
|---|---|---|
pnpm | PNPM_STORE_DIR, npm_config_store_dir | pnpm store |
npm | npm_config_cache | npm download cache |
yarn | YARN_CACHE_FOLDER | yarn cache |
playwright | PLAYWRIGHT_BROWSERS_PATH | playwright install fills it once |
{name, path} | — | A path under /workspace linked to the cache, e.g. {name: next, path: apps/web/.next/cache} |
Limits
| Limit | Value |
|---|---|
| Job timeout | default 30 min, max 60 min |
| Jobs per config | 10 |
| Steps per job | 20 |
| Resources per job | 2 CPU, 4 GiB memory, 20 GiB disk (fork pull requests: 1 CPU, 2 GiB memory, 10 GiB disk) |
| Artifact retention | 1–30 days (default 7) |
| Concurrent jobs per workspace | Hobby 1 · Starter 2 · Pro 4 · Team 8 |
Pricing
CI minutes are build minutes. Each job counts the time it ran, rounded up to the minute, including jobs that fail, time out or are cancelled mid-run, against the same monthly allowance as your builds; the usage card shows the total. Above the allowance, paid plans pay $0.008 per minute, and the minutes count toward your spend cap. Hobby is never billed per minute: it stops at the allowance (100 build + CI minutes a month). At 80% the dashboard shows a warning; once the allowance is used up, new builds and CI jobs are refused until the monthly reset or an upgrade. Running sites stay up, and rollbacks to an existing image still work.
| Plan | Build + CI minutes included / month | Then |
|---|---|---|
| Hobby | 100 | Hard stop: new builds and CI jobs pause until the monthly reset |
| Starter | 400 | $0.008 per build minute |
| Pro | 1,000 | $0.008 per build minute |
| Team | 10,000 | $0.008 per build minute |
| Enterprise | Unlimited | Contract |
Converting from GitHub Actions
Most workflows translate one-to-one: a job is a job, a run: step is a step. Actions you uses: become a line of shell or a config key:
| GitHub Actions | .buildfyio/ci.yml |
|---|---|
on: push / pull_request | on.push.branches, on.pull_request |
runs-on: ubuntu-latest | image: (any public image; default Node from .nvmrc) |
timeout-minutes: 20 | timeout: 20m |
env: | env: (job level) |
${{ secrets.X }} | secrets: [X], then $X |
actions/checkout | Remove it: the commit is already in /workspace, full history |
actions/setup-node | Remove it, or set image: node:20-bookworm |
pnpm/action-setup | Remove it: pnpm from packageManager is enabled automatically |
actions/cache | cache: [pnpm] or {name, path} |
actions/upload-artifact | artifacts: with when and retention |
if: failure() (upload) | when: failure |
${{ github.sha }} / github.ref_name | $BUILDFYIO_SHA / $BUILDFYIO_REF |
Before and after
# .github/workflows/ci.yml
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version-file: .nvmrc, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm test
env: { API_KEY: ${{ secrets.API_KEY }} }
- uses: actions/upload-artifact@v4
if: failure()
with: { name: results, path: test-results, retention-days: 7 }# .buildfyio/ci.yml
version: 1
jobs:
test:
# actions/setup-node → the image (default follows .nvmrc)
timeout: 20m
cache: [pnpm] # setup-node "cache: pnpm" / actions/cache
secrets: [API_KEY] # ${{ secrets.API_KEY }} → $API_KEY
steps:
# actions/checkout → nothing: the commit is already in /workspace (full history)
# pnpm/action-setup → nothing: the runner enables corepack's pnpm automatically
- run: pnpm install --frozen-lockfile
- run: pnpm test
artifacts: # actions/upload-artifact
- path: test-results
when: failure # if: failure()
retention: 7d # retention-days: 7Not supported
- Matrices (
strategy.matrix): write one job per combination, or loop in shell. - Reusable workflows (
workflow_call) and composite actions. - Marketplace actions (
uses:): replace each with its shell equivalent, as above. - Service containers (
services:): start the dependency inside the job or use an embedded/in-memory variant for tests. needs:between jobs: jobs run independently and in parallel.
CLI
buildfy ci runs [--ref <branch>] [--status <status>] [--limit <n>] # list runs buildfy ci run --ref <branch> # start a run at the branch head (manual) buildfy ci logs <runId|jobId> [--job <name>] [--follow] buildfy ci rerun <runId> buildfy ci cancel <runId> buildfy ci artifacts <runId> # list artifacts buildfy ci artifact <artifactId> --out <file> # download one buildfy ci validate [path] # check .buildfyio/ci.yml locally (default path) buildfy ci secrets ls buildfy ci secrets set <KEY> # value read from stdin / prompt, never an argument buildfy ci secrets rm <KEY>
API tokens need the ci:read scope to list runs, read logs and download artifacts (a read token also works), and ci:run to start, rerun or cancel. Writing CI secrets needs admin.
MCP
| Tool | Does | Scope |
|---|---|---|
list_ci_runs | Runs of a project, filter by branch and status | ci:read |
get_ci_run | Status, jobs, steps, failure reason and the last error lines | ci:read |
get_ci_artifact | A download URL for an artifact | ci:read |
validate_ci_config | Validate a ci.yml (text or the file at a ref) | ci:read |
run_ci | Start a run on a branch | ci:run |
rerun_ci | Rerun a finished run | ci:run |
cancel_ci_run | Cancel a queued or running run | ci:run |