#!/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 [--value] * node scripts/ci-classify-production.mjs --files-from [--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 }, ) }