feat(OPS71): Fase 2 — remover arquivos do fluxo Forgejo (workflows/scripts/specs) pós-validação do GitHub Actions

14 arquivos removidos: .forgejo/workflows/* (6), forgejo-pr-automerge.mjs,
forgejo-dispatch.mjs, ci-classify-production.mjs, lib/dockerignore.mjs,
lib/branch-protection.mjs + 3 specs órfãs. Consumidores ajustados:
test-affected-core (HIGH_RISK_EXACT), ciSkipInvariants (re-ancorado no
deploy.yml dispatch-only — deploy nunca automático), comentários com
referência morta (gate-ci, github-pr-automerge, agent-pr-ready-automerge).
Docs: AGENTS.md, runbook (rollback via git history), README (era GitHub).
Tracker intocado (forgejo-api/agent-*/issue-transition ficam).
This commit is contained in:
Francisco Solla 2026-08-19 20:58:53 -03:00 committed by github-actions[bot]
parent 3ea16243ca
commit 02c1e4f36b
25 changed files with 115 additions and 1494 deletions

View file

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

View file

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

View file

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

View file

@ -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
# <deployed-rev>..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:-<none>}"
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

View file

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

View file

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

View file

@ -6,8 +6,8 @@
# veto (the CLI skips them). Fork PRs are excluded (the repo is public; a # 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. # merged fork PR would deploy) — same RCE-guard class as the other workflows.
# #
# GitHub's NATIVE auto-merge replaces the Forgejo-era poll CLI # GitHub's NATIVE auto-merge replaces the Forgejo-era poll CLI (removed in
# (forgejo-pr-automerge.mjs): `enablePullRequestAutoMerge` (GraphQL, REBASE — # OPS71 Fase 2): `enablePullRequestAutoMerge` (GraphQL, REBASE —
# the repo's canonical merge style) is server-side — GitHub only merges when # 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 # 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 # green and the PR is mergeable. The OPS64 "rollup can lie" defense is

File diff suppressed because one or more lines are too long

View file

@ -6,16 +6,16 @@
> **Cheatsheet de operação agentic (humanos)** — o fluxo em 5 linhas: > **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`). > 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. > 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. Com mudança de produção → o job `deploy` publica no homeserver (`jorgesolla1313.com.br`, self-hosted) — migrações aplicadas antes do rollout. > 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 Forgejo; envs de prod em `~/stack/teqo-1313.env` no homeserver. Branch protection de `main` já aplicada (reaplicar: `pnpm configure:branch-protection`). > 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`. > 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`. > 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. > **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: `.forgejo/workflows/ci-pr.yml` + `ci.yml`. > 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. Teqo starts as the official digital platform for **deputado Jorge Solla** and evolves into a **white-label civic engagement platform** for politicians in Brazil.

View file

@ -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 <sha>` **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** — 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 <sha>` **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/<filename>` (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** — 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/<filename>` (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/<filename>` 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 `<slug>.<ext>`), 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`. **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/<filename>` 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 `<slug>.<ext>`), 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`.

View file

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

View file

@ -84,8 +84,10 @@ secrets de integração: `FORGEJO_API_TOKEN` (flips pós-merge) e
deploy manual de validação. deploy manual de validação.
Rollback do cutover (se o GitHub falhar cedo): religar o runner do Forgejo e 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 abrir PRs no Forgejo a partir do main congelado — os workflows de
até a Fase 2) — remotes: `git remote set-url origin ssh://git@192.168.15.142:2222/fsolla/teqo.git`. `.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) ## Rollback (do deploy)

View file

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

View file

@ -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 <sha> [--value]
* node scripts/ci-classify-production.mjs --files-from <file> [--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 },
)
}

View file

@ -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 <workflow-file> <ref> [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 <workflow-file> [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)
}

View file

@ -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 <N>
*/
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 <N>')
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)
}

View file

