#!/usr/bin/env node 'use strict'; const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const RELEASE = '2.0.0-rc.1'; const RELEASE_DIR = `docs/releases/${RELEASE}`; const SCHEMA_VERSION = 'ecc.release-approval-gate.v1'; const SCRIPT_PATH = 'scripts/release-approval-gate.js'; const OWNER_PACKET_PATH = `${RELEASE_DIR}/owner-approval-packet-2026-05-19.md`; const URL_LEDGER_PATH = `${RELEASE_DIR}/release-url-ledger-2026-05-19.md`; const PREVIEW_MANIFEST_PATH = `${RELEASE_DIR}/preview-pack-manifest.md`; const REQUIRED_COMMAND = 'npm run release:approval-gate -- --format json'; const REQUIRED_DECISIONS = [ { id: 'github-prerelease', label: 'GitHub prerelease', }, { id: 'npm-next-publish', label: 'npm `next` publish', }, { id: 'claude-plugin-tag', label: 'Claude plugin tag', }, { id: 'codex-repo-marketplace', label: 'Codex repo marketplace', }, { id: 'ecc-tools-billing-language', label: 'ECC Tools billing language', }, { id: 'video-upload', label: 'Video upload', }, { id: 'social-and-longform', label: 'X, LinkedIn, GitHub Discussion, longform', }, { id: 'outbound-growth', label: 'Sponsor, partner, consulting, conference, podcast outreach', }, ]; const REQUIRED_URL_SURFACES = [ { id: 'github-prerelease-url', label: 'GitHub prerelease URL', exampleUrl: 'https://github.com/affaan-m/ECC/releases/tag/v2.0.0-rc.1', }, { id: 'npm-rc-package-url', label: 'npm rc package URL', exampleUrl: 'https://www.npmjs.com/package/ecc-universal/v/2.0.0-rc.1', }, { id: 'claude-plugin-tag-url', label: 'Claude plugin tag URL', exampleUrl: 'https://github.com/affaan-m/ECC/releases/tag/ecc--v2.0.0-rc.1', }, { id: 'codex-repo-marketplace-evidence', label: 'Codex repo-marketplace evidence', exampleUrl: 'https://github.com/affaan-m/ECC/tree/v2.0.0-rc.1/.codex-plugin', }, { id: 'primary-launch-video-url', label: 'Primary launch video URL', exampleUrl: 'https://x.com/affaanmustafa/status/0000000000000000000', }, { id: 'short-clip-urls', label: 'Short clip URLs', exampleUrl: 'https://x.com/affaanmustafa/status/0000000000000000001', }, { id: 'ecc-tools-billing-readiness-url', label: 'ECC Tools billing/readiness URL', exampleUrl: 'https://github.com/ECC-Tools', }, ]; const ANNOUNCEMENT_FILES = [ `${RELEASE_DIR}/release-notes.md`, `${RELEASE_DIR}/x-thread.md`, `${RELEASE_DIR}/linkedin-post.md`, `${RELEASE_DIR}/article-outline.md`, `${RELEASE_DIR}/partner-sponsor-talks-pack.md`, 'docs/business/social-launch-copy.md', ]; function usage() { console.log([ 'Usage: node scripts/release-approval-gate.js [--format ] [--root ]', '', 'Final approval gate for ECC 2.0 rc.1 publication and outbound actions.', '', 'Options:', ' --format Output format (default: text)', ' --json Alias for --format json', ' --root Repository root to inspect (default: cwd)', ' --help, -h Show this help', ].join('\n')); } function readArgValue(args, index, flagName) { const value = args[index + 1]; if (!value || value.startsWith('--')) { throw new Error(`${flagName} requires a value`); } return value; } function parseArgs(argv) { const args = argv.slice(2); const parsed = { format: 'text', help: false, root: path.resolve(process.cwd()), }; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === '--help' || arg === '-h') { parsed.help = true; continue; } if (arg === '--json') { parsed.format = 'json'; continue; } if (arg === '--format') { parsed.format = readArgValue(args, index, arg).toLowerCase(); index += 1; continue; } if (arg.startsWith('--format=')) { parsed.format = arg.slice('--format='.length).toLowerCase(); continue; } if (arg === '--root') { parsed.root = path.resolve(readArgValue(args, index, arg)); index += 1; continue; } if (arg.startsWith('--root=')) { parsed.root = path.resolve(arg.slice('--root='.length)); continue; } throw new Error(`Unknown argument: ${arg}`); } if (!['text', 'json'].includes(parsed.format)) { throw new Error(`Invalid format: ${parsed.format}. Use text or json.`); } return parsed; } function readText(rootDir, relativePath) { try { return fs.readFileSync(path.join(rootDir, relativePath), 'utf8'); } catch (_error) { return ''; } } function fileExists(rootDir, relativePath) { return fs.existsSync(path.join(rootDir, relativePath)); } function safeParseJson(text) { if (!text.trim()) { return null; } try { return JSON.parse(text); } catch (_error) { return null; } } function normalizeLabel(value) { return String(value) .replace(/[`*_]/g, '') .replace(/\s+/g, ' ') .trim() .toLowerCase(); } function normalizeState(value) { return String(value) .replace(/[`*_]/g, '') .replace(/\s+/g, ' ') .trim() .toLowerCase(); } function splitMarkdownRow(row) { const trimmed = row.trim(); if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) { return []; } return trimmed .slice(1, -1) .split('|') .map(cell => cell.trim()); } function parseDecisionRegister(packet) { const decisions = new Map(); for (const line of packet.split('\n')) { const cells = splitMarkdownRow(line); if (cells.length < 4) { continue; } const [decision, state] = cells; const normalizedDecision = normalizeLabel(decision); if ( !normalizedDecision || normalizedDecision === 'decision' || /^-+$/.test(normalizedDecision) ) { continue; } decisions.set(normalizedDecision, normalizeState(state)); } return decisions; } function isApproved(state) { return state === 'approve' || state === 'approved'; } function lineNumberForIndex(text, index) { return text.slice(0, index).split('\n').length; } function findAnnouncementOffenders(rootDir, relativePaths) { const offenders = []; const privatePathPattern = /\/Users\/(?!\.\.\.)[A-Za-z0-9._-]+|\/home\/(?!user|runner)[A-Za-z0-9._-]+/g; const anglePlaceholderPattern = /<(?!(?:https?:\/\/|mailto:|#))[^>\n]*(?:url|link|todo|tbd|placeholder)[^>\n]*>/gi; const barePlaceholderPattern = /\bTODO\b|\bTBD\b|\bPLACEHOLDER\b/g; for (const relativePath of relativePaths) { const text = readText(rootDir, relativePath); if (!text) { offenders.push({ path: relativePath, line: 1, marker: 'missing file', }); continue; } for (const match of text.matchAll(privatePathPattern)) { offenders.push({ path: relativePath, line: lineNumberForIndex(text, match.index), marker: match[0], }); } for (const match of text.matchAll(anglePlaceholderPattern)) { offenders.push({ path: relativePath, line: lineNumberForIndex(text, match.index), marker: match[0], }); } for (const match of text.matchAll(barePlaceholderPattern)) { offenders.push({ path: relativePath, line: lineNumberForIndex(text, match.index), marker: match[0], }); } } return offenders; } function ledgerBlockers(ledger) { const blockers = []; if (/^##\s+Approval-Gated URLs\s*$/im.test(ledger)) { blockers.push('approval-gated URL section still present'); } for (const [pattern, label] of [ [/not published yet/i, 'not-published marker still present'], [/must return/i, 'must-return readback marker still present'], [/Gate before use/i, 'gate-before-use column still present'], [/\bpending\b/i, 'pending marker still present'], [/\bblocked\b/i, 'blocked marker still present'], ]) { if (pattern.test(ledger)) { blockers.push(label); } } return blockers; } function makeCheck(id, status, evidence, fix) { return { id, status, evidence, fix: status === 'pass' ? '' : fix, }; } function topActionsForChecks(checks) { const actions = []; const failedIds = new Set(checks.filter(check => check.status !== 'pass').map(check => check.id)); if (failedIds.has('release-approval-script-registered')) { actions.push('Wire release:approval-gate into package.json, package files, and the preview-pack manifest.'); } if (failedIds.has('owner-decisions-approved')) { actions.push('Approve, defer, or block each owner decision row explicitly after final evidence is rerun from the release commit.'); } if (failedIds.has('release-url-ledger-finalized')) { actions.push('Replace approval-gated URL ledger rows with live readback URLs from the approved release, package, plugin, video, and billing surfaces.'); } if (failedIds.has('final-evidence-command-listed')) { actions.push('Add release:approval-gate to the final evidence command lists before asking for publication approval.'); } if (failedIds.has('announcement-copy-finalized')) { actions.push('Remove unresolved placeholders and private local paths from launch, social, and outbound copy.'); } if (failedIds.has('public-action-guard-present')) { actions.push('Restore the explicit no-outbound/no-publish authorization boundary in the owner packet.'); } return actions; } function buildReport(options = {}) { const rootDir = path.resolve(options.root || process.cwd()); const packageJson = safeParseJson(readText(rootDir, 'package.json')) || {}; const packageScripts = packageJson.scripts || {}; const packageFiles = Array.isArray(packageJson.files) ? packageJson.files : []; const ownerPacket = readText(rootDir, OWNER_PACKET_PATH); const ledger = readText(rootDir, URL_LEDGER_PATH); const manifest = readText(rootDir, PREVIEW_MANIFEST_PATH); const decisions = parseDecisionRegister(ownerPacket); const missingDecisions = []; const unapprovedDecisions = []; for (const decision of REQUIRED_DECISIONS) { const state = decisions.get(normalizeLabel(decision.label)); if (!state) { missingDecisions.push(decision.label); } else if (!isApproved(state)) { unapprovedDecisions.push(`${decision.label}=${state}`); } } const missingUrlSurfaces = REQUIRED_URL_SURFACES .filter(surface => !ledger.includes(surface.label)) .map(surface => surface.label); const urlBlockers = ledgerBlockers(ledger); const announcementOffenders = findAnnouncementOffenders(rootDir, ANNOUNCEMENT_FILES); const commandListedIn = [ ownerPacket.includes(REQUIRED_COMMAND) ? OWNER_PACKET_PATH : '', ledger.includes(REQUIRED_COMMAND) ? URL_LEDGER_PATH : '', manifest.includes(REQUIRED_COMMAND) ? PREVIEW_MANIFEST_PATH : '', ].filter(Boolean); const checks = [ makeCheck( 'release-approval-script-registered', packageScripts['release:approval-gate'] === `node ${SCRIPT_PATH}` && packageFiles.includes(SCRIPT_PATH) && fileExists(rootDir, SCRIPT_PATH) && manifest.includes(`\`${SCRIPT_PATH}\``) && manifest.includes(REQUIRED_COMMAND) ? 'pass' : 'fail', 'package script, npm package file entry, local script, and preview-pack manifest reference', 'Add release:approval-gate to package scripts, package files, and preview-pack-manifest.md.' ), makeCheck( 'owner-decisions-approved', missingDecisions.length === 0 && unapprovedDecisions.length === 0 ? 'pass' : 'fail', missingDecisions.length === 0 && unapprovedDecisions.length === 0 ? `${REQUIRED_DECISIONS.length} owner decision rows are approved` : `missing decisions: ${missingDecisions.join(', ') || 'none'}; pending decisions: ${unapprovedDecisions.join(', ') || 'none'}`, 'Set every required owner decision row to approve only after the final release evidence has been rerun.' ), makeCheck( 'release-url-ledger-finalized', ledger && missingUrlSurfaces.length === 0 && urlBlockers.length === 0 ? 'pass' : 'fail', ledger && missingUrlSurfaces.length === 0 && urlBlockers.length === 0 ? `${REQUIRED_URL_SURFACES.length} final URL surfaces are recorded without approval-gated blockers` : `missing URL surfaces: ${missingUrlSurfaces.join(', ') || 'none'}; blockers: ${urlBlockers.join(', ') || 'none'}`, 'Regenerate the release URL ledger after the approved publication actions and record live readback URLs.' ), makeCheck( 'final-evidence-command-listed', commandListedIn.length === 3 ? 'pass' : 'fail', commandListedIn.length === 3 ? `${REQUIRED_COMMAND} is listed in owner packet, URL ledger, and preview manifest` : `${REQUIRED_COMMAND} listed in: ${commandListedIn.join(', ') || 'none'}`, 'List release:approval-gate in every final evidence command block.' ), makeCheck( 'announcement-copy-finalized', announcementOffenders.length === 0 ? 'pass' : 'fail', announcementOffenders.length === 0 ? `${ANNOUNCEMENT_FILES.length} launch/outbound copy files have no placeholders or private paths` : `offenders: ${announcementOffenders.map(item => `${item.path}:${item.line}`).join(', ')}`, 'Replace placeholders with live URLs and remove private local paths from launch/outbound copy.' ), makeCheck( 'public-action-guard-present', ownerPacket.includes( 'No outbound email, personal-account post, package publish, plugin tag, or billing announcement is authorized by this packet alone.' ) ? 'pass' : 'fail', 'owner packet preserves the explicit no-public-action authorization boundary', 'Restore the owner-packet sentence that blocks outbound, posts, package publish, plugin tags, and billing announcements.' ), ]; const failed = checks.filter(check => check.status !== 'pass'); const digest = crypto .createHash('sha256') .update(JSON.stringify(checks.map(check => [check.id, check.status, check.evidence]))) .digest('hex') .slice(0, 12); return { schema_version: SCHEMA_VERSION, release: RELEASE, ready: failed.length === 0, digest, summary: { passed: checks.length - failed.length, failed: failed.length, total: checks.length, }, top_actions: topActionsForChecks(checks), checks, }; } function renderText(report) { const lines = [ 'ECC release approval gate', `Release: ${report.release}`, `Ready: ${report.ready ? 'yes' : 'no'}`, `Digest: ${report.digest}`, '', 'Checks:', ]; for (const check of report.checks) { lines.push(`- ${check.status} ${check.id}: ${check.evidence}`); if (check.fix) { lines.push(` fix: ${check.fix}`); } } if (report.top_actions.length > 0) { lines.push(''); lines.push('Top actions:'); for (const action of report.top_actions) { lines.push(`- ${action}`); } } lines.push(''); lines.push(`Passed: ${report.summary.passed}`); lines.push(`Failed: ${report.summary.failed}`); return `${lines.join('\n')}\n`; } function main() { let parsed; try { parsed = parseArgs(process.argv); } catch (error) { console.error(`Error: ${error.message}`); process.exit(1); } if (parsed.help) { usage(); return; } const report = buildReport({ root: parsed.root }); if (parsed.format === 'json') { console.log(JSON.stringify(report, null, 2)); } else { process.stdout.write(renderText(report)); } if (!report.ready) { process.exit(2); } } if (require.main === module) { main(); } module.exports = { ANNOUNCEMENT_FILES, REQUIRED_COMMAND, REQUIRED_DECISIONS, REQUIRED_URL_SURFACES, buildReport, parseArgs, renderText, };