From 3f47100a8535baec26214ff7c04297eb91e50a0e Mon Sep 17 00:00:00 2001 From: JJ Eaton <58495321+jayleaton@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:05:10 +0700 Subject: [PATCH 1/5] perf(ci): bill by work, not job count; ci-ok as a commit status; timeouts 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 --- .github/actions/check/action.yml | 203 ++++++++++ .github/workflows/ci.yml | 612 +++++++++++++++++-------------- AGENTS.md | 4 +- scripts/ci-plan.sh | 35 ++ scripts/ci-shard.sh | 26 +- 5 files changed, 587 insertions(+), 293 deletions(-) create mode 100644 .github/actions/check/action.yml create mode 100755 scripts/ci-plan.sh diff --git a/.github/actions/check/action.yml b/.github/actions/check/action.yml new file mode 100644 index 0000000..2586fa4 --- /dev/null +++ b/.github/actions/check/action.yml @@ -0,0 +1,203 @@ +# The checks one CI job runs, shared by the single job (mode "all") and the +# two fan-out jobs ("db": lint + DB-backed tests, "other": the rest + mobile). +# Expects: checkout, module cache, Go toolchain and the affected outputs +# (scripts/affected.sh) to be done by the caller. +name: check +description: Lint, test and (optionally) mobile checks for the affected packages +inputs: + mode: { description: "all | db | other", required: true } + packages: { description: "affected import paths (or ./...)", required: true } + dirs: { description: "affected package dirs for golangci-lint", required: true } + full: { description: "full run (shared inputs changed)", required: true } + sqlc: { description: "rerun sqlc", required: true } + templ: { description: "rerun templ", required: true } + generate: { description: "packages to go generate", required: true } + mobile: { description: "run the mobile app checks", required: true } + deploy: { description: "run the deploy/ module checks", required: true } + save: { description: "save the build cache", required: true } +runs: + using: composite + steps: + - name: Packages to test + if: inputs.packages != '' + id: shard + shell: bash + run: ./scripts/ci-shard.sh "${{ inputs.mode }}" + env: + PACKAGES: ${{ inputs.packages }} + + # Boots while caches restore and lint runs; only DB-backed test binaries + # wait for it (see Test). tmpfs and no fsync: test data is throwaway. The + # fan-out "db" job starts it itself, first thing (see ci.yml). + - name: Start Postgres + if: steps.shard.outputs.db == 'true' && inputs.mode != 'db' + shell: bash + run: | + nohup docker run -d --name pg -p 5432:5432 -e POSTGRES_PASSWORD=postgres \ + --tmpfs /var/lib/postgresql postgres:18-alpine \ + -c fsync=off -c synchronous_commit=off -c full_page_writes=off -c max_connections=200 \ + > "$RUNNER_TEMP/pg.log" 2>&1 & + + # Build + lint caches per fan-out mode ("db", "other"), keyed by commit, + # saved on pushes to main (which always fan out) and restored by prefix in + # PRs. The single job ("all") builds what both build, so it restores both. + - uses: actions/cache/restore@v6 + if: inputs.packages != '' && inputs.mode != 'other' + with: + path: | + ~/.cache/go-build + ~/.cache/golangci-lint + key: v6-godb-${{ runner.os }}-${{ github.sha }} + restore-keys: v6-godb-${{ runner.os }}- + - uses: actions/cache/restore@v6 + if: inputs.packages != '' && inputs.mode != 'db' + with: + path: | + ~/.cache/go-build + ~/.cache/golangci-lint + key: v6-goother-${{ runner.os }}-${{ github.sha }} + restore-keys: v6-goother-${{ runner.os }}- + + # ---- lint ("all" and "db") ---- + - name: gofmt + if: inputs.mode != 'other' && inputs.packages != '' + shell: bash + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "$unformatted"; echo 'gofmt: run gofmt -w .'; exit 1 + fi + + - uses: sqlc-dev/setup-sqlc@v5 + if: inputs.mode != 'other' && inputs.sqlc == 'true' + with: + sqlc-version: '1.31.1' + + - name: Generated code is up to date + if: inputs.mode != 'other' && (inputs.sqlc == 'true' || inputs.templ == 'true' || inputs.generate != '') + shell: bash + env: + SQLC: ${{ inputs.sqlc }} + TEMPL: ${{ inputs.templ }} + GENERATE: ${{ inputs.generate }} + run: | + if [ "$SQLC" = true ]; then sqlc generate -f packages/database/sqlc.yaml; fi + for p in $GENERATE; do go generate "$p"; done + if [ "$TEMPL" = true ]; then + for ui in packages/uikit apps/web/ui apps/admin/ui; do if [ -d "$ui" ]; then go tool templ generate -path "$ui"; fi; done + fi + git diff --exit-code + test -z "$(git status --porcelain)" || { git status --short; echo 'untracked generated files: run make generate and commit'; exit 1; } + + # Its own cache rides in the build cache above. + - uses: golangci/golangci-lint-action@v9 + if: inputs.mode != 'other' && inputs.packages != '' + with: + version: v2.15.0 + skip-cache: true + args: ${{ inputs.dirs }} + + - name: Test boundaries + if: inputs.mode != 'other' && inputs.packages != '' + shell: bash + run: ./scripts/check-test-boundaries.sh + + # The probes prove the lint and test rules still bite; their inputs + # (scripts/, .golangci.yml) always force a full run. + - name: Coverage floors name real packages + if: inputs.mode != 'other' && inputs.full == 'true' + shell: bash + run: ./scripts/check-coverage.sh --exist + - name: Layering rules + if: inputs.mode != 'other' && inputs.full == 'true' + shell: bash + run: ./scripts/check-layering.sh + env: + GOLANGCI_LINT: golangci-lint + - name: Test boundary probes + if: inputs.mode != 'db' && inputs.full == 'true' + shell: bash + run: ./scripts/check-test-probes.sh + + # ---- tests ---- + # With -coverprofile go test also compiles packages without tests (lint + # type-checks everything too), so there is no build step. DB-backed test + # binaries wait for Postgres through an -exec wrapper, so compiling + # overlaps its boot. Readiness is checked inside the container over TCP: + # docker's port proxy accepts connections before Postgres listens, and the + # image's first-boot init server answers on the unix socket only. + - name: Test + if: steps.shard.outputs.packages != '' + shell: bash + env: + PACKAGES: ${{ steps.shard.outputs.packages }} + DB: ${{ steps.shard.outputs.db }} + run: | + mkdir -p .cache + exec_flag=() + if [ "$DB" = true ]; then + cat > "$RUNNER_TEMP/wait-db" <<'SH' + #!/usr/bin/env bash + if grep -lqE 'dbtest|TEST_DATABASE_URL' ./*_test.go 2>/dev/null; then + # 2x the measured Postgres boot (12-22 s): never waits longer than 45 s. + for _ in $(seq 1 180); do + docker exec pg pg_isready -q -h 127.0.0.1 -U postgres 2>/dev/null && exec "$@" + sleep 0.25 + done + echo "postgres did not start:" >&2; cat "$RUNNER_TEMP/pg.log" >&2; docker logs pg >&2; exit 1 + fi + exec "$@" + SH + chmod +x "$RUNNER_TEMP/wait-db" + exec_flag=(-exec "$RUNNER_TEMP/wait-db") + fi + go test -race "${exec_flag[@]}" -coverprofile=.cache/coverage.out $PACKAGES # word splitting intended + TESTED="$PACKAGES" ./scripts/check-coverage.sh .cache/coverage.out + + - uses: actions/cache/save@v6 + if: inputs.save == 'true' && inputs.packages != '' && inputs.mode != 'all' + with: + path: | + ~/.cache/go-build + ~/.cache/golangci-lint + key: v6-go${{ inputs.mode }}-${{ runner.os }}-${{ github.sha }} + + # ---- mobile app: typecheck, lint, Expo SDK check, unit tests ---- + # (The contract test, pnpm test:contract, needs Go + Docker and runs locally.) + - uses: pnpm/action-setup@v6 + if: inputs.mobile == 'true' + with: + package_json_file: apps/mobile-app/package.json + - uses: actions/setup-node@v7 + if: inputs.mobile == 'true' + with: + node-version: 22 + cache: pnpm + cache-dependency-path: apps/mobile-app/pnpm-lock.yaml + - name: Mobile app checks + if: inputs.mobile == 'true' + shell: bash + working-directory: apps/mobile-app + run: | + pnpm install --frozen-lockfile + pnpm exec tsc --noEmit + pnpm exec expo lint + pnpm exec expo install --check + pnpm test + + # ---- deploy/: its own Go module (Railway IaC SDK + yaml) ---- + - name: deploy module vet + test + if: inputs.deploy == 'true' + shell: bash + working-directory: deploy + run: | + go vet ./... + (cd .railway && go vet railway.go) + go test ./... + - uses: golangci/golangci-lint-action@v9 + if: inputs.deploy == 'true' + with: + version: v2.15.0 + skip-cache: true + working-directory: deploy + args: -c ../.golangci.yml ./... diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e63089f..380150c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,9 @@ -# Single required check for branch protection and the merge queue: `ci-ok`. -# Other jobs may be skipped by path filters; ci-ok treats skipped as success. -# Draft PRs run no checks: mark ready for review to run CI. PRs check only the -# Go packages their changes can affect (scripts/affected.sh) and run mobile -# only when the app changed; pushes to main, merge_group and -# workflow_dispatch check everything. GitHub cannot filter -# drafts at the trigger, so a draft push still starts a run; its final job -# reports as "ci (draft, not run)", never as ci-ok, so it cannot stand in for -# the required check on that SHA. +# ci-ok, the single required check, is a commit status, not a job: jobs post +# it (pending, then success/failure) on the commit they actually tested, so a +# skipped or cancelled job can never satisfy it and a draft (which starts no +# jobs) never gets it. Jobs bill rounded up to whole minutes, so CI is shaped +# around job count: one job does everything for small changes (docs, mobile, +# a few Go packages); only large or full runs fan out to two jobs. name: CI on: @@ -31,62 +28,64 @@ concurrency: permissions: contents: read pull-requests: read # paths-filter lists PR files through the API + statuses: write # the ci-ok commit status + actions: read # the fan-out finaliser reads the other job's result + +env: + TEST_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/postgres?sslmode=disable + # A stalled cache download falls back to a cache miss after this long instead + # of hanging (default 10 min); restores take 2-12 s. + SEGMENT_DOWNLOAD_TIMEOUT_MINS: 1 jobs: - draft: - if: github.event_name == 'pull_request' && github.event.pull_request.draft && github.event.action == 'synchronize' + # Plans and, unless the change is large, does all the work. Drafts start no + # jobs: ready for review (or any push to a ready PR) runs CI. + ci: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft runs-on: ubuntu-latest - timeout-minutes: 2 - permissions: - actions: read - pull-requests: read + # Timeouts are ~2x measured averages and sit on the steps that do the work + # (see "Timeouts" below); this is only a backstop above their sum. + timeout-minutes: 12 outputs: - run: ${{ steps.check.outputs.run }} + fanout: ${{ steps.plan.outputs.fanout }} + timeout: ${{ steps.timeouts.outputs.fanout }} + poll: ${{ steps.timeouts.outputs.poll }} + target: ${{ steps.target.outputs.sha }} + merge: ${{ steps.target.outputs.merge }} + packages: ${{ steps.affected.outputs.packages }} + dirs: ${{ steps.affected.outputs.dirs }} + full: ${{ steps.affected.outputs.full }} + sqlc: ${{ steps.affected.outputs.sqlc }} + templ: ${{ steps.affected.outputs.templ }} + generate: ${{ steps.affected.outputs.generate }} + mobile: ${{ steps.scope.outputs.mobile }} + deploy: ${{ steps.scope.outputs.deploy }} steps: - - id: check - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - PR: ${{ github.event.pull_request.number }} - SHA: ${{ github.event.pull_request.head.sha }} - WORKFLOW: ${{ github.workflow }} - RUN_ID: ${{ github.run_id }} + # Pushes to main, merge queue, manual runs: this job is the "db" half of + # a full run; start Postgres first thing. + - name: Start Postgres + if: github.event_name != 'pull_request' run: | - run=false - if [ "$(gh pr view "$PR" --json isDraft --jq .isDraft)" = false ]; then - # The ready_for_review run may still be being created; give it time - # so both runs don't do the full checks. - sleep 15 - others=$(gh api "repos/$GH_REPO/actions/runs?head_sha=$SHA&event=pull_request" \ - | jq --arg wf "$WORKFLOW" --argjson id "$RUN_ID" '[.workflow_runs[] | select(.name == $wf and .id != $id)] | length') - [ "$others" = 0 ] && run=true - fi - echo "PR #$PR ready-now run=$run" - echo "run=$run" >> "$GITHUB_OUTPUT" + nohup docker run -d --name pg -p 5432:5432 -e POSTGRES_PASSWORD=postgres \ + --tmpfs /var/lib/postgresql postgres:18-alpine \ + -c fsync=off -c synchronous_commit=off -c full_page_writes=off -c max_connections=200 \ + > "$RUNNER_TEMP/pg.log" 2>&1 & - # PRs and pushes to main run only the jobs whose paths changed; merge_group - # and workflow_dispatch run everything. - # Two parallel jobs (private-repo runners have 2 vCPUs; race-compiling is - # CPU-bound). Each is billed rounded up to a whole minute, so lint shares - # the db shard's runner: it runs while Postgres boots, which the DB tests - # wait for anyway. "db" = lint + packages whose tests use Postgres; - # "other" = the remaining packages. - go: - name: ${{ matrix.shard == 'db' && 'lint + test (db)' || 'test (other)' }} - strategy: - matrix: - shard: [db, other] - needs: draft - if: ${{ !cancelled() && (github.event_name != 'pull_request' || !github.event.pull_request.draft || needs.draft.outputs.run == 'true') }} - runs-on: ubuntu-latest - timeout-minutes: 10 - env: - TEST_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/postgres?sslmode=disable - # Whether the mobile job runs (both legs compute the same value; matrix - # outputs come from whichever leg finishes last). - outputs: - mobile: ${{ steps.mobile-pr.outputs.mobile || steps.mobile-push.outputs.mobile }} - steps: + # PR events list changed files through the API (no checkout needed). + - uses: dorny/paths-filter@v4 + if: github.event_name == 'pull_request' + id: go-pr + with: + predicate-quantifier: every + filters: | + go: + - '**' + - '!docs/**' + - '!apps/mobile-app/**' + - '!deploy/**' # its own module: the deploy checks + - '!*.md' + - '!.gitignore' + - '!.dockerignore' - uses: dorny/paths-filter@v4 if: github.event_name == 'pull_request' id: mobile-pr @@ -97,42 +96,21 @@ jobs: # The mobile route test reads the Go route table. - 'apps/web/internal/config/routes.go' - '.github/workflows/**' - - # Docs-only and mobile-only PRs stop here: no checkout, cache or - # toolchain. PR events list changed files through the API. + - '.github/actions/**' - uses: dorny/paths-filter@v4 if: github.event_name == 'pull_request' - id: filter + id: deploy-pr with: - predicate-quantifier: every filters: | - go: - - '**' - - '!docs/**' - - '!apps/mobile-app/**' - - '!deploy/**' # its own module: the deploy job - - '!*.md' - - '!.gitignore' - - '!.dockerignore' - - name: Scope - id: scope - env: - GO: ${{ github.event_name != 'pull_request' || steps.filter.outputs.go == 'true' }} - run: echo "go=$GO" >> "$GITHUB_OUTPUT" - - # db shard, any Go change: first thing, so it is up before tests run. - # tmpfs and no fsync: test data is throwaway. - - name: Start Postgres - if: steps.scope.outputs.go == 'true' && matrix.shard == 'db' - run: | - nohup docker run -d --name pg -p 5432:5432 -e POSTGRES_PASSWORD=postgres \ - --tmpfs /var/lib/postgresql postgres:18-alpine \ - -c fsync=off -c synchronous_commit=off -c full_page_writes=off -c max_connections=200 \ - > "$RUNNER_TEMP/pg.log" 2>&1 & + deploy: + - 'deploy/**' + - '.golangci.yml' + - '.github/workflows/**' + - '.github/actions/**' - # Depth 2: a PR checkout is the merge commit, and HEAD^1 is the base. + # Depth 2: a PR checkout is the merge commit (HEAD^1 = base, HEAD^2 = head). - uses: actions/checkout@v7 - if: steps.scope.outputs.go == 'true' + if: github.event_name != 'pull_request' || steps.go-pr.outputs.go == 'true' || steps.mobile-pr.outputs.mobile == 'true' || steps.deploy-pr.outputs.deploy == 'true' with: fetch-depth: 2 - uses: dorny/paths-filter@v4 @@ -141,36 +119,68 @@ jobs: with: filters: *mobile-filter - # Modules (and the go.mod toolchain) are keyed by go.sum; module zips and - # dependencies' testdata are deleted before saving (builds read only the - # extracted sources and .ziphash files). Build/lint caches are per shard, - # keyed by commit and saved on every push to main, so PRs restore the - # newest main state by prefix. Bump a prefix when what it caches changes. + - name: Scope + id: scope + env: + EVENT: ${{ github.event_name }} + GO_PR: ${{ steps.go-pr.outputs.go }} + MOBILE: ${{ steps.mobile-pr.outputs.mobile || steps.mobile-push.outputs.mobile }} + DEPLOY: ${{ steps.deploy-pr.outputs.deploy }} + run: | + go=true mobile=$MOBILE deploy=$DEPLOY + if [ "$EVENT" = pull_request ]; then go=$GO_PR; else deploy=true; fi + if [ "$EVENT" = merge_group ] || [ "$EVENT" = workflow_dispatch ]; then mobile=true; fi + echo "go=$go" >> "$GITHUB_OUTPUT"; echo "mobile=${mobile:-false}" >> "$GITHUB_OUTPUT" + echo "deploy=${deploy:-false}" >> "$GITHUB_OUTPUT" + + # The commit ci-ok is posted on: the PR head of the merge commit actually + # checked out (the event's head can be stale when a push and "ready for + # review" land together), else the PR's current head, else github.sha. + - name: Target commit + id: target + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + PR: ${{ github.event.pull_request.number }} + run: | + if [ "$EVENT" != pull_request ]; then + echo "sha=$GITHUB_SHA" >> "$GITHUB_OUTPUT"; echo "merge=$GITHUB_SHA" >> "$GITHUB_OUTPUT" + elif git rev-parse -q --verify HEAD^2 >/dev/null 2>&1; then + echo "sha=$(git rev-parse HEAD^2)" >> "$GITHUB_OUTPUT"; echo "merge=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + else + echo "sha=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" --jq .head.sha)" >> "$GITHUB_OUTPUT" + fi + + - name: ci-ok pending + if: steps.scope.outputs.go == 'true' || steps.scope.outputs.mobile == 'true' || steps.scope.outputs.deploy == 'true' + env: + GH_TOKEN: ${{ github.token }} + SHA: ${{ steps.target.outputs.sha }} + run: | + gh api "repos/$GITHUB_REPOSITORY/statuses/$SHA" -f state=pending -f context=ci-ok \ + -f description="running" -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" >/dev/null + + # Modules (and the go.mod toolchain) keyed by go.sum; zips and dependency + # testdata are trimmed before saving (see the fan-out job). - uses: actions/cache/restore@v6 if: steps.scope.outputs.go == 'true' id: mod-cache + timeout-minutes: 1 with: - path: | - ~/go/pkg/mod + path: ~/go/pkg/mod key: v7-gomod-${{ runner.os }}-${{ hashFiles('go.sum') }} restore-keys: v7-gomod-${{ runner.os }}- - - uses: actions/cache/restore@v6 - if: steps.scope.outputs.go == 'true' - with: - path: | - ~/.cache/go-build - ~/.cache/golangci-lint - key: v5-go${{ matrix.shard }}-${{ runner.os }}-${{ github.sha }} - restore-keys: v5-go${{ matrix.shard }}-${{ runner.os }}- - # No setup-go: the runner's preinstalled go switches to the go.mod - # version itself (GOTOOLCHAIN=auto); the toolchain lives in the module cache. + # No setup-go: the runner's go switches to the go.mod version itself + # (GOTOOLCHAIN=auto); the toolchain lives in the module cache. - name: Go toolchain if: steps.scope.outputs.go == 'true' + # 1-5 s; ~10 s when the toolchain has to be downloaded. + timeout-minutes: 1 run: go version - # PRs check only the packages their changes can affect, and only the - # touched generators; shared inputs mean everything. See scripts/affected.sh. + # PRs check only what their changes can affect; shared inputs, pushes to + # main, the merge queue and manual runs check everything. - name: Affected packages if: steps.scope.outputs.go == 'true' id: affected @@ -178,212 +188,250 @@ jobs: env: AFFECTED_FULL: ${{ github.event_name != 'pull_request' && '1' || '' }} - # ---- lint (db leg) ---- - - name: gofmt - if: steps.scope.outputs.go == 'true' && matrix.shard == 'db' - run: | - unformatted="$(gofmt -l .)" - if [ -n "$unformatted" ]; then - echo "$unformatted" - echo 'gofmt: run gofmt -w .' - exit 1 - fi - - - uses: sqlc-dev/setup-sqlc@v5 - if: steps.affected.outputs.sqlc == 'true' && matrix.shard == 'db' - with: - sqlc-version: '1.31.1' - - - name: Generated code is up to date - if: (steps.affected.outputs.sqlc == 'true' || steps.affected.outputs.templ == 'true' || steps.affected.outputs.generate != '') && matrix.shard == 'db' - env: - SQLC: ${{ steps.affected.outputs.sqlc }} - TEMPL: ${{ steps.affected.outputs.templ }} - GENERATE: ${{ steps.affected.outputs.generate }} - run: | - if [ "$SQLC" = true ]; then sqlc generate -f packages/database/sqlc.yaml; fi - for p in $GENERATE; do go generate "$p"; done - if [ "$TEMPL" = true ]; then - for ui in packages/uikit apps/web/ui apps/admin/ui; do if [ -d "$ui" ]; then go tool templ generate -path "$ui"; fi; done - fi - git diff --exit-code - test -z "$(git status --porcelain)" || { git status --short; echo 'untracked generated files: run make generate and commit'; exit 1; } - - # Its own cache key only changes with go.sum or weekly; its directory - # rides in the shard cache above instead. - - uses: golangci/golangci-lint-action@v9 - if: steps.affected.outputs.packages != '' && matrix.shard == 'db' - with: - version: v2.15.0 - skip-cache: true - args: ${{ steps.affected.outputs.dirs }} - - - name: Test boundaries - if: steps.scope.outputs.go == 'true' && matrix.shard == 'db' - run: ./scripts/check-test-boundaries.sh - - # The probes prove the lint and test rules still bite; their inputs - # (scripts/, .golangci.yml) always force a full run. - - name: Coverage floors name real packages - if: steps.affected.outputs.full == 'true' && matrix.shard == 'db' - run: ./scripts/check-coverage.sh --exist - - - name: Layering rules - if: steps.affected.outputs.full == 'true' && matrix.shard == 'db' - run: ./scripts/check-layering.sh - env: - GOLANGCI_LINT: golangci-lint - - - name: Test boundary probes - if: steps.affected.outputs.full == 'true' && matrix.shard == 'other' - run: ./scripts/check-test-probes.sh - - # ---- tests (both legs) ---- - - name: Shard + - name: Plan if: steps.affected.outputs.packages != '' - id: shard - run: ./scripts/ci-shard.sh "${{ matrix.shard }}" + id: plan + run: ./scripts/ci-plan.sh env: + FULL: ${{ steps.affected.outputs.full }} PACKAGES: ${{ steps.affected.outputs.packages }} - - name: Test - if: steps.shard.outputs.packages != '' + # Timeouts (minutes) for the Check steps, ~2x their measured averages, + # by whether the build cache is warm (a lookup-only probe, no download): + # PR single job: warm 9-77 s (heaviest: admin ~75 s) -> 3; cold 131-159 s -> 6 + # mobile only: 40-45 s -> 2 + # full/fan-out: warm 59-97 s -> 3; cold 185-233 s -> 8 + - uses: actions/cache/restore@v6 + if: steps.affected.outputs.packages != '' + id: build-cache + timeout-minutes: 1 + with: + lookup-only: true + path: ~/.cache/go-build + key: v6-godb-${{ runner.os }}-${{ github.sha }} + restore-keys: v6-godb-${{ runner.os }}- + - name: Timeouts + id: timeouts env: - PACKAGES: ${{ steps.shard.outputs.packages }} - SHARD: ${{ matrix.shard }} + MATCHED: ${{ steps.build-cache.outputs.cache-matched-key }} + PACKAGES: ${{ steps.affected.outputs.packages }} + SINGLE: ${{ github.event_name == 'pull_request' && steps.plan.outputs.fanout != 'true' }} run: | - mkdir -p .cache - # With -coverprofile, go test also compiles packages without tests, - # so there is no separate build step (lint type-checks everything too). - # Test binaries wait for Postgres themselves (-exec wrapper), so - # compiling them overlaps its boot. Checked inside the container over - # TCP: docker's port proxy accepts host connections before Postgres - # listens, and the image's first-boot init server answers on the unix - # socket only. - exec_flag=() - if [ "$SHARD" = db ]; then - cat > "$RUNNER_TEMP/wait-db" <<'SH' - #!/usr/bin/env bash - for _ in $(seq 1 480); do - docker exec pg pg_isready -q -h 127.0.0.1 -U postgres 2>/dev/null && exec "$@" - sleep 0.25 - done - echo "postgres did not start:" >&2; cat "$RUNNER_TEMP/pg.log" >&2; docker logs pg >&2; exit 1 - SH - chmod +x "$RUNNER_TEMP/wait-db" - exec_flag=(-exec "$RUNNER_TEMP/wait-db") - fi - go test -race "${exec_flag[@]}" -coverprofile=.cache/coverage.out $PACKAGES # word splitting intended - # Floors of this shard's packages; the shards together cover all. - TESTED="$PACKAGES" ./scripts/check-coverage.sh .cache/coverage.out + if [ -n "$MATCHED" ]; then check=3 fanout=3; else fanout=8; check=8; [ "$SINGLE" = true ] && check=6; fi + # No Go packages (mobile- or deploy-only): mobile averages 40-45 s. + [ -z "$PACKAGES" ] && check=2 + echo "check=$check" >> "$GITHUB_OUTPUT"; echo "fanout=$fanout" >> "$GITHUB_OUTPUT" + echo "poll=$((fanout + 1))" >> "$GITHUB_OUTPUT" - - uses: actions/cache/save@v6 - if: (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) + # PRs: everything here unless fanned out. Other events: the "db" half. + - name: Check + if: (steps.plan.outputs.fanout != 'true' || github.event_name != 'pull_request') && (steps.scope.outputs.go == 'true' || steps.scope.outputs.mobile == 'true' || steps.scope.outputs.deploy == 'true') + timeout-minutes: ${{ fromJSON(steps.timeouts.outputs.check || '3') }} + uses: ./.github/actions/check with: - path: | - ~/.cache/go-build - ~/.cache/golangci-lint - key: v5-go${{ matrix.shard }}-${{ runner.os }}-${{ github.sha }} + mode: ${{ github.event_name == 'pull_request' && 'all' || 'db' }} + packages: ${{ steps.affected.outputs.packages }} + dirs: ${{ steps.affected.outputs.dirs }} + full: ${{ steps.affected.outputs.full }} + sqlc: ${{ steps.affected.outputs.sqlc }} + templ: ${{ steps.affected.outputs.templ }} + generate: ${{ steps.affected.outputs.generate }} + mobile: ${{ github.event_name == 'pull_request' && steps.scope.outputs.mobile || 'false' }} + deploy: ${{ github.event_name == 'pull_request' && steps.scope.outputs.deploy || 'false' }} + save: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }} - # The db leg (lint) loads every package and its test imports, so it saves - # the module cache. "!" exclusions in cache paths did not apply, so trim - # explicitly: zips (~125 MB incl. the 71 MB toolchain zip) and dependency - # testdata. + # This job (lint) loads every package and its test imports, so it saves + # the module cache. Cache-path "!" exclusions did not apply, so trim + # explicitly: zips (~125 MB incl. the 71 MB toolchain zip) and testdata. - name: Trim module cache - if: (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && steps.mod-cache.outputs.cache-hit != 'true' && matrix.shard == 'db' + if: (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && steps.mod-cache.outputs.cache-hit != 'true' run: | chmod -R u+w ~/go/pkg/mod find ~/go/pkg/mod/cache/download -name '*.zip' -delete find ~/go/pkg/mod -mindepth 2 -type d -name testdata -prune -exec rm -rf {} + - du -sh ~/go/pkg/mod - uses: actions/cache/save@v6 - if: (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && steps.mod-cache.outputs.cache-hit != 'true' && matrix.shard == 'db' + if: (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && steps.mod-cache.outputs.cache-hit != 'true' with: - path: | - ~/go/pkg/mod + path: ~/go/pkg/mod key: v7-gomod-${{ runner.os }}-${{ hashFiles('go.sum') }} - # deploy/ is its own Go module (Railway IaC SDK + yaml), invisible to the - # go job above. Runs only when it, the lint config or a workflow changes. - deploy: - needs: draft - if: ${{ !cancelled() && (github.event_name != 'pull_request' || !github.event.pull_request.draft || needs.draft.outputs.run == 'true') }} + # PR, not fanned out: this job's own result (docs-only PRs land here with + # nothing to check: success). Other events: wait for "test (other)". + - name: ci-ok result + if: always() && !cancelled() && (steps.plan.outputs.fanout != 'true' || github.event_name != 'pull_request') && steps.target.outputs.sha != '' + env: + GH_TOKEN: ${{ github.token }} + SHA: ${{ steps.target.outputs.sha }} + STATUS: ${{ job.status }} + EVENT: ${{ github.event_name }} + POLL_MIN: ${{ steps.timeouts.outputs.poll || '9' }} + run: | + other=success + if [ "$EVENT" != pull_request ]; then + other="" + for _ in $(seq 1 $(( (${POLL_MIN:-9}) * 30 ))); do + other=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/attempts/$GITHUB_RUN_ATTEMPT/jobs" \ + --jq '[.jobs[] | select(.name == "test (other)" and .conclusion != "skipped")] | if length == 1 and .[0].status == "completed" then .[0].conclusion else "" end') + [ -n "$other" ] && break + sleep 2 + done + fi + echo "this=$STATUS other=${other:-unfinished}" + state=failure; [ "$STATUS" = success ] && [ "$other" = success ] && state=success + gh api "repos/$GITHUB_REPOSITORY/statuses/$SHA" -f state=$state -f context=ci-ok \ + -f description="$state" -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" >/dev/null + + # A run cancelled by a newer push can still report job.status success + # (seen on a superseded PR run), so a cancelled run only ever posts failure. + - name: ci-ok cancelled + if: cancelled() && (steps.plan.outputs.fanout != 'true' || github.event_name != 'pull_request') && steps.target.outputs.sha != '' + env: + GH_TOKEN: ${{ github.token }} + SHA: ${{ steps.target.outputs.sha }} + run: | + gh api "repos/$GITHUB_REPOSITORY/statuses/$SHA" -f state=failure -f context=ci-ok \ + -f description="cancelled" -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" >/dev/null + + # Pushes to main, merge queue, manual runs: the "other" half of the full run, + # in parallel with the ci job (no planning job in front: these always run + # everything). Warm ~55-92 s (incl. mobile), cold ~185 s: 4. + other: + name: test (other) + if: github.event_name != 'pull_request' runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 12 # backstop; the Check step has the measured timeout steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 2 - uses: dorny/paths-filter@v4 - if: github.event_name == 'pull_request' - id: filter + if: github.event_name == 'push' + id: mobile-push with: - filters: | - deploy: - - 'deploy/**' - - '.golangci.yml' - - '.github/workflows/**' - - name: Scope - id: scope + filters: *mobile-filter + - uses: actions/cache/restore@v6 + timeout-minutes: 1 + with: + path: ~/go/pkg/mod + key: v7-gomod-${{ runner.os }}-${{ hashFiles('go.sum') }} + restore-keys: v7-gomod-${{ runner.os }}- + - name: Go toolchain + timeout-minutes: 1 + run: go version + - name: Affected packages + id: affected + run: ./scripts/affected.sh HEAD^1 env: - RUN: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deploy == 'true' }} - run: echo "run=$RUN" >> "$GITHUB_OUTPUT" - - uses: actions/checkout@v7 - if: steps.scope.outputs.run == 'true' - - name: vet + test - if: steps.scope.outputs.run == 'true' - working-directory: deploy - run: | - go version - go vet ./... - (cd .railway && go vet railway.go) - go test ./... - - uses: golangci/golangci-lint-action@v9 - if: steps.scope.outputs.run == 'true' + AFFECTED_FULL: '1' + # Warm 63-92 s (incl. mobile) -> 3; cold ~185-229 s -> 8. + - uses: actions/cache/restore@v6 + id: build-cache + timeout-minutes: 1 + with: + lookup-only: true + path: ~/.cache/go-build + key: v6-goother-${{ runner.os }}-${{ github.sha }} + restore-keys: v6-goother-${{ runner.os }}- + - name: Check + timeout-minutes: ${{ steps.build-cache.outputs.cache-matched-key != '' && 3 || 8 }} + uses: ./.github/actions/check with: - version: v2.15.0 - working-directory: deploy - args: -c ../.golangci.yml ./... + mode: other + packages: ${{ steps.affected.outputs.packages }} + dirs: ${{ steps.affected.outputs.dirs }} + full: ${{ steps.affected.outputs.full }} + sqlc: ${{ steps.affected.outputs.sqlc }} + templ: ${{ steps.affected.outputs.templ }} + generate: ${{ steps.affected.outputs.generate }} + mobile: ${{ github.event_name != 'push' || steps.mobile-push.outputs.mobile == 'true' }} + deploy: 'true' + save: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }} - # Typecheck, lint, Expo SDK version check and unit tests. The contract test - # (pnpm test:contract) needs Go + Docker and runs locally. - mobile: - needs: go - if: ${{ !cancelled() && (github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' || needs.go.outputs.mobile == 'true') }} + # PRs with a large affected set or shared-input changes: two parallel jobs + # (private-repo runners have 2 vCPUs; race-compiling is CPU-bound). "db" = + # lint + DB-backed tests, posts ci-ok once "other" (the rest + mobile) is done. + fanout: + name: ${{ matrix.mode == 'db' && 'lint + test (db)' || 'test (other)' }} + needs: ci + if: needs.ci.outputs.fanout == 'true' && github.event_name == 'pull_request' + strategy: + matrix: + mode: [db, other] runs-on: ubuntu-latest - timeout-minutes: 10 - defaults: - run: - working-directory: apps/mobile-app + timeout-minutes: 12 # backstop; the Check step has the measured timeout steps: - - uses: actions/checkout@v7 + # First thing, so it is up before the DB tests run. + - name: Start Postgres + if: matrix.mode == 'db' + run: | + nohup docker run -d --name pg -p 5432:5432 -e POSTGRES_PASSWORD=postgres \ + --tmpfs /var/lib/postgresql postgres:18-alpine \ + -c fsync=off -c synchronous_commit=off -c full_page_writes=off -c max_connections=200 \ + > "$RUNNER_TEMP/pg.log" 2>&1 & - # Version from packageManager in package.json. - - uses: pnpm/action-setup@v6 + - uses: actions/checkout@v7 + with: + ref: ${{ needs.ci.outputs.merge }} + fetch-depth: 2 + - uses: actions/cache/restore@v6 + timeout-minutes: 1 + id: mod-cache with: - package_json_file: apps/mobile-app/package.json + path: ~/go/pkg/mod + key: v7-gomod-${{ runner.os }}-${{ hashFiles('go.sum') }} + restore-keys: v7-gomod-${{ runner.os }}- + - name: Go toolchain + # 1-5 s; ~10 s when the toolchain has to be downloaded. + timeout-minutes: 1 + run: go version - - uses: actions/setup-node@v7 + - name: Check + timeout-minutes: ${{ fromJSON(needs.ci.outputs.timeout || '8') }} + uses: ./.github/actions/check with: - node-version: 22 - cache: pnpm - cache-dependency-path: apps/mobile-app/pnpm-lock.yaml + mode: ${{ matrix.mode }} + packages: ${{ needs.ci.outputs.packages }} + dirs: ${{ needs.ci.outputs.dirs }} + full: ${{ needs.ci.outputs.full }} + sqlc: ${{ needs.ci.outputs.sqlc }} + templ: ${{ needs.ci.outputs.templ }} + generate: ${{ needs.ci.outputs.generate }} + mobile: ${{ matrix.mode == 'other' && needs.ci.outputs.mobile || 'false' }} + deploy: ${{ matrix.mode == 'other' && needs.ci.outputs.deploy || 'false' }} + save: 'false' - - run: pnpm install --frozen-lockfile - - run: pnpm exec tsc --noEmit - - run: pnpm exec expo lint - - run: pnpm exec expo install --check - - run: pnpm test + # Waits for "other", then posts ci-ok for both. Two jobs are named + # "test (other)" (this matrix leg; the full-run job): the one that ran is + # the one not skipped. Also runs when this job + # failed or the run was cancelled (posts failure: never green untested). + - name: ci-ok result + if: always() && !cancelled() && matrix.mode == 'db' + timeout-minutes: ${{ fromJSON(needs.ci.outputs.poll || '9') }} + env: + GH_TOKEN: ${{ github.token }} + SHA: ${{ needs.ci.outputs.target }} + STATUS: ${{ job.status }} + POLL_MIN: ${{ needs.ci.outputs.poll || '9' }} + run: | + other="" + for _ in $(seq 1 $(( (${POLL_MIN:-9}) * 30 ))); do + other=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/attempts/$GITHUB_RUN_ATTEMPT/jobs" \ + --jq '[.jobs[] | select(.name == "test (other)" and .conclusion != "skipped")] | if length == 1 and .[0].status == "completed" then .[0].conclusion else "" end') + [ -n "$other" ] && break + sleep 2 + done + echo "db=$STATUS other=${other:-unfinished}" + state=failure; [ "$STATUS" = success ] && [ "$other" = success ] && state=success + gh api "repos/$GITHUB_REPOSITORY/statuses/$SHA" -f state=$state -f context=ci-ok \ + -f description="$state" -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" >/dev/null - # Skipped in runs that stay a draft (no billed minute). A skipped job shows - # its raw name expression, never "ci-ok", so it cannot stand in for the - # required check; when it runs, the name evaluates to "ci-ok". - ci-ok: - name: ${{ github.event.pull_request.draft && needs.draft.outputs.run != 'true' && 'ci (draft, not run)' || 'ci-ok' }} - if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft || needs.draft.outputs.run == 'true') }} - needs: [draft, go, deploy, mobile] - runs-on: ubuntu-latest - timeout-minutes: 1 - steps: - - name: Fail if any job failed or was cancelled - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + # A run cancelled by a newer push can still report job.status success + # (seen on a superseded PR run), so a cancelled run only ever posts failure. + - name: ci-ok cancelled + if: cancelled() && matrix.mode == 'db' + env: + GH_TOKEN: ${{ github.token }} + SHA: ${{ needs.ci.outputs.target }} run: | - echo 'draft=${{ needs.draft.result }} go=${{ needs.go.result }} deploy=${{ needs.deploy.result }} mobile=${{ needs.mobile.result }}' - exit 1 + gh api "repos/$GITHUB_REPOSITORY/statuses/$SHA" -f state=failure -f context=ci-ok \ + -f description="cancelled" -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" >/dev/null diff --git a/AGENTS.md b/AGENTS.md index d33c644..77c603e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,7 @@ The most common defect is a change that works on the path you tested and is miss - `make lint test` before every commit. `make ci` runs exactly what CI runs: build, generated-code diff (sqlc, mocks, templ), gofmt + golangci-lint, the test-location check, `go test -race` against a throwaway Postgres with per-boundary coverage floors, and the probes that prove the layering and test rules still bite (`make lint-layering lint-tests`). - CI on a PR checks only what the change can affect: `scripts/affected.sh` maps changed files to Go packages and adds every package whose code or tests import them (`go list -test -deps`), then builds, lints, tests and coverage-checks just those, and reruns only the generators whose inputs or outputs changed. Changes to go.mod/go.sum, tools/, scripts/, workflows, `.golangci.yml`, the Makefile, migrations or sqlc config run everything, as do pushes to main and the merge queue. `make affected` shows what your branch would run. A new shared input (a file that can change many packages' results without being in their package graph) belongs in the script's full-run list. -- CI layout: two parallel Go jobs. `lint + test (db)` runs lint while Postgres boots, then the packages whose tests use Postgres (`dbtest` or `TEST_DATABASE_URL` in their tests, see `scripts/ci-shard.sh`); `test (other)` runs the rest. Jobs bill rounded up to whole minutes, so avoid adding short jobs: fold small checks into an existing job. Opening a PR as a draft starts no jobs; pushes to a draft cost one (the draft check). Caches: modules keyed by go.sum; per-shard build/lint caches keyed by commit, saved on every push to main and restored by prefix in PRs. Pushes to main each run on their own (no queueing). +- CI layout: jobs bill rounded up to whole minutes, so CI is shaped around job count. One `ci` job plans and, for small changes, does everything (docs-only: nothing to check; mobile-only: the app checks; a few Go packages: lint + tests; plus the mobile or `deploy/` module checks when those changed). PRs with a large affected set (more test packages than the threshold in `scripts/ci-plan.sh`) or shared-input changes fan out to `lint + test (db)` and `test (other)`. Pushes to main, the merge queue and manual runs always run everything: the `ci` job takes the lint + DB-backed half and `test (other)` runs in parallel, with no planning job in front. Timeouts are ~2x each job's measured average (see the comments in the workflow). Shared steps live in `.github/actions/check`. Drafts start no jobs. Avoid adding short jobs: fold small checks into the existing ones. Caches: modules keyed by go.sum; build/lint caches per job mode keyed by commit, saved on every push to main and restored by prefix in PRs. - Run the narrowest thing first: `go test ./apps/web/internal/services/...`. - Behaviour changes ship with tests at the boundaries below, and only there. - A test that needs a sleep or timeout to pass is wrong. @@ -132,7 +132,7 @@ Secrets never reach templates, SSE, JSON or logs: - Conventional commit titles in plain language: `fix(api): PATCH /users no longer drops the image`. - Body: the problem in a sentence or two, then the fix. UI changes need before/after images; motion needs a short video. - One concern per PR. If the description says "also", split it. -- `ci-ok` is the single required check. When babysitting CI, verify each finding against the source, fix real ones, and dismiss false positives with a reason. +- `ci-ok` is the single required check. It is a commit status posted by the CI jobs on the commit they tested (pending, then success/failure), not a job, so skipped or cancelled jobs and drafts can never satisfy it. Require it in branch protection as a status check (source: GitHub Actions). When babysitting CI, verify each finding against the source, fix real ones, and dismiss false positives with a reason. ## Where code lives diff --git a/scripts/ci-plan.sh b/scripts/ci-plan.sh new file mode 100755 index 0000000..10609bb --- /dev/null +++ b/scripts/ci-plan.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Decides whether CI checks the affected packages in one job or fans out to +# two parallel jobs (lint + DB-backed tests | other tests). Every job bills +# rounded up to a whole minute, so one job is cheaper whenever it finishes in +# about a minute; fan out only when it would not. +# Usage: FULL=true|false PACKAGES="" scripts/ci-plan.sh +# Prints (and appends to $GITHUB_OUTPUT when set): +# fanout=true|false tested= (n = affected packages that have tests) +set -euo pipefail +cd "$(dirname "$0")/.." + +# Fan out above this many affected packages with tests. Measured single-job +# times (warm caches): 1 tested package (web) 36-52 s, 4 (admin) 67-76 s; +# fanning out adds the planning job (~12 s + scheduling) in front of two +# ~45-68 s jobs, so it only pays off for sets whose single job would clearly +# pass ~90 s, e.g. full runs (16 tested packages). +threshold=${CI_FANOUT_TESTED:-8} + +mod=$(go list -m) +tested=0 +read -r -a pkgs <<<"${PACKAGES:-}" +for p in "${pkgs[@]}"; do + d=${p/#$mod/.} + if compgen -G "$d/*_test.go" >/dev/null; then tested=$((tested + 1)); fi +done + +fanout=false +if [ "${FULL:-false}" = true ] || [ "$tested" -gt "$threshold" ]; then fanout=true; fi + +out() { + printf '%s=%s\n' "$1" "$2" + if [ -n "${GITHUB_OUTPUT:-}" ]; then printf '%s=%s\n' "$1" "$2" >>"$GITHUB_OUTPUT"; fi +} +out fanout "$fanout" +out tested "$tested" diff --git a/scripts/ci-shard.sh b/scripts/ci-shard.sh index c3f6dfe..fa4c510 100755 --- a/scripts/ci-shard.sh +++ b/scripts/ci-shard.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash -# Splits the packages to test across the two CI test shards: "db" gets the -# packages whose tests use Postgres (dbtest or TEST_DATABASE_URL in their -# _test.go files), "other" gets the rest. -# Usage: PACKAGES="|./..." scripts/ci-shard.sh db|other +# Picks the packages one CI job tests: "db" = packages whose tests use +# Postgres (dbtest or TEST_DATABASE_URL in their _test.go files), "other" = +# the rest, "all" = both (a single job that does everything). +# Usage: PACKAGES="|./..." scripts/ci-shard.sh db|other|all # Prints (and appends to $GITHUB_OUTPUT when set): -# packages= (empty: nothing to test) +# packages= (empty: nothing to test) +# db=true|false some of them need Postgres set -euo pipefail cd "$(dirname "$0")/.." shard=$1 @@ -16,13 +17,20 @@ else fi mod=$(go list -m) -mine=() +mine=() db=false for p in "${all[@]}"; do [ -n "$p" ] || continue d=${p/#$mod/.} if grep -lqE 'dbtest|TEST_DATABASE_URL' "$d"/*_test.go 2>/dev/null; then kind=db; else kind=other; fi - if [ "$kind" = "$shard" ]; then mine+=("$p"); fi + if [ "$shard" = all ] || [ "$kind" = "$shard" ]; then + mine+=("$p") + if [ "$kind" = db ]; then db=true; fi + fi done -printf 'packages=%s\n' "${mine[*]}" -if [ -n "${GITHUB_OUTPUT:-}" ]; then printf 'packages=%s\n' "${mine[*]}" >>"$GITHUB_OUTPUT"; fi +out() { + printf '%s=%s\n' "$1" "$2" + if [ -n "${GITHUB_OUTPUT:-}" ]; then printf '%s=%s\n' "$1" "$2" >>"$GITHUB_OUTPUT"; fi +} +out packages "${mine[*]}" +out db "$db" From 1cabc2e0be9c26c8e51fd4a64e2b771c00c7078f Mon Sep 17 00:00:00 2001 From: JJ Eaton <58495321+jayleaton@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:13:08 +0700 Subject: [PATCH 2/5] perf(ci): one job by default for every run; sharding only as an opt-in 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 --- .github/actions/check/action.yml | 23 ++---- .github/workflows/ci.yml | 134 +++++++------------------------ AGENTS.md | 2 +- scripts/ci-plan.sh | 35 -------- 4 files changed, 39 insertions(+), 155 deletions(-) delete mode 100755 scripts/ci-plan.sh diff --git a/.github/actions/check/action.yml b/.github/actions/check/action.yml index 2586fa4..294713a 100644 --- a/.github/actions/check/action.yml +++ b/.github/actions/check/action.yml @@ -38,25 +38,16 @@ runs: -c fsync=off -c synchronous_commit=off -c full_page_writes=off -c max_connections=200 \ > "$RUNNER_TEMP/pg.log" 2>&1 & - # Build + lint caches per fan-out mode ("db", "other"), keyed by commit, - # saved on pushes to main (which always fan out) and restored by prefix in - # PRs. The single job ("all") builds what both build, so it restores both. + # Build + lint cache, keyed by commit: saved by the single CI job on pushes + # to main and restored by prefix everywhere else (incl. opt-in fan-out). - uses: actions/cache/restore@v6 - if: inputs.packages != '' && inputs.mode != 'other' - with: - path: | - ~/.cache/go-build - ~/.cache/golangci-lint - key: v6-godb-${{ runner.os }}-${{ github.sha }} - restore-keys: v6-godb-${{ runner.os }}- - - uses: actions/cache/restore@v6 - if: inputs.packages != '' && inputs.mode != 'db' + if: inputs.packages != '' with: path: | ~/.cache/go-build ~/.cache/golangci-lint - key: v6-goother-${{ runner.os }}-${{ github.sha }} - restore-keys: v6-goother-${{ runner.os }}- + key: v7-go-${{ runner.os }}-${{ github.sha }} + restore-keys: v7-go-${{ runner.os }}- # ---- lint ("all" and "db") ---- - name: gofmt @@ -155,12 +146,12 @@ runs: TESTED="$PACKAGES" ./scripts/check-coverage.sh .cache/coverage.out - uses: actions/cache/save@v6 - if: inputs.save == 'true' && inputs.packages != '' && inputs.mode != 'all' + if: inputs.save == 'true' && inputs.packages != '' && inputs.mode == 'all' with: path: | ~/.cache/go-build ~/.cache/golangci-lint - key: v6-go${{ inputs.mode }}-${{ runner.os }}-${{ github.sha }} + key: v7-go-${{ runner.os }}-${{ github.sha }} # ---- mobile app: typecheck, lint, Expo SDK check, unit tests ---- # (The contract test, pnpm test:contract, needs Go + Docker and runs locally.) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 380150c..9030338 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,15 +1,22 @@ # ci-ok, the single required check, is a commit status, not a job: jobs post # it (pending, then success/failure) on the commit they actually tested, so a # skipped or cancelled job can never satisfy it and a draft (which starts no -# jobs) never gets it. Jobs bill rounded up to whole minutes, so CI is shaped -# around job count: one job does everything for small changes (docs, mobile, -# a few Go packages); only large or full runs fan out to two jobs. +# jobs) never gets it. Jobs bill rounded up to whole minutes, so CI is ONE job: +# it checks the affected packages (all of them on main, merge queue and manual +# runs) plus the mobile app and deploy/ module when those change. Sharding is +# an opt-in fallback only: a "ci:fanout" label on the PR or the fanout input +# of a manual run. name: CI on: pull_request: types: [opened, synchronize, reopened, ready_for_review] workflow_dispatch: + inputs: + fanout: + description: Split the checks across two parallel jobs (faster wall time, more billed minutes) + type: boolean + default: false push: branches: [main] merge_group: @@ -29,7 +36,7 @@ permissions: contents: read pull-requests: read # paths-filter lists PR files through the API statuses: write # the ci-ok commit status - actions: read # the fan-out finaliser reads the other job's result + actions: read # the opt-in fan-out finaliser reads the other job's result env: TEST_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/postgres?sslmode=disable @@ -38,8 +45,8 @@ env: SEGMENT_DOWNLOAD_TIMEOUT_MINS: 1 jobs: - # Plans and, unless the change is large, does all the work. Drafts start no - # jobs: ready for review (or any push to a ready PR) runs CI. + # Does all the work (unless fan-out was asked for). Drafts start no jobs: + # ready for review (or any push to a ready PR) runs CI. ci: if: github.event_name != 'pull_request' || !github.event.pull_request.draft runs-on: ubuntu-latest @@ -61,16 +68,6 @@ jobs: mobile: ${{ steps.scope.outputs.mobile }} deploy: ${{ steps.scope.outputs.deploy }} steps: - # Pushes to main, merge queue, manual runs: this job is the "db" half of - # a full run; start Postgres first thing. - - name: Start Postgres - if: github.event_name != 'pull_request' - run: | - nohup docker run -d --name pg -p 5432:5432 -e POSTGRES_PASSWORD=postgres \ - --tmpfs /var/lib/postgresql postgres:18-alpine \ - -c fsync=off -c synchronous_commit=off -c full_page_writes=off -c max_connections=200 \ - > "$RUNNER_TEMP/pg.log" 2>&1 & - # PR events list changed files through the API (no checkout needed). - uses: dorny/paths-filter@v4 if: github.event_name == 'pull_request' @@ -188,13 +185,13 @@ jobs: env: AFFECTED_FULL: ${{ github.event_name != 'pull_request' && '1' || '' }} + # Opt-in only: one job is cheapest (jobs bill rounded up to minutes). - name: Plan if: steps.affected.outputs.packages != '' id: plan - run: ./scripts/ci-plan.sh env: - FULL: ${{ steps.affected.outputs.full }} - PACKAGES: ${{ steps.affected.outputs.packages }} + FANOUT: ${{ contains(github.event.pull_request.labels.*.name, 'ci:fanout') || inputs.fanout == true }} + run: echo "fanout=$FANOUT" >> "$GITHUB_OUTPUT" # Timeouts (minutes) for the Check steps, ~2x their measured averages, # by whether the build cache is warm (a lookup-only probe, no download): @@ -208,8 +205,8 @@ jobs: with: lookup-only: true path: ~/.cache/go-build - key: v6-godb-${{ runner.os }}-${{ github.sha }} - restore-keys: v6-godb-${{ runner.os }}- + key: v7-go-${{ runner.os }}-${{ github.sha }} + restore-keys: v7-go-${{ runner.os }}- - name: Timeouts id: timeouts env: @@ -223,21 +220,20 @@ jobs: echo "check=$check" >> "$GITHUB_OUTPUT"; echo "fanout=$fanout" >> "$GITHUB_OUTPUT" echo "poll=$((fanout + 1))" >> "$GITHUB_OUTPUT" - # PRs: everything here unless fanned out. Other events: the "db" half. - name: Check - if: (steps.plan.outputs.fanout != 'true' || github.event_name != 'pull_request') && (steps.scope.outputs.go == 'true' || steps.scope.outputs.mobile == 'true' || steps.scope.outputs.deploy == 'true') + if: steps.plan.outputs.fanout != 'true' && (steps.scope.outputs.go == 'true' || steps.scope.outputs.mobile == 'true' || steps.scope.outputs.deploy == 'true') timeout-minutes: ${{ fromJSON(steps.timeouts.outputs.check || '3') }} uses: ./.github/actions/check with: - mode: ${{ github.event_name == 'pull_request' && 'all' || 'db' }} + mode: all packages: ${{ steps.affected.outputs.packages }} dirs: ${{ steps.affected.outputs.dirs }} full: ${{ steps.affected.outputs.full }} sqlc: ${{ steps.affected.outputs.sqlc }} templ: ${{ steps.affected.outputs.templ }} generate: ${{ steps.affected.outputs.generate }} - mobile: ${{ github.event_name == 'pull_request' && steps.scope.outputs.mobile || 'false' }} - deploy: ${{ github.event_name == 'pull_request' && steps.scope.outputs.deploy || 'false' }} + mobile: ${{ steps.scope.outputs.mobile }} + deploy: ${{ steps.scope.outputs.deploy }} save: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }} # This job (lint) loads every package and its test imports, so it saves @@ -255,36 +251,23 @@ jobs: path: ~/go/pkg/mod key: v7-gomod-${{ runner.os }}-${{ hashFiles('go.sum') }} - # PR, not fanned out: this job's own result (docs-only PRs land here with - # nothing to check: success). Other events: wait for "test (other)". + # This job's own result (docs-only PRs land here with nothing to check: + # success). With fan-out, the db job posts it instead. - name: ci-ok result - if: always() && !cancelled() && (steps.plan.outputs.fanout != 'true' || github.event_name != 'pull_request') && steps.target.outputs.sha != '' + if: always() && !cancelled() && steps.plan.outputs.fanout != 'true' && steps.target.outputs.sha != '' env: GH_TOKEN: ${{ github.token }} SHA: ${{ steps.target.outputs.sha }} STATUS: ${{ job.status }} - EVENT: ${{ github.event_name }} - POLL_MIN: ${{ steps.timeouts.outputs.poll || '9' }} run: | - other=success - if [ "$EVENT" != pull_request ]; then - other="" - for _ in $(seq 1 $(( (${POLL_MIN:-9}) * 30 ))); do - other=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/attempts/$GITHUB_RUN_ATTEMPT/jobs" \ - --jq '[.jobs[] | select(.name == "test (other)" and .conclusion != "skipped")] | if length == 1 and .[0].status == "completed" then .[0].conclusion else "" end') - [ -n "$other" ] && break - sleep 2 - done - fi - echo "this=$STATUS other=${other:-unfinished}" - state=failure; [ "$STATUS" = success ] && [ "$other" = success ] && state=success + state=failure; [ "$STATUS" = success ] && state=success gh api "repos/$GITHUB_REPOSITORY/statuses/$SHA" -f state=$state -f context=ci-ok \ -f description="$state" -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" >/dev/null # A run cancelled by a newer push can still report job.status success # (seen on a superseded PR run), so a cancelled run only ever posts failure. - name: ci-ok cancelled - if: cancelled() && (steps.plan.outputs.fanout != 'true' || github.event_name != 'pull_request') && steps.target.outputs.sha != '' + if: cancelled() && steps.plan.outputs.fanout != 'true' && steps.target.outputs.sha != '' env: GH_TOKEN: ${{ github.token }} SHA: ${{ steps.target.outputs.sha }} @@ -292,68 +275,13 @@ jobs: gh api "repos/$GITHUB_REPOSITORY/statuses/$SHA" -f state=failure -f context=ci-ok \ -f description="cancelled" -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" >/dev/null - # Pushes to main, merge queue, manual runs: the "other" half of the full run, - # in parallel with the ci job (no planning job in front: these always run - # everything). Warm ~55-92 s (incl. mobile), cold ~185 s: 4. - other: - name: test (other) - if: github.event_name != 'pull_request' - runs-on: ubuntu-latest - timeout-minutes: 12 # backstop; the Check step has the measured timeout - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 2 - - uses: dorny/paths-filter@v4 - if: github.event_name == 'push' - id: mobile-push - with: - filters: *mobile-filter - - uses: actions/cache/restore@v6 - timeout-minutes: 1 - with: - path: ~/go/pkg/mod - key: v7-gomod-${{ runner.os }}-${{ hashFiles('go.sum') }} - restore-keys: v7-gomod-${{ runner.os }}- - - name: Go toolchain - timeout-minutes: 1 - run: go version - - name: Affected packages - id: affected - run: ./scripts/affected.sh HEAD^1 - env: - AFFECTED_FULL: '1' - # Warm 63-92 s (incl. mobile) -> 3; cold ~185-229 s -> 8. - - uses: actions/cache/restore@v6 - id: build-cache - timeout-minutes: 1 - with: - lookup-only: true - path: ~/.cache/go-build - key: v6-goother-${{ runner.os }}-${{ github.sha }} - restore-keys: v6-goother-${{ runner.os }}- - - name: Check - timeout-minutes: ${{ steps.build-cache.outputs.cache-matched-key != '' && 3 || 8 }} - uses: ./.github/actions/check - with: - mode: other - packages: ${{ steps.affected.outputs.packages }} - dirs: ${{ steps.affected.outputs.dirs }} - full: ${{ steps.affected.outputs.full }} - sqlc: ${{ steps.affected.outputs.sqlc }} - templ: ${{ steps.affected.outputs.templ }} - generate: ${{ steps.affected.outputs.generate }} - mobile: ${{ github.event_name != 'push' || steps.mobile-push.outputs.mobile == 'true' }} - deploy: 'true' - save: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }} - - # PRs with a large affected set or shared-input changes: two parallel jobs - # (private-repo runners have 2 vCPUs; race-compiling is CPU-bound). "db" = - # lint + DB-backed tests, posts ci-ok once "other" (the rest + mobile) is done. + # Opt-in fallback ("ci:fanout" label, or the fanout input of a manual run): + # two parallel jobs for faster wall time at more billed minutes. "db" = lint + # + DB-backed tests, posts ci-ok once "other" (the rest + mobile/deploy) is done. fanout: name: ${{ matrix.mode == 'db' && 'lint + test (db)' || 'test (other)' }} needs: ci - if: needs.ci.outputs.fanout == 'true' && github.event_name == 'pull_request' + if: needs.ci.outputs.fanout == 'true' strategy: matrix: mode: [db, other] diff --git a/AGENTS.md b/AGENTS.md index 77c603e..914662e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,7 @@ The most common defect is a change that works on the path you tested and is miss - `make lint test` before every commit. `make ci` runs exactly what CI runs: build, generated-code diff (sqlc, mocks, templ), gofmt + golangci-lint, the test-location check, `go test -race` against a throwaway Postgres with per-boundary coverage floors, and the probes that prove the layering and test rules still bite (`make lint-layering lint-tests`). - CI on a PR checks only what the change can affect: `scripts/affected.sh` maps changed files to Go packages and adds every package whose code or tests import them (`go list -test -deps`), then builds, lints, tests and coverage-checks just those, and reruns only the generators whose inputs or outputs changed. Changes to go.mod/go.sum, tools/, scripts/, workflows, `.golangci.yml`, the Makefile, migrations or sqlc config run everything, as do pushes to main and the merge queue. `make affected` shows what your branch would run. A new shared input (a file that can change many packages' results without being in their package graph) belongs in the script's full-run list. -- CI layout: jobs bill rounded up to whole minutes, so CI is shaped around job count. One `ci` job plans and, for small changes, does everything (docs-only: nothing to check; mobile-only: the app checks; a few Go packages: lint + tests; plus the mobile or `deploy/` module checks when those changed). PRs with a large affected set (more test packages than the threshold in `scripts/ci-plan.sh`) or shared-input changes fan out to `lint + test (db)` and `test (other)`. Pushes to main, the merge queue and manual runs always run everything: the `ci` job takes the lint + DB-backed half and `test (other)` runs in parallel, with no planning job in front. Timeouts are ~2x each job's measured average (see the comments in the workflow). Shared steps live in `.github/actions/check`. Drafts start no jobs. Avoid adding short jobs: fold small checks into the existing ones. Caches: modules keyed by go.sum; build/lint caches per job mode keyed by commit, saved on every push to main and restored by prefix in PRs. +- CI layout: jobs bill rounded up to whole minutes, so CI is ONE job (`ci`): affected-only generators, gofmt, golangci-lint and race tests (all packages on pushes to main, the merge queue and manual runs), plus the mobile app and `deploy/` module checks when those change. Docs-only PRs post `ci-ok` from that job with nothing to check; drafts start no jobs. Sharding is an opt-in fallback only: label a PR `ci:fanout` (takes effect on its next push) or run the workflow manually with `fanout`, which splits into `lint + test (db)` and `test (other)` for faster wall time at more billed minutes. Timeouts are ~2x measured averages (see the workflow comments). Shared steps live in `.github/actions/check`. Avoid adding jobs: fold new checks into the existing one. Caches: modules keyed by go.sum; one build/lint cache keyed by commit, saved on every push to main and restored by prefix elsewhere. - Run the narrowest thing first: `go test ./apps/web/internal/services/...`. - Behaviour changes ship with tests at the boundaries below, and only there. - A test that needs a sleep or timeout to pass is wrong. diff --git a/scripts/ci-plan.sh b/scripts/ci-plan.sh deleted file mode 100755 index 10609bb..0000000 --- a/scripts/ci-plan.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash -# Decides whether CI checks the affected packages in one job or fans out to -# two parallel jobs (lint + DB-backed tests | other tests). Every job bills -# rounded up to a whole minute, so one job is cheaper whenever it finishes in -# about a minute; fan out only when it would not. -# Usage: FULL=true|false PACKAGES="" scripts/ci-plan.sh -# Prints (and appends to $GITHUB_OUTPUT when set): -# fanout=true|false tested= (n = affected packages that have tests) -set -euo pipefail -cd "$(dirname "$0")/.." - -# Fan out above this many affected packages with tests. Measured single-job -# times (warm caches): 1 tested package (web) 36-52 s, 4 (admin) 67-76 s; -# fanning out adds the planning job (~12 s + scheduling) in front of two -# ~45-68 s jobs, so it only pays off for sets whose single job would clearly -# pass ~90 s, e.g. full runs (16 tested packages). -threshold=${CI_FANOUT_TESTED:-8} - -mod=$(go list -m) -tested=0 -read -r -a pkgs <<<"${PACKAGES:-}" -for p in "${pkgs[@]}"; do - d=${p/#$mod/.} - if compgen -G "$d/*_test.go" >/dev/null; then tested=$((tested + 1)); fi -done - -fanout=false -if [ "${FULL:-false}" = true ] || [ "$tested" -gt "$threshold" ]; then fanout=true; fi - -out() { - printf '%s=%s\n' "$1" "$2" - if [ -n "${GITHUB_OUTPUT:-}" ]; then printf '%s=%s\n' "$1" "$2" >>"$GITHUB_OUTPUT"; fi -} -out fanout "$fanout" -out tested "$tested" From 724e522347a28df01a835443f58e1359c96b1914 Mon Sep 17 00:00:00 2001 From: JJ Eaton <58495321+jayleaton@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:29:13 +0700 Subject: [PATCH 3/5] perf(ci): Check-step timeouts per case: affected/all packages x warm/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 --- .github/workflows/ci.yml | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9030338..116163c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,9 +50,11 @@ jobs: ci: if: github.event_name != 'pull_request' || !github.event.pull_request.draft runs-on: ubuntu-latest - # Timeouts are ~2x measured averages and sit on the steps that do the work - # (see "Timeouts" below); this is only a backstop above their sum. - timeout-minutes: 12 + # The ~2x-average timeouts sit on the steps that do the work (see + # "Timeouts" below): the job cannot know at start whether a run is full or + # cold. This is only a backstop just above the worst case (all packages, + # cold: ~5.5 min job, Check step capped at 11). + timeout-minutes: 13 outputs: fanout: ${{ steps.plan.outputs.fanout }} timeout: ${{ steps.timeouts.outputs.fanout }} @@ -193,11 +195,13 @@ jobs: FANOUT: ${{ contains(github.event.pull_request.labels.*.name, 'ci:fanout') || inputs.fanout == true }} run: echo "fanout=$FANOUT" >> "$GITHUB_OUTPUT" - # Timeouts (minutes) for the Check steps, ~2x their measured averages, - # by whether the build cache is warm (a lookup-only probe, no download): - # PR single job: warm 9-77 s (heaviest: admin ~75 s) -> 3; cold 131-159 s -> 6 - # mobile only: 40-45 s -> 2 - # full/fan-out: warm 59-97 s -> 3; cold 185-233 s -> 8 + # Timeouts (minutes) for the Check step (the work), ~2x its measured + # average for the case, by whether the build cache is warm (a lookup-only + # probe, no download) and whether all packages are checked: + # affected set: warm 9-77 s (heaviest: admin ~75 s) -> 3; cold 131-159 s -> 6 + # all packages: warm 138 s -> 5; cold 312 s -> 11 + # mobile/deploy only (no Go packages): 40-45 s -> 2 + # opt-in fan-out jobs: warm 59-97 s -> 3; cold 185-233 s -> 8 - uses: actions/cache/restore@v6 if: steps.affected.outputs.packages != '' id: build-cache @@ -212,10 +216,13 @@ jobs: env: MATCHED: ${{ steps.build-cache.outputs.cache-matched-key }} PACKAGES: ${{ steps.affected.outputs.packages }} - SINGLE: ${{ github.event_name == 'pull_request' && steps.plan.outputs.fanout != 'true' }} + FULL: ${{ steps.affected.outputs.full }} run: | - if [ -n "$MATCHED" ]; then check=3 fanout=3; else fanout=8; check=8; [ "$SINGLE" = true ] && check=6; fi - # No Go packages (mobile- or deploy-only): mobile averages 40-45 s. + if [ -n "$MATCHED" ]; then + fanout=3; check=3; [ "$FULL" = true ] && check=5 + else + fanout=8; check=6; [ "$FULL" = true ] && check=11 + fi [ -z "$PACKAGES" ] && check=2 echo "check=$check" >> "$GITHUB_OUTPUT"; echo "fanout=$fanout" >> "$GITHUB_OUTPUT" echo "poll=$((fanout + 1))" >> "$GITHUB_OUTPUT" From 7054c1f6f0080c8e424d05a02585f97b48d31aad Mon Sep 17 00:00:00 2001 From: JJ Eaton <58495321+jayleaton@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:38:49 +0700 Subject: [PATCH 4/5] perf(ci): a main push whose tree already passed CI on its PR reuses that 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 --- .github/workflows/ci.yml | 57 +++++++++++++++++++++++++++--------- AGENTS.md | 2 +- scripts/ci-reuse.sh | 62 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 15 deletions(-) create mode 100755 scripts/ci-reuse.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 116163c..1f6ef2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,10 @@ on: push: branches: [main] merge_group: + # Refreshes the caches PRs restore (pushes to main that reuse a PR's result + # do not); skipped when main has not moved since the last saved cache. + schedule: + - cron: '17 */4 * * *' # PR runs: draft and real runs use separate groups, so a skipped draft run # (e.g. a push landing a second before "ready for review") never cancels the @@ -36,7 +40,7 @@ permissions: contents: read pull-requests: read # paths-filter lists PR files through the API statuses: write # the ci-ok commit status - actions: read # the opt-in fan-out finaliser reads the other job's result + actions: read # the opt-in fan-out finaliser reads the other job's result; scheduled runs list caches env: TEST_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/postgres?sslmode=disable @@ -61,6 +65,7 @@ jobs: poll: ${{ steps.timeouts.outputs.poll }} target: ${{ steps.target.outputs.sha }} merge: ${{ steps.target.outputs.merge }} + tree: ${{ steps.target.outputs.tree }} packages: ${{ steps.affected.outputs.packages }} dirs: ${{ steps.affected.outputs.dirs }} full: ${{ steps.affected.outputs.full }} @@ -109,9 +114,25 @@ jobs: # Depth 2: a PR checkout is the merge commit (HEAD^1 = base, HEAD^2 = head). - uses: actions/checkout@v7 - if: github.event_name != 'pull_request' || steps.go-pr.outputs.go == 'true' || steps.mobile-pr.outputs.mobile == 'true' || steps.deploy-pr.outputs.deploy == 'true' with: fetch-depth: 2 + + # A push to main whose tree is identical to a PR merge commit that + # already passed CI is not tested again: ci-ok is posted from that + # result. Safe by construction: the PR's ci-ok must be a success posted by + # this workflow (github-actions[bot]) recording exactly this tree (see + # "ci-ok result"); if main moved in between (tree differs) or the result + # is missing or not success, the run checks everything as usual. + # A scheduled run is skipped when the cache for this commit already exists. + - name: Reuse or skip + id: reuse + if: github.event_name == 'push' || github.event_name == 'schedule' + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + MESSAGE: ${{ github.event.head_commit.message }} + run: ./scripts/ci-reuse.sh + - uses: dorny/paths-filter@v4 if: github.event_name == 'push' id: mobile-push @@ -125,12 +146,17 @@ jobs: GO_PR: ${{ steps.go-pr.outputs.go }} MOBILE: ${{ steps.mobile-pr.outputs.mobile || steps.mobile-push.outputs.mobile }} DEPLOY: ${{ steps.deploy-pr.outputs.deploy }} + SKIP: ${{ steps.reuse.outputs.skip }} run: | go=true mobile=$MOBILE deploy=$DEPLOY if [ "$EVENT" = pull_request ]; then go=$GO_PR; else deploy=true; fi if [ "$EVENT" = merge_group ] || [ "$EVENT" = workflow_dispatch ]; then mobile=true; fi + if [ "$SKIP" = true ]; then go=false mobile=false deploy=false; fi + save=false + case $EVENT in workflow_dispatch | schedule) save=true ;; push) [ "$GITHUB_REF" = refs/heads/main ] && save=true ;; esac echo "go=$go" >> "$GITHUB_OUTPUT"; echo "mobile=${mobile:-false}" >> "$GITHUB_OUTPUT" - echo "deploy=${deploy:-false}" >> "$GITHUB_OUTPUT" + echo "deploy=${deploy:-false}" >> "$GITHUB_OUTPUT"; echo "save=$save" >> "$GITHUB_OUTPUT" + echo "skip=${SKIP:-false}" >> "$GITHUB_OUTPUT" # The commit ci-ok is posted on: the PR head of the merge commit actually # checked out (the event's head can be stale when a push and "ready for @@ -144,14 +170,15 @@ jobs: run: | if [ "$EVENT" != pull_request ]; then echo "sha=$GITHUB_SHA" >> "$GITHUB_OUTPUT"; echo "merge=$GITHUB_SHA" >> "$GITHUB_OUTPUT" - elif git rev-parse -q --verify HEAD^2 >/dev/null 2>&1; then - echo "sha=$(git rev-parse HEAD^2)" >> "$GITHUB_OUTPUT"; echo "merge=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" else - echo "sha=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" --jq .head.sha)" >> "$GITHUB_OUTPUT" + echo "sha=$(git rev-parse HEAD^2)" >> "$GITHUB_OUTPUT"; echo "merge=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" fi + # The tree tested (the merge commit's), recorded in ci-ok so a push + # to main with exactly this tree can reuse the result. + echo "tree=$(git rev-parse HEAD^{tree})" >> "$GITHUB_OUTPUT" - name: ci-ok pending - if: steps.scope.outputs.go == 'true' || steps.scope.outputs.mobile == 'true' || steps.scope.outputs.deploy == 'true' + if: (steps.scope.outputs.go == 'true' || steps.scope.outputs.mobile == 'true' || steps.scope.outputs.deploy == 'true') env: GH_TOKEN: ${{ github.token }} SHA: ${{ steps.target.outputs.sha }} @@ -241,19 +268,19 @@ jobs: generate: ${{ steps.affected.outputs.generate }} mobile: ${{ steps.scope.outputs.mobile }} deploy: ${{ steps.scope.outputs.deploy }} - save: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }} + save: ${{ steps.scope.outputs.save }} # This job (lint) loads every package and its test imports, so it saves # the module cache. Cache-path "!" exclusions did not apply, so trim # explicitly: zips (~125 MB incl. the 71 MB toolchain zip) and testdata. - name: Trim module cache - if: (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && steps.mod-cache.outputs.cache-hit != 'true' + if: steps.scope.outputs.save == 'true' && steps.mod-cache.outputs.cache-hit != 'true' run: | chmod -R u+w ~/go/pkg/mod find ~/go/pkg/mod/cache/download -name '*.zip' -delete find ~/go/pkg/mod -mindepth 2 -type d -name testdata -prune -exec rm -rf {} + - uses: actions/cache/save@v6 - if: (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && steps.mod-cache.outputs.cache-hit != 'true' + if: steps.scope.outputs.save == 'true' && steps.mod-cache.outputs.cache-hit != 'true' with: path: ~/go/pkg/mod key: v7-gomod-${{ runner.os }}-${{ hashFiles('go.sum') }} @@ -261,20 +288,21 @@ jobs: # This job's own result (docs-only PRs land here with nothing to check: # success). With fan-out, the db job posts it instead. - name: ci-ok result - if: always() && !cancelled() && steps.plan.outputs.fanout != 'true' && steps.target.outputs.sha != '' + if: always() && !cancelled() && steps.plan.outputs.fanout != 'true' && steps.target.outputs.sha != '' && steps.scope.outputs.skip != 'true' env: GH_TOKEN: ${{ github.token }} SHA: ${{ steps.target.outputs.sha }} + TREE: ${{ steps.target.outputs.tree }} STATUS: ${{ job.status }} run: | state=failure; [ "$STATUS" = success ] && state=success gh api "repos/$GITHUB_REPOSITORY/statuses/$SHA" -f state=$state -f context=ci-ok \ - -f description="$state" -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" >/dev/null + -f description="$state tree=$TREE" -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" >/dev/null # A run cancelled by a newer push can still report job.status success # (seen on a superseded PR run), so a cancelled run only ever posts failure. - name: ci-ok cancelled - if: cancelled() && steps.plan.outputs.fanout != 'true' && steps.target.outputs.sha != '' + if: cancelled() && steps.plan.outputs.fanout != 'true' && steps.target.outputs.sha != '' && steps.scope.outputs.skip != 'true' env: GH_TOKEN: ${{ github.token }} SHA: ${{ steps.target.outputs.sha }} @@ -345,6 +373,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} SHA: ${{ needs.ci.outputs.target }} + TREE: ${{ needs.ci.outputs.tree }} STATUS: ${{ job.status }} POLL_MIN: ${{ needs.ci.outputs.poll || '9' }} run: | @@ -358,7 +387,7 @@ jobs: echo "db=$STATUS other=${other:-unfinished}" state=failure; [ "$STATUS" = success ] && [ "$other" = success ] && state=success gh api "repos/$GITHUB_REPOSITORY/statuses/$SHA" -f state=$state -f context=ci-ok \ - -f description="$state" -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" >/dev/null + -f description="$state tree=$TREE" -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" >/dev/null # A run cancelled by a newer push can still report job.status success # (seen on a superseded PR run), so a cancelled run only ever posts failure. diff --git a/AGENTS.md b/AGENTS.md index 914662e..c01a9b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,7 @@ The most common defect is a change that works on the path you tested and is miss - `make lint test` before every commit. `make ci` runs exactly what CI runs: build, generated-code diff (sqlc, mocks, templ), gofmt + golangci-lint, the test-location check, `go test -race` against a throwaway Postgres with per-boundary coverage floors, and the probes that prove the layering and test rules still bite (`make lint-layering lint-tests`). - CI on a PR checks only what the change can affect: `scripts/affected.sh` maps changed files to Go packages and adds every package whose code or tests import them (`go list -test -deps`), then builds, lints, tests and coverage-checks just those, and reruns only the generators whose inputs or outputs changed. Changes to go.mod/go.sum, tools/, scripts/, workflows, `.golangci.yml`, the Makefile, migrations or sqlc config run everything, as do pushes to main and the merge queue. `make affected` shows what your branch would run. A new shared input (a file that can change many packages' results without being in their package graph) belongs in the script's full-run list. -- CI layout: jobs bill rounded up to whole minutes, so CI is ONE job (`ci`): affected-only generators, gofmt, golangci-lint and race tests (all packages on pushes to main, the merge queue and manual runs), plus the mobile app and `deploy/` module checks when those change. Docs-only PRs post `ci-ok` from that job with nothing to check; drafts start no jobs. Sharding is an opt-in fallback only: label a PR `ci:fanout` (takes effect on its next push) or run the workflow manually with `fanout`, which splits into `lint + test (db)` and `test (other)` for faster wall time at more billed minutes. Timeouts are ~2x measured averages (see the workflow comments). Shared steps live in `.github/actions/check`. Avoid adding jobs: fold new checks into the existing one. Caches: modules keyed by go.sum; one build/lint cache keyed by commit, saved on every push to main and restored by prefix elsewhere. +- CI layout: jobs bill rounded up to whole minutes, so CI is ONE job (`ci`): affected-only generators, gofmt, golangci-lint and race tests (all packages on pushes to main, the merge queue and manual runs), plus the mobile app and `deploy/` module checks when those change. Docs-only PRs post `ci-ok` from that job with nothing to check; drafts start no jobs. Sharding is an opt-in fallback only: label a PR `ci:fanout` (takes effect on its next push) or run the workflow manually with `fanout`, which splits into `lint + test (db)` and `test (other)` for faster wall time at more billed minutes. Timeouts are ~2x measured averages (see the workflow comments). Shared steps live in `.github/actions/check`. Avoid adding jobs: fold new checks into the existing one. A push to main whose tree is identical to a PR merge commit that already passed CI is not tested again: `ci-ok` is posted from that result (`scripts/ci-reuse.sh`; PR results record the tested tree in their `ci-ok` description). Caches: modules keyed by go.sum; one build/lint cache keyed by commit, saved by main pushes that run and by a scheduled run every 4 hours (skipped when main has not moved), restored by prefix elsewhere. - Run the narrowest thing first: `go test ./apps/web/internal/services/...`. - Behaviour changes ship with tests at the boundaries below, and only there. - A test that needs a sleep or timeout to pass is wrong. diff --git a/scripts/ci-reuse.sh b/scripts/ci-reuse.sh new file mode 100755 index 0000000..d5ec05d --- /dev/null +++ b/scripts/ci-reuse.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Decides whether a CI run can skip its checks (run by the ci job, see +# .github/workflows/ci.yml "Reuse or skip"): +# push: the pushed commit's tree is identical to the merge commit a PR's +# CI tested and passed. Every condition must hold: the PR's latest +# ci-ok status on its head is a success, posted by +# github-actions[bot], recording exactly this tree +# ("success tree="). Then ci-ok success is posted on the +# pushed commit with a pointer to that result. +# schedule: the build cache for this commit already exists. +# Anything else, or any doubt: skip=false and the run checks everything. +# Env: EVENT, MESSAGE (head commit message), GH_TOKEN, GITHUB_REPOSITORY, +# GITHUB_SHA, GITHUB_SERVER_URL, GITHUB_RUN_ID, RUNNER_OS +# Prints (and appends to $GITHUB_OUTPUT when set): skip=true|false +set -euo pipefail +cd "$(dirname "$0")/.." + +out() { + echo "skip=$1" + if [ -n "${GITHUB_OUTPUT:-}" ]; then echo "skip=$1" >>"$GITHUB_OUTPUT"; fi + exit 0 +} +repo=$GITHUB_REPOSITORY sha=$GITHUB_SHA + +case ${EVENT:-} in +schedule) + key="v7-go-${RUNNER_OS:-Linux}-$sha" + if [ "$(gh cache list --key "$key" --json key --jq "[.[] | select(.key == \"$key\")] | length" 2>/dev/null || echo 0)" != 0 ]; then + echo "cache $key already saved: nothing to refresh" + out true + fi + out false + ;; +push) ;; +*) out false ;; +esac + +tree=$(git rev-parse "$sha^{tree}") +# PRs this commit came from: GitHub's association (squash/merge/rebase via the +# PR), plus "(#N)" in the first line, which GitHub adds to squash-merge titles. +prs=$( + { + gh api "repos/$repo/commits/$sha/pulls" --jq '.[].number' 2>/dev/null || true + head -n1 <<<"${MESSAGE:-}" | grep -oE '\(#[0-9]+\)' | tr -dc '0-9\n' || true + } | sort -un +) +for pr in $prs; do + head=$(gh api "repos/$repo/pulls/$pr" --jq .head.sha 2>/dev/null) || continue + # Newest first; only the latest ci-ok counts. + st=$(gh api "repos/$repo/commits/$head/statuses" \ + --jq '[.[] | select(.context == "ci-ok")][0] | "\(.state)|\(.creator.login)|\(.description)|\(.target_url)"' 2>/dev/null) || continue + IFS='|' read -r state creator desc url <<<"$st" + echo "PR #$pr head ${head:0:7}: ci-ok=$state by $creator ($desc)" + if [ "$state" = success ] && [ "$creator" = "github-actions[bot]" ] && [ "$desc" = "success tree=$tree" ]; then + gh api "repos/$repo/statuses/$sha" -f state=success -f context=ci-ok \ + -f description="reused PR #$pr (tree ${tree:0:12})" -f target_url="$url" >/dev/null + echo "tree $tree passed CI on PR #$pr: reusing that result" + out true + fi +done +echo "no PR result for tree $tree: running the checks" +out false From 687f7a3d96baa2cc2d907f3ed057c81895d81f9f Mon Sep 17 00:00:00 2001 From: JJ Eaton <58495321+jayleaton@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:00:03 +0700 Subject: [PATCH 5/5] perf(ci): a pushed commit that already passed CI itself is not re-tested Covers main fast-forwarding to a merge-queue commit CI already tested. Co-Authored-By: Claude Opus 5.5 --- scripts/ci-reuse.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scripts/ci-reuse.sh b/scripts/ci-reuse.sh index d5ec05d..8f2109d 100755 --- a/scripts/ci-reuse.sh +++ b/scripts/ci-reuse.sh @@ -7,6 +7,8 @@ # github-actions[bot], recording exactly this tree # ("success tree="). Then ci-ok success is posted on the # pushed commit with a pointer to that result. +# Also skipped when the pushed commit itself already has that +# success (main fast-forwarded to a merge-queue commit CI tested). # schedule: the build cache for this commit already exists. # Anything else, or any doubt: skip=false and the run checks everything. # Env: EVENT, MESSAGE (head commit message), GH_TOKEN, GITHUB_REPOSITORY, @@ -36,6 +38,15 @@ push) ;; esac tree=$(git rev-parse "$sha^{tree}") + +# The commit itself already passed (e.g. main fast-forwarded to a merge-queue +# commit that CI tested): nothing to do. +self=$(gh api "repos/$repo/commits/$sha/statuses" \ + --jq '[.[] | select(.context == "ci-ok")][0] | "\(.state)|\(.creator.login)|\(.description)"' 2>/dev/null || true) +if [ "$self" = "success|github-actions[bot]|success tree=$tree" ]; then + echo "commit ${sha:0:7} already has ci-ok success for tree $tree" + out true +fi # PRs this commit came from: GitHub's association (squash/merge/rebase via the # PR), plus "(#N)" in the first line, which GitHub adds to squash-merge titles. prs=$(