@ -1,9 +1,9 @@
#!/usr/bin/env node #!/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 * 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 → * (OPS59): it runs in CI — selected/afetado no PR (`ci-pr.yml` → `checks`)
* `checks` → `deploy`). Local optional feedback: `pnpm test:e2e:affected`. * e full no verify do `deploy.yml`. Local optional feedback: `pnpm test:e2e:affected`.
* Docs guards (OPS63): the `docs-guards` checks (changelog append-only, * Docs guards (OPS63): the `docs-guards` checks (changelog append-only,
* aggregate sync, conflict markers) also run here — cheap git diffs, same * aggregate sync, conflict markers) also run here — cheap git diffs, same
* scripts the CI job runs (the `changelog-rewrite:` escape stays CI-only, * scripts the CI job runs (the `changelog-rewrite:` escape stays CI-only,

View file

@ -4,9 +4,9 @@
* .github/workflows/agent-pr-ready-automerge.yml with the built-in * .github/workflows/agent-pr-ready-automerge.yml with the built-in
* GITHUB_TOKEN (permissions: contents+pull-requests write). * GITHUB_TOKEN (permissions: contents+pull-requests write).
* *
* Unlike the Forgejo-era CLI (forgejo-pr-automerge.mjs), there is NO poll * Unlike the Forgejo-era automerger (removed in OPS71 Fase 2), there is NO
* loop: `enablePullRequestAutoMerge` is server-side — GitHub only merges when * poll loop: `enablePullRequestAutoMerge` is server-side — GitHub only merges
* every required check (incl. `CI (PR) / checks`) is green and the PR is * 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 * mergeable. The OPS64 "never trust the rollup" pin is structural here: a
* single sequential job posts one honest check-run, and the server enforces * single sequential job posts one honest check-run, and the server enforces
* it. Draft policy (OPS57) preserved: only `cursor/*` heads are marked * it. Draft policy (OPS57) preserved: only `cursor/*` heads are marked

View file

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

View file

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

View file

@ -29,8 +29,6 @@ export const HIGH_RISK_EXACT = new Set([
'scripts/e2e-affected.mjs', 'scripts/e2e-affected.mjs',
'scripts/run-e2e-affected.mjs', 'scripts/run-e2e-affected.mjs',
'scripts/ci-scope.mjs', 'scripts/ci-scope.mjs',
'scripts/ci-classify-production.mjs',
'scripts/lib/dockerignore.mjs',
'scripts/check-test-locations.mjs', 'scripts/check-test-locations.mjs',
'scripts/check-plans-only-pr-closes.mjs', 'scripts/check-plans-only-pr-closes.mjs',
'scripts/lib/plansOnlyClosesGuard.mjs', 'scripts/lib/plansOnlyClosesGuard.mjs',

View file

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

View file

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

View file

@ -77,8 +77,6 @@ describe('ciSkipInvariants', () => {
it('keeps local e2e build artifacts outside the development dist directory', () => { it('keeps local e2e build artifacts outside the development dist directory', () => {
for (const file of [ for (const file of [
'.forgejo/workflows/ci.yml',
'.forgejo/workflows/ci-pr.yml',
'.gitignore', '.gitignore',
'.prettierignore', '.prettierignore',
'eslint.config.mjs', 'eslint.config.mjs',
@ -96,8 +94,6 @@ describe('ciSkipInvariants', () => {
for (const path of [ for (const path of [
'scripts/lib/test-affected-core.mjs', 'scripts/lib/test-affected-core.mjs',
'scripts/ci-scope.mjs', 'scripts/ci-scope.mjs',
'scripts/ci-classify-production.mjs',
'scripts/lib/dockerignore.mjs',
'scripts/check-test-locations.mjs', 'scripts/check-test-locations.mjs',
'scripts/check-plans-only-pr-closes.mjs', 'scripts/check-plans-only-pr-closes.mjs',
'scripts/lib/plansOnlyClosesGuard.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)', () => { it('deploy is manual-only — never a push/schedule trigger (OPS71)', () => {
const ci = readFileSync(join(repoRoot, '.forgejo/workflows/ci.yml'), 'utf8') const deploy = readFileSync(join(repoRoot, '.github/workflows/deploy.yml'), 'utf8')
expect(ci).toContain('schedule:') expect(deploy).toContain('workflow_dispatch:')
expect(ci).toContain("cron: '*/30 * * * *'") expect(deploy).not.toMatch(/^\s*push:/m)
expect(ci).not.toMatch(/push:\s*\n\s*branches:\s*\[main\]/) expect(deploy).not.toMatch(/^\s*schedule:/m)
expect(ci).toContain('workflow_dispatch:')
// The cheap gate (no pnpm) classifies production change and every // The OPS65 production-change gate died with the Forgejo ci.yml: the
// expensive job consumes it, fail-open on gate errors. The gate is // manual dispatch always runs the full suite (verify) before deploy, so
// main-only: a branch dispatch must not run branch code on the host. // no PR workflow may reference the removed classifier.
expect(ci).toContain('gate production change') const ciPr = readFileSync(join(repoRoot, '.github/workflows/ci-pr.yml'), 'utf8')
expect(ci).toContain("if: github.ref == 'refs/heads/main'") expect(ciPr).not.toContain('ci-classify-production.mjs')
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'",
)
}) })
it('every campaign domain dir is covered by the e2e affected manifest', () => { it('every campaign domain dir is covered by the e2e affected manifest', () => {

View file

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