Add release approval gate

This commit is contained in:
Affaan Mustafa
2026-05-19 18:05:46 -04:00
committed by Affaan Mustafa
parent 2c0d226439
commit 9819626459
14 changed files with 928 additions and 7 deletions

View File

@@ -85,7 +85,7 @@ As of 2026-05-19:
current May 19 queue-zero state, canonical ECC identity merge, release video
suite gate, partner/sponsor/talk outreach pack, owner approval packet
(`owner-approval-packet-2026-05-19.md`), preview-pack smoke digest
`790430aef4a8`, local 2560-test suite, PR #2001 merge and GitHub Actions run
`531328aaaa53`, local 2560-test suite, PR #2001 merge and GitHub Actions run
`26102500291` success, PR #2002's owner-approval dashboard gate refresh and
GitHub Actions run `26103853507`, PR #2004's Linear readiness evidence sync
and GitHub Actions run `26105012698`, plus PR #2005's post-PR #2004
@@ -755,7 +755,7 @@ is not complete unless the evidence column exists and has been freshly verified.
| Manage repository discussions | Repo-family discussion recheck plus response playbook | Platform audit reports 0 discussion maintainer-touch gaps and 0 answerable Q&A missing accepted answers; trunk has 59 total discussions after #2003 was routed with a maintainer response; `docs/architecture/discussion-response-playbook.md` distinguishes support, maintainer coordination, stale/concluded, release, informational, and security-sensitive response paths | Complete |
| Manage PR discussions | PR review/comment closure plus merge/close state | ECC #1990-#2011 merged through the harness audit, canonical identity, release video suite, growth outreach, evidence refresh, visual QA, suite-count, owner-approval packet, owner-approval dashboard gate, Linear readiness evidence, supply-chain evidence gate, per-project Claude Code adapter, continuous-learning project-registry hygiene, and GateGuard quoted git introspection batch; no open tracked PRs remain | Complete |
| Salvage useful stale work | `docs/stale-pr-salvage-ledger.md` plus `docs/legacy-artifact-inventory.md` | Ledger records salvaged, superseded, skipped, and manual-review tails; #1815-#1818 added cost tracking, skill scout, frontend design guidance, code-reviewer false-positive guardrails, and the May 12 gap pass; #1687, #1609, #1563, #1564, and #1565 localization tails are attached to Linear ITO-55 for language-owner review and no automatic import remains release-blocking | Complete; repeat legacy scan before release |
| ECC 2.0 preview pack ready | Release docs, quickstart, publication readiness, release notes | `docs/releases/2.0.0-rc.1/` and readiness docs are in-tree; May 19 evidence records queue-zero state, canonical ECC identity, release video suite, growth outreach pack, owner approval packet, local 2560-test suite, PR #2001 merge and GitHub Actions run `26102500291`, PR #2002 owner-approval dashboard gate refresh and GitHub Actions run `26103853507`, PR #2004 Linear readiness evidence sync and GitHub Actions run `26105012698`, PR #2008 supply-chain evidence gate CI run `26108473648`, post-PR #2006 main CI run `26109953093`, PR #2009 project-registry hygiene GitHub Actions run `26111313938`, post-PR #2009 main CI run `26111946778`, post-PR #2011 GateGuard main CI run `26113695068`, May 19 operator dashboard, `owner-approval-packet-2026-05-19.md`, and preview-pack smoke digest `790430aef4a8` | Needs final release approval |
| ECC 2.0 preview pack ready | Release docs, quickstart, publication readiness, release notes | `docs/releases/2.0.0-rc.1/` and readiness docs are in-tree; May 19 evidence records queue-zero state, canonical ECC identity, release video suite, growth outreach pack, owner approval packet, local 2560-test suite, PR #2001 merge and GitHub Actions run `26102500291`, PR #2002 owner-approval dashboard gate refresh and GitHub Actions run `26103853507`, PR #2004 Linear readiness evidence sync and GitHub Actions run `26105012698`, PR #2008 supply-chain evidence gate CI run `26108473648`, post-PR #2006 main CI run `26109953093`, PR #2009 project-registry hygiene GitHub Actions run `26111313938`, post-PR #2009 main CI run `26111946778`, post-PR #2011 GateGuard main CI run `26113695068`, May 19 operator dashboard, `owner-approval-packet-2026-05-19.md`, `release-approval-gate.js`, and preview-pack smoke digest `531328aaaa53` | Needs final release approval |
| Hermes specialized skills included safely | Hermes setup/import docs and sanitized skill surface | Hermes setup and import playbook are public; secrets stay local | Needs final release review |
| Naming and rename readiness | Naming matrix across package/plugin/docs/social surfaces | `docs/releases/2.0.0-rc.1/naming-and-publication-matrix.md` records current package, repo, Claude plugin, Codex plugin, OpenCode, and npm availability evidence | Complete for rc.1; post-rc rename remains future work |
| Claude and Codex plugin publication | Contact/submission path with required artifacts and status | Publication readiness, naming matrix, and May 12 dry-run evidence document plugin validation, clean-checkout Claude tag/install smoke, and Codex marketplace CLI shape | Needs explicit approval for real tag/push and marketplace submission |

View File

@@ -21,6 +21,9 @@
- verify package, plugin, marketplace, OpenCode, and agent metadata stays at `2.0.0-rc.1`
- verify `ecc2/Cargo.toml` stays at `0.1.0` for rc.1; `ecc2/` remains an alpha control-plane scaffold
- complete `publication-readiness.md` with fresh evidence before any GitHub release, npm publish, plugin submission, or announcement post
- run `npm run release:approval-gate -- --format json` after owner approvals
and live URL readbacks are recorded; it must return ready true before any
publish, upload, social, or outbound action
- rerun the release name/plugin publication checklist before creating a
GitHub prerelease, publishing npm, pushing Claude plugin tags, recording the
Codex marketplace path, or posting public copy

View File

@@ -15,7 +15,8 @@ Source commit for the clean evidence baseline this packet extends:
| Evidence | Current recorded state | Repeat before approval |
| --- | --- | --- |
| Platform audit | ready true, 0 open PRs, 0 open issues, 0 discussion gaps, 0 dirty files | yes |
| Preview pack smoke | ready true, digest `790430aef4a8`, 5/5 checks | yes |
| Preview pack smoke | ready true, digest `531328aaaa53`, 5/5 checks | yes |
| Release approval gate | ready false, digest `ef8f49f727b7`, 4/6 checks pass; owner decisions and live URL readbacks pending | yes |
| Video suite | ready true, 15/15 source assets, 13/13 suite artifacts, 12/12 publish candidates | yes |
| Release surface tests | 27/27 passed after this packet was added | yes |
| Full local suite | 2560/2560 passed after PR #2011 was prepared; focused GateGuard regression passed 91/91 again on current `main` | yes |
@@ -56,6 +57,7 @@ Run these from the exact release commit before approving publication:
git status --short --branch
node scripts/platform-audit.js --json
npm run preview-pack:smoke -- --format json
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

View File

