Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/actions/plan/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Expand ci-fleet task plan
description: Validate a project CI plan and emit a deterministic GitHub Actions matrix.

inputs:
plan-path:
description: Path to the project-owned CI task plan.
required: false
default: scripts/ci/plan.json
group:
description: Aggregate group to expand (fast or full).
required: false
default: fast

outputs:
matrix:
description: GitHub Actions matrix containing one entry per task shard.
value: ${{ steps.expand.outputs.matrix }}
job-count:
description: Number of independently schedulable jobs.
value: ${{ steps.expand.outputs.job-count }}
estimated-test-minutes:
description: Sum of expected test payload across all emitted shards.
value: ${{ steps.expand.outputs.estimated-test-minutes }}

runs:
using: composite
steps:
- id: expand
shell: bash
run: >-
python3 "${GITHUB_ACTION_PATH}/plan.py"
--plan "${{ inputs.plan-path }}"
--group "${{ inputs.group }}"
--github-output "${GITHUB_OUTPUT}"
157 changes: 157 additions & 0 deletions .github/actions/plan/plan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Validate and expand a ci-fleet project task plan."""

from __future__ import annotations

import argparse
import json
import math
import re
import sys
from pathlib import Path
from typing import Any


TASK_ID = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
PLAN_KEYS = {
"schema_version",
"target_wall_clock_minutes",
"max_job_minutes",
"shard_target_minutes",
"tasks",
}
TASK_KEYS = {"id", "groups", "shards", "estimated_minutes_per_shard"}


def fail(message: str) -> None:
raise ValueError(message)


def exact_keys(value: Any, required: set[str], path: str, optional: set[str] | None = None) -> None:
if not isinstance(value, dict):
fail(f"{path} must be an object")
optional = optional or set()
missing = required - set(value)
unknown = set(value) - required - optional
if missing:
fail(f"{path} is missing: {', '.join(sorted(missing))}")
if unknown:
fail(f"{path} contains unknown keys: {', '.join(sorted(unknown))}")


def load_plan(path: Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
fail(f"{path} was not found")
raise AssertionError from exc
except json.JSONDecodeError as exc:
fail(f"{path}:{exc.lineno}:{exc.colno} is not valid JSON: {exc.msg}")
raise AssertionError from exc
exact_keys(value, PLAN_KEYS, "$", {"$schema"})
return value


def validate_and_expand(plan: dict[str, Any], group: str) -> tuple[dict[str, list[dict[str, Any]]], int]:
if group not in {"fast", "full"}:
fail("group must be fast or full")
if plan["schema_version"] != 1:
fail("$.schema_version must equal 1")
if plan["target_wall_clock_minutes"] != 5:
fail("$.target_wall_clock_minutes must equal 5")
if plan["max_job_minutes"] != 5:
fail("$.max_job_minutes must equal 5")
target = plan["shard_target_minutes"]
if type(target) is not int or not 1 <= target <= 4:
fail("$.shard_target_minutes must be an integer between 1 and 4")
tasks = plan["tasks"]
if not isinstance(tasks, list) or not tasks:
fail("$.tasks must be a non-empty array")

seen: set[str] = set()
coverage = {"fast": 0, "full": 0}
include: list[dict[str, Any]] = []
estimated_total = 0
for index, task in enumerate(tasks):
path = f"$.tasks[{index}]"
exact_keys(task, TASK_KEYS, path)
task_id = task["id"]
if not isinstance(task_id, str) or not TASK_ID.fullmatch(task_id):
fail(f"{path}.id must be a lowercase slug")
if task_id in {"fast", "full"}:
fail(f"{path}.id uses a reserved aggregate name")
if task_id in seen:
fail(f"{path}.id duplicates {task_id}")
seen.add(task_id)

groups = task["groups"]
if not isinstance(groups, list) or not groups or any(value not in {"fast", "full"} for value in groups):
fail(f"{path}.groups must contain fast and/or full")
if len(groups) != len(set(groups)):
fail(f"{path}.groups must be unique")
if "fast" in groups and "full" not in groups:
fail(f"{path}.groups must include full whenever it includes fast")
for value in groups:
coverage[value] += 1

shards = task["shards"]
if type(shards) is not int or shards < 1:
fail(f"{path}.shards must be a positive integer")
estimate = task["estimated_minutes_per_shard"]
if type(estimate) not in {int, float} or isinstance(estimate, bool) or estimate <= 0 or estimate > target:
fail(f"{path}.estimated_minutes_per_shard must be greater than zero and at most {target}")

if group in groups:
for shard in range(1, shards + 1):
include.append(
{
"task": task_id,
"shard": shard,
"shards": shards,
"estimated_minutes": estimate,
}
)
estimated_total += estimate

if coverage["fast"] == 0 or coverage["full"] == 0:
fail("$.tasks must provide both fast and full coverage")
if len(include) > 256:
fail(f"expanded {group} matrix has {len(include)} jobs; GitHub permits at most 256")
if not include:
fail(f"group {group} contains no tasks")
return {"include": include}, math.ceil(estimated_total)


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--plan", type=Path, required=True)
parser.add_argument("--group", choices=("fast", "full"), default="fast")
parser.add_argument("--github-output", type=Path)
return parser.parse_args()


def main() -> int:
args = parse_args()
try:
plan = load_plan(args.plan)
matrix, estimated_total = validate_and_expand(plan, args.group)
except ValueError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
encoded = json.dumps(matrix, separators=(",", ":"))
if args.github_output:
with args.github_output.open("a", encoding="utf-8") as output:
output.write(f"matrix={encoded}\n")
output.write(f"job-count={len(matrix['include'])}\n")
output.write(f"estimated-test-minutes={estimated_total}\n")
print(encoded)
print(
f"OK: {args.group} expands to {len(matrix['include'])} jobs "
f"covering approximately {estimated_total} test-minutes",
file=sys.stderr,
)
return 0


if __name__ == "__main__":
raise SystemExit(main())
63 changes: 63 additions & 0 deletions .github/actions/plan/test_plan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Regression tests for ci-fleet task-plan expansion."""

