cratis-ci-pipeline-performance · git:20261026.fc845e3 · 2026-10-26 · sha256 646c1aa43bc3a2c8

cratis-ci-pipeline-performance git:20261026.fc845e3A

Immutable. This exact content never changes and is served at /api/v1/blob/646c1aa43bc3a2c8.

---
name: cratis-ci-pipeline-performance
description: Use when GitHub Actions is slow, queues, wastes runner minutes or repeatedly reruns and cancels work. Not for fixing a red build, runtime application performance or release-assurance planning.
license: LICENSE
---

# Measure the workflow, then pick the lever

A workflow is slow for one of four different reasons, and each has a different
fix. Adding runners cures a queue and does nothing for a serial build; a faster
build does nothing for a workflow that fails a quarter of the time. Measure,
classify, change one lever, measure again. Never optimize from the YAML alone.

Capacity, timeout, concurrency and runner invariants live in
[github-actions.md](../../rules/github-actions.md); obey them and do not restate them in a change.
Workflow-only pull requests carry the `no-release` label per
[pull-requests.md](../../rules/pull-requests.md).

## 1. Measure first

Execute the bundled script (needs `gh` authenticated for the repository, and `jq`):

```bash
bash scripts/ci-timings.sh --repo OWNER/REPO --workflow ci.yml --limit 50 --jobs 10
```

Add `--branch main` or `--event pull_request` to compare populations; PR and
default-branch runs behave differently. `bash scripts/ci-timings.sh --self-test`
proves the script itself works without network access. Exit codes: `0` report
printed, `2` usage error, `3` no runs or no data, `4` missing `gh`/`jq` or `gh`
not authenticated. Exit `3` and `4` are an unknown, not a clean workflow: say the
measurement was not made.

The report separates **wall clock** (what a developer waits for) from
**runner-minutes** (what the organization pays for or occupies). A change can
cut one and raise the other; report both. It prints a one-line classification:

| Class | Hint threshold | Meaning | First lever |
|---|---|---|---|
| failure-bound | failed or rerun >= 15% of completed runs | Time and minutes go into runs that are redone | Flaky and hung jobs; fail fast |
| cancel-bound | cancelled >= 20% | Superseded work still ran heavy legs | Cheap gate first, path filters, smaller PR matrix |
| queue-bound | median longest job wait >= 25% of wall | Jobs wait for runners, not for work | Fewer, bigger jobs; runner routing |
| execution-bound | none of the above | The critical path is serial work | Build once and shard; caching |

The thresholds are a hint, not a verdict. Several classes can hold at once; the
script lists extras after the primary class. Then find the **critical path over
the required checks**: the longest chain of `needs` from the start to the last
required job. Only work on that chain shortens wall clock; shaving a parallel
sibling does not.

Without `gh` access, read the workflow YAML, state that the result is
**static-only analysis**, and rank findings as hypotheses to measure later.

## 2. Guardrails before proposing a fix

- **Never hide required validation.** Release, schema, migration, analyzer and
  shared-library checks stay on the path that gates a merge or a release. Moving
  them to a schedule needs an explicit owner decision, not an optimization.
- **Say what less parallelism costs.** Merging jobs lowers queue and setup cost
  but lengthens the critical path when the merged work was parallel. State the
  expected wall change and runner-minute change together.
- **Separate repository changes from organization settings.** Workflow YAML is a
  repository change. Runner capacity, scale sets and plan limits are not; report
  them as a request to the owner and do not work around them in YAML.
- **One lever per change.** Two levers at once make the measured difference
  unattributable.
- **Rank by what it saves.** Estimate minutes per day as (saving per run) x
  (runs per day) from the report, and take the top one to three items.

## 3. Lever catalogue

Each lever lists when it fits, the Cratis default and the main pitfall. Read the
named reference before applying one.