@@ -17,6 +17,7 @@ surfaces, or posting announcements.
| `docs/architecture/observability-readiness.md` | Local operator-readiness gate | Verified by `npm run observability:ready` |
| `docs/architecture/progress-sync-contract.md` | GitHub, Linear, handoff, roadmap, and work-item sync boundary | Checked by `node scripts/platform-audit.js --json` |
| `scripts/preview-pack-smoke.js` | Deterministic preview-pack smoke gate | Verified by `npm run preview-pack:smoke` |
| `scripts/release-approval-gate.js` | Final owner-decision, live-URL, and launch-copy gate | Must return ready true before any release publish, package publish, plugin tag, video upload, announcement, or outbound batch |
| `docs/releases/2.0.0-rc.1/release-notes.md` | GitHub release copy source | Must be refreshed with final live release/package/plugin URLs before publication |
| `docs/releases/2.0.0-rc.1/quickstart.md` | Clone-to-first-workflow path | Covers clone, install, verify, first skill, and harness switch |
| `docs/releases/2.0.0-rc.1/launch-checklist.md` | Operator launch checklist | Must remain approval-gated for release, package, plugin, and announcement actions |
@@ -25,7 +26,7 @@ surfaces, or posting announcements.
| `docs/releases/2.0.0-rc.1/publication-evidence-2026-05-16.md` | Current May 16/17 queue cleanup, recsys skill merge, GateGuard triage, PR #1947 supply-chain protection, AgentShield #87 plugin-cache confidence evidence, AgentShield #88 evidence-pack inspect/readback, AgentShield #89 evidence-pack fleet routing, AgentShield #90 fleet review items, AgentShield #91 policy export, AgentShield #92 policy promotion, ECC-Tools #76 fleet-summary consumption, ECC-Tools #77 hosted finding evidence paths, ECC-Tools #78 harness policy-route linking, dashboard refresh, and combined Node/Rust/release-surface gate evidence through the May 16 mirror | Must still be repeated from a strict clean checkout before real publication |
| `docs/releases/2.0.0-rc.1/publication-evidence-2026-05-17.md` | May 17 queue-zero state, Japanese localization merge, Dependabot TypeScript and Node type merges, post-merge ja-JP lint repair, Mini Shai-Hulud/TanStack protection recheck, npm audit/signature checks, legacy and Linear progress routing, deterministic preview-pack smoke, operator dashboard refresh, Linear sync, and GitHub CI evidence for `27dc2918` | Superseded by the May 18 evidence snapshot; repeat from a strict clean checkout before real publication |
| `docs/releases/2.0.0-rc.1/publication-evidence-2026-05-18.md` | May 18 queue-zero state, #1970/#1971/#1972 merge batch, #1978 review/closure, supply-chain recheck, AgentShield evidence mirror, Linear sync, current-head CI/security scan success for `4470e2e6`, and ITO-46 naming/plugin publication closure | Superseded by the May 19 ECC identity, video, and growth evidence snapshot |
| `docs/releases/2.0.0-rc.1/publication-evidence-2026-05-19.md` | Current May 19 evidence for canonical ECC identity, release video suite, partner/sponsor/talk outreach pack, owner approval packet, May 19 operator dashboard, preview-pack smoke digest `790430aef4a8`, 2560-test local suite, PR #1998 visual QA CI success, PR #1999 dashboard evidence CI success, PR #2000 suite-count evidence success, PR #2001 owner approval packet CI success, PR #2002 owner-approval dashboard gate CI success, PR #2004 Linear readiness evidence sync CI success, PR #2008 supply-chain evidence gate CI success, post-PR #2006 main CI success, PR #2009 project-registry hygiene CI success, post-PR #2009 main CI success, post-PR #2011 GateGuard CI success, and the May 19 Linear sync document | Current strongest readiness snapshot; must still be repeated from a strict clean checkout before real publication |
| `docs/releases/2.0.0-rc.1/publication-evidence-2026-05-19.md` | Current May 19 evidence for canonical ECC identity, release video suite, partner/sponsor/talk outreach pack, owner approval packet, release approval gate, May 19 operator dashboard, preview-pack smoke digest `531328aaaa53`, 2560-test local suite, PR #1998 visual QA CI success, PR #1999 dashboard evidence CI success, PR #2000 suite-count evidence success, PR #2001 owner approval packet CI success, PR #2002 owner-approval dashboard gate CI success, PR #2004 Linear readiness evidence sync CI success, PR #2008 supply-chain evidence gate CI success, post-PR #2006 main CI success, PR #2009 project-registry hygiene CI success, post-PR #2009 main CI success, post-PR #2011 GateGuard CI success, and the May 19 Linear sync document | Current strongest readiness snapshot; must still be repeated from a strict clean checkout before real publication |
| `docs/releases/2.0.0-rc.1/operator-readiness-dashboard-2026-05-17.md` | Previous prompt-to-artifact operator dashboard | Superseded by the May 18 generated dashboard |
| `docs/releases/2.0.0-rc.1/operator-readiness-dashboard-2026-05-18.md` | Previous prompt-to-artifact operator dashboard | Superseded by the May 19 generated dashboard |
| `docs/releases/2.0.0-rc.1/operator-readiness-dashboard-2026-05-19.md` | Current prompt-to-artifact operator dashboard | Shows PR/issue/discussion/platform/supply-chain gates current and adds the current `$1,728/mo` to `$10,000/mo` hypergrowth, video owner-approval, and outbound-pack operating lanes |
@@ -80,6 +81,7 @@ Run these from the exact release commit before publication:
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
@@ -98,6 +100,8 @@ The preview pack is assembled, but publication is still blocked until these live
surfaces exist and are recorded in a final evidence file:
- final release URL ledger regenerated from the intended release commit;
- `npm run release:approval-gate -- --format json` returning ready true after
owner approvals and live URL readbacks are recorded;
- final release name/plugin publication checklist rerun from the intended
release commit;
- GitHub prerelease `v2.0.0-rc.1`;

