mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-20 19:29:58 +08:00
Add release approval gate
This commit is contained in:
committed by
Affaan Mustafa
parent
2c0d226439
commit
9819626459
@@ -18,6 +18,7 @@ const REQUIRED_ARTIFACTS = [
|
||||
'docs/architecture/observability-readiness.md',
|
||||
'docs/architecture/progress-sync-contract.md',
|
||||
'scripts/preview-pack-smoke.js',
|
||||
'scripts/release-approval-gate.js',
|
||||
`${RELEASE_DIR}/release-notes.md`,
|
||||
`${RELEASE_DIR}/quickstart.md`,
|
||||
`${RELEASE_DIR}/launch-checklist.md`,
|
||||
@@ -47,6 +48,7 @@ const REQUIRED_VERIFICATION_COMMANDS = [
|
||||
'git status --short --branch',
|
||||
'node scripts/platform-audit.js --json',
|
||||
'npm run preview-pack:smoke',
|
||||
'npm run release:approval-gate -- --format json',
|
||||
'npm run release:video-suite -- --format json',
|
||||
'npm run harness:adapters -- --check',
|
||||
'npm run harness:audit -- --format json',
|
||||
|
||||
553
scripts/release-approval-gate.js
Normal file
553
scripts/release-approval-gate.js
Normal file
@@ -0,0 +1,553 @@
|
||||
#!/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,
|
||||
};
|
||||
Reference in New Issue
Block a user