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). Every job is metered per second while it runs.
For a headless runner you host yourself, see Runner as CI; the hosted service below shares the check and status vocabulary but not the machine.
Enable it#
- Open the project in the web app → CI tab (or the tray's Project
settings → Hosted CI, or run
swarmfile ci enable). - Toggle Enable hosted CI for this project. This is the cost guard: no workflow runs until an owner turns it on.
- Commit a
.swarmfile/ci.ymlto the branch you want to run from.
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).
A first workflow#
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):
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#
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 isbash -e -u -o pipefail, overridable per step.if:on a job or a step acceptssuccess(),failure(),always(),cancelled(), comparisons,!,&&,||, parentheses, and the functionscontains,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.needsjobs that did not succeed cause their dependents to be skipped (ci_needs_failed) - unless the dependent carries an explicitif:other than a literalsuccess(), which is admitted and evaluated withfailure()true for the failed need. A job withcontinue-on-error: truecounts as success forneeds.sparse:checks out only the listed paths, so a huge repo only materializes what the job uses - see 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:
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 and
swarmfile materialize --sparse. Importing a GitHub workflow? An
actions/checkout step's sparse-checkout becomes this field.
Matrix jobs#
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::
# .swarmfile/actions/setup/action.yml
name: Setup
inputs:
node:
default: "20"
runs:
using: composite
steps:
- run: echo "using node ${{ inputs.node }}"
# .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:
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:- anddownloads:fetches each name from the newest successful producer in the job's transitiveneedsclosure (a missing producer fails the job). Limits: 1 GiB per artifact, 5 GiB per run, 30-day retention; apaththat climbs out of the workspace is refused. - Caches restore after checkout and before the first step: the exact
key, then the newest entry matching anyrestore-keysprefix; 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-artifactandactions/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 - 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. Verify GitHub's current
rates at their
pricing calculator,
and see the dated side-by-side on the
GitHub comparison page.
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
blockedrun. - 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>/environand 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) - useimage:instead, which selects one of the curated runner images or an org-registered one;size:selects resources independently. workflow_dispatchinputs ARE supported (typedstring/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 instead.
Going further#
swarmfile ci list|status|logs|cancel|runand the secret/variable/size commands: see the CLI reference.- The desktop tray shows the same runs, logs and settings per project.