View File

@@ -63,7 +63,8 @@ Tracked repositories in the platform audit were:
| Gate | Command | Result |
| --- | --- | --- |
| Release-surface tests | `node tests/docs/ecc2-release-surface.test.js` | 27 passed, 0 failed |
| Preview-pack smoke | `npm run preview-pack:smoke -- --format json` | Ready true; digest `790430aef4a8`; 31 required artifacts; 5 passed, 0 failed |
| Preview-pack smoke | `npm run preview-pack:smoke -- --format json` | Ready true; digest `531328aaaa53`; 32 required artifacts; 5 passed, 0 failed |
| Release approval gate | `npm run release:approval-gate -- --format json` | Expected blocked; digest `ef8f49f727b7`; 4 passed, 2 failed; owner decisions and live URL readbacks remain approval-gated |
| Operator dashboard | `npm run operator:dashboard -- --write docs/releases/2.0.0-rc.1/operator-readiness-dashboard-2026-05-19.md` | Regenerated from the May 19 `main` baseline with platform audit ready true, 0 tracked PRs, 0 tracked issues, 0 discussion gaps, `$1,728/mo` current MRR, `$10,000/mo` target MRR, the release video suite marked current, and top actions for plugin publication, notifications, outbound approval, AgentShield, and ECC Tools billing |
| Supply-chain verification | `npm audit --audit-level=moderate`; `npm audit signatures`; `yarn install --immutable --mode=skip-build` | Current supply-chain refresh found 0 npm vulnerabilities, verified 254 registry signatures and 30 attestations, and accepted the Yarn lock after pinning `@types/node@25.7.0` plus refreshing `brace-expansion` to `5.0.6` / `1.1.14` |
| Release video suite | `npm run release:video-suite -- --format json --summary` with `ECC_VIDEO_SOURCE_ROOT` and `ECC_VIDEO_RELEASE_SUITE_ROOT` | Ready true; 15/15 source assets present; 13/13 render, timeline, caption, EDL, and segment artifacts present; 12/12 publish-candidate outputs present with zero detected black-frame segments; primary rough render self-eval passed at 144.759 seconds, 1920x1080, 1 audio stream, and 106.78 MB |

View File

@@ -102,7 +102,8 @@ Record the exact commit SHA and command output before any publication action:
| Evidence | Command | Required result | Recorded output |
| --- | --- | --- | --- |
| Clean release branch | `git status --short --branch` | On intended release commit; no unrelated files | Current May 19 baseline `bc519e5b8ed42f26c0a5a611756e04351c323f21`: `## main...origin/main`; repeat from the exact final publication commit before release |
| Preview-pack smoke | `npm run preview-pack:smoke` | Preview pack artifacts, Hermes boundary, final verification command list, and publication blockers pass | `publication-evidence-2026-05-19.md`: ready yes, digest `790430aef4a8`, 31 artifacts, 5 passed, 0 failed; repeat in the final strict clean-checkout release pass |
| Preview-pack smoke | `npm run preview-pack:smoke` | Preview pack artifacts, Hermes boundary, final verification command list, and publication blockers pass | `publication-evidence-2026-05-19.md`: ready yes, digest `531328aaaa53`, 32 artifacts, 5 passed, 0 failed; repeat in the final strict clean-checkout release pass |
| Release approval gate | `npm run release:approval-gate -- --format json` | Ready true only after owner decision rows are approved, live release/package/plugin/video/billing URLs are recorded, and launch/outbound copy has no placeholders or private paths | Current May 19 state is intentionally blocked because owner decisions and live URL readbacks remain approval-gated |
| Harness audit | `npm run harness:audit -- --format json` | 80/80 passing | Current release gate: 80/80 across 8 applicable categories, 0 top actions |
| Adapter scorecard | `npm run harness:adapters -- --check` | PASS | Current release gate: PASS, 11 adapters |
| Observability readiness | `npm run observability:ready` | 21/21 passing | Current release gate: 21/21, ready true |