**Build once, shard the tests.** Fits execution-bound workflows where build and
tests run serially or several jobs each rebuild. Default: one job builds in
`Release`, uploads the output with `actions/upload-artifact`, and a matrix of shard jobs downloads it and runs
`dotnet test --no-build`. Pitfall: a shard whose filter matches nothing still
passes, so assert that every shard ran tests. Read
`references/build-once-and-shard.md` when applying or reviewing this.

**Fewer, bigger jobs.** Fits queue-bound workflows: every job pays a scheduler
start, checkout and toolchain setup. Default: merge small sibling jobs that share
a setup; keep shards only where the work is long enough to repay a start. Pitfall:
merging the long pole into a serial chain. Read `references/build-once-and-shard.md`.

**Dependency caching.** Fits any job with a measurable restore or install step.
Default: the shared `Cratis/Workflows` composite actions `dotnet-cache` and
`yarn-cache` (restore on pull requests, save on the default branch), NuGet lock files and
`yarn install --immutable`. Pitfall: a cache key that changes every run, or a
different path list per workflow, which creates separate caches. Read
`references/caching.md`.

**Docker layer cache.** Fits image builds that repeat unchanged layers. Default:
`docker/build-push-action` with `cache-from`/`cache-to` of type `gha` and a custom
`scope` per image and platform. Pitfall: the default scope is shared, so builds
overwrite each other's cache. Read `references/caching.md`.

**Path filters with an always-run aggregator.** Fits cancel-bound workflows and
documentation-only changes. Default: filter heavy jobs by changed paths, and make
a single aggregator job with `if: always()` check every `needs.*.result`; require
only the aggregator. Pitfall: a required check skipped by `paths:` never reports
and the pull request hangs. Read `references/required-checks-and-path-filters.md`.

**Concurrency.** Pull-request verification uses `cancel-in-progress: true` keyed
by workflow and ref. Publish never cancels; where releases share one group, `queue:
max` keeps up to 100 pending runs in order instead of replacing the single pending
one. Pitfall: leaving the single pending slot where a replaced merge's run alone
would have released something (merges into the default branch share a group, also
when keyed on `github.ref` for merged `pull_request` `closed` events). A workflow
whose release tooling aggregates since the last release may keep one pending slot,
documented. Read `references/publish-pipelines.md`.

