- 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
131 lines
4.2 KiB
JavaScript
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 },
|
|
)
|
|
}
|