Files
everything-claude-code/scripts/release-approval-gate.js
2026-05-19 18:18:54 -04:00

554 lines
16 KiB
JavaScript

#!/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 <text|json>] [--root <dir>]',
'',
'Final approval gate for ECC 2.0 rc.1 publication and outbound actions.',
'',
'Options:',
' --format <text|json> Output format (default: text)',
' --json Alias for --format json',
' --root <dir> 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,
};