diff --git a/.forgejo/workflows/agent-pr-ready-automerge.yml b/.forgejo/workflows/agent-pr-ready-automerge.yml deleted file mode 100644 index acb6e38a..00000000 --- a/.forgejo/workflows/agent-pr-ready-automerge.yml +++ /dev/null @@ -1,57 +0,0 @@ -# Safety net: every same-repo PR to main must be Ready + auto-merge. -# Covers Cloud ManagePullRequest defaulting to draft, agents that forget to -# arm the merge, and human/worktree PRs (the OPS52 incident was a human PR -# whose merge failed silently). Draft PRs from non-`cursor/*` branches are -# never touched — the draft flag is the actor's veto (the CLI skips them). -# Fork PRs are excluded (the repo is public; a merged fork PR would deploy). -# -# Forgejo port (OPS50): Forgejo's API has no auto-merge scheduling, so the -# workflow runs scripts/forgejo-pr-automerge.mjs (plain Node): marks draft → -# ready, waits for the commit statuses (the branch-protection `checks` gate), -# then merges by rebase. Same semantics as `gh pr merge --auto --rebase`, -# implemented as poll + merge. GitHub's cancel-workflow-on-failure action was -# dropped (no GITHUB_RUN_ID plumbing on Forgejo). -# -# OPS61: the job injects FORGEJO_API_TOKEN. The merge POST performs a git push -# of the rebased branch to main; Forgejo's pre-receive hook rejects that push -# when the caller's user has no repo write — and the native GITHUB_TOKEN acts -# as the Actions system user (no write, and the same 403 that broke issue -# label flips). Every pre-OPS61 "auto-merge" that ended success had actually -# found the PR already merged manually; the CLI's own merge attempts died on -# 409 "User permission denied for writing". -name: PR Ready + auto-merge - -on: - pull_request: - types: [opened, reopened, synchronize, ready_for_review, converted_to_draft] - branches: [main] - -jobs: - ready-automerge: - # Same-repo only. This MUST stay in the `if:` (pre-checkout): for a fork - # PR, actions/checkout checks out the fork's head, so a same-repo guard - # inside the CLI would already be running the fork's own script — RCE with - # the Actions token. Fork PRs are excluded here because the repo is public - # and a merged fork PR would deploy attacker code to production. The draft - # policy lives in the CLI (skips non-`cursor/*` drafts) because there the - # PR state is re-read from the API, not from webhook payload fields. - if: >- - github.event.pull_request.state == 'open' && - github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - # OPS61: the CLI now waits for the full CI (PR) cascade (the `checks` - # rollup posts only after every ci-pr job, incl. e2e shards) — 55 min keeps - # 10 min of slack over the CLI's 45 min timeout. - timeout-minutes: 55 - env: - FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }} - steps: - - uses: actions/checkout@v5 - # Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache: - # setup-node@v5 defaults to true and looks for pnpm before pnpm/action-setup. - - uses: actions/setup-node@v5 - with: - node-version: 24 - package-manager-cache: false - - name: Mark Ready and merge when checks pass - run: node scripts/forgejo-pr-automerge.mjs --pr "${{ github.event.pull_request.number }}" diff --git a/.forgejo/workflows/archive-cursor-agent.yml b/.forgejo/workflows/archive-cursor-agent.yml deleted file mode 100644 index 54856ef2..00000000 --- a/.forgejo/workflows/archive-cursor-agent.yml +++ /dev/null @@ -1,51 +0,0 @@ -# Operator helper: cancel+archive a Cursor Cloud agent (CURSOR_API_KEY secret). -name: Archive Cursor agent (ops) -on: - workflow_dispatch: - inputs: - agent_id: - description: Cursor agent id (bc-…) - required: true - type: string - run_id: - description: Cursor run id (run-…) optional cancel first - required: false - type: string -jobs: - archive: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 - with: - node-version: 24 - package-manager-cache: false - - name: Cancel run (if any) + archive agent - env: - CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} - AGENT_ID: ${{ inputs.agent_id }} - RUN_ID: ${{ inputs.run_id }} - run: | - node --input-type=module <<'NODE' - import { - archiveCursorAgent, - cancelCursorRun, - } from './scripts/lib/agent-pool-cursor.mjs' - - const agentId = process.env.AGENT_ID - const runId = process.env.RUN_ID?.trim() - if (!process.env.CURSOR_API_KEY) { - console.error('CURSOR_API_KEY missing') - process.exit(1) - } - if (runId) { - try { - await cancelCursorRun(agentId, runId) - console.log('cancelled', runId) - } catch (error) { - console.warn('cancel failed (continuing):', error.message) - } - } - await archiveCursorAgent(agentId) - console.log('archived', agentId) - NODE diff --git a/.forgejo/workflows/ci-pr.yml b/.forgejo/workflows/ci-pr.yml deleted file mode 100644 index 1035650f..00000000 --- a/.forgejo/workflows/ci-pr.yml +++ /dev/null @@ -1,209 +0,0 @@ -# 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. -# -# Forgejo port (OPS50): Postgres services live on the job network and are -# reached by NAME (postgres-int / postgres-build on 5432) — no host publish, -# so concurrent runs never collide on host ports (OPS62 X1). Two isolated -# services keep e2e from inheriting int fixture residue. -name: CI (PR) - -on: - pull_request: - branches: [main] - -env: - PAYLOAD_SECRET: test-only-secret-not-used-in-production - NEXT_PUBLIC_SITE_URL: http://localhost:3000 - -jobs: - checks: - runs-on: ubuntu-latest - timeout-minutes: 50 - services: - # Services are reached by NAME on the job network (no host publish — - # OPS62 X1): concurrent runs never collide on host ports, which the - # single long-lived job would hold for the whole run. Two isolated - # services — int and build+e2e — keep e2e from inheriting int fixture - # residue (same isolation the former jobs had). Forgejo 9 does not - # expand expressions in `services.ports`, so a per-run port scheme was - # dropped in favour of the job-network DNS. - postgres-int: - image: postgres:17-alpine - env: - POSTGRES_USER: teqo - POSTGRES_PASSWORD: teqo - POSTGRES_DB: teqo_test - 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 - 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: Classify PR blast radius - 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" - echo "build_mode=$(echo "$result" | jq -r .build.mode)" >> "$GITHUB_OUTPUT" - 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" - - # 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 }} - PR_BODY: ${{ github.event.pull_request.body }} - run: node scripts/check-changelog-append-only.mjs - - name: Aggregate is up to date with docs/changelog - run: node scripts/build-changelog.mjs --check - - name: No conflict markers in markdown diffs - env: - GITHUB_BASE_REF: ${{ github.base_ref }} - run: node scripts/check-docs-conflict-markers.mjs - - 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 - - - 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) - run: | - set -euo pipefail - echo "DATABASE_URL=postgresql://teqo:teqo@postgres-int:5432/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@postgres-int:5432/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) - if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode != 'none' - run: | - set -euo pipefail - echo "DATABASE_URL=postgresql://teqo:teqo@postgres-build:5432/teqo_test" >> "$GITHUB_ENV" - echo "DATABASE_URL=postgresql://teqo:teqo@postgres-build:5432/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 deleted file mode 100644 index 266d2d9e..00000000 --- a/.forgejo/workflows/ci.yml +++ /dev/null @@ -1,229 +0,0 @@ -# Post-merge verifier on `main`, in a fixed 30-minute window (OPS65): the -# workflow no longer fires on every push. The `schedule` trigger polls every -# 30 min and a cheap gate (`gate`, runs-on host, no pnpm) classifies whether -# anything that would enter the docker build context changed since the -# revision running on the homeserver (the `org.opencontainers.image.revision` -# label of the running container, read over ssh). -# - Deployed == HEAD → suite + deploy skip; the run stays green. -# - Anything else (production change, unknown deployed state, dispatch) → -# full suite; deploy in the same window. -# - `workflow_dispatch` always runs the full pipeline (explicit operator -# intent); the deploy is idempotent via the "already deployed" guard in -# scripts/deploy-homeserver.sh. -# -# Fail-open: an unreadable/unknown deployed revision makes the gate report -# production=true, so the suite ALWAYS verifies main and a quiet day only -# skips when we KNOW deployed == HEAD. A gate that cannot classify at all -# (git/.dockerignore error) exits non-zero and turns the run RED — never a -# silent green blackout. The deploy job only runs when the gate succeeded -# AND production changed AND checks are green — never deploy blind. -# -# 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. -# -# 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. -# -# Forgejo port (OPS50): Postgres services live on the job network and are -# reached by NAME (postgres-int / postgres-build on 5432) — no host publish, -# so concurrent runs never collide on host ports (OPS62 X1). 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: - schedule: - - cron: '*/30 * * * *' - workflow_dispatch: - -env: - PAYLOAD_SECRET: test-only-secret-not-used-in-production - NEXT_PUBLIC_SITE_URL: http://localhost:3000 - -jobs: - gate: - name: gate production change - # Host runner: needs `ssh homeserver` (same environment as the deploy - # job — the key lives on the workstation host) and a full clone to diff - # ..HEAD. Main + dispatch only: a branch dispatch must not - # run that branch's code on the host — the skipped gate makes the suite - # run fail-open on ubuntu containers instead (test-only secrets). - if: github.ref == 'refs/heads/main' - runs-on: host - timeout-minutes: 10 - outputs: - production: ${{ steps.classify.outputs.production }} - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - uses: actions/setup-node@v5 - with: - node-version: 24 - package-manager-cache: false - - name: Read deployed revision and classify production change - id: classify - run: | - set -euo pipefail - # Truth = the RUNNING container's revision label (the compose file - # can lie after a failed rollback — it is swapped before the - # rollout). Empty/malformed = unknown deployed state → the - # classifier reports production=true (full run, green). - deployed_sha="$(ssh homeserver "docker inspect -f '{{index .Config.Labels \"org.opencontainers.image.revision\"}}' teqo-1313 2>/dev/null" || true)" - if ! printf '%s' "$deployed_sha" | grep -Eq '^[0-9a-f]{40}$'; then - deployed_sha="" - fi - echo "deployed_sha: ${deployed_sha:-}" - out="$(node scripts/ci-classify-production.mjs --deployed "$deployed_sha")" - printf '%s\n' "$out" - echo "production=$(printf '%s' "$out" | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).production')" >> "$GITHUB_OUTPUT" - - checks: - runs-on: ubuntu-latest - timeout-minutes: 50 - needs: [gate] - # Fail-open: an errored gate never skips the verifier; only a gate that - # succeeded AND found no production change skips the suite. - if: needs.gate.result != 'success' || needs.gate.outputs.production == 'true' - services: - # Services are reached by NAME on the job network (no host publish — - # OPS62 X1): concurrent runs never collide on host ports, which the - # single long-lived job would hold for the whole run. Two isolated - # services — int and build+e2e — keep e2e from inheriting int fixture - # residue. Forgejo 9 does not expand expressions in `services.ports`, - # so a per-run port scheme was dropped in favour of the job-network DNS. - postgres-int: - image: postgres:17-alpine - env: - POSTGRES_USER: teqo - POSTGRES_PASSWORD: teqo - POSTGRES_DB: teqo_test - 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 - 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: 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) - run: | - set -euo pipefail - echo "DATABASE_URL=postgresql://teqo:teqo@postgres-int:5432/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@postgres-int:5432/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: - # 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) - run: | - set -euo pipefail - echo "DATABASE_URL=postgresql://teqo:teqo@postgres-build:5432/teqo_test" >> "$GITHUB_ENV" - echo "DATABASE_URL=postgresql://teqo:teqo@postgres-build:5432/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 - - - 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: 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: - NEXT_DIST_DIR: .next-e2e - - 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"). - env: - PLAYWRIGHT_WORKERS: 4 - run: pnpm test:e2e - - deploy: - name: deploy homeserver (teqo-1313) - # OPS53. Runs directly on the runner HOST (not a job container): the - # deploy needs the workstation's SSH key to reach the homeserver - # (`ssh homeserver`) and streams the whole remote build/migrate/rollout - # to this job's logs. Deliberately wired ONLY here — main window + - # workflow_dispatch — never in ci-pr.yml: a host job is code execution as - # `fsolla` on the workstation, and main only ever moves through a merged - # PR whose full suite was already green. - # OPS65: double-gated — the verifier must be green AND the gate must have - # found a production change. The explicit `if` prevents both "deploy - # blind on a failed gate" and "deploy with a red suite" (a bare output - # condition would run despite failed needs). - needs: [checks, gate] - if: needs.checks.result == 'success' && needs.gate.outputs.production == 'true' - runs-on: host - timeout-minutes: 60 - steps: - - uses: actions/checkout@v5 - - name: Deploy teqo-1313 on the homeserver - run: | - set -euo pipefail - ssh homeserver "bash -s -- $GITHUB_SHA" < scripts/deploy-homeserver.sh diff --git a/.forgejo/workflows/issue-done-on-main-merge.yml b/.forgejo/workflows/issue-done-on-main-merge.yml deleted file mode 100644 index 52bbe6f2..00000000 --- a/.forgejo/workflows/issue-done-on-main-merge.yml +++ /dev/null @@ -1,30 +0,0 @@ -# Main merge flips claimed issues to done + in-prod. -# Forgejo port (OPS50): the gh loop became scripts/forgejo-issue-transition.mjs -# (plain Node, reads the PR body from the API itself). -# OPS61: the job injects FORGEJO_API_TOKEN — the native GITHUB_TOKEN 403s on -# issue label writes, and the flip was silently skipped (script logged the 403 -# and the job still ended success). The fork guard is the same class as -# agent-pr-ready-automerge.yml: on a merged fork PR the checkout pulls the -# fork's own script, which would run with the repo PAT (RCE). -name: Main merge flips claimed issues to done + in-prod - -on: - pull_request: - types: [closed] - -jobs: - issue-done: - if: github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - env: - FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }} - steps: - - uses: actions/checkout@v5 - # Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache: - # setup-node@v5 defaults to true and looks for pnpm before pnpm/action-setup. - - uses: actions/setup-node@v5 - with: - node-version: 24 - package-manager-cache: false - - name: Flip Closes/Fixes #N issues to done and in-prod - run: node scripts/forgejo-issue-transition.mjs --pr "${{ github.event.pull_request.number }}" diff --git a/.forgejo/workflows/plan-issue-ready-on-main-merge.yml b/.forgejo/workflows/plan-issue-ready-on-main-merge.yml deleted file mode 100644 index 88b7c354..00000000 --- a/.forgejo/workflows/plan-issue-ready-on-main-merge.yml +++ /dev/null @@ -1,44 +0,0 @@ -# Safety net OPS18: when a PR merges to main with `Related #N`, promote Issues -# that are still awaiting their intention plan (`blocked` + docs/plans/ link) -# to `ready`. Idempotent with `pnpm agent:ready` (agent happy path). -# -# Does NOT run on open PRs — only after merge — so the claim race stays closed. -# Job is gated on Related # in the body so non-plan merges skip checkout/Node. -# Forgejo port (OPS50): script reads the PR via the Forgejo API (plain Node). -# OPS61: the job injects FORGEJO_API_TOKEN — the native GITHUB_TOKEN 403s on -# issue label writes (same silent-skip failure as issue-done-on-main-merge). -# Fork guard: on a merged fork PR the checkout pulls the fork's own script, -# which would run with the repo PAT (RCE — same class as -# agent-pr-ready-automerge.yml). -name: Promote plan-issue Related on main merge - -on: - pull_request: - types: [closed] - branches: [main] - -jobs: - plan-issue-ready: - if: >- - github.event.pull_request.merged == true && - github.event.pull_request.head.repo.full_name == github.repository && - ( - contains(github.event.pull_request.body, 'Related #') || - contains(github.event.pull_request.body, 'related #') || - contains(github.event.pull_request.body, 'RELATED #') - ) - runs-on: ubuntu-latest - timeout-minutes: 5 - env: - FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }} - steps: - - uses: actions/checkout@v5 - # Script is plain Node (stdlib) — no pnpm. Disable - # package-manager-cache: setup-node@v5 defaults to true and looks for - # pnpm (packageManager in package.json) before pnpm/action-setup. - - uses: actions/setup-node@v5 - with: - node-version: 24 - package-manager-cache: false - - name: Promote Related #N awaiting plan → ready - run: node scripts/agent-promote-related-on-merge.mjs --pr "${{ github.event.pull_request.number }}" diff --git a/.github/workflows/agent-pr-ready-automerge.yml b/.github/workflows/agent-pr-ready-automerge.yml index 001c7677..d31d0e1c 100644 --- a/.github/workflows/agent-pr-ready-automerge.yml +++ b/.github/workflows/agent-pr-ready-automerge.yml @@ -6,8 +6,8 @@ # veto (the CLI skips them). Fork PRs are excluded (the repo is public; a # merged fork PR would deploy) — same RCE-guard class as the other workflows. # -# GitHub's NATIVE auto-merge replaces the Forgejo-era poll CLI -# (forgejo-pr-automerge.mjs): `enablePullRequestAutoMerge` (GraphQL, REBASE — +# GitHub's NATIVE auto-merge replaces the Forgejo-era poll CLI (removed in +# OPS71 Fase 2): `enablePullRequestAutoMerge` (GraphQL, REBASE — # the repo's canonical merge style) is server-side — GitHub only merges when # every required check (the `CI (PR) / checks` rollup of ci-pr.yml, OPS62) is # green and the PR is mergeable. The OPS64 "rollup can lie" defense is diff --git a/AGENTS.md b/AGENTS.md index a5a519ff..dad43d36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ These are product/architecture constraints — not a ban on editing existing cod - Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campaign)` internal campaign tool. No separate Rust service or frontend app for `/campanha`; it is modeled with Payload collections. The campaign roles are `coordinator` (label "Coordenador Geral"), `advisor` ("Assessor"), `leader` ("Liderança"), and `candidate` ("Candidato", unrestricted visibility), all on the separate auth collection `campaignUser` (see "Campaign auth" below). An advisor sees and manages ONLY the municipalities they administer; a leader is locked down to the supporter contact tool. - **2026-07-23 remodel (DEPLOYED to production 2026-07-23): "Município" is the operational unit** (it replaced "Praça", which replaced "Núcleo Eleitoral"). Municípios are PREDEFINED (seeded, geography read-only): 435 rows — one per Bahia municipality (416), except Salvador (19 zone Municípios, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated); shared/leaky TSE zones are cut at the municipal boundary. Code identifier: `municipality`. Leaderships are one record per person (`contact` UNIQUE) linked to N municipalities and N organizations; vote pledges are per leadership×municipality with `declaredVotes` (staff-entered, leader-visible) vs `estimatedVotes` (three scenarios, staff-only, hidden from the leader). Campaign production data was reset by the remodel migrations (public-site data untouched). Master plan: `docs/plans/remodelagem-municipios.md` (supersedes `remodelagem-pracas.md`). Details: "Campaign Municípios model" below. -- Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). **CI/PR/deploy live on GitHub Actions (OPS71, 2026-08-19); the ISSUE TRACKER stays on the Forgejo** (`git.solla.dev/fsolla/teqo` — labels, claims, `pnpm issue`/`agent:*` and the post-merge flips all keep talking to the Forgejo API; GitHub is only the Actions host). The PR CI (`ci-pr.yml`, job único `checks` — OPS62) runs the same cascade as always (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado, skips via `ci-scope.mjs`); its check-run `CI (PR) / checks` is the required check of `main` — literal de match na proteção: `checks` (o GitHub casa pelo nome do check-run/job; a UI exibe `CI (PR) / checks` — pin ao vivo PR #742) — and the gate of the **native auto-merge** (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`, GraphQL `enablePullRequestAutoMerge`, rebase — the server only merges with the required check green). **There is NO main verifier (`ci.yml`) anymore**: publishing is a **manual `workflow_dispatch`** (`.github/workflows/deploy.yml`) with two jobs — `verify` (hosted `ubuntu-latest`, suíte **full** incl. e2e full) → `deploy` (`needs: [verify]`, `runs-on: [self-hosted, homeserver]`): the GitHub self-hosted runner ON the homeserver executes `scripts/deploy-homeserver.sh` **locally** (no SSH; outbound-only runner, works behind the tunnel; the hosted job never touches the homeserver). The remote script is idempotent ("already deployed" → skip), builds the migrator image first, applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before the runner build** (OPS66: the static build reads the new schema — build-after-migrate, never the reverse), builds the standalone image at the pushed SHA (BuildKit secrets from `~/stack/teqo-1313.env`), pushes it to the local registry (`localhost:5000`), and recreates the container; the repo comes from `TEQO_REPO_URL` (default `https://github.com/fsolla/teqo.git`, public). `pnpm push` pushes to `origin` — **the GitHub remote** (one-time local cutover: `git remote set-url origin git@github.com:fsolla/teqo.git`; the Forgejo stays as a secondary remote and its git repo freezes at the cutover point — tracker intacto por API). The Forgejo-era files (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs`) are preserved as rollback until the OPS71 Fase 2 removal. Runbook (rollback, falhas conhecidas, passos do cutover): `docs/ops/teqo-1313-deploy.md`. **The agent-pool supervisor workflow was killed in OPS65** (the `*/10` tick cost 144 full `pnpm install`/day on the workstation; `agent-pool.yml` is gone, pool scripts/skills stay dormant). **The production database lives on the homeserver Postgres (`teqo_1313`, migrated from Neon in OPS51, 2026-08-17)** — the full production content (contacts, signatures, subscriptions, campaign) was restored there via `pg_dump`/`pg_restore` (dump in `/srv/hdd/backups/teqo-neon-pre-migracao/`); Neon remains alive and untouched as the documented rollback (re-point `DATABASE_URL` in `~/stack/teqo-1313.env`). Media moved to the homeserver Garage S3 in OPS52 (2026-08-18): the `media` collection uploads via `@payloadcms/storage-s3` when the `S3_*` envs are set, and Payload proxies the files through `/api/media/file/...` (the bucket stays private). Production env lives in `~/stack/teqo-1313.env` (bucket `teqo-media`, endpoint `http://host.docker.internal:3900` — o container alcança o Garage no host via `extra_hosts: host-gateway` + regras ufw `allow from 10.0.11.0/24 to any port 3900` (INPUT e route/FWD); a workstation/tailnet usa `http://100.119.220.31:3900`, key `teqo-key`); a partial `S3_*` config aborts the boot, and no `S3_*` means local disk (dev/test). The 40 pre-existing media rows are RECOVERED (OPS52-media, 2026-08-18): 38/40 objects restaurados no bucket a partir do WordPress (38 capas servindo 200 em `/api/media/file/...`; exceções sem fonte: `fim-escala-6x1.jpg` e `jorgesolla.jpg`) — re-executar via `pnpm media:recover` (restaura objetos sem tocar no DB; runbook em `docs/plans/ops52-media-recuperar-arquivos-impl.md`; desde OPS52-media-guard o reconcile exige `MEDIA_RECOVER_CONFIRM=1` — guard de intenção explícita para escrita no bucket, `--dry-run`/`--verify` seguem sem flag); the URLs themselves are the same contract the proxy now serves. Never point dev/test databases at the homeserver prod DB either — the local guards (`guard-dev-db`, `assertTestDatabase`) still enforce `localhost`/`_test`-suffixed targets. +- Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). **CI/PR/deploy live on GitHub Actions (OPS71, 2026-08-19); the ISSUE TRACKER stays on the Forgejo** (`git.solla.dev/fsolla/teqo` — labels, claims, `pnpm issue`/`agent:*` and the post-merge flips all keep talking to the Forgejo API; GitHub is only the Actions host). The PR CI (`ci-pr.yml`, job único `checks` — OPS62) runs the same cascade as always (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado, skips via `ci-scope.mjs`); its check-run `CI (PR) / checks` is the required check of `main` — literal de match na proteção: `checks` (o GitHub casa pelo nome do check-run/job; a UI exibe `CI (PR) / checks` — pin ao vivo PR #742) — and the gate of the **native auto-merge** (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`, GraphQL `enablePullRequestAutoMerge`, rebase — the server only merges with the required check green). **There is NO main verifier (`ci.yml`) anymore**: publishing is a **manual `workflow_dispatch`** (`.github/workflows/deploy.yml`) with two jobs — `verify` (hosted `ubuntu-latest`, suíte **full** incl. e2e full) → `deploy` (`needs: [verify]`, `runs-on: [self-hosted, homeserver]`): the GitHub self-hosted runner ON the homeserver executes `scripts/deploy-homeserver.sh` **locally** (no SSH; outbound-only runner, works behind the tunnel; the hosted job never touches the homeserver). The remote script is idempotent ("already deployed" → skip), builds the migrator image first, applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before the runner build** (OPS66: the static build reads the new schema — build-after-migrate, never the reverse), builds the standalone image at the pushed SHA (BuildKit secrets from `~/stack/teqo-1313.env`), pushes it to the local registry (`localhost:5000`), and recreates the container; the repo comes from `TEQO_REPO_URL` (default `https://github.com/fsolla/teqo.git`, public). `pnpm push` pushes to `origin` — **the GitHub remote** (one-time local cutover: `git remote set-url origin git@github.com:fsolla/teqo.git`; the Forgejo stays as a secondary remote and its git repo freezes at the cutover point — tracker intacto por API). The Forgejo-era files (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs`) were removed in the OPS71 Fase 2 (2026-08-19); rollback to the Forgejo CI flow is a revert of that removal — the frozen Forgejo `main` still holds the full pre-cutover tree. Runbook (rollback, falhas conhecidas, passos do cutover): `docs/ops/teqo-1313-deploy.md`. **The agent-pool supervisor workflow was killed in OPS65** (the `*/10` tick cost 144 full `pnpm install`/day on the workstation; `agent-pool.yml` is gone, pool scripts/skills stay dormant). **The production database lives on the homeserver Postgres (`teqo_1313`, migrated from Neon in OPS51, 2026-08-17)** — the full production content (contacts, signatures, subscriptions, campaign) was restored there via `pg_dump`/`pg_restore` (dump in `/srv/hdd/backups/teqo-neon-pre-migracao/`); Neon remains alive and untouched as the documented rollback (re-point `DATABASE_URL` in `~/stack/teqo-1313.env`). Media moved to the homeserver Garage S3 in OPS52 (2026-08-18): the `media` collection uploads via `@payloadcms/storage-s3` when the `S3_*` envs are set, and Payload proxies the files through `/api/media/file/...` (the bucket stays private). Production env lives in `~/stack/teqo-1313.env` (bucket `teqo-media`, endpoint `http://host.docker.internal:3900` — o container alcança o Garage no host via `extra_hosts: host-gateway` + regras ufw `allow from 10.0.11.0/24 to any port 3900` (INPUT e route/FWD); a workstation/tailnet usa `http://100.119.220.31:3900`, key `teqo-key`); a partial `S3_*` config aborts the boot, and no `S3_*` means local disk (dev/test). The 40 pre-existing media rows are RECOVERED (OPS52-media, 2026-08-18): 38/40 objects restaurados no bucket a partir do WordPress (38 capas servindo 200 em `/api/media/file/...`; exceções sem fonte: `fim-escala-6x1.jpg` e `jorgesolla.jpg`) — re-executar via `pnpm media:recover` (restaura objetos sem tocar no DB; runbook em `docs/plans/ops52-media-recuperar-arquivos-impl.md`; desde OPS52-media-guard o reconcile exige `MEDIA_RECOVER_CONFIRM=1` — guard de intenção explícita para escrita no bucket, `--dry-run`/`--verify` seguem sem flag); the URLs themselves are the same contract the proxy now serves. Never point dev/test databases at the homeserver prod DB either — the local guards (`guard-dev-db`, `assertTestDatabase`) still enforce `localhost`/`_test`-suffixed targets. - Donations are NOT handled in this app — they run through QueroApoiar (`apoiar.me/jorgesolla`, TSE-homologated). The site only needs a "Doar" link/CTA out to that URL. - **Embasamento de produto/domínio:** `docs/research/` guarda o compêndio de literatura (campanha de DF na Bahia, geografia do voto, cartografia eleitoral) e o relatório de discovery aprovado (2026-07-21) com o playbook dado→decisão. Para métricas, análises territoriais, mapa e priorização em `/campanha`, consulte-o antes de inventar — o kernel: a disputa de DF é conta de quociente fragmentada; leitura útil é relativa e local (captura, LQ, quantis, cobertura de meta por pledges), nunca % estadual absoluto. diff --git a/README.md b/README.md index 98b0101e..3dad0584 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,16 @@ > **Cheatsheet de operação agentic (humanos)** — o fluxo em 5 linhas: > -> 1. O agente roda `pnpm agent:claim` (ou `pnpm worktree next --issue N`) e pega a próxima Issue `ready` por prioridade (`prio:P0..P3`). +> 1. O agente roda `pnpm agent:claim` (ou `pnpm worktree next --issue N`) e pega a próxima Issue `ready` por prioridade (`prio:P0..P3`). O **tracker de Issues vive no Forgejo** (`git.solla.dev/fsolla/teqo`); o código/PR/CI vive no **GitHub**. > 2. Ele implementa, roda o fast gate (`lint + typecheck + unit`) e `pnpm push -u origin HEAD` — abre a PR para `main` (Ready, `Closes #N`). -> 3. CI verde → o safety net `agent-pr-ready-automerge.yml` mergea em `main` por rebase; o verificador `ci.yml` do main roda a suíte completa na janela fixa de 30 min. -> 4. Com mudança de produção → o job `deploy` publica no homeserver (`jorgesolla1313.com.br`, self-hosted) — migrações aplicadas antes do rollout. -> 5. Secrets humanos (uma vez): `FORGEJO_API_TOKEN` no Forgejo; envs de prod em `~/stack/teqo-1313.env` no homeserver. Branch protection de `main` já aplicada (reaplicar: `pnpm configure:branch-protection`). +> 3. CI verde (`CI (PR) / checks`) → o safety net `agent-pr-ready-automerge.yml` arma o **auto-merge nativo** (rebase) — o servidor só mergea com o required check verde. +> 4. Publicar é **manual**: dispatch de `deploy.yml` roda a suíte full (`verify`) e o job `deploy` publica no homeserver (`jorgesolla1313.com.br`, runner self-hosted) — migrações aplicadas antes do rollout. Nada é automático pós-merge. +> 5. Secrets humanos (uma vez): `FORGEJO_API_TOKEN` no GitHub (flips pós-merge no Forgejo); envs de prod em `~/stack/teqo-1313.env` no homeserver. Branch protection de `main` já aplicada (reaplicar: `pnpm configure:branch-protection`). > > Comandos: `pnpm agent:claim | agent:register | agent:prioritize | agent:file-miss | worktree next` e `pnpm db:seed:minimal`. > Labels: estado `ready|in-progress|blocked|done|in-prod`, `prio:P0..P3`, `kind:*`, `needs:migration|consent`, `requirements-changed`. -> **Agente faz sozinho:** claim → implementar → PR → main. **Só humano:** envs do homeserver, branch protection, runbook de rollback. -> Tudo em detalhe: [`docs/AGENT-OPS.md`](docs/AGENT-OPS.md) · CI: `.forgejo/workflows/ci-pr.yml` + `ci.yml`. +> **Agente faz sozinho:** claim → implementar → PR → main. **Só humano:** deploy (dispatch), envs do homeserver, branch protection, runbook de rollback. +> Tudo em detalhe: [`docs/AGENT-OPS.md`](docs/AGENT-OPS.md) · CI: `.github/workflows/ci-pr.yml` · Deploy: `.github/workflows/deploy.yml`. Teqo starts as the official digital platform for **deputado Jorge Solla** and evolves into a **white-label civic engagement platform** for politicians in Brazil. diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 2d0514e0..0f363e38 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -40,6 +40,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **Recently resolved (2026-08-19):** **OPS71** — CI/PR/deploy do Forgejo Actions de volta para o **GitHub Actions**, com o **tracker de Issues permanecendo no Forgejo** (labels, claims, `pnpm issue`/`agent:*` e flips pós-merge continuam via API do Forgejo; o GitHub é só o host do Actions). O PR CI (`ci-pr.yml`, job único `checks` — OPS62) roda no GitHub com a mesma cascata (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado via `ci-scope.mjs`), com `concurrency` nativo cancelando o run em push novo e `if:` same-repo (repo público — fork não queima minutes). Seu check-run `CI (PR) / checks` é o required check do `main` e o gate do **auto-merge nativo** (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`: draft `cursor/*` → Ready via PATCH, arm `enablePullRequestAutoMerge` GraphQL com REBASE — o servidor espera o required check verde; sem poll; o pin OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto). **`ci.yml` (verificador de main) eliminado** — publicar é **dispatch manual** (`deploy.yml`): `verify` (hosted, suíte full incl. e2e full) → `deploy` (`needs`, `runs-on: [self-hosted, homeserver]`) executa `scripts/deploy-homeserver.sh ` **localmente** no runner do GitHub instalado no homeserver (sem SSH; outbound-only; o hosted nunca toca o homeserver); o script re-aponta o workspace `~/teqo-deploy` para `TEQO_REPO_URL` (default `https://github.com/fsolla/teqo.git`, público). `pnpm push` passa a empurrar para `origin` = **GitHub** (cutover manual de remote documentado; o repo Forgejo congela em main, tracker vivo por API). Novos helpers plain-Node zero-dep: `scripts/lib/github-api.mjs` (REST+GraphQL, retry OPS67), `scripts/lib/github-pr-flow.mjs` (decisão draft/veto/skip, pura), `scripts/lib/github-branch-protection.mjs` (regra desejada + drift, `enforce_admins: true`), `scripts/github-pr.mjs` (criação de PR Ready — nunca draft), `scripts/configure-branch-protection.mjs` reescrito para a REST do GitHub; `forgejo-issue-transition.mjs` aceita `PR_BODY` (body do PR via evento GitHub; flip continua no Forgejo por `FORGEJO_API_TOKEN`). **Cutover em duas fases (gate):** nada do Forgejo foi apagado (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs` preservados como rollback) — a remoção é entrega sucessora pós-validação. Testes: `githubApi` (10), `githubBranchProtection` (10), `githubPrFlow` (9) unit novos. Docs: `AGENTS.md`, `docs/AGENT-OPS.md`, `.agents/rules/agent-pr-workflow.mdc`, `work-issue/execution-pipeline.md`, `agent-work-issue`, `capture-review-debts`, runbook `docs/ops/teqo-1313-deploy.md` (dispatch manual, runner no homeserver, cutover com ordem, rollback). +**Recently resolved (2026-08-19):** **OPS71 Fase 2** — remoção dos arquivos do fluxo Forgejo após validação ao vivo do GitHub Actions (PRs #742/#743: CI verde, auto-merge nativo, flips via API). **14 arquivos deletados:** `.forgejo/workflows/` (6 — ci-pr.yml, ci.yml, agent-pr-ready-automerge.yml, issue-done-on-main-merge.yml, plan-issue-ready-on-main-merge.yml, archive-cursor-agent.yml), `scripts/forgejo-pr-automerge.mjs`, `scripts/forgejo-dispatch.mjs`, `scripts/ci-classify-production.mjs`, `scripts/lib/dockerignore.mjs`, `scripts/lib/branch-protection.mjs` e as specs órfãs `ciClassifyProduction`/`dockerignoreMatch`/`branchProtection` `.unit.spec.ts`. **Consumidores funcionais achados pela varredura (fora do escopo literal do body) e ajustados na mesma mudança:** `scripts/lib/test-affected-core.mjs` perdeu as 2 entries de `HIGH_RISK_EXACT` dos scripts mortos; `tests/unit/ciSkipInvariants.unit.spec.ts` tirou os 2 `.forgejo/workflows/*` do loop `.next-e2e`, os 2 paths do pin de high-risk e **reescreveu o teste da janela de 30 min (OPS65) para o invariante vivo da era GitHub**: `deploy.yml` é `workflow_dispatch`-only (sem `push:`/`schedule:` — deploy nunca automático) e `ci-pr.yml` não referencia o gate morto. **Docs:** `AGENTS.md` (frase "preservados até Fase 2" → removidos; rollback = revert do PR ou clone do main congelado do Forgejo), runbook `docs/ops/teqo-1313-deploy.md` (Rollback do cutover idem), `README.md` (cheatsheet de 5 linhas era-Forgejo com janela de 30 min + deploy automático → era-GitHub: auto-merge nativo + deploy manual dispatch), comentários com referência morta em `scripts/gate-ci.mjs`, `scripts/github-pr-automerge.mjs` e `.github/workflows/agent-pr-ready-automerge.yml`. **Intocados (anti-goal tracker):** `forgejo-api.mjs`, `agent-*.mjs`, `issue.mjs`, `forgejo-issue-transition.mjs`, `agent-promote-related-on-merge.mjs`, `.forgejo/worktree.env` (consumido pelo provisionamento de worktree), `.dockerignore` (o arquivo — `docker build` do deploy o consome; só o lib de parse morreu). `docs/AGENT-OPS.md` já era era-GitHub (zero referências). Histórico (plans/changelogs antigos) congelado. Sem migration/access/Consent/UI. Gate: tsc/lint/format/knip/cycles/unit+int/build verdes; grep de saneamento `forgejo-pr-automerge|forgejo-dispatch|ci-classify|dockerignore.mjs|branch-protection.mjs` → só histórico. + **Recently resolved (2026-08-18):** **OPS52** — a media saiu do Vercel Blob para o Garage S3 do homeserver (bucket `teqo-media` + key `teqo-key`, já existentes no Garage v2.1.0). O plugin `@payloadcms/storage-vercel-blob` (e o `@vercel/blob` do seed) foi trocado por `@payloadcms/storage-s3` na config, no padrão R2 da doc do Payload (`endpoint` + `forcePathStyle: true`, region `garage`). **Decisão de arquitetura (revisão A→B durante a execução, gate com o humano):** o Garage v2.1.0 **não suporta acesso anônimo na API S3** (sem `--public-read`; source: "when we support anonymous access in the future"; testado: `anonymous`/`anon` → `NoSuchAccessKey`), então o bucket fica **privado** e a app **serve a media por proxy** — sem `disablePayloadAccessControl`, o `staticHandler` do plugin streama o objeto do Garage em `/api/media/file/` (range + etag/304 nativos) — o **mesmo contrato de URL relativa que as 40 rows pré-existentes já têm** (a recuperação dos arquivos é a Issue #10/OPS52-media). **Fail-closed:** `resolveS3StorageEnv` (`src/utilities/mediaStorage.ts`, unit-testado) — envs `S3_*` parciais → throw no boot com a lista exata de faltantes; nenhuma → storage local (dev/test). Envs novas: `S3_BUCKET`, `S3_ENDPOINT`, `S3_REGION` (default `garage`), `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` (`.env.example`, `.env.test`, worktree.mjs com cópia all-or-nothing). Seed de posts: pre-delete do Blob removido (o S3 sobrescreve o key determinístico nativamente — idempotência intacta). Prova de contrato real: PUT/GET/DELETE via SDK contra o Garage (tailnet `http://100.119.220.31:3900`), depois media criada pelo Payload local com S3 ativo → `media.url` relativa + `GET /api/media/file/...` → 200 image/png; bucket devolvido a 0 objects e `.env.local` do worktree limpo (sem credenciais de prod). Sem migration (config-only; importMap regenerado sem `VercelBlobClientUploadHandler`). Runbook de cutover registrado no impl plan: setar as 5 `S3_*` no `~/stack/teqo-1313.env` (endpoint tailnet) antes do próximo deploy (OPS53). Repo: `docs/plans/ops52-blob-vercel-para-garage-impl.md`; infra-solla `STATE.md` atualizado à parte. **Recently resolved (2026-08-18):** **OPS52-media** — os objetos de media de produção (as ~40 capas dos posts, que existiam como rows com URL relativa `/api/media/file/` mas sem arquivo em lugar nenhum) são recuperáveis do WordPress de origem com `pnpm media:recover` (`scripts/recover-media.mjs`, novo): lê as rows de `media` + `post.coverImage` (mapeamento exato row→slug; fallback pelo filename determinístico `.`), resolve a capa com a MESMA lógica do seed (featured media do REST `_embed`, senão 1ª imagem inline do corpo — só 7/43 posts têm featured), baixa e faz `PutObject` no bucket das envs `S3_*` com key = `filename` da row, **sem nunca escrever no DB** (rows e URLs intocadas; o staticHandler do Payload passa a servir). Extraído para `scripts/lib/wpArticles.mjs` o fetch ao vivo do WordPress (seed-posts agora importa de lá — sem twin; unit-testado). Fail-closed: exige as 4 `S3_*` completas (`resolveS3StorageEnv` reusado; recuperação nunca vai pro storage local), `ALLOW_REMOTE_DB=true` explícito para DB remoto + echo do alvo (DB/bucket/endpoint), e timeouts curtos no SDK (`@smithy/node-http-handler`, 5s/15s) — endpoint inalcançável falha em segundos em vez de pendurar (a rota pública de media de prod pendurava >25s; evidência de possível falta de rota container→Garage, diagnosticada no runbook do impl plan). Modos: reconcile (padrão, upload idempotente por overwrite), `--dry-run` (plano + `headObject`), `--verify` (HEAD da URL pública por filename — o aceite da Issue, sem credencial). Runbook de execução no homeserver: `docs/plans/ops52-media-recuperar-arquivos-impl.md`. diff --git a/docs/changelog/2026-08-19-ops71-fase2.md b/docs/changelog/2026-08-19-ops71-fase2.md new file mode 100644 index 00000000..31c7dded --- /dev/null +++ b/docs/changelog/2026-08-19-ops71-fase2.md @@ -0,0 +1 @@ +**Recently resolved (2026-08-19):** **OPS71 Fase 2** — remoção dos arquivos do fluxo Forgejo após validação ao vivo do GitHub Actions (PRs #742/#743: CI verde, auto-merge nativo, flips via API). **14 arquivos deletados:** `.forgejo/workflows/` (6 — ci-pr.yml, ci.yml, agent-pr-ready-automerge.yml, issue-done-on-main-merge.yml, plan-issue-ready-on-main-merge.yml, archive-cursor-agent.yml), `scripts/forgejo-pr-automerge.mjs`, `scripts/forgejo-dispatch.mjs`, `scripts/ci-classify-production.mjs`, `scripts/lib/dockerignore.mjs`, `scripts/lib/branch-protection.mjs` e as specs órfãs `ciClassifyProduction`/`dockerignoreMatch`/`branchProtection` `.unit.spec.ts`. **Consumidores funcionais achados pela varredura (fora do escopo literal do body) e ajustados na mesma mudança:** `scripts/lib/test-affected-core.mjs` perdeu as 2 entries de `HIGH_RISK_EXACT` dos scripts mortos; `tests/unit/ciSkipInvariants.unit.spec.ts` tirou os 2 `.forgejo/workflows/*` do loop `.next-e2e`, os 2 paths do pin de high-risk e **reescreveu o teste da janela de 30 min (OPS65) para o invariante vivo da era GitHub**: `deploy.yml` é `workflow_dispatch`-only (sem `push:`/`schedule:` — deploy nunca automático) e `ci-pr.yml` não referencia o gate morto. **Docs:** `AGENTS.md` (frase "preservados até Fase 2" → removidos; rollback = revert do PR ou clone do main congelado do Forgejo), runbook `docs/ops/teqo-1313-deploy.md` (Rollback do cutover idem), `README.md` (cheatsheet de 5 linhas era-Forgejo com janela de 30 min + deploy automático → era-GitHub: auto-merge nativo + deploy manual dispatch), comentários com referência morta em `scripts/gate-ci.mjs`, `scripts/github-pr-automerge.mjs` e `.github/workflows/agent-pr-ready-automerge.yml`. **Intocados (anti-goal tracker):** `forgejo-api.mjs`, `agent-*.mjs`, `issue.mjs`, `forgejo-issue-transition.mjs`, `agent-promote-related-on-merge.mjs`, `.forgejo/worktree.env` (consumido pelo provisionamento de worktree), `.dockerignore` (o arquivo — `docker build` do deploy o consome; só o lib de parse morreu). `docs/AGENT-OPS.md` já era era-GitHub (zero referências). Histórico (plans/changelogs antigos) congelado. Sem migration/access/Consent/UI. Gate: tsc/lint/format/knip/cycles/unit+int/build verdes; grep de saneamento `forgejo-pr-automerge|forgejo-dispatch|ci-classify|dockerignore.mjs|branch-protection.mjs` → só histórico. diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index 91649d1f..6dcc8b9f 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -84,8 +84,10 @@ secrets de integração: `FORGEJO_API_TOKEN` (flips pós-merge) e deploy manual de validação. Rollback do cutover (se o GitHub falhar cedo): religar o runner do Forgejo e -abrir PRs no Forgejo (os workflows de `.forgejo/workflows/` seguem no repo -até a Fase 2) — remotes: `git remote set-url origin ssh://git@192.168.15.142:2222/fsolla/teqo.git`. +abrir PRs no Forgejo a partir do main congelado — os workflows de +`.forgejo/workflows/` foram removidos na OPS71 Fase 2, mas restauram do git +history (`git revert` do PR da remoção, ou clone do main congelado do Forgejo) +— remotes: `git remote set-url origin ssh://git@192.168.15.142:2222/fsolla/teqo.git`. ## Rollback (do deploy) diff --git a/docs/plans/ops71-fase2-remover-arquivos-forgejo-impl.md b/docs/plans/ops71-fase2-remover-arquivos-forgejo-impl.md new file mode 100644 index 00000000..b10ca02a --- /dev/null +++ b/docs/plans/ops71-fase2-remover-arquivos-forgejo-impl.md @@ -0,0 +1,83 @@ +# Impl: OPS71 Fase 2 — remover arquivos do Forgejo (workflows/scripts) após validação do GitHub Actions + +Status: aprovado +Atualizado em: 2026-08-19 +Issue: #111 +Intenção: docs/plans/ops71-ci-github-actions-tracker-forgejo.md (§"Fase 2 — entrega sucessora", linha 99-101 do impl da Fase 1) +Appetite restante: pequeno — remoção + fixups de consumidores + docs (1 sessão) + +## Leitura da intenção + +- **Outcome:** o fluxo GitHub Actions (OPS71 Fase 1, validado ao vivo: PRs #742/#743 — CI verde, auto-merge nativo, flips via API) é a única via de CI/PR/deploy no repo; os arquivos do fluxo Forgejo saem do tree; docs residuais atualizados; nada do tracker é tocado. +- **O que NÃO negociar:** tracker no Forgejo (Issues/labels/claims/flips via API) — `forgejo-api.mjs`, `agent-*.mjs`, `issue.mjs`, `forgejo-issue-transition.mjs`, `agent-promote-related-on-merge.mjs` **ficam**; `.forgejo/worktree.env` fica (fora do escopo `workflows/*`, consumido pelo provisionamento de worktree); desligar o runner do Forgejo é passo manual do runbook (fora do código); `ci.yml` de main não volta (deploy = dispatch manual). +- **O que reavaliar:** a hipótese da intenção dizia "grep `forgejo-pr-automerge|forgejo-dispatch|ci-classify|dockerignore` como saída" — a varredura achou **dois consumidores funcionais** além de docs, não listados no escopo literal: `scripts/lib/test-affected-core.mjs` (HIGH_RISK_EXACT com os 2 paths mortos) e `tests/unit/ciSkipInvariants.unit.spec.ts` (lê `.forgejo/workflows/ci.yml`+`ci-pr.yml` e pina o gate OPS65). Ambos **precisam ser editados** — são o contrato que quebraria se os arquivos sumissem. Também achado: `README.md` ainda descreve o fluxo Forgejo pré-Fase 1 (janela de 30 min, deploy automático, linha de CI apontando `.forgejo/workflows/`). + +## Abordagem recomendada + +```mermaid +flowchart LR + D[14 arquivos deletados] --> C1[test-affected-core: -2 entries HIGH_RISK] + D --> C2[ciSkipInvariants: -2 paths .next-e2e, -2 high-risk, teste OPS65 reescrito para deploy.yml dispatch-only] + D --> C3[gate-ci: comentário .github/workflows/ci-pr.yml] + D --> C4[github-pr-automerge + agent-pr-ready-automerge: comentário sem referência morta] + D --> DOC[AGENTS.md + runbook + README — era GitHub só] + DOC --> CL[changelog ops71-fase2 + changelog:build] +``` + +**Opções consideradas:** + +- **A — Remoção seca + fixups mínimos dos consumidores** (o escopo do body + os 2 consumidores funcionais + 3 docs). Nada de novo é criado; invariantes de CI re-ancorados no `.github/workflows/`. +- **B — Reescrever `ciSkipInvariants` para pinar "`.forgejo/` não existe"** — redundante: a deleção é o próprio diff do PR; e o teste que importa é o invariante _vivo_ (deploy nunca automático), que re-pino no `deploy.yml`. +- **C — Deletar o teste OPS65 inteiro** (remover o teste da janela de 30 min e pronto) — perderia o pin "nunca deploy automático" sem reposição; o GitHub manteve o espírito (dispatch manual), o teste deve continuar existindo apontando para o arquivo certo. + +**Recomendação:** A — porque os consumidores são pequenos e bem delimitados, a substituição do pin OPS65 pelo pin equivalente da era GitHub (`deploy.yml` sem trigger `push`/`schedule`; `ci-pr.yml` sem o gate morto) preserva a invariante de deploy manual, e nada além do escopo declarado é tocado. + +**Rejeitadas:** + +- **Manter os arquivos como rollback inerte** — o propósito da Issue é a remoção; rollback passa a ser git history (o main congelado do Forgejo é uma cópia íntegra da era Forgejo; o PR é um commit reversível). +- **Deletar `forgejo-issue-transition.mjs`/`agent-promote-related-on-merge.mjs`/`forgejo-api.mjs`/`agent-*.mjs`** — anti-goal explícito (tracker vivo). +- **Deletar `.forgejo/worktree.env`** — fora do escopo (`workflows/*`); consumido pelo provisionamento de worktree. +- **Editar `docs/plans/*`, `docs/changelog/*`, `docs/CHANGELOG-AGENTS.md`** — história congelada/insert-only; a narrativa de "preservados até Fase 2" vive nos changelogs antigos e permanece. +- **Tocar `docs/AGENT-OPS.md`** — já é era-GitHub (grep não acha referência a nenhum arquivo removido). + +### Componentes / mudanças + +- **Deletados (14):** `.forgejo/workflows/` (6: ci-pr.yml, ci.yml, agent-pr-ready-automerge.yml, issue-done-on-main-merge.yml, plan-issue-ready-on-main-merge.yml, archive-cursor-agent.yml); `scripts/forgejo-pr-automerge.mjs`, `scripts/forgejo-dispatch.mjs`, `scripts/ci-classify-production.mjs`, `scripts/lib/dockerignore.mjs`, `scripts/lib/branch-protection.mjs`; specs `tests/unit/ciClassifyProduction.unit.spec.ts`, `tests/unit/dockerignoreMatch.unit.spec.ts`, `tests/unit/branchProtection.unit.spec.ts`. Nenhuma importação viva aponta para eles (grep: só `test-affected-core` + `ciSkipInvariants` + docs). +- **`scripts/lib/test-affected-core.mjs`**: remove `scripts/ci-classify-production.mjs` e `scripts/lib/dockerignore.mjs` de `HIGH_RISK_EXACT` (paths mortos nunca aparecem em diff; listas devem espelhar o tree). +- **`tests/unit/ciSkipInvariants.unit.spec.ts`**: (1) tira `ci.yml`+`ci-pr.yml` do loop `.next-e2e`; (2) tira os 2 paths do pin de high-risk; (3) reescreve o teste "main CI 30-min window (OPS65)" para a era GitHub: lê `.github/workflows/deploy.yml` e pina `workflow_dispatch:` presente, `push:`/`schedule:` ausentes (deploy nunca automático) e `.github/workflows/ci-pr.yml` sem `ci-classify-production.mjs` (gate morto). +- **`scripts/gate-ci.mjs`** (comentário do header): "Local mirror of `.forgejo/workflows/ci-pr.yml`" → `.github/workflows/ci-pr.yml`. +- **`scripts/github-pr-automerge.mjs` + `.github/workflows/agent-pr-ready-automerge.yml`**: comentários com o parêntese "(forgejo-pr-automerge.mjs)" → referência genérica à era Forgejo (arquivo não existe mais). +- **`AGENTS.md`**: frase "The Forgejo-era files (...) are preserved as rollback until the OPS71 Fase 2 removal" → removidos na Fase 2; rollback via git history (main congelado do Forgejo / revert do PR). +- **`docs/ops/teqo-1313-deploy.md`** (§"Rollback do cutover"): "os workflows de `.forgejo/workflows/` seguem no repo até a Fase 2" → arquivos removidos; restauração via git history do main congelado do Forgejo. +- **`README.md`**: bloco de fluxo stale (janela 30 min, deploy automático, linha `CI: .forgejo/workflows/ci-pr.yml + ci.yml`) → fluxo era-GitHub (PR → `CI (PR) / checks` → auto-merge nativo → deploy manual dispatch). +- **Changelog:** `docs/changelog/2026-08-19-ops71-fase2.md` + `pnpm changelog:build`. +- **Migration:** sem migration (nenhum schema). +- **Access / Consent / UI:** N/A. + +## Fases verificáveis + +1. **Deleção + fixups de código** — `git rm` dos 14 arquivos; edita `test-affected-core.mjs`, `ciSkipInvariants.unit.spec.ts`, comentários de `gate-ci.mjs`/`github-pr-automerge.mjs`/`agent-pr-ready-automerge.yml`. Gate: `pnpm test:unit` (ciSkipInvariants/testAffected/github\*). +2. **Docs + changelog** — AGENTS.md, runbook, README, `docs/changelog/2026-08-19-ops71-fase2.md` + `pnpm changelog:build`. +3. **Gates finais** — `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test` (unit+int), `pnpm build`; grep de saneamento (`forgejo-pr-automerge|forgejo-dispatch|ci-classify|dockerignore.mjs|branch-protection.mjs` → só histórico/docs/changelogs); push → PR GitHub → CI (PR) / checks → auto-merge. + +## Rabbit holes / Não escopo (engenharia) + +- Re-engenharia do `ciSkipInvariants` além do re-pino — não; só o que o arquivo lê muda. +- `pnpm configure:branch-protection`/github libs — intocados (era GitHub). +- Pool/`agent-pool*` dormente (OPS65) — intocado. +- Desligar o runner do Forgejo — passo manual no runbook, não código. +- `.dockerignore` (o arquivo) — **permanece**: o `docker build` do `deploy-homeserver.sh` o consome; só o lib `dockerignore.mjs` (parse de regras, gate OPS65) morre. + +## Riscos e mitigação + +- **`ciSkipInvariants` quebrar por leitura de arquivo removido** — os 3 pontos são editados na mesma fase da deleção; gate local `pnpm test:unit` antes de qualquer push. +- **Pin perdido do "deploy manual"** — mitigado pelo teste reescrito (dispatch-only, sem `push:`/`schedule:`); se um dia o `ci.yml` voltar com trigger, o teste acende. +- **Referência morta residual fora do grep** — saneamento final com o grep do escopo + knip (dead exports/deps é CI-blocking); docs históricos (plans/changelogs antigos) são allowlist consciente. +- **Knip acusar `branch-protection.mjs`/`dockerignore.mjs` como órfãos na Fase 1→Fase 2** — a deleção é atômica com a remoção das referências (mesma fase); nada fica órfão no diff. +- **PR de plano vs `check-plans-only-pr-closes`** — este PR tem código (não é plans-only); guard não se aplica. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto (remoção completa + docs; tracker intacto) +- [x] Invariantes AGENTS/engineering-standards (sem migration/schema; nada do tracker; docs históricos intocados; changelog insert-only) +- [x] Testes de domínio previstos: ciSkipInvariants re-ancorado na era GitHub (deploy dispatch-only); unit suíte verde diff --git a/scripts/ci-classify-production.mjs b/scripts/ci-classify-production.mjs deleted file mode 100644 index b7ab0935..00000000 --- a/scripts/ci-classify-production.mjs +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env node -/** - * ci-classify-production — OPS65 production-change gate for the main CI - * window. Classifies whether the merge head differs from the deployed - * artifact in anything that would enter the docker build context. - * - * The `.dockerignore` is the source of truth (the Dockerfile does `COPY . .`): - * a changed path is production when it is NOT excluded by the ignore - * patterns. The gate is FAIL-OPEN toward production: dispatch (explicit - * operator intent) and an unknown deployed SHA always run the full suite; - * a classification ERROR (git/.dockerignore unreadable, no input) prints - * production=true AND exits 1, so the workflow run turns red — the suite - * still runs (fail-open) but the deploy never happens silently. A green run - * only ever means "the gate could classify and there is nothing to do". - * - * Plain Node, stdlib only — the gate must stay cheap (no pnpm install). - * - * node scripts/ci-classify-production.mjs --deployed [--value] - * node scripts/ci-classify-production.mjs --files-from [--value] - * - * Output: JSON { production, reason, changed, kept, skipped } on stdout - * (`--value` prints only `true`/`false`). Exit 0 = classified; exit 1 = - * could not classify (production=true printed, run should go red). - */ -import { execFileSync } from 'node:child_process' -import { readFileSync } from 'node:fs' -import { pathToFileURL } from 'node:url' - -import { isDockerignored, parseDockerignore } from './lib/dockerignore.mjs' - -const parseArgs = (argv) => { - const flags = {} - for (let index = 2; index < argv.length; index += 1) { - const arg = argv[index] - if (arg.startsWith('--')) { - const name = arg.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase()) - const next = argv[index + 1] - flags[name] = typeof next === 'string' && !next.startsWith('--') ? next : true - if (typeof next === 'string' && !next.startsWith('--')) index += 1 - } - } - return flags -} - -const flags = parseArgs(process.argv) -const deployed = flags.deployed -const filesFrom = flags.filesFrom -const valueOnly = flags.value === true - -/** - * @param {string[]} changed - repo-relative changed paths - * @param {import('./lib/dockerignore.mjs').DockerignoreRule[]} patterns - - * .dockerignore rules - * @returns {{ production: boolean, kept: string[], skipped: string[] }} - */ -export function classifyProduction(changed, patterns) { - const kept = [] - const skipped = [] - for (const path of changed) { - if (path.length === 0) continue - if (isDockerignored(path, patterns)) skipped.push(path) - else kept.push(path) - } - return { production: kept.length > 0, kept, skipped } -} - -const report = (production, reason, extra = {}) => { - const result = { production, reason, ...extra } - if (valueOnly) { - console.log(production) - return - } - console.log(JSON.stringify(result)) -} - -/** - * Could not classify at all (git/.dockerignore unreadable, no input): run - * the full pipeline AND make the run red — a gate that cannot classify must - * never look like "nothing to do". - */ -const failOpenRed = (reason) => { - report(true, reason, { changed: [], kept: [], skipped: [] }) - process.exitCode = 1 -} - -// Import-safe: the module is unit-tested (classifyProduction), so the CLI -// body only runs when executed as a script. -const isCli = - typeof process.argv[1] === 'string' && import.meta.url === pathToFileURL(process.argv[1]).href -if (isCli) { - main() -} - -function main() { - if (process.env.GITHUB_EVENT_NAME === 'workflow_dispatch') { - report(true, 'workflow_dispatch (intenção explícita)') - return - } - - let changed - try { - if (filesFrom) { - changed = readFileSync(filesFrom, 'utf8').split('\n').filter(Boolean) - } else if (deployed === '') { - // The workflow always passes --deployed (possibly empty: no container - // revision on the homeserver). An empty deployed SHA is a legitimate - // "unknown deployed state" — run the full pipeline, keep the run green. - report(true, 'sem SHA deployado (estado desconhecido)', { - changed: [], - kept: [], - skipped: [], - }) - return - } else if (deployed === true) { - failOpenRed('--deployed sem valor') - return - } else if (deployed) { - changed = execFileSync('git', ['diff', '--name-only', deployed, 'HEAD', '--'], { - encoding: 'utf8', - }) - .split('\n') - .filter(Boolean) - } else { - failOpenRed('sem SHA deployado nem files-from') - return - } - } catch (error) { - failOpenRed(`erro ao obter o diff (${error instanceof Error ? error.message : error})`) - return - } - - if (changed.length === 0) { - report(false, 'diff vazio (nada a fazer)', { - changed: [], - kept: [], - skipped: [], - }) - return - } - - let rules - try { - rules = parseDockerignore(readFileSync('.dockerignore', 'utf8')) - } catch (error) { - failOpenRed(`.dockerignore ilegível (${error instanceof Error ? error.message : error})`) - return - } - - const classified = classifyProduction(changed, rules) - report( - classified.production, - classified.production ? 'mudança de produção desde o último deploy' : 'sem mudança de produção', - { changed, kept: classified.kept, skipped: classified.skipped }, - ) -} diff --git a/scripts/forgejo-dispatch.mjs b/scripts/forgejo-dispatch.mjs deleted file mode 100644 index 6972dc38..00000000 --- a/scripts/forgejo-dispatch.mjs +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Plain-Node CLI (no pnpm) — dispatch a Forgejo workflow via workflow_dispatch. - * Used by CI jobs (requeue) and humans. - * - * node scripts/forgejo-dispatch.mjs [key=value …] - */ - -import { createApi } from './lib/forgejo-api.mjs' - -const [, , workflowFile, ref = 'main', ...inputPairs] = process.argv -if (!workflowFile) { - console.error('Usage: node scripts/forgejo-dispatch.mjs [ref] [key=value …]') - process.exit(1) -} - -const inputs = {} -for (const pair of inputPairs) { - const eq = pair.indexOf('=') - if (eq <= 0) continue - inputs[pair.slice(0, eq)] = pair.slice(eq + 1) -} - -const api = createApi({}) -try { - await api.workflowDispatch(workflowFile, { ref, inputs }) - console.log( - `[forgejo-dispatch] ${workflowFile}@${ref} enviado${Object.keys(inputs).length > 0 ? ` (${Object.keys(inputs).join(', ')})` : ''}`, - ) -} catch (error) { - console.error(`[forgejo-dispatch] falhou: ${error instanceof Error ? error.message : error}`) - process.exit(1) -} diff --git a/scripts/forgejo-pr-automerge.mjs b/scripts/forgejo-pr-automerge.mjs deleted file mode 100644 index a142769b..00000000 --- a/scripts/forgejo-pr-automerge.mjs +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Plain-Node CLI (no pnpm) — PR safety net: wait for checks and merge by - * rebase (the Forgejo equivalent of `gh pr merge --auto --rebase`, which the - * Forgejo API does not offer as a scheduled operation). Idempotent: already - * merged → no-op success. The verdict comes from re-reading the PR (Forgejo's - * merge endpoint answers 200 + empty body on success, so the POST response - * alone cannot prove the merge) — "já mergeado" is only printed for a PR the - * server actually reports as merged; a failed merge exits 1 loudly. - * - * Draft policy (OPS57): only `cursor/*` heads get marked ready. A draft from - * any other branch is the actor's veto — the CLI skips it (exit 0) so the - * workflow never forces a human/plans PR to ready. - * - * node scripts/forgejo-pr-automerge.mjs --pr - */ - -import { createApi } from './lib/forgejo-api.mjs' - -const parseArgs = (argv) => { - const flags = {} - for (let index = 2; index < argv.length; index += 1) { - const arg = argv[index] - if (arg.startsWith('--')) { - const name = arg.slice(2) - const next = argv[index + 1] - flags[name] = typeof next === 'string' && !next.startsWith('--') ? next : true - if (typeof next === 'string' && !next.startsWith('--')) index += 1 - } - } - return flags -} - -const flags = parseArgs(process.argv) -const prNumber = Number(flags.pr) -if (!Number.isInteger(prNumber) || prNumber <= 0) { - console.error('Usage: node scripts/forgejo-pr-automerge.mjs --pr ') - process.exit(1) -} - -const api = createApi({}) -try { - const pr = await api.getPullRequest(prNumber) - if (!pr || pr.state !== 'OPEN') { - console.log( - `[forgejo-pr-automerge] PR #${prNumber} não está aberto (${pr?.state ?? 'inexistente'}) — skip`, - ) - process.exit(0) - } - if (pr.base.ref !== 'main') { - console.log(`[forgejo-pr-automerge] PR #${prNumber} base é ${pr.base.ref} (não main) — skip`) - process.exit(0) - } - if (pr.isDraft) { - if (!pr.head.ref.startsWith('cursor/')) { - console.log( - `[forgejo-pr-automerge] PR #${prNumber} é draft fora de cursor/* — skip (veto do ator)`, - ) - process.exit(0) - } - console.log(`[forgejo-pr-automerge] PR #${prNumber} é draft — marcando Ready`) - await api.markPullRequestReady(prNumber) - } else { - console.log(`[forgejo-pr-automerge] PR #${prNumber} já Ready`) - } - const { - attempted, - merged, - pr: after, - } = await api.autoMerge(prNumber, { - // OPS61: the wait now covers the whole CI (PR) cascade (the `checks` - // rollup only posts after every ci-pr job), so the 30 min default was too - // tight under load — e2e shards + int + build run before the rollup. - timeoutMs: 45 * 60 * 1000, - log: (line) => console.log(`[forgejo-pr-automerge] ${line}`), - }) - if (merged) { - console.log( - attempted - ? `[forgejo-pr-automerge] PR #${prNumber} mergeado (rebase)` - : `[forgejo-pr-automerge] PR #${prNumber} já mergeado`, - ) - } else { - console.log( - after?.state === 'CLOSED' - ? `[forgejo-pr-automerge] PR #${prNumber} fechado sem merge — skip` - : `[forgejo-pr-automerge] PR #${prNumber} voltou a draft — skip (veto do ator)`, - ) - } -} catch (error) { - console.error(`[forgejo-pr-automerge] falhou: ${error instanceof Error ? error.message : error}`) - process.exit(1) -} diff --git a/scripts/gate-ci.mjs b/scripts/gate-ci.mjs index 8c0268b3..78328a2d 100644 --- a/scripts/gate-ci.mjs +++ b/scripts/gate-ci.mjs @@ -1,9 +1,9 @@ #!/usr/bin/env node /** - * Local mirror of `.forgejo/workflows/ci-pr.yml` (serial): phase-1 cheap checks, + * Local mirror of `.github/workflows/ci-pr.yml` (serial): phase-1 cheap checks, * then phase-2 expensive (int/build). e2e is NOT part of the local mirror - * (OPS59): it runs once, in CI (PR job `e2e` → `checks`; main `ci.yml` e2e → - * `checks` → `deploy`). Local optional feedback: `pnpm test:e2e:affected`. + * (OPS59): it runs in CI — selected/afetado no PR (`ci-pr.yml` → `checks`) + * e full no verify do `deploy.yml`. Local optional feedback: `pnpm test:e2e:affected`. * Docs guards (OPS63): the `docs-guards` checks (changelog append-only, * aggregate sync, conflict markers) also run here — cheap git diffs, same * scripts the CI job runs (the `changelog-rewrite:` escape stays CI-only, diff --git a/scripts/github-pr-automerge.mjs b/scripts/github-pr-automerge.mjs index 1276690c..f5c376b7 100644 --- a/scripts/github-pr-automerge.mjs +++ b/scripts/github-pr-automerge.mjs @@ -4,9 +4,9 @@ * .github/workflows/agent-pr-ready-automerge.yml with the built-in * GITHUB_TOKEN (permissions: contents+pull-requests write). * - * Unlike the Forgejo-era CLI (forgejo-pr-automerge.mjs), there is NO poll - * loop: `enablePullRequestAutoMerge` is server-side — GitHub only merges when - * every required check (incl. `CI (PR) / checks`) is green and the PR is + * Unlike the Forgejo-era automerger (removed in OPS71 Fase 2), there is NO + * poll loop: `enablePullRequestAutoMerge` is server-side — GitHub only merges + * when every required check (incl. `CI (PR) / checks`) is green and the PR is * mergeable. The OPS64 "never trust the rollup" pin is structural here: a * single sequential job posts one honest check-run, and the server enforces * it. Draft policy (OPS57) preserved: only `cursor/*` heads are marked diff --git a/scripts/lib/branch-protection.mjs b/scripts/lib/branch-protection.mjs deleted file mode 100644 index 3cd313c1..00000000 --- a/scripts/lib/branch-protection.mjs +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Pure decision for the main branch protection rule (OPS61). - * - * The desired rule is the repo contract: main requires the CI (PR) cascade - * rollup as the only status check (`strict=false`, 0 reviews). The context is - * a glob on purpose — Forgejo matches `status_check_contexts` with gobwas/glob - * against the real commit-status contexts (`CI (PR) / checks (pull_request)` — - * the ` (event)` suffix is part of the context), so the literal `checks` would - * match nothing and block every merge. - */ - -export const DESIRED_RULE = Object.freeze({ - rule_name: 'main', - enable_status_check: true, - status_check_contexts: ['CI (PR) / checks*'], - enable_push: false, - required_approvals: 0, - dismiss_stale_approvals: false, -}) - -const COMPARED_FIELDS = [ - 'rule_name', - 'enable_status_check', - 'status_check_contexts', - 'enable_push', - 'required_approvals', - 'dismiss_stale_approvals', -] - -const sameStrings = (have, want) => - have.length === want.length && - want.every((value) => have.includes(value)) && - have.every((value) => want.includes(value)) - -/** - * True when the existing rule (from the API, already normalized) matches the - * desired rule on every compared field — array fields as sets. - */ -export const ruleMatches = (existing, desired = DESIRED_RULE) => { - if (!existing) return false - return COMPARED_FIELDS.every((field) => { - const want = desired[field] - const have = existing[field] - if (Array.isArray(want)) return Array.isArray(have) && sameStrings(have, want) - return have === want - }) -} - -/** - * What to do with the rule: `create` (no rule), `update` (rule exists with - * drift), `noop` (already compliant). Idempotent re-runs land on `noop`. - * - * @param {object|null} existing - normalized rule from the API, or null - * @param {object} [desired] - * @returns {{ action: 'create' | 'update' | 'noop' }} - */ -export const planBranchProtectionRule = (existing, desired = DESIRED_RULE) => { - if (ruleMatches(existing, desired)) return { action: 'noop' } - return { action: existing ? 'update' : 'create' } -} diff --git a/scripts/lib/dockerignore.mjs b/scripts/lib/dockerignore.mjs deleted file mode 100644 index c21a5dd3..00000000 --- a/scripts/lib/dockerignore.mjs +++ /dev/null @@ -1,106 +0,0 @@ -/** - * dockerignore match — pure `.dockerignore` semantics (moby patternmatcher - * subset) for the CI production-change gate (OPS65). No git, no fs, no deps. - * - * The gate classifies "production change" by asking: would this path enter the - * docker build context? The `.dockerignore` is the source of truth for the - * artifact (the Dockerfile does `COPY . .`), so a path is production when it - * is NOT excluded by the ignore patterns. - * - * Implemented rules (moby semantics): - * - blank lines and `#` comments are ignored - * - `!` prefixes a negation (re-include); last match wins - * - trailing `/` is a directory pattern (the dir itself and its tree) - * - leading `/` anchors the pattern to the context root - * - a pattern with no other `/` matches the basename at ANY depth - * - any matched path also excludes everything under it (the builder prunes - * at the matched directory, so `docs` excludes `docs/x.md`) - * - `*` matches any run of non-separator chars, `?` a single one - * - `**` matches any number of directories (globstar) - */ - -/** A compiled ignore rule: regex against the cleaned repo-relative path. */ -export class DockerignoreRule { - /** - * @param {RegExp} regex - compiled matcher - * @param {boolean} negate - `!` prefix: re-include when matched - * @param {string} source - original pattern text (diagnostics) - */ - constructor(regex, negate, source) { - this.regex = regex - this.negate = negate - this.source = source - } -} - -/** - * @param {string} pattern - one `.dockerignore` line (no trailing newline) - * @returns {DockerignoreRule} - */ -export function compileDockerignoreRule(pattern) { - const source = pattern - let negate = false - if (pattern.startsWith('!')) { - negate = true - pattern = pattern.slice(1) - } - if (pattern.endsWith('/')) pattern = pattern.slice(0, -1) - const anchored = pattern.startsWith('/') - if (anchored) pattern = pattern.slice(1) - - let body = '' - for (let index = 0; index < pattern.length; index += 1) { - const char = pattern[index] - if (char === '*') { - if (pattern[index + 1] === '*') { - index += 1 - if (pattern[index + 1] === '/') { - body += '(?:.*/)?' - index += 1 - } else { - body += '.*' - } - } else { - body += '[^/]*' - } - } else if (char === '?') { - body += '[^/]' - } else if ('\\^$.|+()[]{}'.includes(char)) { - body += `\\${char}` - } else { - body += char - } - } - - const anyDepth = !anchored && !pattern.includes('/') ? '(?:.*/)?' : '' - const subtree = '(?:/.*)?' - const regexSource = body.length > 0 ? `^${anyDepth}${body}${subtree}$` : '$^' - return new DockerignoreRule(new RegExp(regexSource), negate, source) -} - -/** - * @param {string} text - full `.dockerignore` content - * @returns {DockerignoreRule[]} rules in order (last match wins) - */ -export function parseDockerignore(text) { - return text - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0 && !line.startsWith('#')) - .map(compileDockerignoreRule) -} - -/** - * Last-match-wins exclusion test for one repo-relative path. - * - * @param {string} path - repo-relative, forward slashes (e.g. "src/app/x.ts") - * @param {DockerignoreRule[]} rules - from parseDockerignore - * @returns {boolean} true when the path is excluded from the build context - */ -export function isDockerignored(path, rules) { - let ignored = false - for (const rule of rules) { - if (rule.regex.test(path)) ignored = !rule.negate - } - return ignored -} diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index 709c10d7..5225b5ae 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -29,8 +29,6 @@ export const HIGH_RISK_EXACT = new Set([ 'scripts/e2e-affected.mjs', 'scripts/run-e2e-affected.mjs', 'scripts/ci-scope.mjs', - 'scripts/ci-classify-production.mjs', - 'scripts/lib/dockerignore.mjs', 'scripts/check-test-locations.mjs', 'scripts/check-plans-only-pr-closes.mjs', 'scripts/lib/plansOnlyClosesGuard.mjs', diff --git a/tests/unit/branchProtection.unit.spec.ts b/tests/unit/branchProtection.unit.spec.ts deleted file mode 100644 index babaed5e..00000000 --- a/tests/unit/branchProtection.unit.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { - DESIRED_RULE, - planBranchProtectionRule, - ruleMatches, -} from '../../scripts/lib/branch-protection.mjs' - -describe('branch-protection rule planning (OPS61)', () => { - it('plans create when no rule exists', () => { - expect(planBranchProtectionRule(null).action).toBe('create') - }) - - it('plans noop when the rule already matches', () => { - const compliant = { ...DESIRED_RULE, branch_name: 'main' } - expect(planBranchProtectionRule(compliant).action).toBe('noop') - expect(ruleMatches(compliant)).toBe(true) - }) - - it('plans update when the rule drifts', () => { - const drift = { ...DESIRED_RULE, status_check_contexts: ['checks'] } - expect(planBranchProtectionRule(drift).action).toBe('update') - expect(planBranchProtectionRule(drift).action).not.toBe('noop') - }) - - it('detects drift in the contexts list (extra context)', () => { - const extra = { - ...DESIRED_RULE, - status_check_contexts: ['CI (PR) / checks*', 'migration-lock'], - } - expect(planBranchProtectionRule(extra).action).toBe('update') - }) - - it('compares status_check_contexts as a set (order does not matter)', () => { - const desired = { ...DESIRED_RULE, status_check_contexts: ['a', 'b'] } - const existing = { ...desired, status_check_contexts: ['b', 'a'] } - expect(ruleMatches(existing, desired)).toBe(true) - expect(ruleMatches({ ...desired, status_check_contexts: ['a'] }, desired)).toBe(false) - }) - - it('the desired glob matches the real observed rollup context, not the push rollup', () => { - // Forgejo matches rule contexts as gobwas globs against the full context - // string — including the ` (event)` suffix — so the observed rollup is - // `CI (PR) / checks (pull_request)`. A literal `checks` would never match. - const glob = DESIRED_RULE.status_check_contexts[0] - const toRegex = (pattern: string) => - new RegExp('^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace('*', '.*') + '$') - const match = toRegex(glob) - expect(match.test('CI (PR) / checks (pull_request)')).toBe(true) - expect(match.test('CI / checks (push)')).toBe(false) - expect(match.test('CI (PR) / checks')).toBe(true) - }) -}) diff --git a/tests/unit/ciClassifyProduction.unit.spec.ts b/tests/unit/ciClassifyProduction.unit.spec.ts deleted file mode 100644 index 6e5b865c..00000000 --- a/tests/unit/ciClassifyProduction.unit.spec.ts +++ /dev/null @@ -1,223 +0,0 @@ -// @vitest-environment node - -import { execFileSync, spawnSync } from 'node:child_process' -import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' - -import { afterAll, beforeAll, describe, expect, it } from 'vitest' - -import { classifyProduction } from '../../scripts/ci-classify-production.mjs' -import { parseDockerignore } from '../../scripts/lib/dockerignore.mjs' - -const repoRoot = join(fileURLToPath(new URL('.', import.meta.url)), '../..') -const cliPath = join(repoRoot, 'scripts/ci-classify-production.mjs') -const realRules = parseDockerignore(readFileSync(join(repoRoot, '.dockerignore'), 'utf8')) - -// OPS71 (fix 2026-08-19): git hooks (husky pre-push) export GIT_DIR/GIT_WORK_TREE -// — inherited here, they redirect ANY git invocation (scratch repo setup AND -// the CLI's `git diff`) to the OUTER repo of the worktree where the suite -// runs, committing into the branch of the cwd worktree (observed: OPS71 and -// S10 worktrees polluted with the scratch commits). Strip them everywhere. -const strippedEnv = { - ...process.env, - GIT_DIR: undefined, - GIT_WORK_TREE: undefined, - GIT_INDEX_FILE: undefined, - GIT_CEILING_DIRECTORIES: undefined, -} - -let fixturesDir: string -let filesFrom: string -let gitRepo: string -let fixtureShas: { baseSha: string; prodSha: string; docsSha: string } - -beforeAll(() => { - fixturesDir = mkdtempSync(join(tmpdir(), 'ci-classify-production-')) - filesFrom = join(fixturesDir, 'changed.txt') - gitRepo = join(fixturesDir, 'repo') - // Hermetic git: every command below targets the scratch repo and ONLY it. - // With the env stripped, a stray resolution FAILS the rev-parse assert - // below instead of polluting the outer repo. - const runGit = (args: string[]) => execFileSync('git', args, { env: strippedEnv }) - runGit(['init', '-q', '-b', 'main', gitRepo]) - const topLevel = execFileSync('git', ['-C', gitRepo, 'rev-parse', '--show-toplevel'], { - encoding: 'utf8', - env: strippedEnv, - }).trim() - if (topLevel !== gitRepo) { - throw new Error( - `scratch git repo inesperado: --show-toplevel=${topLevel} (esperado ${gitRepo}) — abortando para não poluir o repo externo`, - ) - } - writeFileSync(join(gitRepo, '.dockerignore'), 'docs\n') - writeFileSync(join(gitRepo, 'README.md'), 'a\n') - const commit = (message: string) => - runGit([ - '-C', - gitRepo, - '-c', - 'user.name=t', - '-c', - 'user.email=t@t', - 'commit', - '-q', - '-m', - message, - ]) - runGit(['-C', gitRepo, 'add', '-A']) - commit('base') - const baseSha = execFileSync('git', ['-C', gitRepo, 'rev-parse', 'HEAD'], { - encoding: 'utf8', - env: strippedEnv, - }).trim() - execFileSync('mkdir', ['-p', join(gitRepo, 'src'), join(gitRepo, 'docs')]) - writeFileSync(join(gitRepo, 'src', 'a.ts'), 'x\n') - writeFileSync(join(gitRepo, 'docs', 'x.md'), 'x\n') - runGit(['-C', gitRepo, 'add', '-A']) - commit('prod change') - const prodSha = execFileSync('git', ['-C', gitRepo, 'rev-parse', 'HEAD'], { - encoding: 'utf8', - env: strippedEnv, - }).trim() - writeFileSync(join(gitRepo, 'docs', 'y.md'), 'x\n') - runGit(['-C', gitRepo, 'add', '-A']) - commit('docs only') - const docsSha = execFileSync('git', ['-C', gitRepo, 'rev-parse', 'HEAD'], { - encoding: 'utf8', - env: strippedEnv, - }).trim() - fixtureShas = { baseSha, prodSha, docsSha } -}) - -afterAll(() => { - execFileSync('rm', ['-rf', fixturesDir]) -}) - -const fixture = () => fixtureShas - -const runCli = (args: string[], options: { cwd?: string } = {}) => { - const env = { ...strippedEnv, GITHUB_EVENT_NAME: 'schedule' } - const result = spawnSync('node', [cliPath, ...args], { - encoding: 'utf8', - env, - cwd: options.cwd, - }) - return { out: result.stdout.trim(), status: result.status } -} - -const writeChanged = (paths: string[]) => writeFileSync(filesFrom, paths.join('\n')) - -describe('classifyProduction (pure)', () => { - it('keeps paths the docker build context receives', () => { - const { production, kept, skipped } = classifyProduction( - ['src/app/page.tsx', 'docs/plans/x.md', 'package.json'], - realRules, - ) - expect(production).toBe(true) - expect(kept).toEqual(['src/app/page.tsx', 'package.json']) - expect(skipped).toEqual(['docs/plans/x.md']) - }) - - it('reports no production change when every path is ignored', () => { - const { production, kept, skipped } = classifyProduction( - ['docs/ops/teqo-1313-deploy.md', '.agents/skills/foo/SKILL.md'], - realRules, - ) - expect(production).toBe(false) - expect(kept).toEqual([]) - expect(skipped).toHaveLength(2) - }) -}) - -describe('ci-classify-production CLI (OPS65 gate)', () => { - it('reports JSON with production, changed, kept and skipped', () => { - writeChanged(['docs/plans/x.md', 'src/lib/foo.ts']) - const { out, status } = runCli(['--files-from', filesFrom]) - expect(status).toBe(0) - const result = JSON.parse(out) - expect(result.production).toBe(true) - expect(result.kept).toEqual(['src/lib/foo.ts']) - expect(result.skipped).toEqual(['docs/plans/x.md']) - }) - - it('--value prints only the boolean', () => { - writeChanged(['docs/plans/x.md']) - expect(runCli(['--files-from', filesFrom, '--value']).out).toBe('false') - writeChanged(['src/app/page.tsx']) - expect(runCli(['--files-from', filesFrom, '--value']).out).toBe('true') - }) - - it('an empty diff means nothing to do (production=false)', () => { - writeChanged([]) - const { out, status } = runCli(['--files-from', filesFrom]) - expect(status).toBe(0) - const result = JSON.parse(out) - expect(result.production).toBe(false) - expect(result.reason).toContain('nada a fazer') - }) - - it('classifies a real git diff against the deployed revision', () => { - const { baseSha, prodSha, docsSha } = fixture() - // HEAD is the docs-only commit; from the base SHA the diff mixes the - // production change (src/a.ts) with the docs-only commits. - const prod = JSON.parse(runCli(['--deployed', baseSha], { cwd: gitRepo }).out) - expect(prod.production).toBe(true) - expect(prod.changed).toEqual(['docs/x.md', 'docs/y.md', 'src/a.ts']) - expect(prod.kept).toEqual(['src/a.ts']) - expect(prod.skipped).toEqual(['docs/x.md', 'docs/y.md']) - - const docs = JSON.parse(runCli(['--deployed', prodSha], { cwd: gitRepo }).out) - expect(docs.production).toBe(false) - expect(docs.skipped).toEqual(['docs/y.md']) - - const same = JSON.parse(runCli(['--deployed', docsSha], { cwd: gitRepo }).out) - expect(same.production).toBe(false) - }) - - it('keeps the run green when the deployed SHA is unknown (empty)', () => { - const { out, status } = runCli(['--deployed', '']) - expect(status).toBe(0) - const result = JSON.parse(out) - expect(result.production).toBe(true) - expect(result.reason).toContain('sem SHA deployado') - }) - - it('turns the run red when it cannot classify (no input)', () => { - const { out, status } = runCli([]) - expect(status).not.toBe(0) - expect(JSON.parse(out).production).toBe(true) - }) - - it('turns the run red when the deployed revision cannot be diffed', () => { - const { out, status } = runCli(['--deployed', 'ffffffffffffffffffffffffffffffffffffffff'], { - cwd: gitRepo, - }) - expect(status).not.toBe(0) - const result = JSON.parse(out) - expect(result.production).toBe(true) - expect(result.reason).toContain('erro ao obter o diff') - }) - - it('turns the run red when the .dockerignore is unreadable', () => { - writeChanged(['src/app/page.tsx']) - const { out, status } = runCli(['--files-from', filesFrom], { cwd: fixturesDir }) - expect(status).not.toBe(0) - const result = JSON.parse(out) - expect(result.production).toBe(true) - expect(result.reason).toContain('.dockerignore ilegível') - }) - - it('workflow_dispatch always runs the full pipeline', () => { - const env = { ...strippedEnv, GITHUB_EVENT_NAME: 'workflow_dispatch' } - const result = spawnSync('node', [cliPath, '--files-from', filesFrom], { - encoding: 'utf8', - env, - }) - const parsed = JSON.parse(result.stdout) - expect(result.status).toBe(0) - expect(parsed.production).toBe(true) - expect(parsed.reason).toContain('workflow_dispatch') - }) -}) diff --git a/tests/unit/ciSkipInvariants.unit.spec.ts b/tests/unit/ciSkipInvariants.unit.spec.ts index 8570d5f1..593aa34d 100644 --- a/tests/unit/ciSkipInvariants.unit.spec.ts +++ b/tests/unit/ciSkipInvariants.unit.spec.ts @@ -77,8 +77,6 @@ describe('ciSkipInvariants', () => { it('keeps local e2e build artifacts outside the development dist directory', () => { for (const file of [ - '.forgejo/workflows/ci.yml', - '.forgejo/workflows/ci-pr.yml', '.gitignore', '.prettierignore', 'eslint.config.mjs', @@ -96,8 +94,6 @@ describe('ciSkipInvariants', () => { for (const path of [ 'scripts/lib/test-affected-core.mjs', 'scripts/ci-scope.mjs', - 'scripts/ci-classify-production.mjs', - 'scripts/lib/dockerignore.mjs', 'scripts/check-test-locations.mjs', 'scripts/check-plans-only-pr-closes.mjs', 'scripts/lib/plansOnlyClosesGuard.mjs', @@ -111,30 +107,17 @@ describe('ciSkipInvariants', () => { } }) - it('main CI runs in a 30-minute window with a production gate (OPS65)', () => { - const ci = readFileSync(join(repoRoot, '.forgejo/workflows/ci.yml'), 'utf8') - expect(ci).toContain('schedule:') - expect(ci).toContain("cron: '*/30 * * * *'") - expect(ci).not.toMatch(/push:\s*\n\s*branches:\s*\[main\]/) - expect(ci).toContain('workflow_dispatch:') + it('deploy is manual-only — never a push/schedule trigger (OPS71)', () => { + const deploy = readFileSync(join(repoRoot, '.github/workflows/deploy.yml'), 'utf8') + expect(deploy).toContain('workflow_dispatch:') + expect(deploy).not.toMatch(/^\s*push:/m) + expect(deploy).not.toMatch(/^\s*schedule:/m) - // The cheap gate (no pnpm) classifies production change and every - // expensive job consumes it, fail-open on gate errors. The gate is - // main-only: a branch dispatch must not run branch code on the host. - expect(ci).toContain('gate production change') - expect(ci).toContain("if: github.ref == 'refs/heads/main'") - expect(ci).toContain('fetch-depth: 0') - expect(ci).toContain('scripts/ci-classify-production.mjs') - expect(ci).toContain( - "needs.gate.result != 'success' || needs.gate.outputs.production == 'true'", - ) - - // Deploy is double-gated: green rollup AND production change (never - // deploy blind, never deploy with a red suite). - expect(ci).toContain('needs: [checks, gate]') - expect(ci).toContain( - "needs.checks.result == 'success' && needs.gate.outputs.production == 'true'", - ) + // The OPS65 production-change gate died with the Forgejo ci.yml: the + // manual dispatch always runs the full suite (verify) before deploy, so + // no PR workflow may reference the removed classifier. + const ciPr = readFileSync(join(repoRoot, '.github/workflows/ci-pr.yml'), 'utf8') + expect(ciPr).not.toContain('ci-classify-production.mjs') }) it('every campaign domain dir is covered by the e2e affected manifest', () => { diff --git a/tests/unit/dockerignoreMatch.unit.spec.ts b/tests/unit/dockerignoreMatch.unit.spec.ts deleted file mode 100644 index 82f99d0e..00000000 --- a/tests/unit/dockerignoreMatch.unit.spec.ts +++ /dev/null @@ -1,107 +0,0 @@ -// @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 { - compileDockerignoreRule, - isDockerignored, - parseDockerignore, -} from '../../scripts/lib/dockerignore.mjs' - -const repoRoot = join(fileURLToPath(new URL('.', import.meta.url)), '../..') -const dockerignore = readFileSync(join(repoRoot, '.dockerignore'), 'utf8') - -describe('dockerignoreMatch (OPS65 production-change gate)', () => { - it('compiles the moby semantics: basename at any depth, subtree exclusion', () => { - const rules = parseDockerignore('docs\n*.log\n') - expect(isDockerignored('docs', rules)).toBe(true) - expect(isDockerignored('docs/plans/x.md', rules)).toBe(true) - expect(isDockerignored('a/docs/plans/x.md', rules)).toBe(true) - expect(isDockerignored('x.log', rules)).toBe(true) - expect(isDockerignored('a/b/x.log', rules)).toBe(true) - expect(isDockerignored('src/app/page.tsx', rules)).toBe(false) - }) - - it('anchors leading-slash patterns to the context root', () => { - const rules = parseDockerignore('/*.png\n') - expect(isDockerignored('a.png', rules)).toBe(true) - expect(isDockerignored('sub/b.png', rules)).toBe(false) - }) - - it('honours ! negations with last-match-wins', () => { - const rules = parseDockerignore('.env.*\n!.env.example\n') - expect(isDockerignored('.env.local', rules)).toBe(true) - expect(isDockerignored('.env.example', rules)).toBe(false) - expect(isDockerignored('a/.env.example', rules)).toBe(false) - }) - - it('supports globstar and directory-only patterns', () => { - const rules = parseDockerignore('**/fixtures\na/**/b\n') - expect(isDockerignored('x/fixtures/y.json', rules)).toBe(true) - expect(isDockerignored('a/b', rules)).toBe(true) - expect(isDockerignored('a/x/b', rules)).toBe(true) - expect(isDockerignored('a/x/c/b', rules)).toBe(true) - }) - - it('ignores blank lines and comments', () => { - const rules = parseDockerignore('# header\n\ndocs\n') - expect(rules).toHaveLength(1) - expect(isDockerignored('docs/x', rules)).toBe(true) - }) - - it('escapes regex metacharacters in plain patterns', () => { - const rules = parseDockerignore('foo.bar\n') - expect(isDockerignored('foo.bar', rules)).toBe(true) - expect(isDockerignored('fooXbar', rules)).toBe(false) - }) - - it('treats the repo .dockerignore as the source of truth', () => { - const rules = parseDockerignore(dockerignore) - // Anything the image needs is production: changes force the full suite. - for (const path of [ - 'src/app/page.tsx', - 'src/migrations/20260818_x.ts', - 'public/favicon.ico', - 'Dockerfile', - '.dockerignore', - 'AGENTS.md', - 'package.json', - 'pnpm-lock.yaml', - 'scripts/deploy-homeserver.sh', - 'scripts/ci-classify-production.mjs', - '.forgejo/workflows/ci.yml', - '.forgejo/workflows/ci-pr.yml', - 'tests/unit/dockerignoreMatch.unit.spec.ts', - '.env.example', - ]) { - expect(isDockerignored(path, rules), path).toBe(false) - } - // Non-artifact paths skip the suite (quiet days cost seconds). - for (const path of [ - 'docs/plans/x.md', - 'docs/ops/teqo-1313-deploy.md', - '.agents/skills/foo/SKILL.md', - '.env.local', - '.env.test.local', - 'data/tse/x.zip', - 'media/x.jpg', - 'foo.png', - 'x.log', - 'x.tsbuildinfo', - 'node_modules/x', - '.next/x', - '.next-e2e/x', - ]) { - expect(isDockerignored(path, rules), path).toBe(true) - } - }) - - it('keeps the .dockerignore itself explicit (self-referential gate)', () => { - const rule = compileDockerignoreRule('!.dockerignore') - expect(rule.negate).toBe(true) - }) -})