Skip to content

Orgabot is in private alpha. Request access, or join our Discord.

← All documentation
Reference

CI pipelines: provider-neutral jobs and execution policy

Describe CI once, then choose where each target runs with organization, project and target policy, and see why every job landed where it did.

Last updated

What this is

A CI pipeline is described once, in a provider-neutral file, and the place each part of it runs is decided separately by policy. The pipeline says what runs: commands, the job graph, a matrix, artifacts, the toolchain and platform a job needs, and how much it is trusted. Policy says where it runs: local Docker, a native macOS, Linux or Windows worker, local GitHub Actions compatibility, a self-hosted runner, or a GitHub-hosted runner.

Neither document can speak for the other. That separation is the safety property: a pull request can change what a job does, but it cannot route that job onto a privileged runner, because routing is not in the repository.

This page covers the contract and the policy resolver. Running jobs on each backend arrives with the execution adapters that follow it on the roadmap; today orgabot ci resolve tells you exactly where every job would run and why.

The two documents

ConcernLives in
Targets, jobs, needs, matrix, steps, shell, workdir, services, inputs and outputs, artifacts, secret references, environment, toolchain/OS/architecture requirements, timeouts, resource claims, trust class, side-effect classthe repository file .orgabot/ci.json (in the project's workdir)
Backends, routing mode, preference and fallback, export permission, spend caps, timeout caps, organization constraints, which projects are migratedthe installation file ~/.orgabot/ciPolicy.json

A routing key in the repository file (runs-on, backend, preference, constraints and similar) is refused with a message that says it belongs in installation settings. Nothing in ciPolicy.json defines a job.

Targets

A target is what a pipeline proves or produces, and its kind is one of check, test, build, package, release or deployment. A target is not an operating system: a lint check, a Windows installer and a production site deploy are all targets. A target may name a platform it produces for, and a job inherits that as a requirement unless it states its own.

A target belongs to a registered project. By default that is the pipeline's own project; a target may instead name a registered child of the same repository (a monorepo workdir registration) with "project". Mapping a target onto a project in a different repository is refused, so a pipeline can never claim ownership of someone else's repository.

To rename a target without losing its policy override, list the old name in formerIds, or record "targetAliases": { "old": "new" } for the project in ciPolicy.json.

Secrets are references

A pipeline never holds a secret value. A job lists "secrets": [{ "name": "APPLE_ID_PASSWORD", "ref": "apple-notarization" }], where ref is a logical name the installation binds or a broker secret handle. A literal that looks like a token, key, private key or a password in a URL is refused wherever it appears, as is a credential-named environment variable set to a literal. Every run records its pipeline verbatim, so this is also what keeps secrets out of the audit trail.

How policy is resolved

Policy resolves field by field, broadest first:

built-in defaults, then organization defaults, then project overrides, then target overrides

Each layer states only what it changes. A field a layer does not mention is inherited, and every resolved field reports the layer that decided it, for example preference = [local-docker, local-native-macos] (set by organization org_senternet).

A layer can say three things about a field besides nothing:

  • A value replaces what it inherited. Lists replace rather than merge: a target that sets "preference": ["github-hosted-windows"] means exactly that list.
  • `{"$clear": true}` empties the field (no explicit backend, an empty preference list, no allow-list, no timeout cap). Fields that always need a value, such as mode, cannot be cleared.
  • `{"$inherit": true}` resets the field to inherit. It has the same effect as omitting it, and the resolution says who reset it.

The built-in default is local only: prefer local-docker, then local-native-macos, queue when unavailable, spend nothing, export nothing.

Choosing a backend

mode picks how candidates are formed:

  • explicit: exactly one backend.
  • preference: the ordered preference list.
  • optimized: every allowed backend, ranked by optimizeFor (cost, speed or locality), with ties broken by name so the answer never changes between runs.

optimizeFor also accepts an organization optimization profile name and stores the objective it means for a machine: balanced and cost-first mean cost, latency-first means speed, and local-first and privacy-first mean locality. quality-first and custom are refused here, because they describe how Orgabot picks a model, and a CI job's result is its exit code whichever machine runs it. A profile only ranks backends. It never names a model, never adds a backend, and never relaxes an organization constraint: keeping source on your machines is allowSourceExport, not privacy-first.

A job's requirements (OS, architecture, toolchains, capabilities such as codesign:apple, service containers, resource claims) then filter those candidates. They never add one. A Windows job under a policy that names only local backends fails with "no eligible backend" instead of quietly moving to a hosted runner. When a requirement rules out the first preference, the first eligible backend is used, and that is not a fallback.

onUnavailable decides what happens when the chosen backend is reported unavailable: queue, fail, request_approval for the next eligible backend, or automatic_fallback to it. Automatic fallback onto a remote or metered backend also needs the organization constraint allowAutomaticRemoteFallback: true; without it the fallback becomes an approval request. Availability that has not been reported is not treated as unavailable, and a failing test never triggers another backend.

maxSpendUsdPerRun caps the whole run on a target, not one job. A metered backend whose estimated cost for a single job is already over the cap is ineligible for that job, and after placement the estimated cost of every job on the target (including fallbacks waiting for approval) is added up. A run whose total is over the cap is refused with spend_cap_exceeded, so a matrix cannot multiply the bill past the cap one cheap job at a time.

Approving a fallback

A fallback that needs approval goes into the ordinary approval queue. When orgabot ci resolve <project> --record finds one, it files a pending approval whose action names the exact project, pipeline, job and backend, for example orgabot ci fallback premail premail-ci lint -> local-act. Resolving again reuses the pending request instead of filing a second one.

Approve it with orgabot approve <id>, then resolve again with --approval <id>. Only that job moves to that backend. An approval that is still pending, was denied or voided, belongs to another project, or is not a CI approval refuses the run instead of being recorded. The run snapshot lists the approvals the run used and the ones it is still waiting on.

An approval is single-use. The recorded run that moves a job on it spends it (the approval is voided with that run's id), so the next run that needs the same fallback files a new request instead of reusing the old grant.

Each job placement also explains its own values: where its timeout came from (the job, or a policy cap), which matrix axis, job requirement or target platform decided its OS and architecture, and which layer chose the selection mode.

Organization constraints

An organization's constraints are hard limits, not defaults. Only the organization can set them, and no project or target can weaken them:

ConstraintEffect
deniedBackendsnever eligible, and naming one in a lower layer is refused
allowedBackendKindsonly these backend kinds are eligible
allowSourceExport: falseno backend that sends source off your machines is eligible, and enabling export below is refused
allowArtifactExport: falseinput artifacts never go to such a backend
maxSpendUsdPerRunceiling on every spend cap below it
allowAutomaticRemoteFallbackpermits unattended fallback onto remote or metered backends
maxTrustno job may declare a higher trust class

An attempt to exceed a constraint is refused and named in the output. It is never silently clamped.

Example: the Premail reference flow

{
  "schemaVersion": 2,
  "organizations": {
    "org_senternet": {
      "defaults": { "mode": "preference", "preference": ["local-docker", "local-native-macos"] },
      "constraints": { "allowSourceExport": true, "maxSpendUsdPerRun": 10 }
    }
  },
  "projects": {
    "premail": {
      "organization": "org_senternet",
      "migrated": true,
      "targets": {
        "windows-installer": {
          "preference": ["github-hosted-windows"],
          "allowSourceExport": true,
          "maxSpendUsdPerRun": 5
        }
      }
    }
  }
}

The organization defaults to local Docker and Premail inherits it, so lint, tests and Linux packages run there. The macOS release job requires macOS and codesign:apple, so Docker is ineligible and the native macOS worker is selected. The Windows installer target overrides its preference to GitHub-hosted Windows and is the only target allowed to export source.

Pull requests cannot grant themselves privileges

A pipeline file from a pull request, or from a working tree, is untrusted. It is compared with the same file on the default branch, and it may not:

  • raise a job's trust or side-effect class, or add a new job above untrusted trust or artifact side effects;
  • raise a target's side-effect ceiling, change its deployment environment, or remap it to another project;
  • reference a secret the trusted job did not already reference.

Any of these refuses the whole resolution, naming each one. A production deployment target in an untrusted pipeline is never routed at all: its jobs are withheld from the run. Granting a stronger privilege means merging it to the default branch like any other reviewed change.

Legacy projects keep working

Nothing changes for a project until ciPolicy.json marks it "migrated": true. Until then its existing GitHub workflows and its verification configuration (orgabot project set-verify, orgabot project verify-policy) stay authoritative, and orgabot ci resolve shows a legacy (preview) of where its jobs would go after migration.

Conflicts between the two documents

SituationResult
A policy override names a target the pipeline no longer hasreported as an orphan and not applied
Overrides exist under both a target's old and new namerefused as ambiguous; keep one
The pipeline's projectId is not the project being resolvedrefused
The project's overrides were written under a different organization than it now belongs torefused until re-confirmed under the current organization
A policy names a backend that does not existrefused, listing the known backends

ciPolicy.json is optional. When it does not exist, every project is legacy and the local-only defaults apply. When it exists but cannot be read or validated, resolution stops and names the file and the key rather than guessing.

Commands

orgabot ci validate .orgabot/ci.json
orgabot ci policy
orgabot ci resolve premail
orgabot ci resolve premail --unavailable local-docker
orgabot ci resolve premail --record
orgabot ci snapshot <run-id>

resolve prints every target's resolved fields with their sources and every job instance's placement, including why each other backend was ineligible. Each job also shows where its own fields came from: its timeout (the job's value, or the policy cap that lowered it), its OS and architecture (a matrix axis, the job's requires, or the target's platform), and its selection mode. --available and --unavailable let you rehearse an outage. --record seals an immutable run snapshot in ~/.orgabot/ci/runs/: the commit, the pipeline, the policy that applied, the resolution, who asked, and the trace. A snapshot is written once and snapshot refuses one whose contents no longer match its seal.

Schema versions

ciPolicy.json is at schemaVersion 2. A version 1 file (one backend and a fallback list per scope) is upgraded when read, and orgabot ci policy --upgrade rewrites it in place after keeping a backup. The upgrade never grants anything: a version 1 fallback list becomes onUnavailable: "request_approval". A file from a newer Orgabot is refused rather than read as an older one.