Browse docs
Docs / Guides / Hosted CI
View as Markdown

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#

  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.

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#

TriggerRuns whenFilters
on.commitA commit landsbranches (globs, ! negates), paths (changed paths; empty match means no run)
on.tagA tag is createdpattern (globs)
on.manualSomeone dispatches it - the Runs tab's Run workflow, the tray, or swarmfile ci runinputs (optional; see below)
on.merge_requestAn MR opens, its head moves, it reopens or becomes ready for reviewtypes, branches (base-branch globs), paths
on.scheduleA 5-field UTC cron expression firescrons, 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 is bash -e -u -o pipefail, overridable per step.
  • if: on a job or a step accepts success(), failure(), always(), cancelled(), 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#

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:

ContextContents
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:

ImageIncludes
swarmfile-standard (default)git, git-lfs, Node.js, Python 3, curl, build-essential
swarmfile-nodethe 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: - 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#

SizevCPUMemoryMapped container
small0.251 GiBbasic
medium (default)16 GiBstandard-2
large28 GiBstandard-3
xlarge412 GiBstandard-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:

SizevCPU / memory / diskPer minutePer hour
small0.25 / 1 GiB / 4 GB$0.00051$0.03
medium (default)1 / 6 GiB / 12 GB$0.00237$0.14
large2 / 8 GiB / 16 GB$0.00403$0.24
xlarge4 / 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 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 instead.

Going further#

  • swarmfile ci list|status|logs|cancel|run and the secret/variable/size commands: see the CLI reference.
  • The desktop tray shows the same runs, logs and settings per project.