mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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:
parent
3ea16243ca
commit
02c1e4f36b
25 changed files with 115 additions and 1494 deletions
|
|
@ -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 }}"
|
||||
|
|
@ -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
|
||||
|
|
@ -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 }}
|
||||
|
|
@ -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
|
||||
|
|
@ -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 }}"
|
||||
|
|
@ -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 }}"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
12
README.md
12
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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-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`.
|
||||
|
|
|
|||
1
docs/changelog/2026-08-19-ops71-fase2.md
Normal file
1
docs/changelog/2026-08-19-ops71-fase2.md
Normal 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.
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
83
docs/plans/ops71-fase2-remover-arquivos-forgejo-impl.md
Normal file
83
docs/plans/ops71-fase2-remover-arquivos-forgejo-impl.md
Normal 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
|
||||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue