# Hosted CI

Hosted CI runs the workflows in `.swarmfile/ci.yml` on Swarmfile-managed
containers. There is nothing to install on a build machine and nothing to
keep patched: a commit (or a manual dispatch) starts a container, the
container streams the job's log back as it runs, and the result lands on the
branch as a normal check.

Hosted CI is off for a project until an owner enables it, and a workflow
only runs automatically from a branch with **effective protection** unless
the project explicitly opts into unprotected branches (see
[Security](#security)). Every job is metered per second while it runs.

For a headless runner you host yourself, see
[Runner as CI](https://swarmfile.com/docs/guides/runner-as-ci); the hosted service below shares
the check and status vocabulary but not the machine.

## Enable it

<div class="docs-steps">

1. Open the project in the web app → **CI** tab (or the tray's **Project
   settings → Hosted CI**, or run `swarmfile ci enable`).
2. Toggle **Enable hosted CI for this project**. This is the cost guard: no
   workflow runs until an owner turns it on.
3. Commit a `.swarmfile/ci.yml` to the branch you want to run from.

</div>

The **CI** tab also holds the project's secrets and variables and shows the
runner-size catalog (every built-in and the org's custom sizes, each with
its per-second rate, read-only). Custom sizes are org-wide and are managed
in **Settings → Hosted CI** (or `swarmfile ci size`). The **Runs** tab lists every hosted run on the branch,
with a per-job log viewer and a **Cancel** button; like **Files** and
**History** it is member-only - external collaborators (guests) are
refused. A finished run can be re-run from its page (`swarmfile ci rerun
<run-id>`), and a single failed job re-queued (`swarmfile ci retry
<job-id>`). A run that ends in failure - including a timeout, a cancel, a
queue expiry, a blocked start or an infrastructure fault - notifies its
starter, and on a protected branch that branch's recent committers too, by
bell/tray and the personal webhook, not email (see
[Notifications](https://swarmfile.com/docs/guides/notifications)).

## A first workflow

```yaml
version: 1
name: Build and test

on:
  commit:
    branches: [main]
  manual: true

jobs:
  test:
    size: medium
    timeout: 20m
    steps:
      - name: Install
        run: npm ci
      - name: Test
        run: npm test
```

Commit that to a protected `main` and a run starts on the commit. The
**Runs** tab shows it live; `swarmfile ci logs <job-id>` streams the same
text in a terminal.

## Triggers

| Trigger | Runs when | Filters |
|---|---|---|
| `on.commit` | A commit lands | `branches` (globs, `!` negates), `paths` (changed paths; empty match means no run) |
| `on.tag` | A tag is created | `pattern` (globs) |
| `on.manual` | Someone dispatches it - the Runs tab's **Run workflow**, the tray, or `swarmfile ci run` | `inputs` (optional; see below) |
| `on.merge_request` | An MR opens, its head moves, it reopens or becomes ready for review | `types`, `branches` (base-branch globs), `paths` |
| `on.schedule` | A 5-field UTC cron expression fires | `crons`, `branches` (default branch if omitted) |

A `paths` filter on a commit or an MR compares against the changed entries;
if the change set is unknown the filter matches nothing (a run is never
started on a guess). A schedule fires at most once per missed occurrence:
a hub that was asleep catches up the **latest** due firing, not every one.

### Manual inputs

A manual trigger can declare typed inputs. The dispatch form (web or tray)
renders them automatically, and the CLI passes them with
`swarmfile ci run --input name=value` (repeatable; values travel as strings
and coerce to the declared type):

```yaml
on:
  manual:
    inputs:
      environment:
        description: Where to deploy
        type: choice
        options: [staging, production]
        default: staging
      dry_run:
        type: boolean
        default: false
      retries:
        type: number
        required: true
```

Types are `string` (default), `boolean`, `number` and `choice` (with
`options`). `required: true` with no default must be supplied; an optional
input with no default gets the type's zero (`""`, `false`, `0`). Values are
read as `${{ inputs.<name> }}` anywhere an expression is allowed - including
a job `if:`, which is evaluated when the run is created, so a job can be
skipped statically. An unknown input name, a missing required one, or a
value that does not match the declared type (or choice options) refuses the
dispatch with a visible `ci_input_invalid` blocked run. Input names use
letters, digits and underscores.

## Jobs and steps

```yaml
jobs:
  build:
    name: Build
    size: large            # small | medium (default) | large | xlarge, or a custom size
    image: swarmfile-node  # optional; default swarmfile-standard (see Runner images)
    environment: production # optional; binds the job to an org environment (see Environments)
    approval: true         # optional; force an approval even if the environment names none
    timeout: 30m           # default 30m; 120m maximum (Enterprise up to 360m)
    needs: [setup]         # waits for every listed job
    continue-on-error: true
    if: success()
    env:
      NODE_ENV: production
    secrets: [NPM_TOKEN]   # ONLY these secrets are readable by this job
    outputs: [artifact]
    sparse: ["src/**"]     # optional - check out only these paths
    steps:
      - id: prep
        name: Prepare
        run: echo "sha=$(git rev-parse HEAD)" >> "$CI_OUTPUT"
      - name: Publish
        if: success()
        working-directory: packages/app
        shell: bash
        env:
          TOKEN: ${{ secrets.NPM_TOKEN }}
        run: npm publish --provenance
```

- Every job runs in its own fresh container with a clean checkout of the
  triggering commit.
- `run:` is a shell script; the default shell is `bash -e -u -o pipefail`,
  overridable per step.
- `if:` on a job or a step accepts `success()`, `failure()`, `always()`,
  `cancelled()`<!-- docs-lint-allow cancelled: the CI status function is spelled this way in the dialect -->, comparisons, `!`, `&&`, `||`, parentheses, and the
  functions `contains`, `startsWith`, `endsWith`, `format`, `join`,
  `toJSON`, `fromJSON`. A condition the runner cannot evaluate is treated
  as **false** - a job never runs on a condition no one can explain.
- `needs` jobs that did not succeed cause their dependents to be skipped
  (`ci_needs_failed`) - unless the dependent carries an explicit `if:`
  other than a literal `success()`, which is admitted and evaluated with
  `failure()` true for the failed need. A job with `continue-on-error:
  true` counts as success for `needs`.
- `sparse:` checks out only the listed paths, so a huge repo only
  materializes what the job uses - see [Partial checkout](#partial-checkout).

### Partial checkout

Every job gets a clean checkout of the triggering commit. For a large repo,
`sparse:` narrows it to the paths a job needs:

```yaml
jobs:
  test:
    sparse:
      - "src/**"
      - "*.md"
    steps:
      - run: npm test
```

A path is checked out when it matches a glob, or when a directory in its
ancestry matches. A glob with no `/` matches by name at any depth (`*.md`
finds `docs/notes.md`); one containing `/` is anchored to the repo root
(`assets/models/*.bin` does **not** match `vendor/assets/models/x.bin`).
Everything else is neither fetched nor written, and a directory holding no
matching file is not created. A leading `!` excludes a path that an earlier
glob included (gitignore-style, last match wins; at least one non-`!` glob is
required), and `#` comments are not supported. `exclude: [glob]` is sugar for
those `!` negations - and with no `sparse:` it means "everything except"
(check out the whole tree, then drop the exclusions). At most 64 globs of 1024
characters each. Omit `sparse:`/`exclude:` (or leave them empty) for a full
checkout. The same globs work for the self-hosted
[runner](https://swarmfile.com/docs/guides/runner-as-ci#checkout-scope-sparse) and
`swarmfile materialize --sparse`. Importing a GitHub workflow? An
`actions/checkout` step's `sparse-checkout` becomes this field.

### Matrix jobs

```yaml
jobs:
  test:
    matrix:
      os: [linux, windows]
      node: ["18", "20"]
    steps:
      - run: echo "${{ matrix.os }} on node ${{ matrix.node }}"
```

Each combination becomes its own job (`test (os=linux, node=18)`); a job
that `needs` a matrix waits for all of its rows. `include:` adds or augments
combinations and `exclude:` removes them, with GitHub's semantics. At most
64 combinations per job, 200 declared jobs per workflow and 256 expanded
jobs per run - past the combination cap the workflow is refused
`ci_matrix_too_large`; past a job cap, `ci_too_many_jobs` (declared) or
`ci_jobs_too_many` (expanded).

### Expressions and environment

`${{ … }}` works in `env:`, `run:`, `working-directory` and `if:`.
**Never interpolate an expression directly into `run:` for a value a
teammate can influence** (`${{ inputs.* }}`, `ci.branch`, `ci.actor`,
`vars.*`): like GitHub, the value is substituted into the shell command
verbatim, so `run: ./deploy.sh ${{ inputs.environment }}` lets a dispatcher
inject shell syntax. Pass it through `env:` instead -
`env: { TARGET: "${{ inputs.environment }}" }` then `run: ./deploy.sh
"$TARGET"`. Only `steps.*` outputs are substituted whole at run time; an
expression that *combines* a step output with other values inside `run:` or
`env:` is not evaluated (the literal text reaches the shell) - compute it in
a step and reference the output.
The context is:

| Context | Contents |
|---|---|
| `ci.*` | `branch`, `sha`, `tag`, `actor`, `event`, `run_id`, `run_number`, `status` |
| `env.*` | job `env:` values in a job `if:`; a step's own `env:` is not visible to that step's `if:` (declare the value at the job level to branch on it) |
| `vars.*` | project variables, then org variables (project wins) |
| `secrets.*` | the job's declared secrets only |
| `needs.<job>.outputs.*` | a completed dependency's `$CI_OUTPUT` values |
| `steps.<id>.outputs.*` | an earlier step's `$CI_OUTPUT` values |
| `matrix.*` | the job's combination values |
| `inputs.*` | the manual run's resolved `workflow_dispatch` inputs |

The runner exports `CI=true`, `CI_BRANCH`, `CI_SHA`, `CI_TAG`,
`CI_ACTOR`, `CI_RUN_ID`, `CI_RUN_NUMBER`, `CI_JOB_KEY`, `CI_JOB_ID`,
`CI_WORKSPACE`, `CI_PROJECT_ID`, `CI_ORG_ID`, `CI_EVENT` and `CI_OUTPUT`
(the `name=value` file for job outputs). Print `::add-mask::<value>` and
the runner masks that value (and the job token) out of the log from that
point on.

### Composite actions

Shared logic can live in a local composite action: a directory under
`.swarmfile/actions/<name>/` containing an `action.yml` with `inputs:` and
`runs.steps` (run-only steps; no nested `uses`, no `timeout`). A job step
uses it with a repo-relative path and passes inputs with `with:`:

```yaml
# .swarmfile/actions/setup/action.yml
name: Setup
inputs:
  node:
    default: "20"
runs:
  using: composite
  steps:
    - run: echo "using node ${{ inputs.node }}"
```

```yaml
# .swarmfile/ci.yml (jobs.<id>.steps)
- uses: ./.swarmfile/actions/setup
  with:
    node: "22"          # a scalar, or one whole ${{ … }} expression
- uses: ./.swarmfile/actions/setup
  with:
    node: ${{ matrix.node }}   # the outer expression survives
```

The action's steps are expanded into the job at compile time: every
`${{ inputs.<name> }}` becomes the caller's `with` value (or the declared
default; a required input with neither refuses the workflow). The caller's
`env:`, `if:` and `continue-on-error:` apply to the expanded steps (the
action's own step `env` wins on a name collision). A missing action file
refuses the workflow `ci_action_not_found`; remote/marketplace `uses:`
stays refused. Because the whole expansion happens before the run is
created, an action change takes effect on the next run that reads the
branch - and `swarmfile ci run` (manual dispatch) recompiles the branch
head like any other run.

## Runner images

`image:` selects the container a job runs in. Omit it and the job gets
`swarmfile-standard` (Debian bookworm with git, git-lfs, Node.js, Python 3
and a C/C++ toolchain). The curated catalog is available on every plan:

| Image | Includes |
|---|---|
| `swarmfile-standard` (default) | git, git-lfs, Node.js, Python 3, curl, build-essential |
| `swarmfile-node` | the standard image with Node.js 22 LTS |

An organization can also register its own images (**Settings → Hosted CI →
Runner images**): the reference must be digest-pinned and pushed to the
managed registry first - pushing is all that's needed; jobs target the
digest directly, with no deploy. Org names resolve before catalog names,
so an org image may deliberately shadow a catalog name. Using an org image
requires the Pro plan or above - a job on a lower plan is refused with
`ci_byo_image_requires_plan`; an unknown image refuses with
`ci_image_unknown`, and a reference the platform cannot pull fails the job
with `ci_image_pull_failed`.

## Environments and approvals

An organization defines **environments** (Settings → Hosted CI →
Environments): a name, an optional URL, and a reviewer rule - zero or more
reviewers as org members and/or owner/admin/member roles. A job binds to one
with `environment: <name>`; if the environment names reviewers, the job
**waits for an approval** once its `needs` are satisfied. Nothing runs and
nothing is billed while it waits; a reviewer (any one of the named users, or
a member whose role satisfies a named role - `role:admin` admits owners too)
approves or denies it from the run page, and the bell notifies every
eligible reviewer. Denial fails the job `environment_denied` and skips its
dependents; approval queues it immediately.

A job may also declare `approval: true` beside `environment:` - that adds a
gate when the environment names no reviewers (owners and admins then
approve). A declaration can only add protection: it never broadens or
removes the environment's reviewer set. Each environment can also turn on
**"the run's starter cannot self-approve"**, which refuses the person who
triggered the run even if they are a listed reviewer. A job naming an
environment the org has not defined is blocked with
`ci_environment_unknown`; retrying a failed gated job asks for approval
again rather than starting unapproved. Reviewer changes apply to requests
already waiting: removing a reviewer stops that person deciding a pending
request, and clearing the reviewer list (with no `approval:` on the job)
un-gates it - any member can then advance it from the run page. Deleting an
environment leaves a pending request's reviewer set in place, so it can
still be decided. Reviewer user ids are checked against org membership when
the environment is saved. An environment also has its own
**secrets and variables scope** (Settings → Hosted CI → Environments →
**Secrets & variables** on the environment's row): a job that declares the
environment resolves those on top of the project and org scopes, and the
environment wins on a name collision - the natural place for deploy
credentials that must not be readable by a job running on a pull request.

## Secrets and variables

Project secrets and variables live in the project's **CI** tab; org-scoped
ones are available to every project and live in the dashboard's **Settings
→ Hosted CI**, together with the org's environments (each with its own
secret/variable scope), its custom runner sizes, its runner images and its
CI policy - the org-level enable/disable control and the **Force unprotected
runs off** override. All of these are owner/admin-managed.

Resolution precedence is environment (only for a job declaring it) →
project → org: the most specific scope wins on a name collision.

- Secret **values are write-only**: they are never shown again, in any
  surface.
- A job must list the secret names it uses in `secrets:`. Referencing an
  undeclared secret fails the workflow before the job starts.
- Org secrets are only resolvable from **private** projects (an
  environment's secrets inherit this: a public project's workflows see
  project-scope values only).
- End-to-end encrypted projects do not run hosted CI: the runner holds no
  E2E keys by design.

## Artifacts and dependency caches

A job can publish files for later jobs, and restore/save a dependency cache
between runs:

```yaml
jobs:
  build:
    artifacts:
      - name: dist
        path: packages/app/dist
        if-no-files-found: error   # warn (default) | error | ignore
    cache:
      key: deps-${{ ci.branch }}-${{ ci.sha }}
      paths: [.npm]
      restore-keys: [deps-${{ ci.branch }}-]
    steps:
      - run: npm ci
      - run: npm run build

  deploy:
    needs: [build]
    downloads: [dist]              # fetched after checkout, before steps
    steps:
      - run: ./packages/app/dist/deploy.sh
```

- **Artifacts** are bundled (tar.gz) at job end - on success or failure, or
  gated by the entry's `if:` - and `downloads:` fetches each name from the
  newest successful producer in the job's transitive `needs` closure (a
  missing producer fails the job). Limits: 1 GiB per artifact, 5 GiB per
  run, 30-day retention; a `path` that climbs out of the workspace is
  refused.
- **Caches** restore after checkout and before the first step: the exact
  `key`, then the newest entry matching any `restore-keys` prefix; the save
  happens only when the job succeeds. Key supports `${{ }}` (no secrets).
  Limit: 5 GiB per entry, 30-day retention.
- The GitHub importer maps `actions/upload-artifact`, `actions/download-artifact`
  and `actions/cache` (or the split restore/save pair) onto these fields
  when their arguments are literal.

## Runner sizes, billing and limits

| Size | vCPU | Memory | Mapped container |
|---|---|---|---|
| `small` | 0.25 | 1 GiB | basic |
| `medium` (default) | 1 | 6 GiB | standard-2 |
| `large` | 2 | 8 GiB | standard-3 |
| `xlarge` | 4 | 12 GiB | standard-4 |

Orgs can define custom sizes (quarter-vCPU steps, up to the platform
ceilings); each custom size rounds **up** to the smallest offered
container, and its per-second rate follows that container. Sizes are
org-wide; manage them in **Settings → Hosted CI** or with
`swarmfile ci size` (the project CI tab lists them read-only, with rates).
Define one with `swarmfile ci size set build-heavy --vcpu 2 --memory 8
--disk 16` (or the size form in Settings → Hosted CI), then ask for it by
name in a job: `size: build-heavy`.

**Billing** is per second of container time at the mapped container's rate
(at-cost × 1.1), with a 60-second minimum per job. The run list
shows each job's billed seconds and charge. Compute counts against the
org's [spend cap](https://swarmfile.com/docs/admin/billing-and-plans) - over the cap, a new job
is refused with a blocked `spend_cap_reached` run and reads keep working. A
Community org needs a payment method on file before its first job (an owner
adds it in **Billing**) and runs
under the platform's Community compute ceiling; platform ceilings apply.

### What a job costs

Rates are the provider's list price for each container, recovered at cost
× 1.1, at current provider pricing:

| Size | vCPU / memory / disk | Per minute | Per hour |
|---|---|---|---|
| `small` | 0.25 / 1 GiB / 4 GB | $0.00051 | $0.03 |
| `medium` (default) | 1 / 6 GiB / 12 GB | $0.00237 | $0.14 |
| `large` | 2 / 8 GiB / 16 GB | $0.00403 | $0.24 |
| `xlarge` | 4 / 12 GiB / 20 GB | $0.00735 | $0.44 |

A job bills `max(60 s, elapsed)` at its resolved size's rate; the run list
shows each job's billed seconds and exact charge. If provider pricing
moves, this table and the in-app rates move with it.

**How this compares to GitHub-hosted runners.** Our billing is per second,
with no included-minute allowance on any plan. GitHub's is per minute,
with partial minutes rounded up, and its private-repo plans include a
monthly minute allowance (public-repo jobs are free) - below that
allowance, staying on GitHub can be cheaper. Past it, the comparable Linux
job costs less here: our `large` (2 vCPU / 8 GiB) is $0.00403/min, while
GitHub's published 2-core rate works out about 50% higher per paid minute,
and per-second billing widens the gap on jobs that don't land on whole
minutes - a 5m30s job bills 330 seconds here instead of six minutes there.
GitHub's larger, macOS and GPU runners are premium-priced and aren't
offered on hosted CI; those jobs belong on the
[self-hosted runner](https://swarmfile.com/docs/guides/runner-as-ci). Verify GitHub's current
rates at their
[pricing calculator](https://github.com/pricing/calculator?feature=actions),
and see the dated side-by-side on the
[GitHub comparison page](https://swarmfile.com/compare/github-lfs).

Other limits: a job's `size` must resolve (an unknown name fails the job
rather than guessing); a job's log is capped at 50 MiB and a run's log at
200 MiB (output past a cap is dropped and the log is marked truncated -
the earliest output is what is kept) and logs are retained for 30 days;
concurrent jobs are capped by plan (Community 1, Starter 2, Pro 8,
Enterprise 16, weighted by size).

## Security

A workflow can read the project's secrets, so hosted CI treats it like
code that runs with credentials:

- Automatic runs come only from branches with **effective protection**
  (a protected branch rule) - a push to an unprotected branch is
  refused with a visible `blocked` run.
- A project owner can opt into unprotected branches (the **Allow
  unprotected runs** switch in the CI tab, `swarmfile ci enable
  --allow-unprotected`). Only do this when everyone who can push to those
  branches is trusted like a branch protector.
- A blocked run still writes its check row as a failure, so a required
  check can never be skipped by breaking CI.
- Job tokens are scoped to one job, one project and the triggering ref,
  expire with the job, and are stripped from every step's environment; the
  runner also blocks a step from reading the engine's `/proc/<pid>/environ`
  and masks the token from logs.

## What is not supported

The dialect is small on purpose and fails closed: anything it does not
understand refuses the workflow with a line number.

- No marketplace/remote `uses:` (never arbitrary code); a LOCAL composite
  action under `./.swarmfile/actions/<name>` IS supported (see Composite
  actions).
- No reusable workflows (`workflow_call`), `permissions`, or service
  containers. `environment:` + `approval:` ARE supported (see above).
  Artifacts and dependency caches are native (see below).
- No `container:` (GitHub's per-job container) - use `image:` instead, which
  selects one of the curated runner images or an org-registered one;
  `size:` selects resources independently.
- `workflow_dispatch` inputs ARE supported (typed `string`/`boolean`/
  `number`/`choice`; see Manual inputs). A manual run otherwise takes the
  branch only.
- No merge queues, no fork PRs, no `pull_request_target`.
- No self-hosted labels (`self-hosted`) - use the
  [runner as CI](https://swarmfile.com/docs/guides/runner-as-ci) instead.

## Going further

- `swarmfile ci list|status|logs|cancel|run` and the secret/variable/size
  commands: see the [CLI reference](https://swarmfile.com/docs/cli/swarmfile).
- The desktop tray shows the same runs, logs and settings per project.