from __future__ import annotations

import copy
import json
import unittest
from pathlib import Path

from plan import validate_and_expand


ROOT = Path(__file__).resolve().parents[3]


def sample_plan() -> dict:
return json.loads((ROOT / "examples/project/scripts/ci/plan.json").read_text(encoding="utf-8"))


class PlanTests(unittest.TestCase):
def assert_rejected(self, plan: dict, expected: str) -> None:
with self.assertRaisesRegex(ValueError, expected):
validate_and_expand(plan, "full")

def test_example_expands_to_independent_shards(self) -> None:
matrix, total = validate_and_expand(sample_plan(), "full")
self.assertEqual(len(matrix["include"]), 13)
self.assertEqual(total, 45)

def test_fast_is_a_smaller_group(self) -> None:
matrix, total = validate_and_expand(sample_plan(), "fast")
self.assertEqual(len(matrix["include"]), 6)
self.assertEqual(total, 20)

def test_five_minute_job_ceiling_is_mandatory(self) -> None:
plan = sample_plan()
plan["max_job_minutes"] = 6
self.assert_rejected(plan, "must equal 5")

def test_shard_estimate_cannot_consume_startup_reserve(self) -> None:
plan = sample_plan()
plan["tasks"][0]["estimated_minutes_per_shard"] = 5
self.assert_rejected(plan, "at most 4")

def test_duplicate_task_ids_are_rejected(self) -> None:
plan = sample_plan()
plan["tasks"][1]["id"] = plan["tasks"][0]["id"]
self.assert_rejected(plan, "duplicates")

