teqo-backup/tests/unit/ciClassifyProduction.unit.spec.ts
Francisco Solla 9504c7ccf6 ops(OPS65): CI do main em janela de 30 min (gate .dockerignore fail-open) + matar agent-pool
- ci.yml: on push removido → schedule */30 + workflow_dispatch (fundido com a
  estrutura de job único sequencial do OPS62 X1)
- job gate (host, main-only, sem pnpm): revision do container rodando no
  homeserver (label org.opencontainers.image.revision — o compose pode mentir
  após rollback falho) + diff + classificador .dockerignore
- fail-open: revision desconhecida → suite roda (verde); incapaz de classificar
  → run vermelho (nunca blackout silencioso); deploy duplo-gated
  (needs: [checks, gate] + if explícito)
- deploy-homeserver.sh: guard 'already deployed' idempotente (mesma revision)
- agent-pool.yml removido; pool dormente (144 pnpm install/dia no tick */10)
- specs: dockerignoreMatch, ciClassifyProduction (incl. git-diff com repo
  scratch e exit codes), pins ciSkipInvariants/deployScript; HIGH_RISK_EXACT
- docs: runbook (janela/gate/verificação ao vivo/falhas), AGENTS.md,
  AGENT-OPS.md (tabela X1 fundida), skills pool dormente, changelog OPS65
- impl plan aprovado; intenção marcada entregue
2026-08-18 21:08:12 -03:00

194 lines
7 KiB
TypeScript

// @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'))
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')
execFileSync('git', ['init', '-q', '-b', 'main', gitRepo])
writeFileSync(join(gitRepo, '.dockerignore'), 'docs\n')
writeFileSync(join(gitRepo, 'README.md'), 'a\n')
const commit = (message: string) =>
execFileSync('git', [
'-C',
gitRepo,
'-c',
'user.name=t',
'-c',
'user.email=t@t',
'commit',
'-q',
'-m',
message,
])
execFileSync('git', ['-C', gitRepo, 'add', '-A'])
commit('base')
const baseSha = execFileSync('git', ['-C', gitRepo, 'rev-parse', 'HEAD'], {
encoding: 'utf8',
}).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')
execFileSync('git', ['-C', gitRepo, 'add', '-A'])
commit('prod change')
const prodSha = execFileSync('git', ['-C', gitRepo, 'rev-parse', 'HEAD'], {
encoding: 'utf8',
}).trim()
writeFileSync(join(gitRepo, 'docs', 'y.md'), 'x\n')
execFileSync('git', ['-C', gitRepo, 'add', '-A'])
commit('docs only')
const docsSha = execFileSync('git', ['-C', gitRepo, 'rev-parse', 'HEAD'], {
encoding: 'utf8',
}).trim()
fixtureShas = { baseSha, prodSha, docsSha }
})
afterAll(() => {
execFileSync('rm', ['-rf', fixturesDir])
})
const fixture = () => fixtureShas
const runCli = (args: string[], options: { cwd?: string } = {}) => {
const env = { ...process.env, 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 = { ...process.env, 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')
})
})