teqo/scripts/ci-classify-production.mjs
Francisco Solla 9d7e4a1414 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
- 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
2026-08-18 20:06:22 -03:00

131 lines
4.2 KiB
JavaScript

#!/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 FAILS CLOSED toward "production": dispatch (explicit
* operator intent), an unknown deployed SHA, an empty diff vs the deployed
* SHA being unreadable — any doubt runs the full suite.
*
* 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`). Errors never fail the job: they
* log to stderr, report production=true and exit 0 — the suite runs.
*/
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))
}
const failOpen = (reason) => report(true, reason, { changed: [], kept: [], skipped: [] })
// 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) {
changed = execFileSync('git', ['diff', '--name-only', deployed, 'HEAD', '--'], {
encoding: 'utf8',
})
.split('\n')
.filter(Boolean)
} else {
failOpen('sem SHA deployado nem files-from')
return
}
} catch (error) {
failOpen(`erro ao obter o diff (${error instanceof Error ? error.message : error})`)
return
}
if (changed.length === 0) {
report(false, 'deployed_sha == HEAD (nada a fazer)', {
changed: [],
kept: [],
skipped: [],
})
return
}
let rules
try {
rules = parseDockerignore(readFileSync('.dockerignore', 'utf8'))
} catch (error) {
failOpen(`.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 },
)
}