def test_fast_tasks_must_also_run_in_full(self) -> None:
plan = sample_plan()
plan["tasks"][0]["groups"] = ["fast"]
self.assert_rejected(plan, "whenever it includes fast")

def test_matrix_limit_is_enforced(self) -> None:
plan = sample_plan()
plan["tasks"][0]["shards"] = 257
self.assert_rejected(plan, "at most 256")


if __name__ == "__main__":
unittest.main()
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ Agents modifying this repository or adapting a project MUST apply these document
- Prefer ephemeral, one-job runners.
- Treat every group of runners sharing one Docker daemon as one security boundary.
- Keep normal CI, release, deployment, repository-writing, and internal-network workloads separable.
- Make cleanup concurrency-aware and scoped to a workflow run.
- Make cleanup concurrency-aware and scoped to a workflow run, task, and shard.
- Keep ordinary task-matrix jobs at a five-minute hard timeout and expected test payload at four minutes or less.
- Preserve `fast` and `full` as aggregates, but schedule granular deterministic task shards on the fleet.
- Do not use unrestricted global Docker pruning as per-job cleanup.
- Pin production dependencies and container images to reviewed versions or digests.
- Do not remove or weaken existing required CI until parallel validation and rollback verification are complete.
Expand All @@ -49,6 +51,8 @@ Runnable changes must eventually include:
- cleanup verification after success, failure, cancellation, and timeout;
- proof that long-lived controller credentials are unavailable to jobs;
- proof that ordinary CI uses read-only permissions;
- task-plan validation and deterministic matrix expansion;
- measured shard duration and five-minute timeout enforcement;
- comparison against the existing CI path during migration.

## Change policy
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,16 @@ flowchart TD

## Project contract

Every participating project must expose:
Every participating project publishes a task plan and a sharding-aware Docker entrypoint:

```bash
./scripts/ci/run.sh fast
./scripts/ci/run.sh full
./scripts/ci/run.sh unit --shard 1/4
./scripts/ci/run.sh integration --shard 2/3
```

Those commands must execute project validation inside project-owned containers. The fleet runner image does not carry project runtimes.
The fleet expands `scripts/ci/plan.json` into one GitHub job per task shard. Ordinary jobs have a hard five-minute timeout and target no more than four minutes of test payload. With sufficient independent work and available workers, total wall-clock time approaches the slowest shard rather than the sum of all tests.

`./scripts/ci/run.sh fast` and `full` remain aggregate local commands. All application validation still executes inside project-owned containers; the fleet runner image does not carry project runtimes.

The mandatory rules are defined in the [Project CI Standard](docs/PROJECT-STANDARD.md). Existing projects follow [Migrating Existing CI](docs/MIGRATING-EXISTING-CI.md) and must complete the [Compliance Checklist](docs/COMPLIANCE-CHECKLIST.md).

Expand Down Expand Up @@ -109,6 +111,8 @@ The scaffold is validated in this repository before being published as the stand
### Copyable examples

- [Experimental read-only workflow](examples/workflows/experimental-smoke.yml.example)
- [Five-minute parallel matrix workflow](examples/workflows/parallel-ci.yml.example)
- [Project task plan](examples/project/scripts/ci/plan.json)
- [Private-repository live pilot workflow](examples/workflows/live-pilot.yml.example)
- [Standard project entrypoint](examples/project/scripts/ci/run.sh)
- [Isolated Compose project](examples/project/compose.ci.yaml)
Expand Down
17 changes: 11 additions & 6 deletions docs/COMPLIANCE-CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,20 @@ A project MUST complete this checklist before its existing required CI is moved

## Project contract

