Skip to content

perf(ci): one CI job by default; ci-ok as a commit status; timeouts at ~2x averages - #67

Merged
jayleaton merged 5 commits into
mainfrom
perf/ci-billing-shape
Oct 22, 2026
Merged

jayleaton merged 5 commits into
mainfrom
perf/ci-billing-shape

Conversation

@jayleaton

@jayleaton jayleaton commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Problem

GitHub bills every job rounded up to a whole minute, so CI cost was driven by job count. A docs-only PR billed 4 minutes for ~19 s of work, and the parallel shards mostly bought rounding waste. No job had a timeout under 10 minutes. JJ's decision: one CI job by default for every PR (and push), sharding only as an opt-in fallback, timeouts at most ~2× measured averages.

How

every run:  ci ─ scope · caches · affected generate-diff · gofmt · golangci-lint · race tests (+ mobile / deploy/ when changed) · post ci-ok
drafts:     no jobs
opt-in:     "ci:fanout" PR label (or manual-run fanout input) ─► ci (plan) ─► lint + test (db) ‖ test (other)
  • ci-ok is a commit status, not a job. The job posts it on the commit it actually tested: pending, then success or failure. No job carries the name, so skipped or cancelled jobs and drafts can never satisfy it, and no aggregator job is needed. Statuses come from github-actions[bot]; main has no branch protection yet, so require the ci-ok status check when enabling it.

  • Drafts start no jobs. The draft check job is gone: each run reports on the PR head of the merge commit it checked out (HEAD^2), which removes the "ready run lands on the old head" race.

  • One job checks everything a change affects: affected packages only on PRs, all packages on main, merge queue and manual runs. The mobile app and deploy/ module checks run inline only when their paths change. That replaces the separate mobile, changes, deploy, ci-ok and shard jobs.

  • Postgres is started in the background only when an affected package's tests use it. DB-backed test binaries wait for it through an -exec wrapper, so the boot overlaps compilation. This stays faster than a services: container, which blocks the job start for 10–16 s.

  • Fan-out is an opt-in fallback only: label a PR ci:fanout (label created; takes effect on the PR's next push), or use the fanout input of a manual run once this is on main. Manual-run inputs are validated against the default branch's workflow. Measured: an all-packages run is ~2–3× the warm PR average (153 s warm, 323 s cold), so fan-out is there when wall time matters more than minutes.

  • A push to main is not tested again when its tree already passed CI (scripts/ci-reuse.sh):

    • Recorded tree: PR runs record the tested merge-commit tree in their ci-ok description (success tree=<sha>).
    • Lookup: a push to main finds its PR through GitHub's commit association or the (#N) squash title.
    • Conditions: it reuses only if that PR's latest ci-ok is a success posted by github-actions[bot] for exactly the pushed tree. Then it posts ci-ok success ("reused PR #N") and runs nothing else: 1 billed minute instead of 3.
    • Merge queue: a pushed commit that already carries its own ci-ok success, as when main fast-forwards to a merge-queue commit, is skipped too.
    • Otherwise it runs normally: main moved in between (tree differs), or the result is missing or not success.
    • Caches: reused pushes don't refresh caches. A scheduled run every 4 hours does, and it is skipped when the cache for that commit already exists; main pushes that do run still save.
  • One build/lint cache (v7-go-*) keyed by commit: saved on pushes to main, restored by prefix everywhere else.

  • Shared steps live in .github/actions/check.

  • Timeouts at ~2× measured averages, on the step that does the work. The job can't know at start whether a run is full or cold, so its 13-min timeout is only a backstop. A lookup-only probe decides warm or cold:

    Check step measured timeout
    affected set, warm 9–77 s 3 min
    affected set, cold 131–159 s 6 min
    all packages, warm 138 s 5 min
    all packages, cold 312 s 11 min
    mobile/deploy only 40–45 s 2 min
    opt-in fan-out jobs warm 59–97 s / cold 185–233 s 3 / 8 min
    • Other limits: cache restores and the toolchain step 1 min; a stalled cache download falls back to a miss after 1 min (SEGMENT_DOWNLOAD_TIMEOUT_MINS, was 10); DB tests wait at most 45 s for Postgres (2× its 12–22 s boot).
  • Rebased on feat(deploy): one-config Railway setup with Go IaC (deploy/platform.yaml) #39 and feat(ui): UI overhaul with shared uikit components and light/dark design tokens #66. feat(deploy): one-config Railway setup with Go IaC (deploy/platform.yaml) #39's separate deploy job (a billed minute on every run) is folded into the single job, and packages/uikit is in the templ list. Nothing is renamed here (the Go Fast Stack rename comes separately).

Evidence

Real Actions runs. PR scenarios use the full lifecycle: open as draft → push to the draft → ready. All scratch PRs (#56–#65, #68–#72) are closed, with branches and caches deleted by recorded name.

case #54 (main): wall / billed fan-out-when-large (this PR's first version): wall / billed one job (now): wall / billed
docs-only 18 s / 4 13 s / 1 13 s / 1
single package (web) 50 s / 4 40–55 s / 1 60 s / 1
typical multi-package (admin 4 tested pkgs; services ~10 pkgs) 55 s / 4 55–77 s / 1–2 67 s / 2
all packages, warm (go.mod PR; main push) 75 s / 5; main ~5 79–96 s / 3–4 122 s / 2 (PR); 159 s / 3 (main-equivalent)
all packages, cold ~9 (#54 main push) 242–259 s / 8–9 328 s / 6
opt-in fan-out (label) – – 59 s / 3
  • Reuse proof: scratch PR scratch: CI reuse proof (do not merge) #73 (closed) added a temporary push trigger for scratch/foundation-reuse-push-* and passed CI, recording success tree=7515882f…. That equals GitHub's refs/pull/73/merge tree.
    • Skip case: a squash-style commit (… (#73)) with exactly that tree pushed to a scratch branch ran 16 s / 1 billed min. The log showed PR #73 … ci-ok=success … reusing that result, and the commit got ci-ok success "reused PR scratch: CI reuse proof (do not merge) #73 (tree 7515882f0721)".
    • No-skip case: the same title with a different tree logged no PR result for tree 9a0055ab…: running the checks and ran everything: 333 s / 6 min. It was cold because a non-main branch can only restore main's caches, which have no v7 key yet.
    • Merge-queue check: a dry run of ci-reuse.sh on that commit afterwards found its own success and skipped.
  • Flaky test, not from this PR: TestAdminManagement (apps/admin/internal/server) failed in 2 of 3 all-packages runs here. It expects eve's audit trail in a fixed order, and two audit entries sometimes come back swapped. Re-runs passed, and main's recent runs are green. It belongs to the admin owner.
  • This PR's own run (all packages, cold, one job): green, ci-ok success, 231 s / 4 min.
  • Semantics checked: a gofmt violation gives ci-ok failure; drafts post nothing. Superseded or cancelled runs that GitHub lets finish post success only for a fully tested tree; an interrupted run posts failure.
  • Wall time: small changes stay about the same. All-packages runs are slower as one job (+40–80 s) and cheaper (−1 to −3 min); ci:fanout is the escape hatch.

Projection at 200 PRs/day

Assumed mix: 10% docs, 5% mobile, 70% small/multi Go, 15% all-packages. Each PR is followed by one merge push to main. A main push costs 1 min when reused and 3 min when it runs. The scheduled cache refresh adds 6 runs/day (~3 min each, 1 min when main hasn't moved).

layout per PR lifecycle main push per merged PR per day per 30 days at $0.008/min
#54 (main) 4.2 ~4.5 8.7 1,740 ~52,200 ~$418
fan-out when large 1.6 ~3.5 5.1 1,020 ~30,500 ~$244
one job, no reuse 1.6 3 4.6 920 ~27,600 ~$221
one job + reuse, 50% of merges up to date 1.6 2 3.6 738 ~22,100 ~$177
one job + reuse, 80% up to date (e.g. "require branches up to date" or a merge queue) 1.6 1.4 3.0 618 ~18,500 ~$148

"Up to date" means main didn't move between the PR's last CI run and its merge. With a merge queue, main fast-forwards to the tested queue commit, which is skipped by its own status.

Checks: actionlint is clean. No Go code changed; local checks are limited to script and workflow linting (compute rule).

Merge Danger

Door: two-way. Only CI changes.

Blast Radius: CI.

  • ci-ok is now a status, not a check run. Any future branch protection should require the ci-ok status.
  • The check name is now ci. lint + test (db) / test (other) appear only with the opt-in fan-out.
  • First runs after merge are cold (new v7-go cache key): a main push ~5.5 min / 6 billed, then warm.

🤖 Generated with Claude Code

@jayleaton
jayleaton marked this pull request as ready for review October 22, 2026 11:48
…outs at ~2x averages

Jobs bill rounded up to whole minutes, so a 19 s docs-only PR billed 4.
- ci-ok is a commit status posted by the jobs on the commit they tested
  (pending, then success/failure), not a job: skipped or cancelled jobs
  and drafts can never satisfy it, and no aggregator job is needed.
- One ci job plans and, for small changes, does everything: docs-only
  posts success, mobile-only runs the app checks, up to 8 affected test
  packages run lint + tests in that job, plus the mobile or deploy/
  module checks when those changed. Larger PR sets and shared-input
  changes fan out to lint + test (db) and test (other). Pushes to main,
  merge queue and manual runs skip the planner: ci does the lint + DB
  half and test (other) runs in parallel.
- Drafts start no jobs; each run reports on the PR head of the merge
  commit it checked out, which removes the race the draft check guarded.
- The separate deploy job (a billed minute on every run) is folded into
  the jobs above as inline checks.
- Shared steps live in .github/actions/check.
- Timeouts are ~2x measured averages, on the steps that do the work and
  cache-aware (lookup-only probe): warm 3 min, cold 6 (PR single job) or
  8 (full/fan-out), mobile/deploy-only 2; cache restores and the
  toolchain 1 min; stalled cache downloads fall back to a miss after
  1 min; DB tests wait at most 45 s for Postgres. Job timeouts are
  backstops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jayleaton
jayleaton force-pushed the perf/ci-billing-shape branch from 7a13ee5 to 3f47100 Compare October 22, 2026 12:05
jayleaton and others added 2 commits October 22, 2026 19:13
…n fallback

Parallel shards mostly bought rounding waste (each job bills rounded up
to a whole minute). The ci job now checks the affected packages (all of
them on main, merge queue and manual runs) plus the mobile and deploy/
checks in one job; one build/lint cache keyed by commit. Fan-out is
opt-in: a ci:fanout PR label or the fanout input of a manual run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cold

Measured as one job: all packages warm 138 s (-> 5 min), cold 312 s
(-> 11); affected sets warm up to ~75 s (-> 3), cold 131-159 s (-> 6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jayleaton jayleaton changed the title perf(ci): bill by work, not job count; ci-ok as a commit status; timeouts at 2x averages perf(ci): one CI job by default; ci-ok as a commit status; timeouts at ~2x averages Oct 22, 2026
jayleaton and others added 2 commits October 22, 2026 19:38
…hat result

PR runs record the tested merge tree in their ci-ok description. A push
to main looks up its PR (commit association or the "(#N)" squash
title) and, if that PR's latest ci-ok is a success posted by
github-actions[bot] for exactly the pushed tree, posts ci-ok success
from it and skips the checks. Main moved in between (tree differs), a
missing or non-success result: the push runs as before. Caches PRs
restore are refreshed by a scheduled run every 4 hours (skipped when
main has not moved) and by main pushes that do run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Covers main fast-forwarding to a merge-queue commit CI already tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant