From 12a4fe1005cbf3727bed2a40c5aa06b60417e228 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 18 Aug 2026 19:00:20 -0300 Subject: [PATCH] =?UTF-8?q?chore(OPS62):=20CI=20fail-fast=20estrutural=20?= =?UTF-8?q?=E2=80=94=20job=20=C3=BAnico=20checks=20sequencial=20(X1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ci-pr.yml e ci.yml deixam de ter jobs irmãos: 1 job 'checks' com steps em série (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e). Step vermelho interrompe o job, o run falha e o slot é liberado — sem API de cancel (Forgejo 9.0.3 não tem), sem watchdog, sem upgrade do Forgejo. Skips do ci-scope.mjs viram step id 'scope'; e2e em 1 processo com PLAYWRIGHT_WORKERS=4 (substitui 2 shards × 2 workers); 2 services Postgres por run com portas host derivadas do run_id (runs concorrentes não colidem); contexto 'CI (PR) / checks' preservado (job id checks — contrato do automerger OPS61); deploy OPS53 gated em needs checks. Plumbing morto removido (e2eShardConfig/e2e_shards). Docs: AGENT-OPS, TESTING.md, changelog. Impl: docs/plans/ops62-ci-fail-fast-forgejo-impl.md (a intenção watchdog+upgrade fica como histórico do gate). --- .forgejo/workflows/ci-pr.yml | 525 ++++++------------ .forgejo/workflows/ci.yml | 225 +++----- docs/AGENT-OPS.md | 19 +- docs/CHANGELOG-AGENTS.md | 4 + docs/TESTING.md | 2 +- docs/changelog/2026-08-18-ops62.md | 1 + docs/plans/ops62-ci-fail-fast-forgejo-impl.md | 83 +++ playwright.config.ts | 5 +- scripts/ci-scope.mjs | 10 +- scripts/lib/test-affected-core.mjs | 15 - tests/unit/ciSkipInvariants.unit.spec.ts | 37 ++ tests/unit/testAffected.unit.spec.ts | 23 - 12 files changed, 384 insertions(+), 565 deletions(-) create mode 100644 docs/changelog/2026-08-18-ops62.md create mode 100644 docs/plans/ops62-ci-fail-fast-forgejo-impl.md diff --git a/.forgejo/workflows/ci-pr.yml b/.forgejo/workflows/ci-pr.yml index e964e1b6..e8faba1b 100644 --- a/.forgejo/workflows/ci-pr.yml +++ b/.forgejo/workflows/ci-pr.yml @@ -1,23 +1,26 @@ -# PR gate: feature → main. Two-phase cascade: -# Phase 1 (cheap, no Postgres): lint, format, typecheck, knip, cycles, unit -# Phase 2 (expensive, Postgres; needs phase 1 green/skipped): int, build, e2e -# Job-level skips come from the `scope` classifier (scripts/ci-scope.mjs). A -# phase-1 failure skips phase 2 via `needs` + explicit if. +# PR gate: feature → main. SINGLE sequential job (OPS62, X1): one container, +# one checkout, one pnpm install, one runner slot. Fail-fast is STRUCTURAL — +# a failed step skips every step after it, the job fails, the run fails and +# the slot is released immediately. No cancel-run API (Forgejo 9.0.3 has +# none), no watchdog job, no upgrades: with one job there are no siblings to +# cancel. Steps keep their own logs, so diagnosis is intact after a failure. +# The job id `checks` preserves the `CI (PR) / checks` status context the +# automerger waitForChecks and branch protection rely on (OPS61). +# +# Job-level skips come from the `scope` classifier (scripts/ci-scope.mjs) run +# as a step; its outputs drive `if:` on the heavy steps (same modes as the +# former per-job cascade: none/changed/full). # # Schema migrations are NOT serialized across PRs (migration-lock removed # 2026-08-12): a stale chain is caught by `pnpm migrate` + `test:int` on every # PR and on main (ci.yml), incl. campaignMigrationReconciliation — the check's # hard fail deadlocked every open schema PR the moment two coexisted. # -# High-risk diffs (schema, lockfile, classifiers — see -# scripts/lib/test-affected-core.mjs) force full unit/int/e2e. Push to main -# always runs the full suite in ci.yml (no affected skips). -# -# Forgejo port (OPS50): no `concurrency:`/`permissions:` (unsupported / -# native token); Postgres services publish per-job ports (5433 int, 5434 -# build, 5435/5436 e2e shards) and jobs reach them through the bridge gateway -# (DB_HOST from /proc/net/route). The GitHub fail-fast action was dropped -# (Forgejo has no cancel-run plumbing); job timeouts cover that role. +# Forgejo port (OPS50): Postgres services publish per-port on the runner +# machine and steps reach them through the bridge gateway (DB_HOST from +# /proc/net/route). Host ports derive from the run id (OPS62 X1) so concurrent +# runs never collide; two isolated services — int and build+e2e — keep e2e +# from inheriting int fixture residue (same isolation the former jobs had). name: CI (PR) on: @@ -29,17 +32,41 @@ env: NEXT_PUBLIC_SITE_URL: http://localhost:3000 jobs: - scope: + checks: runs-on: ubuntu-latest - timeout-minutes: 5 - outputs: - code_mode: ${{ steps.classify.outputs.code_mode }} - build_mode: ${{ steps.classify.outputs.build_mode }} - test_mode: ${{ steps.classify.outputs.test_mode }} - e2e_mode: ${{ steps.classify.outputs.e2e_mode }} - e2e_specs: ${{ steps.classify.outputs.e2e_specs }} - e2e_matrix: ${{ steps.classify.outputs.e2e_matrix }} - e2e_shards: ${{ steps.classify.outputs.e2e_shards }} + timeout-minutes: 50 + services: + # Host ports derive from the run id (OPS62 X1): the single job holds + # both services for the whole run, and concurrent runs on the shared + # runner would collide on fixed ports ("port is already allocated") — + # a ~1% residual collision when two runs share run_id % 100 is loud, + # harmless and fixed by the next push. + postgres-int: + image: postgres:17-alpine + env: + POSTGRES_USER: teqo + POSTGRES_PASSWORD: teqo + POSTGRES_DB: teqo_test + ports: + - ${{ 5433 + (github.run_id % 100) }}:5432 + options: >- + --health-cmd "pg_isready -U teqo" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + postgres-build: + image: postgres:17-alpine + env: + POSTGRES_USER: teqo + POSTGRES_PASSWORD: teqo + POSTGRES_DB: teqo_test + ports: + - ${{ 5533 + (github.run_id % 100) }}:5432 + options: >- + --health-cmd "pg_isready -U teqo" + --health-interval 5s + --health-timeout 5s + --health-retries 12 steps: - uses: actions/checkout@v5 with: @@ -50,11 +77,13 @@ jobs: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile + - name: Classify PR blast radius - id: classify + id: scope env: GITHUB_BASE_REF: ${{ github.base_ref }} run: | + set -euo pipefail result=$(node scripts/ci-scope.mjs) echo "scope: $(echo "$result" | jq -c '{code:.code.mode,build:.build.mode,test:.test.mode,e2e:.e2e.mode,reason:{code:.code.reason,build:.build.reason,test:.test.reason,e2e:.e2e.reason}}')" echo "code_mode=$(echo "$result" | jq -r .code.mode)" >> "$GITHUB_OUTPUT" @@ -62,317 +91,9 @@ jobs: echo "test_mode=$(echo "$result" | jq -r .test.mode)" >> "$GITHUB_OUTPUT" echo "e2e_mode=$(echo "$result" | jq -r .e2e.mode)" >> "$GITHUB_OUTPUT" echo "e2e_specs=$(echo "$result" | jq -r '[.e2e.specs[] | "tests/e2e/" + . + ".e2e.spec.ts"] | join(" ")')" >> "$GITHUB_OUTPUT" - # OPS34: full runs split across shards (one runner per shard); selected - # stays on a single runner (a second build for a handful of specs is - # waste). Single source: e2eShardConfig in scripts/lib/test-affected-core.mjs. - echo "e2e_matrix=$(echo "$result" | jq -c .e2e_shards.matrix)" >> "$GITHUB_OUTPUT" - echo "e2e_shards=$(echo "$result" | jq -r .e2e_shards.total)" >> "$GITHUB_OUTPUT" - lint: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v5 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - name: Check test file locations - run: node scripts/check-test-locations.mjs - - run: pnpm lint - - format: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v5 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm format:check - - typecheck: - runs-on: ubuntu-latest - timeout-minutes: 10 - needs: [scope] - if: needs.scope.outputs.code_mode != 'none' - steps: - - uses: actions/checkout@v5 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm typecheck - - knip: - runs-on: ubuntu-latest - timeout-minutes: 10 - needs: [scope] - if: needs.scope.outputs.code_mode != 'none' - steps: - - uses: actions/checkout@v5 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm knip - - cycles: - runs-on: ubuntu-latest - timeout-minutes: 10 - needs: [scope] - if: needs.scope.outputs.code_mode != 'none' - steps: - - uses: actions/checkout@v5 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm check:cycles - - unit: - runs-on: ubuntu-latest - timeout-minutes: 15 - needs: [scope] - if: needs.scope.outputs.test_mode != 'none' - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - - name: Unit tests (full suite) - if: needs.scope.outputs.test_mode == 'full' - run: pnpm test:unit - - - name: Unit tests (changed since PR base) - if: needs.scope.outputs.test_mode == 'changed' - run: pnpm test:unit -- --changed origin/${{ github.base_ref }} --passWithNoTests - - int: - runs-on: ubuntu-latest - timeout-minutes: 25 - needs: [scope, lint, format, typecheck, knip, cycles, unit] - if: | - always() - && !cancelled() - && needs.scope.outputs.test_mode != 'none' - && needs.lint.result == 'success' - && needs.format.result == 'success' - && (needs.typecheck.result == 'success' || needs.typecheck.result == 'skipped') - && (needs.knip.result == 'success' || needs.knip.result == 'skipped') - && (needs.cycles.result == 'success' || needs.cycles.result == 'skipped') - && (needs.unit.result == 'success' || needs.unit.result == 'skipped') - env: - # Self-hosted runner has 8 CPUs; vitest forks ≈ CPUs and each fork holds - # a Payload pool (max 10) → overflows the service Postgres' 100 clients - # ("too many clients already", CI run 180). 4 forks stay well under. - # GitHub's 2–4 vCPU runners keep the CPU-relative default (no env). - VITEST_MAX_WORKERS: 4 - services: - postgres: - image: postgres:17-alpine - env: - POSTGRES_USER: teqo - POSTGRES_PASSWORD: teqo - POSTGRES_DB: teqo_test - ports: - - 5433:5432 - options: >- - --health-cmd "pg_isready -U teqo" - --health-interval 5s - --health-timeout 5s - --health-retries 12 - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - name: Prepare database URL (service via bridge gateway) - run: | - set -euo pipefail - DB_HOST=$(awk '$2 == "00000000" {a=$3; print strtonum("0x" substr(a,7,2)) "." strtonum("0x" substr(a,5,2)) "." strtonum("0x" substr(a,3,2)) "." strtonum("0x" substr(a,1,2)); exit}' /proc/net/route) - echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:5433/teqo_test" >> "$GITHUB_ENV" - # vitest/playwright re-load .env.test over the process env, so the - # service URL must win through the .env.test.local layer (same - # mechanism `pnpm worktree next` uses). OPS50. - echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:5433/teqo_test" > .env.test.local - - run: pnpm migrate - - run: pnpm db:seed:minimal - - - name: Integration tests (full suite) - if: needs.scope.outputs.test_mode == 'full' - run: pnpm test:int - - - name: Integration tests (changed since PR base) - if: needs.scope.outputs.test_mode == 'changed' - run: pnpm test:int -- --changed origin/${{ github.base_ref }} --passWithNoTests - - build: - runs-on: ubuntu-latest - timeout-minutes: 25 - needs: [scope, lint, format, typecheck, knip, cycles, unit] - if: | - always() - && !cancelled() - && needs.scope.outputs.build_mode != 'none' - && needs.lint.result == 'success' - && needs.format.result == 'success' - && (needs.typecheck.result == 'success' || needs.typecheck.result == 'skipped') - && (needs.knip.result == 'success' || needs.knip.result == 'skipped') - && (needs.cycles.result == 'success' || needs.cycles.result == 'skipped') - && (needs.unit.result == 'success' || needs.unit.result == 'skipped') - services: - postgres: - image: postgres:17-alpine - env: - POSTGRES_USER: teqo - POSTGRES_PASSWORD: teqo - POSTGRES_DB: teqo_test - ports: - - 5434:5432 - options: >- - --health-cmd "pg_isready -U teqo" - --health-interval 5s - --health-timeout 5s - --health-retries 12 - steps: - - uses: actions/checkout@v5 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - name: Prepare database URL (service via bridge gateway) - run: | - set -euo pipefail - DB_HOST=$(awk '$2 == "00000000" {a=$3; print strtonum("0x" substr(a,7,2)) "." strtonum("0x" substr(a,5,2)) "." strtonum("0x" substr(a,3,2)) "." strtonum("0x" substr(a,1,2)); exit}' /proc/net/route) - echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:5434/teqo_test" >> "$GITHUB_ENV" - - name: Build (against the local service database) - run: pnpm build - - e2e: - runs-on: ubuntu-latest - timeout-minutes: 30 - needs: [scope, lint, format, typecheck, knip, cycles, unit] - if: | - always() - && !cancelled() - && needs.scope.outputs.e2e_mode != 'none' - && needs.lint.result == 'success' - && needs.format.result == 'success' - && (needs.typecheck.result == 'success' || needs.typecheck.result == 'skipped') - && (needs.knip.result == 'success' || needs.knip.result == 'skipped') - && (needs.cycles.result == 'success' || needs.cycles.result == 'skipped') - && (needs.unit.result == 'success' || needs.unit.result == 'skipped') - # OPS34: full mode splits across 2 runners; selected mode stays on shard 1 - # (shard 2 skips build+test via step `if`). Static [1, 2] — Forgejo 1.22 - # does not pass job results into matrix expansion, so fromJson(needs.*) - # yields an empty shard. Keep the literal in sync with e2eShardConfig in - # scripts/lib/test-affected-core.mjs (ci.yml pins the same [1, 2] / 2). - # fail-fast: false keeps both shards' logs. - strategy: - fail-fast: false - matrix: - shard: [1, 2] - env: - # Explicit CI workers; the local default stays 2 (playwright.config.ts). - PLAYWRIGHT_WORKERS: 2 - services: - postgres: - image: postgres:17-alpine - env: - POSTGRES_USER: teqo - POSTGRES_PASSWORD: teqo - POSTGRES_DB: teqo_test - ports: - - ${{ matrix.shard == 1 && '5435' || '5436' }}:5432 - options: >- - --health-cmd "pg_isready -U teqo" - --health-interval 5s - --health-timeout 5s - --health-retries 12 - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - name: Cache Playwright browsers - uses: actions/cache@v4 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - playwright-${{ runner.os }}- - - name: Install Playwright chromium - run: pnpm exec playwright install --with-deps chromium - - name: Prepare database URL (service via bridge gateway) - run: | - set -euo pipefail - DB_HOST=$(awk '$2 == "00000000" {a=$3; print strtonum("0x" substr(a,7,2)) "." strtonum("0x" substr(a,5,2)) "." strtonum("0x" substr(a,3,2)) "." strtonum("0x" substr(a,1,2)); exit}' /proc/net/route) - DB_PORT=${{ matrix.shard == 1 && '5435' || '5436' }} - echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:${DB_PORT}/teqo_test" >> "$GITHUB_ENV" - # vitest/playwright re-load .env.test over the process env, so the - # service URL must win through the .env.test.local layer (same - # mechanism `pnpm worktree next` uses). OPS50. - echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:${DB_PORT}/teqo_test" > .env.test.local - - run: pnpm migrate - - run: pnpm db:seed:minimal - - name: Build for e2e - # Selected mode only needs shard 1 (a second build for a handful of - # specs is waste). - if: needs.scope.outputs.e2e_mode == 'full' || matrix.shard == 1 - run: pnpm build - env: - # Same dir the Playwright webServer serves (playwright.config.ts - # falls back to '.next-e2e'; gate-ci.mjs builds there too). - NEXT_DIST_DIR: .next-e2e - - - name: E2E tests (full suite, shard ${{ matrix.shard }}) - if: needs.scope.outputs.e2e_mode == 'full' - # No `--` separator: pnpm would forward it verbatim to playwright and - # the flag would be parsed as a file pattern ("No tests found"). - run: pnpm test:e2e --shard=${{ matrix.shard }}/${{ needs.scope.outputs.e2e_shards }} - - - name: E2E tests (manifest-selected specs) - # Selected specs run once, on shard 1; shard 2 skips (no test step). - if: needs.scope.outputs.e2e_mode == 'selected' && matrix.shard == 1 - run: pnpm test:e2e -- ${{ needs.scope.outputs.e2e_specs }} - - docs-guards: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 + # Content guards are PR-scoped and cheap — run them before the suite so + # a guard failure fails fast instead of after 20 minutes of tests. - name: Changelog is append-only env: GITHUB_BASE_REF: ${{ github.base_ref }} @@ -384,44 +105,112 @@ jobs: env: GITHUB_BASE_REF: ${{ github.base_ref }} run: node scripts/check-docs-conflict-markers.mjs - - plans-only-closes: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - name: Block Closes/Fixes/Resolves on plans-only PRs env: GITHUB_BASE_REF: ${{ github.base_ref }} PR_BODY: ${{ github.event.pull_request.body }} run: node scripts/check-plans-only-pr-closes.mjs - checks: - name: checks - runs-on: ubuntu-latest - needs: - - scope - - lint - - format - - typecheck - - knip - - cycles - - unit - - int - - build - - e2e - - docs-guards - - plans-only-closes - if: always() - steps: - - name: All cascade checks green - env: - NEEDS: ${{ toJSON(needs) }} + - name: Check test file locations + run: node scripts/check-test-locations.mjs + - name: Lint + run: pnpm lint + - name: Format check + run: pnpm format:check + - name: Typecheck + if: steps.scope.outputs.code_mode != 'none' + run: pnpm typecheck + - name: Knip + if: steps.scope.outputs.code_mode != 'none' + run: pnpm knip + - name: Check cycles + if: steps.scope.outputs.code_mode != 'none' + run: pnpm check:cycles + + - name: Unit tests (full suite) + if: steps.scope.outputs.test_mode == 'full' + run: pnpm test:unit + - name: Unit tests (changed since PR base) + if: steps.scope.outputs.test_mode == 'changed' + run: pnpm test:unit -- --changed origin/${{ github.base_ref }} --passWithNoTests + - name: Prepare database URL (int service via bridge gateway) run: | set -euo pipefail - echo "$NEEDS" | jq -e ' - to_entries - | all(.value.result == "success" or .value.result == "skipped") - ' + DB_HOST=$(awk '$2 == "00000000" {a=$3; print strtonum("0x" substr(a,7,2)) "." strtonum("0x" substr(a,5,2)) "." strtonum("0x" substr(a,3,2)) "." strtonum("0x" substr(a,1,2)); exit}' /proc/net/route) + DB_PORT=${{ 5433 + (github.run_id % 100) }} + echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:${DB_PORT}/teqo_test" >> "$GITHUB_ENV" + # vitest re-loads .env.test over the process env, so the service URL + # must win through the .env.test.local layer (same mechanism + # `pnpm worktree next` uses). OPS50. + echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:${DB_PORT}/teqo_test" > .env.test.local + - name: Migrate (int database) + run: pnpm migrate + - name: Seed minimal (int database) + run: pnpm db:seed:minimal + + - name: Integration tests (full suite) + if: steps.scope.outputs.test_mode == 'full' + env: + # Self-hosted runner has 8 CPUs; vitest forks ≈ CPUs and each fork + # holds a Payload pool (max 10) → overflows the service Postgres' 100 + # clients ("too many clients already", CI run 180). 4 forks stay + # well under. The unit step above stays CPU-relative (no DB). + VITEST_MAX_WORKERS: 4 + run: pnpm test:int + - name: Integration tests (changed since PR base) + if: steps.scope.outputs.test_mode == 'changed' + env: + VITEST_MAX_WORKERS: 4 + run: pnpm test:int -- --changed origin/${{ github.base_ref }} --passWithNoTests + + - name: Prepare database URL (build/e2e service via bridge gateway) + if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode != 'none' + run: | + set -euo pipefail + DB_HOST=$(awk '$2 == "00000000" {a=$3; print strtonum("0x" substr(a,7,2)) "." strtonum("0x" substr(a,5,2)) "." strtonum("0x" substr(a,3,2)) "." strtonum("0x" substr(a,1,2)); exit}' /proc/net/route) + DB_PORT=${{ 5533 + (github.run_id % 100) }} + echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:${DB_PORT}/teqo_test" >> "$GITHUB_ENV" + echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:${DB_PORT}/teqo_test" > .env.test.local + - name: Migrate (build/e2e database) + if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode != 'none' + run: pnpm migrate + - name: Seed minimal (build/e2e database) + if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode != 'none' + run: pnpm db:seed:minimal + + - name: Build (against the local service database) + if: steps.scope.outputs.build_mode != 'none' + run: pnpm build + + - name: Cache Playwright browsers + if: steps.scope.outputs.e2e_mode != 'none' + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + playwright-${{ runner.os }}- + - name: Install Playwright chromium + if: steps.scope.outputs.e2e_mode != 'none' + run: pnpm exec playwright install --with-deps chromium + - name: Build for e2e + # Selected mode needs the build too (the Playwright webServer serves + # .next-e2e; the fallback `.next-e2e` dir is what gate-ci.mjs builds). + if: steps.scope.outputs.e2e_mode != 'none' + run: pnpm build + env: + NEXT_DIST_DIR: .next-e2e + - name: E2E tests (full suite, single job, 4 workers) + if: steps.scope.outputs.e2e_mode == 'full' + # Explicit CI workers; the local default stays 2 (playwright.config.ts). + # 4 workers in one process replace the former 2 shards × 2 workers. + # No `--` separator: pnpm would forward it verbatim to playwright and + # the flag would be parsed as a file pattern ("No tests found"). + env: + PLAYWRIGHT_WORKERS: 4 + run: pnpm test:e2e + - name: E2E tests (manifest-selected specs) + if: steps.scope.outputs.e2e_mode == 'selected' + env: + PLAYWRIGHT_WORKERS: 4 + run: pnpm test:e2e -- ${{ steps.scope.outputs.e2e_specs }} diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 7027f2fa..810bd150 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -1,19 +1,25 @@ # Post-merge verifier on `main`: always the FULL suite (no affected skips). +# SINGLE sequential job (OPS62, X1): one container, one checkout, one pnpm +# install, one runner slot. Fail-fast is STRUCTURAL — a failed step skips +# every step after it, the job fails, the run fails and the slot is released +# immediately. No cancel-run API (Forgejo 9.0.3 has none), no watchdog job, +# no upgrades. `deploy` (needs: [checks]) never starts on a red run. +# # When the suite is green, the `deploy` job publishes teqo-1313 on the # homeserver (OPS53): it streams scripts/deploy-homeserver.sh to the homeserver # over SSH; the remote script builds the standalone image at the pushed SHA, # pushes it to the local registry (localhost:5000), applies pending migrations # through the compose maintenance service and rolls the container out. # -# Forgejo port (OPS50): Postgres services publish to per-job HOST ports on the -# runner machine (act/forgejo runner semantics — the canonical 5432 is the -# workstation's own dev database), and jobs reach them through the bridge -# gateway: DB_HOST = gateway IP from /proc/net/route. No `permissions:` -# (Forgejo issues one native GITHUB_TOKEN) and no `concurrency:` (unsupported). -# `gh workflow run` requeues became `node scripts/forgejo-dispatch.mjs`. -# The GitHub fail-fast action was dropped (Forgejo has no cancel-run plumbing); -# job timeouts cover that role. - +# Forgejo port (OPS50): Postgres services publish to per-port HOST ports on +# the runner machine (act/forgejo runner semantics — the canonical 5432 is the +# workstation's own dev database), and steps reach them through the bridge +# gateway: DB_HOST = gateway IP from /proc/net/route. Host ports derive from +# the run id (OPS62 X1) so concurrent runs never collide; two isolated +# services — int and build+e2e — keep e2e from inheriting int fixture residue. +# No `permissions:` (Forgejo issues one native GITHUB_TOKEN) and no +# `concurrency:` (unsupported). `gh workflow run` requeues became +# `node scripts/forgejo-dispatch.mjs`. name: CI on: @@ -26,43 +32,36 @@ env: NEXT_PUBLIC_SITE_URL: http://localhost:3000 jobs: - static: + checks: runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@v5 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: node scripts/check-test-locations.mjs - - run: pnpm lint - - run: pnpm format:check - - run: pnpm typecheck - - run: pnpm knip - - run: pnpm check:cycles - - run: pnpm test:unit - - int: - runs-on: ubuntu-latest - timeout-minutes: 25 - env: - # Self-hosted runner has 8 CPUs; vitest forks ≈ CPUs and each fork holds - # a Payload pool (max 10) → overflows the service Postgres' 100 clients - # ("too many clients already"). 4 forks stay well under. GitHub's 2–4 - # vCPU runners keep the CPU-relative default (no env). - VITEST_MAX_WORKERS: 4 + timeout-minutes: 50 services: - postgres: + # Host ports derive from the run id (OPS62 X1): the single job holds + # both services for the whole run, and concurrent runs on the shared + # runner would collide on fixed ports ("port is already allocated") — + # a ~1% residual collision when two runs share run_id % 100 is loud, + # harmless and fixed by the next push. + postgres-int: image: postgres:17-alpine env: POSTGRES_USER: teqo POSTGRES_PASSWORD: teqo POSTGRES_DB: teqo_test ports: - - 5433:5432 + - ${{ 5433 + (github.run_id % 100) }}:5432 + options: >- + --health-cmd "pg_isready -U teqo" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + postgres-build: + image: postgres:17-alpine + env: + POSTGRES_USER: teqo + POSTGRES_PASSWORD: teqo + POSTGRES_DB: teqo_test + ports: + - ${{ 5533 + (github.run_id % 100) }}:5432 options: >- --health-cmd "pg_isready -U teqo" --health-interval 5s @@ -76,90 +75,60 @@ jobs: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - - name: Prepare database URL (service via bridge gateway) + + - name: Check test file locations + run: node scripts/check-test-locations.mjs + - name: Lint + run: pnpm lint + - name: Format check + run: pnpm format:check + - name: Typecheck + run: pnpm typecheck + - name: Knip + run: pnpm knip + - name: Check cycles + run: pnpm check:cycles + - name: Unit tests (full suite) + run: pnpm test:unit + + - name: Prepare database URL (int service via bridge gateway) run: | set -euo pipefail DB_HOST=$(awk '$2 == "00000000" {a=$3; print strtonum("0x" substr(a,7,2)) "." strtonum("0x" substr(a,5,2)) "." strtonum("0x" substr(a,3,2)) "." strtonum("0x" substr(a,1,2)); exit}' /proc/net/route) - echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:5433/teqo_test" >> "$GITHUB_ENV" + DB_PORT=${{ 5433 + (github.run_id % 100) }} + echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:${DB_PORT}/teqo_test" >> "$GITHUB_ENV" # vitest re-loads .env.test over the process env, so the service URL # must win through the .env.test.local layer (same mechanism # `pnpm worktree next` uses). OPS50. - echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:5433/teqo_test" > .env.test.local - - run: pnpm migrate - - run: pnpm db:seed:minimal - - run: pnpm test:int - - build: - runs-on: ubuntu-latest - timeout-minutes: 25 - services: - postgres: - image: postgres:17-alpine + echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:${DB_PORT}/teqo_test" > .env.test.local + - name: Migrate (int database) + run: pnpm migrate + - name: Seed minimal (int database) + run: pnpm db:seed:minimal + - name: Integration tests (full suite) env: - POSTGRES_USER: teqo - POSTGRES_PASSWORD: teqo - POSTGRES_DB: teqo_test - ports: - - 5434:5432 - options: >- - --health-cmd "pg_isready -U teqo" - --health-interval 5s - --health-timeout 5s - --health-retries 12 - steps: - - uses: actions/checkout@v5 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - name: Prepare database URL (service via bridge gateway) + # Self-hosted runner has 8 CPUs; vitest forks ≈ CPUs and each fork + # holds a Payload pool (max 10) → overflows the service Postgres' 100 + # clients ("too many clients already"). 4 forks stay well under. The + # unit step above stays CPU-relative (no DB). + VITEST_MAX_WORKERS: 4 + run: pnpm test:int + + - name: Prepare database URL (build/e2e service via bridge gateway) run: | set -euo pipefail DB_HOST=$(awk '$2 == "00000000" {a=$3; print strtonum("0x" substr(a,7,2)) "." strtonum("0x" substr(a,5,2)) "." strtonum("0x" substr(a,3,2)) "." strtonum("0x" substr(a,1,2)); exit}' /proc/net/route) - echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:5434/teqo_test" >> "$GITHUB_ENV" + DB_PORT=${{ 5533 + (github.run_id % 100) }} + echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:${DB_PORT}/teqo_test" >> "$GITHUB_ENV" + echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:${DB_PORT}/teqo_test" > .env.test.local + - name: Migrate (build/e2e database) + run: pnpm migrate + - name: Seed minimal (build/e2e database) + run: pnpm db:seed:minimal + - name: Build (against the service database) run: pnpm build - e2e: - runs-on: ubuntu-latest - timeout-minutes: 40 - # Main always runs the full suite: split it across 2 jobs (OPS34), each - # building its own `.next-e2e` (measured 2026-08-10: build 172s + tests - # 196s + ~54s fixed → ≈ 5.5 min per shard vs 435s serial). `fail-fast: - # false` so one shard's failure keeps the other's diagnostics. Keep the - # [1, 2] / 2 literal in sync with `e2eShardConfig` in - # scripts/lib/test-affected-core.mjs (ci-pr pins the same literal). - strategy: - fail-fast: false - matrix: - shard: [1, 2] - env: - # Explicit CI workers; the local default stays 2 (playwright.config.ts). - PLAYWRIGHT_WORKERS: 2 - services: - postgres: - image: postgres:17-alpine - env: - POSTGRES_USER: teqo - POSTGRES_PASSWORD: teqo - POSTGRES_DB: teqo_test - ports: - - ${{ matrix.shard == 1 && '5435' || '5436' }}:5432 - options: >- - --health-cmd "pg_isready -U teqo" - --health-interval 5s - --health-timeout 5s - --health-retries 12 - steps: - - uses: actions/checkout@v5 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - name: Cache Playwright browsers uses: actions/cache@v4 with: @@ -169,44 +138,20 @@ jobs: playwright-${{ runner.os }}- - name: Install Playwright chromium run: pnpm exec playwright install --with-deps chromium - - name: Prepare database URL (service via bridge gateway) - run: | - set -euo pipefail - DB_HOST=$(awk '$2 == "00000000" {a=$3; print strtonum("0x" substr(a,7,2)) "." strtonum("0x" substr(a,5,2)) "." strtonum("0x" substr(a,3,2)) "." strtonum("0x" substr(a,1,2)); exit}' /proc/net/route) - DB_PORT=${{ matrix.shard == 1 && '5435' || '5436' }} - echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:${DB_PORT}/teqo_test" >> "$GITHUB_ENV" - # playwright re-loads .env.test over the process env, so the service - # URL must win through the .env.test.local layer (same mechanism - # `pnpm worktree next` uses). OPS50. - echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:${DB_PORT}/teqo_test" > .env.test.local - - run: pnpm migrate - - run: pnpm db:seed:minimal - name: Build for e2e + # Same dir the Playwright webServer serves (playwright.config.ts + # falls back to '.next-e2e'; gate-ci.mjs builds there too). run: pnpm build env: - # Same dir the Playwright webServer serves (playwright.config.ts - # falls back to '.next-e2e'; gate-ci.mjs builds there too). NEXT_DIST_DIR: .next-e2e - - name: E2E tests (full suite, shard ${{ matrix.shard }}) + - name: E2E tests (full suite, single job, 4 workers) + # Explicit CI workers; the local default stays 2 (playwright.config.ts). + # 4 workers in one process replace the former 2 shards × 2 workers. # No `--` separator: pnpm would forward it verbatim to playwright and # the flag would be parsed as a file pattern ("No tests found"). - run: pnpm test:e2e --shard=${{ matrix.shard }}/2 - - checks: - name: checks - runs-on: ubuntu-latest - needs: [static, int, build, e2e] - if: always() - steps: - - name: All main verifier checks green env: - NEEDS: ${{ toJSON(needs) }} - run: | - set -euo pipefail - echo "$NEEDS" | jq -e ' - to_entries - | all(.value.result == "success" or .value.result == "skipped") - ' + PLAYWRIGHT_WORKERS: 4 + run: pnpm test:e2e deploy: name: deploy homeserver (teqo-1313) diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index 18432634..1f4ce46b 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -69,16 +69,17 @@ Arquivos de "lista" que N agentes escrevem em paralelo (changelog, plans, migra ## CI por alvo -| Workflow | Trigger | Banco | Passos | -| ------------------------------------ | -------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ci-pr.yml` | PR → `main` | service Postgres 17 | Fase 1 barata → fase 2 cara; skips via `ci-scope.mjs`; fail-fast cancela o run no 1º job vermelho; rollup `checks` | -| `ci.yml` | push / dispatch `main` | service Postgres | Full sempre; fail-fast cancela o run no 1º job vermelho; rollup `checks`; job `deploy` (runs-on `host`) publica no homeserver via `scripts/deploy-homeserver.sh` (OPS53) | -| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) | — | `Closes`/`Fixes` → `done` + `in-prod` (PAT `FORGEJO_API_TOKEN`; `GITHUB_TOKEN` 403 em Issues) | -| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` (same-repo) | — | `Related #N` aguardando plano → `ready` (OPS18; soft-skip; mesmo PAT) | -| `agent-pr-ready-automerge.yml` | PR same-repo → `main` (open/sync/…) | — | Safety net via `forgejo-pr-automerge.mjs` (plain Node): espera o rollup `checks` e mergea por rebase com `FORGEJO_API_TOKEN` (o `GITHUB_TOKEN` nativo não tem write no repo — o push do merge é rejeitado com 409); draft `cursor/*` → Ready; draft não-`cursor/*` = veto, skip | -| `agent-pool.yml` | schedule / PR closed `main` / dispatch | — | Supervisor do pool (`POOL_GITHUB_TOKEN`) | +| Workflow | Trigger | Banco | Passos | +| ------------------------------------ | -------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `ci-pr.yml` | PR → `main` | 2× service Postgres 17 | Job único `checks` sequencial (OPS62 X1): guards → lint→format→typecheck→knip→cycles→unit→int→build→e2e; skips via `ci-scope.mjs` (step `scope`); **fail-fast estrutural** — step vermelho interrompe o job, o run falha e o slot é liberado; e2e 1 processo com `PLAYWRIGHT_WORKERS=4`; 2 services por nome na rede do job (`postgres-int`/`postgres-build`, sem publish host) | +| `ci.yml` | push / dispatch `main` | 2× service Postgres 17 | Full sempre; job único `checks` sequencial (mesma mecânica X1); job `deploy` (runs-on `host`) publica no homeserver via `scripts/deploy-homeserver.sh` (OPS53) | +| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) | — | `Closes`/`Fixes` → `done` + `in-prod` (PAT `FORGEJO_API_TOKEN`; `GITHUB_TOKEN` 403 em Issues) | +| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` (same-repo) | — | `Related #N` aguardando plano → `ready` (OPS18; soft-skip; mesmo PAT) | +| `agent-pr-ready-automerge.yml` | PR same-repo → `main` (open/sync/…) | — | Safety net via `forgejo-pr-automerge.mjs` (plain Node): espera o rollup `checks` e mergea por rebase com `FORGEJO_API_TOKEN` (o `GITHUB_TOKEN` nativo não tem write no repo — o push do merge é rejeitado com 409); draft `cursor/*` → Ready; draft não-`cursor/*` = veto, skip | +| `agent-pool.yml` | schedule / PR closed `main` / dispatch | — | Supervisor do pool (`POOL_GITHUB_TOKEN`) | -Action runtimes: `actions/checkout@v5`, `actions/setup-node@v5`, `pnpm/action-setup@v6` e `styfle/cancel-workflow-action@0.13.1` usam Node 24 nativo — sem `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24`. +Action runtimes: `actions/checkout@v5`, `actions/setup-node@v5` e `pnpm/action-setup@v6` usam Node 24 nativo — sem `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24`. +Action runtimes: `actions/checkout@v5`, `actions/setup-node@v5` e `pnpm/action-setup@v6` usam Node 24 nativo — sem `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24`. Fast gate: `pnpm gate:fast`. Push: `pnpm push`. diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index d14eb142..0848d08b 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -53,6 +53,10 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **Recently resolved (2026-08-18):** **OPS63** — os `docs-guards` (OPS44) rodam também no pre-push local: o espelho `scripts/gate-ci.mjs` ganhou os três checks na fase 1 (changelog append-only, agregado sync `--check`, marcadores de conflito em markdown) como chamadas diretas dos mesmos scripts do CI — labels espelhando os steps do job `docs-guards`; header do arquivo e linha final atualizados. `package.json`, `.husky/pre-push`, `git-push.mjs` e workflows `.forgejo/*` intocados: `gate:push` = `gate:ci` continua a cadeia única, o job `docs-guards` segue dono do escape `changelog-rewrite:` (body do PR — CI-only; restauração legítima D8 falha local com a mesma mensagem do CI e usa o bypass documentado `git push --no-verify`, com o CI como via do escape). Docs sincronizadas: `.agents/rules/engineering-standards.mdc` e `agent-pr-workflow.mdc` (gate descrito como "espelho do ci-pr.yml sem e2e, com docs-guards locais"), `docs/AGENT-OPS.md` (contrato item 4 — OPS44: checks no pre-push + nota do escape CI-only). Verificado por grep: `AGENTS.md` e skills não descreviam o gate sem os checks de docs — sem mudança. Anti-goals respeitados: sem bypass novo (falha local honesta), sem `plans-only-closes` local (contrato de PR body). Sem migration/access/Consent/UI/schema. Verificação: três checks green na branch; caminho de falha validado (commit descartável removendo linha do agregado → append-only falha com mensagem acionável); `pnpm gate:ci` local full; CI roda full nesta PR por HIGH_RISK (`gate-ci.mjs`). +**Recently resolved (2026-08-18):** **OPS62** — fail-fast estrutural do CI: `ci-pr.yml` e `ci.yml` deixaram de ter jobs irmãos. Cada workflow agora tem **um único job `checks` sequencial** (ci-pr: guards de conteúdo → lint → format → typecheck → knip → cycles → unit → int → build → e2e, com skips do `ci-scope.mjs` agora como step com outputs; ci.yml: mesma mecânica full + deploy OPS53 intacto), então **step vermelho interrompe o job, o run falha e o slot do runner é liberado imediatamente** — sem API de cancel (o Forgejo 9.0.3 não tem), sem watchdog, sem upgrade do Forgejo no homeserver (decisão X1 do gate de execução substitui o watchdog+upgrade da intenção; o aceite "API de cancel/upgrade" e "mesmos jobs, mesma cascata" foi revisado pelo humano — o que o CI _verifica_ permanece idêntico, só o envelope muda). Diagnóstico preservado: steps têm logs próprios. O job id `checks` mantém o contexto `CI (PR) / checks` que o automerger/`waitForChecks` e a branch protection esperam (OPS61). Paralelismo do e2e recuperado por `PLAYWRIGHT_WORKERS=4` num único processo (substitui os 2 shards × 2 workers; mesma carga); **2 services Postgres isolados** (5433 int, 5434 build+e2e) preservam a topologia de hoje (e2e nunca herda resíduo de fixtures do int); 1 checkout + 1 `pnpm install` por run (antes ~10). Plumbing morto removido: `e2eShardConfig` e o output `e2e_shards` do classifier (sem consumidor). Guards de conteúdo movidos para o início do pipeline (falham rápido e são PR-scoped). Docs: tabela CI do AGENT-OPS reescrita + linha morta do `styfle/cancel-workflow-action` (removida no OPS50) apagada. Sem migration/access/Consent/UI. Testes: invariantes novos em `ciSkipInvariants.unit.spec.ts` (um único job `checks`, sem `matrix:`/`fail-fast:`/`runs-on: host` no ci-pr, `PLAYWRIGHT_WORKERS: 4`, guards antes do lint, deploy gated em `needs: [checks]`); `testAffected.unit.spec.ts` sem o describe do shard config. **Validação ao vivo na própria entrega:** o ci-pr deste PR já é o envelope novo — o run verde prova a suíte inteira no job único; a medição do tempo de parede fica registrada no run. Custo aceito (X1): run verde ~21 min esperados (vs ~15–18 com jobs paralelos) e e2e vermelho é o caso mais lento de veredito; débito registrado no impl plan (matrix fail-fast Y) se a fila do runner apertar. Impl: `docs/plans/ops62-ci-fail-fast-forgejo-impl.md` (a intenção fica como histórico do gate). + +**Recently resolved (2026-08-18):** **OPS62** — fail-fast estrutural do CI: `ci-pr.yml` e `ci.yml` deixaram de ter jobs irmãos. Cada workflow agora tem **um único job `checks` sequencial** (ci-pr: guards de conteúdo → lint → format → typecheck → knip → cycles → unit → int → build → e2e, com skips do `ci-scope.mjs` agora como step com outputs; ci.yml: mesma mecânica full + deploy OPS53 intacto), então **step vermelho interrompe o job, o run falha e o slot do runner é liberado imediatamente** — sem API de cancel (o Forgejo 9.0.3 não tem), sem watchdog, sem upgrade do Forgejo no homeserver (decisão X1 do gate de execução substitui o watchdog+upgrade da intenção; o aceite "API de cancel/upgrade" e "mesmos jobs, mesma cascata" foi revisado pelo humano — o que o CI _verifica_ permanece idêntico, só o envelope muda). Diagnóstico preservado: steps têm logs próprios. O job id `checks` mantém o contexto `CI (PR) / checks` que o automerger/`waitForChecks` e a branch protection esperam (OPS61). Paralelismo do e2e recuperado por `PLAYWRIGHT_WORKERS=4` num único processo (substitui os 2 shards × 2 workers; mesma carga); **2 services Postgres isolados** (int e build+e2e) com **portas host derivadas do `run_id`** (`5433/5533 + run_id % 100` — o job único segura as duas portas pelo run inteiro; runs concorrentes colidiriam em portas fixas) preservam a topologia de hoje (e2e nunca herda resíduo de fixtures do int); 1 checkout + 1 `pnpm install` por run (antes ~10). Plumbing morto removido: `e2eShardConfig` e o output `e2e_shards` do classifier (sem consumidor). Guards de conteúdo movidos para o início do pipeline (falham rápido e são PR-scoped). `/simplify` (3 reviewers): P1 aplicados — `set -euo pipefail` no step `scope` (crash do classifier com outputs vazios silenciaria skips → false green) e portas por run (acima); P2 aplicados — pins de invariantes ancorados em chaves YAML (comentários não satisfazem `needs: [checks]`/`matrix:`/`fail-fast:`), pin do wiring crítico `id: scope` + `if: e2e_mode == 'full'` (perdê-lo pula a suíte em silêncio), comentário morto do sharding em `playwright.config.ts` e `docs/TESTING.md` corrigidos (ainda citavam `.github/workflows` e jobs paralelos); P3 — JSDoc do `ci-scope.mjs` e redação do impl plan; o P2 do `waitForChecks` (timeout 30 min vs job 50 min) fica como débito de CI anotado no impl. Docs: tabela CI do AGENT-OPS reescrita + linha morta do `styfle/cancel-workflow-action` (removida no OPS50) apagada. Sem migration/access/Consent/UI. Testes: invariantes novos em `ciSkipInvariants.unit.spec.ts` (um único job `checks`, sem `matrix:`/`fail-fast:`/`runs-on: host` no ci-pr, `PLAYWRIGHT_WORKERS: 4`, guards antes do lint, deploy gated em `needs: [checks]`); `testAffected.unit.spec.ts` sem o describe do shard config. **Validação ao vivo na própria entrega:** o ci-pr deste PR já é o envelope novo — o run verde prova a suíte inteira no job único; a medição do tempo de parede fica registrada no run. Custo aceito (X1): run verde ~21 min esperados (vs ~15–18 com jobs paralelos) e e2e vermelho é o caso mais lento de veredito; débito registrado no impl plan (matrix fail-fast Y) se a fila do runner apertar. Impl: `docs/plans/ops62-ci-fail-fast-forgejo-impl.md` (a intenção fica como histórico do gate). + **Recently resolved (2026-08-17):** **OPS51** — o banco de produção saiu do Neon para o Postgres do homeserver (`teqo_1313`, container `teqo-1313`), sem mudança de config: o `~/stack/teqo-1313.env` já apontava para o banco preparado (criado 16/08 com role própria + cadeia completa de 56 migrations + zero conteúdo — verificado), então restaurar = a produção "apareceu" no container. **Divergência deliberada da intenção (questão A/B validada como B):** rejeitado criar `teqo_prod` novo — o argumento "o banco do 1313 tem vida própria" não se sustenta (o site 1313 É a produção deste repo) e o OPS53 já trata `teqo_1313` como produção. Execução 100% no homeserver (PII nunca tocou a workstation): URL do Neon puxada do Vercel (`vercel env pull`, projeto `jorgesolla`, `DATABASE_URL_UNPOOLED` → `NEON_DATABASE_URL` em `~/stack/.env` chmod 600), `pg_dump -Fc` (Neon PG 17.10) → `/srv/hdd/backups/teqo-neon-pre-migracao/teqo-neon-full-20260817-204800.dump` (5,8 MB, sha256 `f12196f2…`), `pg_restore --no-owner --no-privileges` como a role `teqo_1313` (drop schema + grant, padrão do `db-pull`), verificado: contagens por tabela idênticas ao Neon (1919 contatos, 39 posts, 1487 assinaturas, 1484 subscrições, 35 campaign_users, 392 lideranças, 2 pledges, 435 municípios, 6 atividades; supporter segue 0 — blocker LGPD ainda de pé), sequences no topo (`last_value == max(id)`), objetos com dono `teqo_1313`, `payload migrate` no-op (56 = 56, serviço de maintenance `teqo-1313-migrate`), 3 credenciais WebAuthn + 34 sessions + 5 chaves Consent restauradas (RP ID não mudou — biometria segue valendo). Smoke sem deploy: `/`, artigo com conteúdo restaurado, `/campanha/login`, `/admin`, `POST /api/revalidate` (`posts` + `global_privacy-policy`), WebAuthn `login-options`, barreira 307 sem sessão — verdes; smoke de browser (login, biometria, convites) validado manualmente. **Rollback documentado:** Neon nunca foi escrito (só lido); voltar = re-apontar `DATABASE_URL` do `teqo-1313.env` para o Neon + `compose up -d`. **Débito registrado (→ OPS52):** as 40 rows de `media` têm URLs relativas (`/api/media/file/…`) sem arquivos em lugar nenhum — capas dão 500 no homeserver e **404 no próprio Vercel/produção atual** (estado pré-existente; migração manteve paridade). Repo: plano da intenção concluído, `AGENTS.md` e help de `db:pull`/`check-push-chain` sem a referência ao Neon; infra-solla `STATE.md` atualizado à parte. **Recently resolved (2026-08-17):** **OPS53** — o deploy do site 1313 virou etapa do CI: mergir em `main` com a suite verde == site atualizado em `jorgesolla1313.com.br`, sem passo manual. O job `deploy` em `.forgejo/workflows/ci.yml` roda no host do runner (workstation, `runs-on: host`) e streama `scripts/deploy-homeserver.sh` para o homeserver via SSH (`ssh homeserver "bash -s -- "`); o script remoto (bash puro, homeserver sem dependências novas) faz: guard de HEAD (`git ls-remote` de `main` == SHA do job, senão skip "stale"), `flock` de serialização, workspace `~/teqo-deploy` (clone do Forgejo local), `docker login localhost:5000` (REGISTRY_USER/PASSWORD de `~/stack/.env`), build dos stages `runner`+`migrator` no homeserver (`--network stack_default` para a geração estática alcançar o `postgres:5432` do compose; secrets de `~/stack/teqo-1313.env` via BuildKit, sem camadas/logs), push no registry local, swap dos tags de imagem no compose (com backup `docker-compose.yml.pre-`), **migrate pelo serviço de maintenance `teqo-1313-migrate` antes do rollout**, `docker compose up -d`, espera do healthcheck e smoke (`/`, `/campanha/login`, `/admin`, barreira 307, WebAuthn `login-options` com body JSON, `POST /api/revalidate` com o secret real — rehearsal validado contra o site vivo). Falha pós-swap → rollback automático best-effort (restore do backup + `up -d` da imagem anterior) + job vermelho. **Divergência deliberada da intenção (fato novo):** a intenção assumia o runner no homeserver; verificado (`infra-solla/STATE.md` + ao vivo) que o runner é a `workstation-runner` na workstation e o stack de produção vive no homeserver — a abordagem A foi corrigida para "job `runs-on: host` na workstation + todo o deploy no homeserver via SSH", o que manteve **zero secrets novos no Forgejo** e zero mudança de infra no compose. Nenhum secret no repo; envs seguem chmod-600 no homeserver. Runbook (rollback manual, falhas conhecidas, decisão de segurança do host label): `docs/ops/teqo-1313-deploy.md` (novo). Guarda estrutural nova: `tests/unit/deployScript.unit.spec.ts` (bash -n + invariantes do pipeline: HEAD guard, BuildKit secrets, ordem migrate→rollout, smoke). AGENTS.md/AGENT-OPS.md atualizados. Primeiro deploy real = o merge deste próprio item (tracer bullet); infra-solla `STATE.md` anotado à parte. diff --git a/docs/TESTING.md b/docs/TESTING.md index 9421a3a8..a0d82450 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -8,7 +8,7 @@ Three layers, all local-database-only (see `local-database` skill; tests refuse - **Integration** (`tests/int/**/*.int.spec.ts`, 71 specs): real Payload + Postgres (`teqo_test`); campaign fixtures allocate seeded municipalities via a Postgres sequence so parallel spec files never share one, and purge residue on claim. Since the miss #73 guardrail (Pass 4), specs that mutate municipalities must use the allocator (no pinned slugs) — enforced by `testMunicipalityAllocatorConventions`. Shared consent rows with stable keys are leased (`tests/helpers/testDatabaseLease.ts`), never owned. - **E2E** (`tests/e2e`, Playwright, 17 specs / 51 cases (Pass 5, 2026-08-03; was 48 at Pass 4)): smoke-level; most campaign behavior is pinned at the int layer. -CI (`.github/workflows/ci-pr.yml`) runs parallel jobs — lint (zero warnings), format, typecheck, knip, cycles, unit/int (**affected by the PR diff**: `vitest --changed` against the PR base via `scripts/test-affected.mjs`, full-suite fallback on high-risk paths), e2e (manifest path→spec via `scripts/e2e-affected.mjs` + `scripts/lib/e2e-affected-manifest.mjs`, blocking; production build + `list`/`github` reporters in CI) and build — against a Postgres 17 service with **`db:seed:minimal`**. Push to `main` (`ci.yml`) always runs the full net including e2e. +CI (`ci-pr.yml`) is a **single sequential job `checks`** (OPS62 X1): guards → lint (zero warnings) → format → typecheck → knip → cycles → unit/int (**affected by the PR diff**: `vitest --changed` against the PR base via `scripts/test-affected.mjs`, full-suite fallback on high-risk paths) → build → e2e (manifest path→spec via `scripts/e2e-affected.mjs` + `scripts/lib/e2e-affected-manifest.mjs`, blocking; production build + `list`/`github` reporters in CI) — against two isolated Postgres 17 services (int / build+e2e) with **`db:seed:minimal`**. Fail-fast is structural: a failed step stops the run; e2e runs with `PLAYWRIGHT_WORKERS=4`. Push to `main` (`ci.yml`) always runs the full net including e2e. **Local e2e before push (same blast radius as PR CI):** `pnpm test:e2e:affected` — runs `e2e-affected.mjs` against `origin/stage`, migrates/seeds, then `pnpm test:e2e` with the selected specs (or the full suite on high-risk diffs). Forward extra Playwright args after `--` for a narrowed repro. Opt into production mode (after fixing the known municipalities-list prod client bug) with `E2E_PROD=1 pnpm test:e2e:affected`. diff --git a/docs/changelog/2026-08-18-ops62.md b/docs/changelog/2026-08-18-ops62.md new file mode 100644 index 00000000..ebc18914 --- /dev/null +++ b/docs/changelog/2026-08-18-ops62.md @@ -0,0 +1 @@ +**Recently resolved (2026-08-18):** **OPS62** — fail-fast estrutural do CI: `ci-pr.yml` e `ci.yml` deixaram de ter jobs irmãos. Cada workflow agora tem **um único job `checks` sequencial** (ci-pr: guards de conteúdo → lint → format → typecheck → knip → cycles → unit → int → build → e2e, com skips do `ci-scope.mjs` agora como step com outputs; ci.yml: mesma mecânica full + deploy OPS53 intacto), então **step vermelho interrompe o job, o run falha e o slot do runner é liberado imediatamente** — sem API de cancel (o Forgejo 9.0.3 não tem), sem watchdog, sem upgrade do Forgejo no homeserver (decisão X1 do gate de execução substitui o watchdog+upgrade da intenção; o aceite "API de cancel/upgrade" e "mesmos jobs, mesma cascata" foi revisado pelo humano — o que o CI _verifica_ permanece idêntico, só o envelope muda). Diagnóstico preservado: steps têm logs próprios. O job id `checks` mantém o contexto `CI (PR) / checks` que o automerger/`waitForChecks` e a branch protection esperam (OPS61). Paralelismo do e2e recuperado por `PLAYWRIGHT_WORKERS=4` num único processo (substitui os 2 shards × 2 workers; mesma carga); **2 services Postgres isolados** (int e build+e2e) com **portas host derivadas do `run_id`** (`5433/5533 + run_id % 100` — o job único segura as duas portas pelo run inteiro; runs concorrentes colidiriam em portas fixas) preservam a topologia de hoje (e2e nunca herda resíduo de fixtures do int); 1 checkout + 1 `pnpm install` por run (antes ~10). Plumbing morto removido: `e2eShardConfig` e o output `e2e_shards` do classifier (sem consumidor). Guards de conteúdo movidos para o início do pipeline (falham rápido e são PR-scoped). `/simplify` (3 reviewers): P1 aplicados — `set -euo pipefail` no step `scope` (crash do classifier com outputs vazios silenciaria skips → false green) e portas por run (acima); P2 aplicados — pins de invariantes ancorados em chaves YAML (comentários não satisfazem `needs: [checks]`/`matrix:`/`fail-fast:`), pin do wiring crítico `id: scope` + `if: e2e_mode == 'full'` (perdê-lo pula a suíte em silêncio), comentário morto do sharding em `playwright.config.ts` e `docs/TESTING.md` corrigidos (ainda citavam `.github/workflows` e jobs paralelos); P3 — JSDoc do `ci-scope.mjs` e redação do impl plan; o P2 do `waitForChecks` (timeout 30 min vs job 50 min) fica como débito de CI anotado no impl. Docs: tabela CI do AGENT-OPS reescrita + linha morta do `styfle/cancel-workflow-action` (removida no OPS50) apagada. Sem migration/access/Consent/UI. Testes: invariantes novos em `ciSkipInvariants.unit.spec.ts` (um único job `checks`, sem `matrix:`/`fail-fast:`/`runs-on: host` no ci-pr, `PLAYWRIGHT_WORKERS: 4`, guards antes do lint, deploy gated em `needs: [checks]`); `testAffected.unit.spec.ts` sem o describe do shard config. **Validação ao vivo na própria entrega:** o ci-pr deste PR já é o envelope novo — o run verde prova a suíte inteira no job único; a medição do tempo de parede fica registrada no run. Custo aceito (X1): run verde ~21 min esperados (vs ~15–18 com jobs paralelos) e e2e vermelho é o caso mais lento de veredito; débito registrado no impl plan (matrix fail-fast Y) se a fila do runner apertar. Impl: `docs/plans/ops62-ci-fail-fast-forgejo-impl.md` (a intenção fica como histórico do gate). diff --git a/docs/plans/ops62-ci-fail-fast-forgejo-impl.md b/docs/plans/ops62-ci-fail-fast-forgejo-impl.md new file mode 100644 index 00000000..610affb5 --- /dev/null +++ b/docs/plans/ops62-ci-fail-fast-forgejo-impl.md @@ -0,0 +1,83 @@ +# Impl: OPS62 — CI: fail-fast estrutural por job único sequencial (X1) + +Status: aprovado +Atualizado em: 2026-08-18 +Issue: #59 +Intenção: docs/plans/ops62-ci-fail-fast-forgejo.md +Appetite restante: ~1 dia eng (corte: nada de watchdog/API/upgrade — decidido no gate de execução) + +## Leitura da intenção + +- **Outcome:** um run de PR (ou de `main`) com **qualquer verificação vermelha** falha o run imediatamente — nada continua rodando depois da primeira falha; o runner é liberado cedo; `checks` vermelho sem esperar o fim dos demais; logs do que já rodou preservados; a suíte **verificada** não muda (mesmas ferramentas e conteúdo: lint, format, typecheck, knip, cycles, unit, int, build, e2e, guards); sem bypass novo além do `--no-verify` documentado. +- **O que NÃO negociar:** anti-goals — não perder diagnóstico (steps preservam logs próprios), não mudar o que o CI _verifica_ (só o envelope de jobs muda), `checks` como gate único (contexto `CI (PR) / checks` preservado para waitForChecks/branch protection); aceitar a perda dos logs do segundo shard de e2e (o e2e perde o paralelismo de jobs — 4 workers num único processo; o gate aceitou perder o shard irmão, e aqui o sharding em si é compensado por workers). +- **Divergência do aceite da intenção (aprovada pelo humano no gate de execução, 2026-08-18):** o aceite "Forgejo do homeserver roda versão com a API de cancel" e "mesmos jobs, mesma cascata" são **substituídos** — a arquitetura escolhida (X1: 1 job sequencial) torna a API de cancel e o upgrade **desnecessários** (fail-fast é estrutural) e muda o agrupamento de jobs (o que é verificado permanece idêntico). O plano de intenção fica como histórico; este impl plan é a forma aprovada. + +## Abordagem recomendada + +```mermaid +flowchart TB + subgraph job[Um único job `checks` por workflow
1 container runner · 1 checkout · 1 pnpm install · 1 slot] + S1[scope classifier] --> S2[guards de conteúdo] + S2 --> S3[check-test-locations] --> L[lint] --> F[format] --> T[typecheck] + T --> K[knip] --> C[cycles] --> U[unit] + U --> M1[migrate + seed 5433] --> I[int] + I --> M2[migrate + seed 5434] --> B1[build] --> B2[build .next-e2e] + B2 --> E[e2e · PLAYWRIGHT_WORKERS=4] + end + L -->|"falha em qualquer step
(if: steps.scope.outputs …)"| FIM + F -->|"steps seguintes não rodam"| FIM[run falha imediato
slot liberado · checks vermelho
logs dos steps rodados preservados] +``` + +**Opções consideradas (gate de execução):** X1 | Y | Z +**Recomendação (escolhida pelo humano):** **X1** — 1 job único por workflow, steps sequenciais, fail-fast **estrutural**: falhou um step → os seguintes nem começam → job falha → run falha → slot liberado. Zero dependência de API de cancel (a rota nem existe no Forgejo 9.0.3), zero watchdog (não há slot extra, token, upgrade, nem risco de infra no homeserver — o Forgejo **não** é atualizado). O paralelismo do e2e é recuperado por `PLAYWRIGHT_WORKERS=4` (o Playwright roda workers dentro de um processo — mesma carga de 2 shards × 2 workers de hoje). + +**Rejeitadas:** + +- **Y — fase 1 sequencial + fase 2 em matrix `fail-fast: true`:** preserva verde ≈ atual e cancela irmãos nativamente, mas depende de o Forgejo 9.0.3 cancelar de fato os irmãos de matrix (comportamento de servidor a verificar ao vivo; se falhasse, virava "tudo roda até o fim" na fase 2), mantém 2 slots em vez de 1, e o YAML de matrix por `mode` é mais complexo de manter que steps em série. **Rejeitada** por simplicidade máxima pedida. +- **Z — watchdog + upgrade do Forgejo 16:** o risco de infra (upgrade de produção no homeserver, compatibilidade runner × v16) deixou de se justificar quando o fail-fast estrutural cobre o aceite com menos peças. **Rejeitada.** +- **Watchdog em `runs-on: host`:** ci-pr jamais roda host (pin OPS53). **Rejeitada.** +- **Paralelizar steps com processos `&`/`wait`:** logs interleaved + fail-fast manual = mini-watchdog em bash. **Rejeitada.** + +### Componentes / mudanças + +- **`.forgejo/workflows/ci-pr.yml`** (reescrito — X1): 1 job `checks` (nome default → contexto `CI (PR) / checks` preservado; **o nome do job é o contrato** do waitForChecks OPS61-2b e da branch protection). Steps em série, cada um com `if:` de skip do classifier: `scope` (`id: scope`; outputs `code_mode/build_mode/test_mode/e2e_mode/e2e_specs` via `$GITHUB_OUTPUT` — mesmo comando do job atual, com `set -euo pipefail`), guards de conteúdo (changelog append-only, aggregate, conflict markers, plans-only — movidos para o começo: falham rápido e são sobre o PR), `check-test-locations`, `lint`, `format:check`, `typecheck`/`knip`/`cycles` (`if: steps.scope.outputs.code_mode != 'none'`), `unit` (full/changed), `migrate`+`seed:minimal` (DB int), `int` (full/changed), `migrate`+`seed:minimal` (DB build/e2e), `build` (if `build_mode != 'none'`), `build .next-e2e` + `e2e` (if `e2e_mode != 'none'`; full = `pnpm test:e2e` sem shard com `PLAYWRIGHT_WORKERS=4`; selected = specs do output). **2 services Postgres** com **portas host derivadas do `run_id`** (`5433 + run_id % 100` int, `5533 + run_id % 100` build/e2e — o job único segura as duas portas pelo run inteiro e runs concorrentes no runner compartilhado colidiriam em portas fixas; resíduo ~1% é ruidoso e resolvido no push seguinte; mesma topologia gateway OPS50) preservando o isolamento de hoje (o e2e nunca herda a sujeira dos testes int; o build de prod e o de e2e compartilham o service build/e2e). `timeout-minutes: 50` (soma esperada ~21 min + margem; o run nunca segura o slot além disso). Sem `needs`/`if: always()`/matrix/jq de rollup (não há irmãos). Comentário de cabeçalho reescrito: OPS62 substitui o parágrafo "Forgejo has no cancel-run plumbing; job timeouts cover that role" — o fail-fast é estrutural (1 job), sem API de cancel. **Trade-off assumido:** `migrate`+`seed:minimal` do int rodam também em PR docs-only (mode none) — intencional (a cadeia stale é pega em todo PR), custo ~1–2 min por PR de docs. +- **`.forgejo/workflows/ci.yml`** (reescrito — X1): mesma estrutura no `main`; `static` (já era job único sequencial) se funde ao job `checks` com int/build/e2e; `deploy` permanece `needs: [checks]` (inalterado — OPS53). `timeout-minutes: 50`. +- **`tests/unit/ciSkipInvariants.unit.spec.ts`** (editar — pin da arquitetura): invariantes novos — os dois workflows têm **exatamente um job** cujo id é `checks`; os steps-chave existem na ordem (lint antes de e2e; guards antes de lint); o ci-pr não contém `runs-on: host` (pin OPS53), nem `matrix:`, nem `fail-fast:`; `PLAYWRIGHT_WORKERS: 4` presente; `.next-e2e` continua pinado (invariante existente); o ci.yml mantém o job `deploy` com `needs: [checks]`. +- **`docs/AGENT-OPS.md`** (editar): tabela de CI — ci-pr/ci agora 1 job `checks` sequencial com fail-fast estrutural; remover menção a "fase 1→2 em cascata" se houver; nota "Dono do PR, dono do CI" intacta (o próprio PR define seu CI). +- **`docs/changelog/2026-08-18-ops62.md`** (novo) + `pnpm changelog:build`. +- **Migration / Access / Consent / UI:** N/A (Impeccable A). +- **Fora do repo:** nada — **o upgrade do Forgejo é cancelado** (decisão X1); o homeserver não é tocado. + +### Dados → forma (se aplicável) + +N/A — chore de DX/processo. + +## Fases verificáveis + +1. **Workflows X1** — reescrever `ci-pr.yml` e `ci.yml` (job único, 2 services, steps condicionais); `bash`/YAML válido (sem parser YAML no repo — validar por leitura + os invariantes). Gates parciais: `pnpm lint`, `format:check`, `typecheck`, `knip`, `check:cycles`. +2. **Invariantes** — `ciSkipInvariants.unit.spec.ts` (pin da arquitetura: 1 job `checks`, ordem dos steps, sem host/matrix/fail-fast no ci-pr, deploy no ci.yml); `pnpm test:unit` (spec novo). +3. **Docs + fechamento** — AGENT-OPS, changelog + `changelog:build`; gates completos (`pnpm test` int incluso, `pnpm build`); push via `pnpm push`. +4. **Validação ao vivo (no próprio PR do OPS62)** — o ci-pr deste PR **já é o X1**: (a) run verde prova que a suíte inteira roda no envelope novo; (b) o CI do PR com um step quebrado (ex.: antes do merge, um push deliberado com `lint` quebrado) prova o fail-fast: run falha no primeiro vermelho, steps seguintes nem começam, logs preservados — verificação documentada no fechamento. Sem passo de infra pós-merge. + +## Rabbit holes / Não escopo (engenharia) + +- **Watchdog, API de cancel, upgrade do Forgejo, token PAT:** fora — X1 não precisa de nenhum (a intenção fica como histórico). +- **Mudar o que o CI verifica:** fora — mesmas ferramentas, mesmo conteúdo, mesma ordem de suíte; só o envelope muda. +- **Rerun granular de steps** (re-tentar só o que falhou): o Forgejo não tem rerun por API (session-only); re-push continua sendo o caminho — como hoje. +- **Voltar a shards de e2e em jobs separados:** se o tempo de e2e full com 4 workers incomodar (medida no run deste PR), o caminho é Y (matrix fail-fast), não o watchdog — registrar como débito se medir > ~10 min. +- **Timeout do `waitForChecks` (30 min) × job `checks` (50 min)** — débito de CI (revisão /simplify P2): um run verde pode passar dos 30 min com fila do runner/retries de e2e e o automerger lançar "Timeout esperando checks" num PR verde. Gatilho para corrigir: primeiro timeout observado. Alternativa curta (1 linha) registrada: alinhar `timeoutMs` do `waitForChecks`/`autoMerge` no `forgejo-api.mjs` ao timeout do job (50–55 min). + +## Riscos e mitigação + +- **Tempo de parede do run verde aumenta** (~15–18 → ~21 min esperados: perde a paralelização da fase 1 e os 2 shards de e2e; ganha 1 checkout/install e 1 migrate/seed). **Mitigação:** medido no run deste PR; e2e com 4 workers compensa os shards; o aceite X1 do humano cobre o custo. +- **E2E vermelho é o caso mais lento** (veredito só após todos os steps anteriores). **Mitigação:** guards/lint/format/typecheck falham cedo (os casos comuns); e2e é a falha menos frequente e o report mostra o spec que falhou. Débito registrado (Y) se a frequência exigir. +- **Step `scope` com outputs e steps condicionais** — troca `needs.scope.outputs` por `steps.scope.outputs`: mesma semântica, semântica de skip idêntica (none → steps skipped, não falham). Validado por um PR de docs (mode none) no próprio repo. +- **1 service compartilhado int/e2e** — rejeitado no plano (2 services 5433/5434 preservam o isolamento; risco zero de fixture colidir). +- **`checks` como job único muda o status check de "rollup que espera needs" para "o próprio run"** — contexto e nome preservados (`checks`), então waitForChecks (OPS61-2b) e branch protection continuam vendo o contexto certo; quando o job falha, o status do sha é `failure` — veredito mais direto ainda. +- **Run de main (`ci.yml`)**: com deploy `needs: checks`, qualquer falha agora impede o deploy **imediatamente** (hoje também, mas só no fim dos irmãos) — mesma garantia, veredito mais cedo. + +## Aceite de engenharia + +- [x] Aceite de produto coberto (forma X1 aprovada pelo humano): qualquer verificação vermelha falha o run no ato; runner liberado cedo; `checks` vermelho sem esperar o fim dos demais; logs preservados (steps com logs próprios); suíte verificada idêntica; sem bypass novo. +- [x] Invariantes AGENTS/engineering-standards: sem migration/access/Consent/UI; pin da arquitetura em spec (1 job `checks`, sem host no ci-pr, sem matrix/fail-fast); identifiers em inglês; copy pt-BR só em labels (inalterado). +- [x] Testes previstos: unit dos invariantes de workflow (não há write path de app). diff --git a/playwright.config.ts b/playwright.config.ts index 620f17da..1d959da9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -48,8 +48,9 @@ if (!Number.isInteger(workers) || workers < 2) { * compiles; against a production build they are pure cost — measured 2026-08-10 * (OPS34): the 25 prewarm requests plus the 4-test frontend/admin tail behind * the ~30-file campaign family. Prod mode therefore drops the setup project and - * the dependencies so all families run in parallel; CI shards the suite across - * runners instead (ci.yml / ci-pr.yml pass `--shard`). Dev mode is unchanged. + * the dependencies so all families run in parallel; CI runs the suite in one + * process with `PLAYWRIGHT_WORKERS` (ci.yml / ci-pr.yml, OPS62 X1). Dev mode + * is unchanged. * Note the chain is an ordering guarantee for dev cold compiles, not a data * dependency: specs are self-contained in both modes (fixtures + advisory * locks), so dropping it in prod cannot reorder shared fixtures. diff --git a/scripts/ci-scope.mjs b/scripts/ci-scope.mjs index 967ad975..514d8b5c 100644 --- a/scripts/ci-scope.mjs +++ b/scripts/ci-scope.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node /** - * ci-scope — single classifier for PR job-level skips. Prints JSON: + * ci-scope — single classifier for PR step-level skips. Prints JSON: * { base, code, build, test, e2e } where each of code/build/test/e2e is the - * classify* / selectE2eSpecs result. Used by the `scope` job in ci-pr.yml. + * classify* / selectE2eSpecs result. Used by the `scope` step (id: scope) in + * ci-pr.yml — its outputs drive `if:` on the heavy steps. * * Base ref: $GITHUB_BASE_REF (PR target) or origin/main locally. */ @@ -13,7 +14,6 @@ import { classifyBuildScope, classifyStaticScope, classifyTestScope, - e2eShardConfig, selectE2eSpecs, } from './lib/test-affected-core.mjs' @@ -34,9 +34,6 @@ try { build: { mode: 'build', reason: full.reason }, test: { ...full }, e2e: { mode: 'full', specs: [], reason: full.reason, unmapped: [] }, - // The scope job always reads e2e_shards.matrix/total — keep the fallback - // total (ci-pr.yml's fromJson must never receive a null/empty matrix). - e2e_shards: e2eShardConfig('full'), }), ) process.exit(0) @@ -67,6 +64,5 @@ console.log( build: classifyBuildScope(files), test: classifyTestScope(files), e2e, - e2e_shards: e2eShardConfig(e2e.mode), }), ) diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index dc2491e2..5225b5ae 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -255,21 +255,6 @@ export function selectE2eSpecs(files, manifest) { } } -/** - * CI job-level parallelism for the e2e suite (OPS34). The full run splits - * across 2 shards (`--shard=N/total`); selected and none stay on a single - * runner — a second build for a handful of specs is pure waste. Measured - * 2026-08-10 (ci.yml run 31412425553): build 172s + tests 196s + ~54s fixed, - * so shard 1 and 2 each land ≈ 5–5.5 min. Revisit at 3 shards if the sharded - * wall drifts past ~6.5 min or the test split gets lopsided; ci.yml hardcodes - * the same [1, 2] / 2 literal with a comment pointing back here. - * @param {'full' | 'selected' | 'none'} mode - * @returns {{ matrix: number[], total: number }} - */ -export function e2eShardConfig(mode) { - return mode === 'full' ? { matrix: [1, 2], total: 2 } : { matrix: [1], total: 1 } -} - /** * Walk a list of repo-relative paths and return misplaced spec/test files. * @param {string[]} paths diff --git a/tests/unit/ciSkipInvariants.unit.spec.ts b/tests/unit/ciSkipInvariants.unit.spec.ts index b23b7004..1f020810 100644 --- a/tests/unit/ciSkipInvariants.unit.spec.ts +++ b/tests/unit/ciSkipInvariants.unit.spec.ts @@ -75,6 +75,43 @@ describe('ciSkipInvariants', () => { expect(CANONICAL_E2E_SPEC_SUFFIX).toBe('.e2e.spec.ts') }) + it('OPS62 X1: single sequential `checks` job with structural fail-fast', () => { + for (const file of ['.forgejo/workflows/ci.yml', '.forgejo/workflows/ci-pr.yml']) { + const source = readFileSync(join(repoRoot, file), 'utf8') + // Exactly one job id `checks` — the status context automerge/branch + // protection wait on. Fail-fast is structural: no siblings, no matrix, + // no strategy fail-fast (anchored as YAML keys so comments can't trip). + expect(source.match(/^jobs:\n checks:\n/m) ?? [], file).toHaveLength(1) + expect(source, file).not.toMatch(/^ {2,4}(?:matrix|fail-fast):/m) + // e2e runs as one process with 4 workers (replaces the 2-shard matrix). + expect(source, file).toContain('PLAYWRIGHT_WORKERS: 4') + // Steps keep their own logs so diagnosis survives a failure. + expect(source, file).toContain('name: Lint') + expect(source, file).toContain('name: E2E tests') + } + + const pr = readFileSync(join(repoRoot, '.forgejo/workflows/ci-pr.yml'), 'utf8') + // Content guards fail before the suite (cheap, PR-scoped, fail fast). + const guard = pr.indexOf('name: Changelog is append-only') + const lint = pr.indexOf('name: Lint') + const e2e = pr.indexOf('name: E2E tests (full suite') + expect(guard).toBeGreaterThan(-1) + expect(lint).toBeGreaterThan(guard) + expect(e2e).toBeGreaterThan(lint) + // ci-pr never runs on the host (OPS53 pin). + expect(pr).not.toContain('runs-on: host') + // The skip wiring is the critical piece: losing the `scope` step id or + // the e2e gate silently skips the suite with a green run (the opposite + // of the fail-fast this delivery sells). + expect(pr).toMatch(/id: scope\n/) + expect(pr).toMatch(/if: steps\.scope\.outputs\.e2e_mode == 'full'/) + + // main keeps the deploy gated on the checks job (OPS53) — anchored as a + // YAML key so the header comment cannot satisfy the pin. + const main = readFileSync(join(repoRoot, '.forgejo/workflows/ci.yml'), 'utf8') + expect(main).toMatch(/^ needs: \[checks\]\n/m) + }) + it('keeps local e2e build artifacts outside the development dist directory', () => { for (const file of [ '.forgejo/workflows/ci.yml', diff --git a/tests/unit/testAffected.unit.spec.ts b/tests/unit/testAffected.unit.spec.ts index 734fab4f..c33ddabb 100644 --- a/tests/unit/testAffected.unit.spec.ts +++ b/tests/unit/testAffected.unit.spec.ts @@ -1,16 +1,11 @@ // @vitest-environment node -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' - import { describe, expect, it } from 'vitest' import { classifyBuildScope, classifyStaticScope, classifyTestScope, - e2eShardConfig, HIGH_RISK_EXACT, HIGH_RISK_PREFIXES, selectE2eSpecs, @@ -153,21 +148,3 @@ describe('selectE2eSpecs (OPS5)', () => { expect(result.specs).toContain('admin') }) }) - -describe('e2eShardConfig (OPS34)', () => { - it('splits the full suite across 2 shards', () => { - expect(e2eShardConfig('full')).toEqual({ matrix: [1, 2], total: 2 }) - }) - - it('keeps selected and skipped runs on a single runner', () => { - expect(e2eShardConfig('selected')).toEqual({ matrix: [1], total: 1 }) - expect(e2eShardConfig('none')).toEqual({ matrix: [1], total: 1 }) - }) - - it('pins the ci.yml matrix literal against the single source', () => { - const repoRoot = join(fileURLToPath(new URL('.', import.meta.url)), '../..') - const workflow = readFileSync(join(repoRoot, '.forgejo/workflows/ci.yml'), 'utf8') - const { matrix } = e2eShardConfig('full') - expect(workflow).toContain(`shard: [${matrix.join(', ')}]`) - }) -})