View File

@@ -66,6 +66,7 @@ npm pack --dry-run --json
npm publish --tag next --dry-run
npm run build:opencode
npm run preview-pack:smoke
npm run release:approval-gate -- --format json
```
If a command is unavailable on the release machine, record the exact error and

View File

@@ -47,6 +47,7 @@ npm view ecc-universal name version dist-tags --json
codex plugin marketplace add --help
rg -n "TODO|TBD|PLACEHOLDER" docs/releases/2.0.0-rc.1
npm run preview-pack:smoke
npm run release:approval-gate -- --format json
```
Do not post the social or notification copy until the approval-gated URLs above

View File

@@ -89,6 +89,7 @@
"scripts/operator-readiness-dashboard.js",
"scripts/platform-audit.js",
"scripts/preview-pack-smoke.js",
"scripts/release-approval-gate.js",
"scripts/release-video-suite.js",
"scripts/hooks/",
"scripts/install-apply.js",
@@ -312,6 +313,7 @@
"observability:ready": "node scripts/observability-readiness.js",
"operator:dashboard": "node scripts/operator-readiness-dashboard.js",
"preview-pack:smoke": "node scripts/preview-pack-smoke.js",
"release:approval-gate": "node scripts/release-approval-gate.js",
"release:video-suite": "node scripts/release-video-suite.js",
"platform:audit": "node scripts/platform-audit.js",
"discussion:audit": "node scripts/discussion-audit.js",

View File

@@ -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',

View 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,
};

View File

