diff --git a/docs/ECC-2.0-GA-ROADMAP.md b/docs/ECC-2.0-GA-ROADMAP.md index dba2b0c4..e350ad5b 100644 --- a/docs/ECC-2.0-GA-ROADMAP.md +++ b/docs/ECC-2.0-GA-ROADMAP.md @@ -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 | diff --git a/docs/releases/2.0.0-rc.1/launch-checklist.md b/docs/releases/2.0.0-rc.1/launch-checklist.md index ac635ae9..de6d135c 100644 --- a/docs/releases/2.0.0-rc.1/launch-checklist.md +++ b/docs/releases/2.0.0-rc.1/launch-checklist.md @@ -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 diff --git a/docs/releases/2.0.0-rc.1/owner-approval-packet-2026-05-19.md b/docs/releases/2.0.0-rc.1/owner-approval-packet-2026-05-19.md index 4bd27ca0..96f0d98b 100644 --- a/docs/releases/2.0.0-rc.1/owner-approval-packet-2026-05-19.md +++ b/docs/releases/2.0.0-rc.1/owner-approval-packet-2026-05-19.md @@ -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 diff --git a/docs/releases/2.0.0-rc.1/preview-pack-manifest.md b/docs/releases/2.0.0-rc.1/preview-pack-manifest.md index 16e518c1..595e0fc1 100644 --- a/docs/releases/2.0.0-rc.1/preview-pack-manifest.md +++ b/docs/releases/2.0.0-rc.1/preview-pack-manifest.md @@ -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`; diff --git a/docs/releases/2.0.0-rc.1/publication-evidence-2026-05-19.md b/docs/releases/2.0.0-rc.1/publication-evidence-2026-05-19.md index 5d3dc12f..25553703 100644 --- a/docs/releases/2.0.0-rc.1/publication-evidence-2026-05-19.md +++ b/docs/releases/2.0.0-rc.1/publication-evidence-2026-05-19.md @@ -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 | diff --git a/docs/releases/2.0.0-rc.1/publication-readiness.md b/docs/releases/2.0.0-rc.1/publication-readiness.md index aaf7fba8..88121da7 100644 --- a/docs/releases/2.0.0-rc.1/publication-readiness.md +++ b/docs/releases/2.0.0-rc.1/publication-readiness.md @@ -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 | diff --git a/docs/releases/2.0.0-rc.1/release-name-plugin-publication-checklist-2026-05-18.md b/docs/releases/2.0.0-rc.1/release-name-plugin-publication-checklist-2026-05-18.md index 6433c372..125dc5e4 100644 --- a/docs/releases/2.0.0-rc.1/release-name-plugin-publication-checklist-2026-05-18.md +++ b/docs/releases/2.0.0-rc.1/release-name-plugin-publication-checklist-2026-05-18.md @@ -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 diff --git a/docs/releases/2.0.0-rc.1/release-url-ledger-2026-05-19.md b/docs/releases/2.0.0-rc.1/release-url-ledger-2026-05-19.md index 9bfbabad..437ea131 100644 --- a/docs/releases/2.0.0-rc.1/release-url-ledger-2026-05-19.md +++ b/docs/releases/2.0.0-rc.1/release-url-ledger-2026-05-19.md @@ -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 diff --git a/package.json b/package.json index 07919d69..203c2a27 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/preview-pack-smoke.js b/scripts/preview-pack-smoke.js index 843eade8..11df8c4c 100644 --- a/scripts/preview-pack-smoke.js +++ b/scripts/preview-pack-smoke.js @@ -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', diff --git a/scripts/release-approval-gate.js b/scripts/release-approval-gate.js new file mode 100644 index 00000000..a1c9e0de --- /dev/null +++ b/scripts/release-approval-gate.js @@ -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 ] [--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, +}; diff --git a/tests/docs/ecc2-release-surface.test.js b/tests/docs/ecc2-release-surface.test.js index 8456ce56..a0b59abd 100644 --- a/tests/docs/ecc2-release-surface.test.js +++ b/tests/docs/ecc2-release-surface.test.js @@ -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}`); } diff --git a/tests/scripts/npm-publish-surface.test.js b/tests/scripts/npm-publish-surface.test.js index 51df54c7..b93b8073 100644 --- a/tests/scripts/npm-publish-surface.test.js +++ b/tests/scripts/npm-publish-surface.test.js @@ -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", diff --git a/tests/scripts/release-approval-gate.test.js b/tests/scripts/release-approval-gate.test.js new file mode 100644 index 00000000..bc064ff3 --- /dev/null +++ b/tests/scripts/release-approval-gate.test.js @@ -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 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(); +}