chore(OPS62): CI fail-fast estrutural — job único checks sequencial (X1)

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).
This commit is contained in:
Francisco Solla 2026-08-18 19:00:20 -03:00 committed by fsolla
parent a1a6160ed8
commit 12a4fe1005
12 changed files with 384 additions and 565 deletions

View file

@ -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 }}

View file

@ -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)

View file

@ -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`.

View file

@ -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 -- <sha>"`); 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-<sha>`), **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.

View file

@ -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`.

View file

@ -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).

View file

@ -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<br/>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<br/>(if: steps.scope.outputs …)"| FIM
F -->|"steps seguintes não rodam"| FIM[run falha imediato<br/>slot liberado · checks vermelho<br/>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).

View file

@ -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.

View file

@ -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),
}),
)

View file

@ -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

View file

@ -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',

View file

@ -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(', ')}]`)
})
})