@@ -177,6 +177,7 @@ test('preview pack manifest assembles release, Hermes, and publication gates', (
'skills/hermes-imports/SKILL.md',
'docs/architecture/harness-adapter-compliance.md',
'scripts/preview-pack-smoke.js',
'scripts/release-approval-gate.js',
'docs/releases/2.0.0-rc.1/publication-readiness.md',
'docs/releases/2.0.0-rc.1/naming-and-publication-matrix.md',
'docs/releases/2.0.0-rc.1/release-url-ledger-2026-05-19.md',
@@ -201,6 +202,7 @@ test('preview pack manifest assembles release, Hermes, and publication gates', (
assert.ok(manifest.includes('no raw workspace exports'));
assert.ok(manifest.includes('Final Verification Commands'));
assert.ok(manifest.includes('npm run preview-pack:smoke'));
assert.ok(manifest.includes('npm run release:approval-gate -- --format json'));
assert.ok(manifest.includes('npm run release:video-suite -- --format json'));
assert.ok(manifest.includes('Reference-Inspired Adapter Direction'));
});
@@ -229,6 +231,7 @@ test('owner approval packet consolidates the final gated decisions', () => {
for (const command of [
'node scripts/platform-audit.js --json',
'npm run preview-pack:smoke -- --format json',
'npm run release:approval-gate -- --format json',
'npm run release:video-suite -- --format json',
'node tests/run-all.js',
]) {
@@ -255,7 +258,7 @@ test('GA roadmap mirrors the current May 19 release evidence', () => {
for (const marker of [
'owner-approval-packet-2026-05-19.md',
'preview-pack smoke digest `790430aef4a8`',
'preview-pack smoke digest `531328aaaa53`',
'local 2560-test suite',
'PR #2001',
'GitHub Actions run `26102500291`',
@@ -344,6 +347,31 @@ test('release video suite manifest gates the content launch lane', () => {
assert.ok(packageJson.files.includes('scripts/release-video-suite.js'));
});
test('release approval gate blocks publication until owner decisions and URLs are final', () => {
const manifest = read('docs/releases/2.0.0-rc.1/preview-pack-manifest.md');
const packet = read('docs/releases/2.0.0-rc.1/owner-approval-packet-2026-05-19.md');
const ledger = read('docs/releases/2.0.0-rc.1/release-url-ledger-2026-05-19.md');
const script = read('scripts/release-approval-gate.js');
const packageJson = JSON.parse(read('package.json'));
for (const marker of [
'ecc.release-approval-gate.v1',
'owner-decisions-approved',
'release-url-ledger-finalized',
'announcement-copy-finalized',
'No outbound email, personal-account post, package publish, plugin tag, or billing announcement',
]) {
assert.ok(script.includes(marker), `release approval gate missing ${marker}`);
}
assert.ok(manifest.includes('scripts/release-approval-gate.js'));
assert.ok(manifest.includes('npm run release:approval-gate -- --format json'));
assert.ok(packet.includes('npm run release:approval-gate -- --format json'));
assert.ok(ledger.includes('npm run release:approval-gate -- --format json'));
assert.strictEqual(packageJson.scripts['release:approval-gate'], 'node scripts/release-approval-gate.js');
assert.ok(packageJson.files.includes('scripts/release-approval-gate.js'));
});
test('partner sponsor talks pack gates the hypergrowth outbound lane', () => {
const partnerPack = read('docs/releases/2.0.0-rc.1/partner-sponsor-talks-pack.md');
const manifest = read('docs/releases/2.0.0-rc.1/preview-pack-manifest.md');
@@ -510,6 +538,7 @@ test('release name and plugin publication checklist freezes rc.1 surfaces', () =
'codex plugin marketplace add --help',
'npm publish --tag next --dry-run',
'npm run preview-pack:smoke',
'npm run release:approval-gate -- --format json',
]) {
assert.ok(checklist.includes(command), `release name/plugin checklist missing command ${command}`);
}

View File

@@ -60,6 +60,7 @@ function buildExpectedPublishPaths(repoRoot) {
"scripts/operator-readiness-dashboard.js",
"scripts/platform-audit.js",
"scripts/preview-pack-smoke.js",
"scripts/release-approval-gate.js",
"scripts/release-video-suite.js",
"scripts/skill-create-output.js",
"scripts/repair.js",
@@ -132,6 +133,7 @@ function main() {
"scripts/discussion-audit.js",
"scripts/operator-readiness-dashboard.js",
"scripts/preview-pack-smoke.js",
"scripts/release-approval-gate.js",
"scripts/release-video-suite.js",
"scripts/work-items.js",
"scripts/platform-audit.js",

View File

@@ -0,0 +1,320 @@
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync, spawnSync } = require('child_process');
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'release-approval-gate.js');
const {
REQUIRED_DECISIONS,
REQUIRED_URL_SURFACES,
buildReport,
parseArgs,
renderText,
} = require(SCRIPT);
const RELEASE_DIR = 'docs/releases/2.0.0-rc.1';
function createTempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function cleanup(dirPath) {
fs.rmSync(dirPath, { recursive: true, force: true });
}
function writeFile(rootDir, relativePath, content) {
const targetPath = path.join(rootDir, relativePath);
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, content);
}
function approvedPacketContent(overrides = {}) {
const decisions = new Map(REQUIRED_DECISIONS.map(decision => [decision.label, 'approve']));
for (const [label, value] of Object.entries(overrides)) {
decisions.set(label, value);
}
return [
'# ECC v2.0.0-rc.1 Owner Approval Packet',
'',
'## Decision Register',
'',
'| Decision | Approve / defer / block | Evidence required first | Notes |',
'| --- | --- | --- | --- |',
...REQUIRED_DECISIONS.map(decision => (
`| ${decision.label} | ${decisions.get(decision.label)} | final evidence | approved fixture |`
)),
'',
'## Final Evidence Commands',
'',
'```bash',
'npm run release:approval-gate -- --format json',
'```',
'',
'No outbound email, personal-account post, package publish, plugin tag, or billing announcement is authorized by this packet alone.',
].join('\n');
}
function finalLedgerContent(extra = '') {
return [
'# ECC v2.0.0-rc.1 Release URL Ledger',
'',
'## Final Published URLs',
'',
'| Surface | URL | Verification |',
'| --- | --- | --- |',
...REQUIRED_URL_SURFACES.map(surface => (
`| ${surface.label} | ${surface.exampleUrl} | readback from final release commit |`
)),
'',
'## Final Verification Commands',
'',
'```bash',
'npm run release:approval-gate -- --format json',
'```',
'',
extra,
].join('\n');
}
function manifestContent() {
return [
'# ECC v2.0.0-rc.1 Preview Pack Manifest',
'',
'| Artifact | Role | Gate |',
'| --- | --- | --- |',
'| `scripts/release-approval-gate.js` | Final owner approval and live URL gate | Verified by `npm run release:approval-gate -- --format json` |',
'',
'## Final Verification Commands',
'',
'```bash',
'npm run release:approval-gate -- --format json',
'```',
].join('\n');
}
function seedRepo(rootDir, overrides = {}) {
const files = {
'package.json': JSON.stringify({
files: ['scripts/release-approval-gate.js'],
scripts: {
'release:approval-gate': 'node scripts/release-approval-gate.js',
},
}, null, 2),
'scripts/release-approval-gate.js': 'release approval gate script',
[`${RELEASE_DIR}/owner-approval-packet-2026-05-19.md`]: approvedPacketContent(),
[`${RELEASE_DIR}/release-url-ledger-2026-05-19.md`]: finalLedgerContent(),
[`${RELEASE_DIR}/preview-pack-manifest.md`]: manifestContent(),
[`${RELEASE_DIR}/release-notes.md`]: 'Release notes with final URLs.',
[`${RELEASE_DIR}/x-thread.md`]: 'X post with final URLs.',
[`${RELEASE_DIR}/linkedin-post.md`]: 'LinkedIn post with final URLs.',
[`${RELEASE_DIR}/article-outline.md`]: 'Article outline with final URLs.',
[`${RELEASE_DIR}/partner-sponsor-talks-pack.md`]: 'Outbound copy with final URLs.',
'docs/business/social-launch-copy.md': 'Business launch copy with final URLs.',
};
for (const [relativePath, content] of Object.entries({ ...files, ...overrides })) {
if (content === null) {
continue;
}
writeFile(rootDir, relativePath, content);
}
}
function run(args = [], options = {}) {
return execFileSync('node', [SCRIPT, ...args], {
cwd: options.cwd || path.join(__dirname, '..', '..'),
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 10000,
});
}
function runProcess(args = [], options = {}) {
return spawnSync('node', [SCRIPT, ...args], {
cwd: options.cwd || path.join(__dirname, '..', '..'),
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 10000,
});
}
function test(name, fn) {
try {
fn();
console.log(` PASS ${name}`);
return true;
} catch (error) {
console.log(` FAIL ${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function runTests() {
console.log('\n=== Testing release-approval-gate.js ===\n');
let passed = 0;
let failed = 0;
if (test('parseArgs accepts approval gate flags and rejects invalid values', () => {
const rootDir = createTempDir('release-approval-args-');
try {
const parsed = parseArgs([
'node',
'script',
'--format=json',
`--root=${rootDir}`,
]);
assert.strictEqual(parsed.format, 'json');
assert.strictEqual(parsed.root, path.resolve(rootDir));
assert.throws(() => parseArgs(['node', 'script', '--format', 'xml']), /Invalid format/);
assert.throws(() => parseArgs(['node', 'script', '--root']), /--root requires a value/);
assert.throws(() => parseArgs(['node', 'script', '--unknown']), /Unknown argument/);
} finally {
cleanup(rootDir);
}
})) passed++; else failed++;
if (test('seeded approved release passes every publication approval check', () => {
const rootDir = createTempDir('release-approval-pass-');
try {
seedRepo(rootDir);
const report = buildReport({ root: rootDir });
assert.strictEqual(report.schema_version, 'ecc.release-approval-gate.v1');
assert.strictEqual(report.ready, true);
assert.strictEqual(report.summary.failed, 0);
assert.deepStrictEqual(report.top_actions, []);
assert.ok(report.checks.every(check => check.status === 'pass'));
const text = renderText(report);
assert.ok(text.includes('Ready: yes'));
assert.ok(text.includes('Failed: 0'));
} finally {
cleanup(rootDir);
}
})) passed++; else failed++;
if (test('deferred owner decisions keep the publication gate blocked', () => {
const rootDir = createTempDir('release-approval-deferred-');
try {
seedRepo(rootDir, {
[`${RELEASE_DIR}/owner-approval-packet-2026-05-19.md`]: approvedPacketContent({
'GitHub prerelease': 'defer',
'Sponsor, partner, consulting, conference, podcast outreach': 'block',
}),
});
const report = buildReport({ root: rootDir });
const decisions = report.checks.find(check => check.id === 'owner-decisions-approved');
assert.strictEqual(report.ready, false);
assert.strictEqual(decisions.status, 'fail');
assert.ok(decisions.evidence.includes('GitHub prerelease=defer'));
assert.ok(decisions.evidence.includes('Sponsor, partner, consulting, conference, podcast outreach=block'));
assert.ok(report.top_actions.some(action => action.includes('Approve, defer, or block')));
} finally {
cleanup(rootDir);
}
})) passed++; else failed++;
if (test('approval-gated URL ledger rows keep the publication gate blocked', () => {
const rootDir = createTempDir('release-approval-ledger-');
try {
seedRepo(rootDir, {
[`${RELEASE_DIR}/release-url-ledger-2026-05-19.md`]: [
'# ECC v2.0.0-rc.1 Release URL Ledger',
'',
'## Approval-Gated URLs',
'',
'| Surface | Intended URL or command | Gate before use |',
'| --- | --- | --- |',
'| GitHub prerelease | https://github.com/affaan-m/ECC/releases/tag/v2.0.0-rc.1 | must return the prerelease |',
].join('\n'),
});
const report = buildReport({ root: rootDir });
const ledger = report.checks.find(check => check.id === 'release-url-ledger-finalized');
assert.strictEqual(report.ready, false);
assert.strictEqual(ledger.status, 'fail');
assert.ok(ledger.evidence.includes('approval-gated URL section still present'));
} finally {
cleanup(rootDir);
}
})) passed++; else failed++;
if (test('announcement drafts fail on unresolved placeholders and private paths', () => {
const rootDir = createTempDir('release-approval-copy-');
try {
seedRepo(rootDir, {
[`${RELEASE_DIR}/x-thread.md`]: 'Ship copy with <video-url> and /Users/affaan/raw-footage.',
});
const report = buildReport({ root: rootDir });
const copy = report.checks.find(check => check.id === 'announcement-copy-finalized');
assert.strictEqual(report.ready, false);
assert.strictEqual(copy.status, 'fail');
assert.ok(copy.evidence.includes(`${RELEASE_DIR}/x-thread.md:1`));
} finally {
cleanup(rootDir);
}
})) passed++; else failed++;
if (test('CLI emits json and uses status 2 for blocked approval reports', () => {
const rootDir = createTempDir('release-approval-cli-');
try {
seedRepo(rootDir);
const stdout = run(['--format=json', `--root=${rootDir}`], { cwd: rootDir });
const parsed = JSON.parse(stdout);
assert.strictEqual(parsed.ready, true);
writeFile(
rootDir,
`${RELEASE_DIR}/owner-approval-packet-2026-05-19.md`,
approvedPacketContent({ 'Video upload': 'defer' })
);
const failedRun = runProcess(['--format=json', `--root=${rootDir}`], { cwd: rootDir });
assert.strictEqual(failedRun.status, 2);
assert.strictEqual(failedRun.stderr, '');
assert.ok(failedRun.stdout.includes('"ready": false'));
} finally {
cleanup(rootDir);
}
})) passed++; else failed++;
if (test('CLI help exits successfully and invalid flags fail before reporting', () => {
const help = runProcess(['--help']);
assert.strictEqual(help.status, 0);
assert.strictEqual(help.stderr, '');
assert.ok(help.stdout.includes('Usage: node scripts/release-approval-gate.js'));
const invalid = runProcess(['--format=xml']);
assert.strictEqual(invalid.status, 1);
assert.strictEqual(invalid.stdout, '');
assert.match(invalid.stderr, /Error: Invalid format/);
})) passed++; else failed++;
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
if (failed > 0) {
process.exit(1);
}
}
if (require.main === module) {
runTests();
}