- [ ] `scripts/ci/run.sh fast` exists and is executable.
- [ ] `scripts/ci/run.sh full` exists when the project has integration tests.
- [ ] Both commands run project validation inside project-owned containers.
- [ ] `scripts/ci/plan.json` declares every ordinary task and deterministic shard count.
- [ ] `scripts/ci/run.sh <task> --shard INDEX/TOTAL` exists and is executable.
- [ ] `scripts/ci/run.sh fast` and `full` remain working aggregate local commands.
- [ ] Every task/shard runs project validation inside project-owned containers.
- [ ] Every ordinary matrix job has `timeout-minutes: 5`.
- [ ] Expected test payload per shard is four minutes or less.
- [ ] Fast tasks are included in the full group.
- [ ] Task IDs are unique and the expanded matrix stays within 256 jobs.
- [ ] The runner host does not provide the project language runtime.
- [ ] Local and CI execution use the same entrypoint.
- [ ] Local aggregate and CI shard execution use the same task implementations.

## Isolation

- [ ] Compose project names are unique per run and attempt.
- [ ] Compose project names are unique per run, attempt, task, and shard.
- [ ] No fixed `container_name` exists.
- [ ] No fixed host port is published.
- [ ] Tests use internal service DNS where possible.
Expand Down Expand Up @@ -49,7 +54,7 @@ A project MUST complete this checklist before its existing required CI is moved
- [ ] Manual experimental run passed.
- [ ] Old and new CI ran on the same commits.
- [ ] Results and artifacts matched.
- [ ] Resource consumption was recorded.
- [ ] Resource consumption, total test-minutes, shard durations, and wall-clock duration were recorded.
- [ ] Forced failure cleanup passed.
- [ ] Cancellation cleanup passed.
- [ ] Existing required checks remained available during validation.
Expand Down
17 changes: 12 additions & 5 deletions docs/MIGRATING-EXISTING-CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,22 @@ Create or adapt the project's test image so the runner host no longer supplies t

The runner host should not determine whether a project needs Node 22, PHP 8.4, PostgreSQL 16, or a future runtime. Those versions belong to the project.

## Step 4: Add the standard adapter
## Step 4: Add the task plan and standard adapter

Add:

```text
scripts/ci/plan.json
scripts/ci/run.sh <task> --shard INDEX/TOTAL
scripts/ci/run.sh fast
scripts/ci/run.sh full
```

The adapter may call existing scripts. This allows a mature Dockerized project to preserve working orchestration while presenting the same interface as a newly containerized project.
Inventory the existing suite by test-minutes, then divide it into independently runnable tasks and deterministic shards. Begin with measured or conservative estimates. Each shard SHOULD target no more than four minutes of test payload and MUST run inside a five-minute job timeout.

The aggregate `fast` and `full` commands may call existing scripts. Fleet workflows expand `plan.json` and execute individual task shards so additional workers reduce wall-clock time.

Do not split solely by test count when durations differ materially. Use historical timing data as it becomes available and rebalance shards that repeatedly approach the ceiling.

## Step 5: Eliminate shared-host collisions

Expand All @@ -69,7 +75,7 @@ Before using a shared host:
- remove fixed `container_name` values;
- remove fixed host port bindings;
- use service DNS on internal Compose networks;
- derive the Compose project name from the workflow run;
- derive the Compose project name from the workflow run, attempt, task, and shard;
- make caches explicitly namespaced;
- ensure cleanup targets only the current run.

Expand All @@ -81,7 +87,7 @@ The first workflow MUST:
- use the experimental runner label;
- declare `permissions: contents: read`;
- set a timeout;
- run one non-destructive suite;
- run one non-destructive task shard inside the five-minute ceiling;
- leave the existing required workflow unchanged.

See [the experimental example](../examples/workflows/experimental-smoke.yml.example).
Expand All @@ -92,7 +98,8 @@ Run both paths on the same commits and compare:

- pass/fail results;
- generated artifacts;
- suite duration;
- total test-minutes, matrix job count, shard balance, and wall-clock duration;
- any shard approaching or exceeding the five-minute ceiling;
- CPU, memory, disk, and cache growth;
- containers, networks, volumes, and workspaces remaining afterward;
- behavior after cancellation and forced failure.
Expand Down
Loading