**Runner routing.** Fits queue-bound workflows. Read the
[Infrastructure runner guide](https://github.com/Cratis/Infrastructure/blob/main/Documentation/deployment/github-runners.md)
for the UpCloud scale sets `cratis-arc` and `cratis-arc-small`; do not copy its
capacity numbers. The rule owns routing and public-fork safety. Read
`references/runner-sizing-and-images.md` when choosing hosted versus self-hosted
runners, sizing in-job parallelism or weighing fan-out against cold starts.

**Publish from tested artifacts.** Fits release workflows that rebuild what the
pull-request run already built and tested. Default: build and pack once, then
publish exactly those packages. Pitfall: a second build can differ from the
tested one. Read `references/publish-pipelines.md`.

**Fail fast on cheap gates.** Fits failure-bound and cancel-bound workflows.
Default: formatting, lint and unit-level checks run first, and expensive legs
depend on them with `needs`. Pitfall: serializing checks that were parallel; keep
the cheap gate short enough to repay its place on the critical path.

**Expensive matrices off the PR path.** Fits cancel-bound workflows with a wide
OS or framework matrix. Default: full matrix on `schedule` and
`workflow_dispatch`, a Linux-only representative smoke on pull requests (the
rule states this). Pitfall: the full matrix needs an owner who reads its failures.

**Flaky and hung jobs.** Fits failure-bound workflows. Default: a
`timeout-minutes` on every job, `--blame-hang` with a mini dump, retry only a diagnosed flake, and
quarantine a test with an issue. Pitfall: a blanket rerun hides the defect and
doubles minutes. Read `references/flaky-and-hung-jobs.md`.

**Merge queue.** Fits repositories where passing pull requests break each other
after merge. Default: add the `merge_group` trigger, and make the aggregator
handle it. Pitfall: without `merge_group` the required check never reports in the
queue. Read `references/required-checks-and-path-filters.md`.

**Compile once, analyze once.** Fits any workflow where one push compiles the same
code several times. Default: build once and reuse the output; run analyzers in one
parallel lint job instead of in every build, and let that job fail early on compile
and analyzer errors before long test legs. Pitfall: dropping analyzers from the only
build that consumers enforce. Read `references/test-setup-cost.md`.

**Cores per job before job count.** Fits uniformly slow jobs. Default: check `nproc`
and the runner's resources before sharding; undersized runners make every job slow.
Pitfall: adding jobs to an undersized pool. Read `references/runner-sizing-and-images.md`.

**Hidden test setup.** Fits test lanes whose step time far exceeds the runner's
reported test time. Default: profile the test host and fix per-class setup upstream
rather than sharding it. Pitfall: sharding that divides the waste. Read
`references/test-setup-cost.md`.

**Test a guarantee where it can break.** Fits suites that re-test a dependency.
Default: run them upstream, or on a dependency pin change or nightly; use snapshots
for shape and post-deploy synthetic checks where possible. Pitfall: relocating the
only check of a required guarantee. Read `references/test-setup-cost.md`.

**Images and runner contents.** Fits jobs that pull unused images or install tools per
run. Default: pull only what the job uses, in parallel, and bake tools into the runner
image; content-addressed caches before warm volumes, publish stays cold. Read
`references/runner-sizing-and-images.md`.

Speed work must not weaken supply-chain hygiene. Read
`references/security-and-pinning.md` when a change adds or updates an action,
touches `permissions:`, uses `pull_request_target`, or adds a cache.

## 4. Verify

1. Re-run the same `ci-timings.sh` command on the same workflow after enough new
   runs exist (at least the sample size of the baseline; say so when fewer).
2. Report expected against measured savings, wall and runner-minutes separately,
   and whether the classification moved. A saving that is not measured is labeled
   as expected.
3. Run the linters locally on every edited workflow when installed: `actionlint`
   (syntax, expressions, embedded shell) and `zizmor` (security audits such as
   `unpinned-uses` and `excessive-permissions`). Pass means exit `0` with no
   findings; a tool that is not installed is reported as not run, not as clean.
   Do not assume CI runs either.
4. Compare required check names before and after; a renamed job silently removes
   a required check. See `references/required-checks-and-path-filters.md`.
5. Confirm the workflow change ships as `no-release`.

## References

- Read `references/build-once-and-shard.md` when changing how a build is shared across jobs or how tests are split.
- Read `references/caching.md` when adding or changing a dependency, Docker or build cache.
- Read `references/required-checks-and-path-filters.md` when adding path filters, an aggregator, or `merge_group`.
- Read `references/publish-pipelines.md` when a release or deploy workflow queues, drops runs or rebuilds.
- Read `references/flaky-and-hung-jobs.md` when runs fail, hang or are rerun.
- Read `references/test-setup-cost.md` when test steps outlast reported test time, tests duplicate a dependency's suite, or compiles repeat per push.
- Read `references/runner-sizing-and-images.md` when choosing runners, sizing parallelism, or when jobs are uniformly slow, pull images, install tools or reuse workspaces.
- Read `references/security-and-pinning.md` when a change touches actions, permissions, triggers or caches.
- Read `references/sources.md` when attributing or extending the method.
- Execute `scripts/ci-timings.sh` for every measurement; read it only to change it. Its self-test reads the synthetic inputs `scripts/fixtures/runs.json` and `scripts/fixtures/jobs/1003.json`, `scripts/fixtures/jobs/1007.json`, `scripts/fixtures/jobs/1010.json`; edit them only together with the expected lines in the script.