- ci.yml: on push removido; schedule */30 + workflow_dispatch - job check (host, sem pnpm): SHA deployado via ssh + diff + classificador .dockerignore (scripts/ci-classify-production.mjs, lib/dockerignore.mjs) - suite fail-open no gate; deploy duplo-gated (checks + production) - deploy-homeserver.sh: guard 'already deployed' (idempotente) - agent-pool.yml removido; pool dormente - specs: dockerignoreMatch, ciClassifyProduction, pins ciSkipInvariants/deployScript - docs: runbook, AGENTS.md, AGENT-OPS.md, changelog
116 lines
4 KiB
TypeScript
116 lines
4 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { execFileSync } 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
|
|
|
|
beforeAll(() => {
|
|
fixturesDir = mkdtempSync(join(tmpdir(), 'ci-classify-production-'))
|
|
filesFrom = join(fixturesDir, 'changed.txt')
|
|
})
|
|
|
|
afterAll(() => {
|
|
execFileSync('rm', ['-rf', fixturesDir])
|
|
})
|
|
|
|
const runCli = (args: string[]) => {
|
|
const env = { ...process.env, GITHUB_EVENT_NAME: 'schedule' }
|
|
return execFileSync('node', [cliPath, ...args], { encoding: 'utf8', env })
|
|
}
|
|
|
|
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 result = JSON.parse(runCli(['--files-from', filesFrom]))
|
|
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']).trim()).toBe('false')
|
|
writeChanged(['src/app/page.tsx'])
|
|
expect(runCli(['--files-from', filesFrom, '--value']).trim()).toBe('true')
|
|
})
|
|
|
|
it('an empty diff means nothing to do (production=false)', () => {
|
|
writeChanged([])
|
|
const result = JSON.parse(runCli(['--files-from', filesFrom]))
|
|
expect(result.production).toBe(false)
|
|
expect(result.reason).toContain('nada a fazer')
|
|
})
|
|
|
|
it('fails open when the deployed SHA is unknown', () => {
|
|
const result = JSON.parse(runCli(['--deployed', '']))
|
|
expect(result.production).toBe(true)
|
|
expect(result.reason).toContain('sem SHA deployado')
|
|
})
|
|
|
|
it('fails open without any input at all', () => {
|
|
const result = JSON.parse(runCli([]))
|
|
expect(result.production).toBe(true)
|
|
})
|
|
|
|
it('fails open on an unreadable .dockerignore', () => {
|
|
writeChanged(['src/app/page.tsx'])
|
|
const env = { ...process.env, GITHUB_EVENT_NAME: 'schedule' }
|
|
const out = execFileSync('node', [cliPath, '--files-from', filesFrom], {
|
|
encoding: 'utf8',
|
|
env,
|
|
cwd: fixturesDir,
|
|
})
|
|
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 out = execFileSync('node', [cliPath, '--files-from', filesFrom], {
|
|
encoding: 'utf8',
|
|
env,
|
|
})
|
|
const result = JSON.parse(out)
|
|
expect(result.production).toBe(true)
|
|
expect(result.reason).toContain('workflow_dispatch')
|
|
})
|
|
})
|