Status: normative design contract
The key words MUST, MUST NOT, REQUIRED, SHOULD, and SHOULD NOT describe requirements for projects using ci-fleet.
Every participating project MUST provide:
scripts/ci/plan.json
scripts/ci/run.sh <task> --shard INDEX/TOTAL
The task plan MUST declare named, independently runnable tasks, their fast and/or full group membership, deterministic shard counts, and expected minutes per shard. Task IDs MUST NOT use the reserved aggregate names fast or full.
Projects MUST also preserve these aggregate developer interfaces:
./scripts/ci/run.sh fast
./scripts/ci/run.sh fullThe fleet MUST schedule the named task shards, not the aggregate command. Aggregate commands exist for local reproduction, migration compatibility, and constrained environments with only one worker.
Existing scripts do not need to be discarded. The standard entrypoint MAY delegate to established project scripts, but the shared fleet MUST NOT contain project-specific test logic.
flowchart LR
A["plan.json"] --> B["GitHub matrix"]
B --> C["task A"]
B --> D["task B: shard 1/N"]
B --> E["task B: shard 2/N"]
C --> F["Project Docker environment"]
D --> F
E --> F
Ordinary CI MUST target a wall-clock duration of five minutes or less when sufficient workers are available.
- Every task-matrix job MUST set
timeout-minutes: 5. - Expected test payload per shard MUST be four minutes or less, reserving time for checkout, container startup, and reporting.
- Sharding MUST be deterministic: the same revision and
INDEX/TOTALpair select the same work. - Tasks and shards SHOULD be balanced using measured historical duration rather than test count.
- Projects MUST split, optimize, or move any indivisible test that cannot fit the ordinary five-minute job ceiling.
- A task plan MUST expand to no more than GitHub's 256-job matrix limit.
Forty-five test-minutes divided among nine workers is a theoretical five-minute lower bound. Real plans generally need more than nine shards because job setup consumes part of the ceiling and test work is not perfectly balanced. Adding workers reduces wall-clock time only while runnable shards remain queued.
Application workflows MUST submit every independent task and shard and MUST NOT use strategy.max-parallel to represent the number of available fleet workers. Excess jobs remain safely queued by GitHub. Pool budgets and controller maxima belong in the private schema-v3 infrastructure configuration described in Git-authored controller desired state.
A project MAY limit concurrency for a documented external-system restriction, such as an API rate limit or a single-writer fixture, only when that limit is independent of fleet size. Workflow-level concurrency groups MAY still cancel obsolete runs or serialize operations that are inherently unsafe to overlap.
A project CI job:
- MUST run application build and validation inside project-owned containers;
- MUST NOT require Node, PHP, Python, Java, Composer, npm, database clients, or other project runtimes installed on the runner host;
- MAY assume only Linux, Git, Bash, Docker Engine, Docker Compose, and the GitHub runner interface;
- MUST use the same project test image locally and in CI;
- MUST pin runtime versions in project-owned image definitions.
A project:
- MUST use a unique Compose project name derived from repository, workflow run ID, run attempt, task, and shard;
- MUST NOT set fixed
container_namevalues; - MUST NOT publish fixed host ports;
- SHOULD run tests against service names on an internal Compose network;
- MUST label fleet-created resources with repository and run identity when Compose does not already provide sufficient identity;
- MUST NOT use host networking;
- MUST NOT mount the host root filesystem or unrelated host paths;
- MUST NOT invoke unrestricted
docker system prune; - MUST remove its run-owned containers, networks, and disposable volumes after success or failure.
Recommended project name construction:
repository="${GITHUB_REPOSITORY:-local/project}"
repo_component="$(printf '%s' "${repository#*/}" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9_-' '-' | cut -c1-12)"
task_component="$(printf '%s' "${CI_FLEET_TASK:-aggregate}" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9_-' '-' | cut -c1-12)"
shard_component="${CI_FLEET_SHARD_INDEX:-1}of${CI_FLEET_SHARD_TOTAL:-1}"
raw_name="ci-${repo_component}-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}-${task_component}-${shard_component}"
COMPOSE_PROJECT_NAME="$(printf '%s' "$raw_name" | tr -cs 'a-z0-9_-' '-' | cut -c1-63)"
export COMPOSE_PROJECT_NAMEThe project entrypoint MUST register cleanup before creating Docker resources:
cleanup() {
docker compose -f compose.ci.yaml down --remove-orphans --volumes
}
trap cleanup EXIT INT TERMProject cleanup is the first layer, not the only layer. Ephemeral runner destruction and host garbage collection remain fleet responsibilities.
Cancellation, host failure, or Docker daemon failure may bypass a shell trap. Therefore, every resource MUST remain identifiable by run and age so fleet cleanup can safely remove abandoned state later.
Ordinary CI:
- MUST declare
permissions: contents: read; - MUST NOT receive deployment, release, production, or internal-network credentials;
- MUST NOT push branches, tags, releases, packages, or commits;
- MUST set
timeout-minutes: 5on every ordinary task-matrix job; - SHOULD use concurrency groups to cancel obsolete runs or protect inherently single-writer operations;
- MUST NOT use concurrency settings to encode current fleet capacity;
- MUST treat pull-request code as untrusted unless repository policy explicitly establishes otherwise.
A job requiring write permission is not ordinary CI. It MUST be a separate job or workflow routed to an appropriate privileged runner group.
- Normal validation SHOULD require no secret.
- Required test secrets MUST be explicitly named and passed by the calling project.
- Reusable workflows MUST NOT use blanket secret inheritance by default.
- Secrets MUST NOT be written into images, image layers, artifacts, caches, or logs.
- Fork pull-request workflows MUST NOT receive repository secrets or write tokens.
- Long-lived fleet controller credentials MUST be unavailable to all job containers.
The following MUST NOT use the normal shared CI runner group:
- production or development deployment;
- release publication;
- branch or tag mutation;
- schema update jobs that commit changes;
- package publication;
- jobs requiring production or internal-management network access.
flowchart TD
A["Workflow job"] --> B{"Writes or deploys?"}
B -->|No| C["Shared read-only CI pool"]
B -->|Yes| D["Separated privileged pool"]
A project MUST document both aggregate local commands and a representative direct shard:
./scripts/ci/run.sh fast
./scripts/ci/run.sh full
./scripts/ci/run.sh unit --shard 1/4Local and CI execution MUST use the same project image and task implementation. CI-only behavior SHOULD be limited to matrix selection, run identity, artifact upload, and GitHub status reporting.
A project is not compliant until all of these pass:
- task plan validation and deterministic matrix expansion;
- every declared task/shard through Docker;
- fast aggregate locally through Docker;
- full aggregate locally through Docker;
- measured evidence that ordinary shards fit the five-minute ceiling;
- manual experimental fleet run;
- parallel old/new CI comparison;
- forced failure cleanup;
- canceled job cleanup;
- repeated run with no fixed-name or fixed-port collision;
- proof that ordinary CI has read-only permissions;
- proof that privileged secrets are absent;
- rollback to the existing runner path.