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 cdb40559..aa015d95 100644 --- a/docs/releases/2.0.0-rc.1/launch-checklist.md +++ b/docs/releases/2.0.0-rc.1/launch-checklist.md @@ -37,6 +37,10 @@ - publish the LinkedIn draft from `linkedin-post.md` - use `article-outline.md` for the longer writeup - record one 30-60 second proof-of-work clip +- validate the release video suite with `npm run release:video-suite -- --format json` + after setting `ECC_VIDEO_SOURCE_ROOT` and `ECC_VIDEO_RELEASE_SUITE_ROOT` +- keep `video-suite-production.md` aligned with the actual primary launch + render, timeline, captions, and self-eval gate ## Demo Asset Suggestions 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 003dbcf7..f8f6ac3f 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 @@ -28,6 +28,7 @@ surfaces, or posting announcements. | `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` | Current prompt-to-artifact operator dashboard | Shows PR/issue/discussion/platform/supply-chain gates current and publication, plugin, billing, AgentShield, ECC Tools, legacy, and Linear productization gaps still open | | `docs/releases/2.0.0-rc.1/release-url-ledger-2026-05-19.md` | Live URL and approval-gated URL ledger for release copy | Must be regenerated from the final release commit before public announcements | +| `docs/releases/2.0.0-rc.1/video-suite-production.md` | Release video production manifest | Gates local media inventory, rough primary render, captions, timeline, self-eval, and no-private-path publication rules | | `docs/releases/2.0.0-rc.1/naming-and-publication-matrix.md` | Naming, slug, and publication-path decision record | Keeps `ECC`, npm `ecc-universal`, and plugin slug `ecc` for rc.1 | | `docs/releases/2.0.0-rc.1/release-name-plugin-publication-checklist-2026-05-18.md` | Release name, package, Claude plugin, Codex plugin, and publication-order checklist | Freezes rc.1 identity and requires final commit evidence before release, npm, plugin, billing, or announcement actions | | `docs/releases/2.0.0-rc.1/x-thread.md` | X launch draft | Must replace placeholders with live URLs after release/package/plugin publication | @@ -75,6 +76,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:video-suite -- --format json npm run harness:adapters -- --check npm run harness:audit -- --format json npm run observability:ready diff --git a/docs/releases/2.0.0-rc.1/video-suite-production.md b/docs/releases/2.0.0-rc.1/video-suite-production.md new file mode 100644 index 00000000..60a86ebb --- /dev/null +++ b/docs/releases/2.0.0-rc.1/video-suite-production.md @@ -0,0 +1,173 @@ +# ECC 2.0 Video Suite Production Manifest + +Snapshot date: 2026-05-19. + +This is the production contract for the ECC 2.0 release video suite. It keeps +the public release story, local source inventory, render outputs, and self-eval +gate in one place without committing raw footage, private transcript exports, or +absolute local paths. + +## Claim + +ECC 2.0 is the harness-native operator system for agentic work. + +The videos should prove that claim directly: + +- one reusable layer across Claude Code, Codex, OpenCode, Cursor, Gemini, Zed, + GitHub Copilot, and terminal workflows; +- reusable skills, rules, hooks, agents, MCP conventions, release gates, and + operator workflows; +- `ecc2/` as the alpha control-plane/TUI direction, not the whole product; +- AgentShield and supply-chain gates as the enterprise trust layer; +- OSS stays free, with GitHub Sponsors, ECC Tools Pro, and consulting as the + funding surface. + +Do not frame the launch as a rename, pivot, config pack, or Claude-only package. + +## Private Inputs + +Do not commit raw footage, transcript JSON, or timeline exports. + +Operators should point the validator at local media using environment variables: + +```bash +ECC_VIDEO_SOURCE_ROOT=/path/to/ecc_2_raws \ +ECC_VIDEO_RELEASE_SUITE_ROOT=/path/to/ecc_2_release_suite \ +npm run release:video-suite -- --format json +``` + +`ECC_VIDEO_SOURCE_ROOT` should contain proof images and may contain an `_edited/` +subdirectory with edited source clips. `ECC_VIDEO_RELEASE_SUITE_ROOT` should +contain `edl/`, `segments/`, `renders/`, `timelines/`, and `transcripts/`. + +## Source Inventory + +These basenames are the required local inputs for the release suite validator. + +| Asset | Lane | Proof | +| --- | --- | --- | +| `longform-full-wide.mp4` | Primary launch video | operator system, control-plane direction, closing proof | +| `sf-longform-full.mp4` | Primary launch video | structured context opener | +| `sf-thread-2-whatisecc.mp4` | What is ECC | category clarity and GitHub App explanation | +| `sf-thread-4-security.mp4` | Security proof | AgentShield, hooks, MCP, permission risk | +| `thread-2-ghapp-money.mp4` | Money/proof clip | OSS plus paid hosting and services | +| `architecture-2-wide.mp4` | B-roll | harness-native architecture | +| `terminal-scan-2-wide.mp4` | Install proof | terminal workflow and install confidence | +| `new_site_raw.mp4` | B-roll | site and product surface | +| `coverage-montage-wide.mp4` | Coverage/social proof | distribution and social proof | +| `metrics-ticker-2-wide.mp4` | Money/proof clip | traction and funnel proof | +| `growth-timeline-2-wide.mp4` | Coverage/social proof | release momentum timeline | +| `gh_app_1.png` | Money/proof clip | hosted GitHub App surface | +| `star_history.png` | Coverage/social proof | OSS adoption chart | +| `x_analytics.png` | Coverage/social proof | social distribution proof | +| `100k.png` | Coverage/social proof | reach milestone proof | + +## Deliverables + +| Deliverable | Length | Aspect | Output | +| --- | ---: | --- | --- | +| Primary launch video | 90-150s | 16:9 | `ecc-2-primary-launch.mp4` | +| Install proof clip | 25-35s | 16:9 and 9:16 | `ecc-2-install-proof-*` | +| What is ECC clip | 45-60s | 16:9 and 9:16 | `ecc-2-what-is-ecc-*` | +| Security proof clip | 45-60s | 16:9 and 9:16 | `ecc-2-security-proof-*` | +| Money/proof clip | 30-45s | 16:9 and 9:16 | `ecc-2-money-proof-*` | +| Coverage/social proof clip | 30-45s | 16:9 and 9:16 | `ecc-2-social-proof-*` | + +## Primary Launch Video + +The rough v1 primary launch assembly is the current spine. It should stay +speech-led, with product proof covering jump cuts and older wording. + +| Order | Source | In | Out | Use | +| --- | --- | ---: | ---: | --- | +| 01 | `sf-longform-full.mp4` | 161.12 | 177.68 | Cleaner opener: ECC as structured context with skills, commands, agents, hooks, and project setup. | +| 02 | `thread-2-ghapp-money.mp4` | 21.84 | 30.40 | Direct product thesis: agentic harness optimization. | +| 03 | `thread-2-ghapp-money.mp4` | 41.00 | 59.72 | Not another harness; ECC is the layer and tooling on top of harnesses. | +| 04 | `longform-full-wide.mp4` | 254.60 | 271.20 | Agentic IDE, observability, tracing, and multi-agent control-plane direction. | +| 05 | `sf-thread-2-whatisecc.mp4` | 40.08 | 60.60 | GitHub App analyzes repos and injects project-specific skills, prompts, and hooks. | +| 06 | `sf-thread-4-security.mp4` | 17.60 | 32.72 | Security risk setup: hooks, MCP servers, permissions. | +| 07 | `sf-thread-4-security.mp4` | 37.28 | 51.32 | AgentShield proof: rules, categories, grades, secrets, injection, exfiltration. | +| 08 | `thread-2-ghapp-money.mp4` | 59.72 | 75.96 | OSS-first business model plus managed GitHub App surface. | +| 09 | `longform-full-wide.mp4` | 507.34 | 525.62 | Close on workflows, tested shipping, and secure daily agent work. | + +Required local rough v1 artifacts: + +- `edl/primary-launch.edl.md` +- `timelines/primary-launch-v1.timeline.json` +- `renders/ecc-2-primary-launch-rough-v1.mp4` +- `renders/ecc-2-primary-launch-rough-v1.captions.srt` +- `segments/primary-launch-v1/01-structured-context.mp4` +- `segments/primary-launch-v1/02-agentic-harness-optimization.mp4` +- `segments/primary-launch-v1/03-not-another-harness.mp4` +- `segments/primary-launch-v1/04-agentic-ide-surface.mp4` +- `segments/primary-launch-v1/05-github-app-proof.mp4` +- `segments/primary-launch-v1/06-security-risk.mp4` +- `segments/primary-launch-v1/07-agentshield-proof.mp4` +- `segments/primary-launch-v1/08-oss-paid-model.mp4` +- `segments/primary-launch-v1/09-close-shipping-system.mp4` + +## video-use compatible workflow + +Use the same production shape as Video Use while keeping the ECC-specific media +stack intact: + +1. Treat transcript and timeline data as the editing surface. +2. Inspect filmstrip or frame samples only at ambiguous cut points. +3. Keep an edit decision list before rendering. +4. Cut deterministically with FFmpeg. +5. Add proof overlays with Remotion or Manim where product claims need visual + evidence. +6. Export the MP4 plus editable timeline and caption state. +7. Run self-eval before any upload or social post. + +Do not dump frames into the repo. Frame samples used for self-eval belong in the +local release suite workspace. + +## Browser Capture Plan + +Use Browser or equivalent desktop capture only for proof footage that must be +current on release day: + +| Surface | Capture | +| --- | --- | +| GitHub repo | README hero, install block, sponsor links, release notes | +| Codex plugin | repo marketplace install path and local plugin README | +| OpenCode package | package install and plugin banner | +| ECC Tools Pro | billing/product page only after live readback confirms claims | +| AgentShield | CLI output, policy category view, supply-chain gate | +| `ecc2/` | alpha control-plane/TUI surface with alpha framing | + +If a surface is not live, use a local browser capture and label it as local or +release-candidate proof. Do not claim marketplace, billing, or official +directory availability before evidence exists. + +## Self-Eval Gate + +Run the validator: + +```bash +ECC_VIDEO_SOURCE_ROOT=/path/to/ecc_2_raws \ +ECC_VIDEO_RELEASE_SUITE_ROOT=/path/to/ecc_2_release_suite \ +npm run release:video-suite -- --format json +``` + +Then manually check the final render for: + +- no blank frames or accidental desktop exposure; +- no stale repo name, pivot, rename, or Claude-only framing in captions; +- no captions that rewrite speech into a false claim; +- no stale URLs, old install commands, or pre-rename repository links; +- no internal MRR numbers unless the post explicitly needs them; +- audio continuity across every cut; +- first 10 seconds clearly say what ECC is; +- final CTA routes to repo, sponsor, Pro, or consulting without clutter. + +## Do Not Publish If + +- `npm run release:video-suite` is not ready for the local source roots. +- The primary launch render is outside the 90-150 second target. +- Captions mention the old repository name. +- Product proof relies on private screens, secrets, customer data, or raw local + paths. +- The release URL, npm, plugin, billing, or marketplace claims outrun the + evidence in `publication-readiness.md`. diff --git a/docs/releases/2.0.0/ecc-2-hypergrowth-release-command-center.md b/docs/releases/2.0.0/ecc-2-hypergrowth-release-command-center.md index 986aa079..f1563e06 100644 --- a/docs/releases/2.0.0/ecc-2-hypergrowth-release-command-center.md +++ b/docs/releases/2.0.0/ecc-2-hypergrowth-release-command-center.md @@ -50,7 +50,7 @@ MRR growth should come from four lanes at once: | Package and plugin publication | `ecc-universal@2.0.0-rc.1` dry-runs clean, npm `next` is approved, Claude plugin tag dry-runs, Codex repo marketplace smoke passes, OpenCode build passes | Refresh publication evidence from final commit | | Product proof | Quickstart, cross-harness architecture, demo prompts, `ecc2/` alpha boundary, AgentShield safety proof, and hosted ECC Tools links are consistent | Keep proof surfaces concrete | | Revenue proof | Sponsor tiers, Pro pricing, consulting CTA, partner CTA, and billing-readback language are current | Do not announce billing claims before live readback | -| Content proof | Launch video, short-form clips, screenshots, release notes, GitHub Discussion, X, LinkedIn, and longform post are aligned | Produce video suite from existing raw material | +| Content proof | Launch video, short-form clips, screenshots, release notes, GitHub Discussion, X, LinkedIn, and longform post are aligned | Validate `video-suite-production.md` and the local render suite | | Community proof | Discord invite, rules, channels, onboarding, and sponsor/community routing are ready | Needs invite/token decision before public links | ## Video Suite @@ -124,7 +124,8 @@ Avoid: 1. Land the public repo identity fixes. 2. Refresh package, plugin, workflow, release, and launch-copy URLs. 3. Record final publication evidence from the exact release commit. -4. Produce the video suite manifest and transcripts from existing raw material. +4. Produce the video suite manifest and transcripts from existing raw material; + gate it with `npm run release:video-suite -- --format json`. 5. Browser-capture the README, ECC Tools app, install flow, and relevant proof surfaces for b-roll. 6. Render the primary launch video plus five short clips. diff --git a/package.json b/package.json index 84baea88..479374f2 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-video-suite.js", "scripts/hooks/", "scripts/install-apply.js", "scripts/install-plan.js", @@ -311,6 +312,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:video-suite": "node scripts/release-video-suite.js", "platform:audit": "node scripts/platform-audit.js", "discussion:audit": "node scripts/discussion-audit.js", "security:ioc-scan": "node scripts/ci/scan-supply-chain-iocs.js", diff --git a/scripts/preview-pack-smoke.js b/scripts/preview-pack-smoke.js index 946d35f9..598e8fc2 100644 --- a/scripts/preview-pack-smoke.js +++ b/scripts/preview-pack-smoke.js @@ -29,6 +29,7 @@ const REQUIRED_ARTIFACTS = [ `${RELEASE_DIR}/operator-readiness-dashboard-2026-05-17.md`, `${RELEASE_DIR}/operator-readiness-dashboard-2026-05-18.md`, `${RELEASE_DIR}/release-url-ledger-2026-05-19.md`, + `${RELEASE_DIR}/video-suite-production.md`, `${RELEASE_DIR}/naming-and-publication-matrix.md`, `${RELEASE_DIR}/release-name-plugin-publication-checklist-2026-05-18.md`, `${RELEASE_DIR}/x-thread.md`, @@ -42,6 +43,7 @@ const REQUIRED_VERIFICATION_COMMANDS = [ 'git status --short --branch', 'node scripts/platform-audit.js --json', 'npm run preview-pack:smoke', + 'npm run release:video-suite -- --format json', 'npm run harness:adapters -- --check', 'npm run harness:audit -- --format json', 'npm run observability:ready', diff --git a/scripts/release-video-suite.js b/scripts/release-video-suite.js new file mode 100644 index 00000000..c19b9ca6 --- /dev/null +++ b/scripts/release-video-suite.js @@ -0,0 +1,747 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const RELEASE = '2.0.0-rc.1'; +const SCHEMA_VERSION = 'ecc.release-video-suite.v1'; +const VIDEO_MANIFEST_PATH = `docs/releases/${RELEASE}/video-suite-production.md`; +const HYPERGROWTH_DOC_PATH = 'docs/releases/2.0.0/ecc-2-hypergrowth-release-command-center.md'; + +const REQUIRED_DOC_MARKERS = [ + 'ECC 2.0 Video Suite Production Manifest', + 'video-use compatible workflow', + 'ECC_VIDEO_SOURCE_ROOT', + 'ECC_VIDEO_RELEASE_SUITE_ROOT', + 'Primary launch video', + 'Self-Eval Gate', + 'Do Not Publish If', +]; + +const REQUIRED_SOURCE_ASSETS = [ + { + id: 'primary-longform-wide', + file: 'longform-full-wide.mp4', + lane: 'primary-launch', + proof: 'operator system, control-plane direction, closing proof', + }, + { + id: 'primary-shortform-full', + file: 'sf-longform-full.mp4', + lane: 'primary-launch', + proof: 'structured context opener', + }, + { + id: 'what-is-ecc-wide', + file: 'sf-thread-2-whatisecc.mp4', + lane: 'what-is-ecc', + proof: 'category clarity and GitHub App explanation', + }, + { + id: 'security-wide', + file: 'sf-thread-4-security.mp4', + lane: 'security-proof', + proof: 'AgentShield, hooks, MCP, permission risk', + }, + { + id: 'money-proof-wide', + file: 'thread-2-ghapp-money.mp4', + lane: 'money-proof', + proof: 'OSS plus paid hosting and services', + }, + { + id: 'architecture-wide', + file: 'architecture-2-wide.mp4', + lane: 'b-roll', + proof: 'harness-native architecture', + }, + { + id: 'terminal-scan-wide', + file: 'terminal-scan-2-wide.mp4', + lane: 'install-proof', + proof: 'terminal workflow and install confidence', + }, + { + id: 'site-raw', + file: 'new_site_raw.mp4', + lane: 'b-roll', + proof: 'site and product surface', + }, + { + id: 'coverage-montage', + file: 'coverage-montage-wide.mp4', + lane: 'coverage-proof', + proof: 'distribution and social proof', + }, + { + id: 'metrics-ticker-wide', + file: 'metrics-ticker-2-wide.mp4', + lane: 'money-proof', + proof: 'traction and funnel proof', + }, + { + id: 'growth-timeline-wide', + file: 'growth-timeline-2-wide.mp4', + lane: 'coverage-proof', + proof: 'release momentum timeline', + }, + { + id: 'github-app-proof-1', + file: 'gh_app_1.png', + lane: 'money-proof', + proof: 'hosted GitHub App surface', + }, + { + id: 'stars', + file: 'star_history.png', + lane: 'coverage-proof', + proof: 'OSS adoption chart', + }, + { + id: 'x-analytics', + file: 'x_analytics.png', + lane: 'coverage-proof', + proof: 'social distribution proof', + }, + { + id: '100k-proof', + file: '100k.png', + lane: 'coverage-proof', + proof: 'reach milestone proof', + }, +]; + +const REQUIRED_SUITE_ARTIFACTS = [ + { + id: 'primary-edl', + relativePath: 'edl/primary-launch.edl.md', + kind: 'edl', + }, + { + id: 'primary-timeline-v1', + relativePath: 'timelines/primary-launch-v1.timeline.json', + kind: 'timeline', + }, + { + id: 'primary-captions-v1', + relativePath: 'renders/ecc-2-primary-launch-rough-v1.captions.srt', + kind: 'captions', + }, + { + id: 'primary-render-v1', + relativePath: 'renders/ecc-2-primary-launch-rough-v1.mp4', + kind: 'video', + minDurationSeconds: 90, + maxDurationSeconds: 150, + }, + { + id: 'segment-structured-context', + relativePath: 'segments/primary-launch-v1/01-structured-context.mp4', + kind: 'video', + }, + { + id: 'segment-agentic-harness-optimization', + relativePath: 'segments/primary-launch-v1/02-agentic-harness-optimization.mp4', + kind: 'video', + }, + { + id: 'segment-not-another-harness', + relativePath: 'segments/primary-launch-v1/03-not-another-harness.mp4', + kind: 'video', + }, + { + id: 'segment-agentic-ide-surface', + relativePath: 'segments/primary-launch-v1/04-agentic-ide-surface.mp4', + kind: 'video', + }, + { + id: 'segment-github-app-proof', + relativePath: 'segments/primary-launch-v1/05-github-app-proof.mp4', + kind: 'video', + }, + { + id: 'segment-security-risk', + relativePath: 'segments/primary-launch-v1/06-security-risk.mp4', + kind: 'video', + }, + { + id: 'segment-agentshield-proof', + relativePath: 'segments/primary-launch-v1/07-agentshield-proof.mp4', + kind: 'video', + }, + { + id: 'segment-oss-paid-model', + relativePath: 'segments/primary-launch-v1/08-oss-paid-model.mp4', + kind: 'video', + }, + { + id: 'segment-close-shipping-system', + relativePath: 'segments/primary-launch-v1/09-close-shipping-system.mp4', + kind: 'video', + }, +]; + +function usage() { + console.log([ + 'Usage: node scripts/release-video-suite.js [options]', + '', + 'Validates the ECC 2.0 release video production lane without committing raw media paths.', + '', + 'Options:', + ' --format Output format (default: text)', + ' --json Alias for --format json', + ' --root Repository root to inspect (default: cwd)', + ' --source-root Directory containing ECC 2 source media, with optional _edited subdir', + ' --suite-root Directory containing render/timeline/transcript outputs', + ' --skip-probe Skip ffprobe duration reads for fixture or dry-run checks', + ' --summary Emit compact JSON when used with --format json', + ' --help, -h Show this help', + '', + 'Environment:', + ' ECC_VIDEO_SOURCE_ROOT', + ' ECC_VIDEO_RELEASE_SUITE_ROOT', + ].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()), + sourceRoot: process.env.ECC_VIDEO_SOURCE_ROOT || '', + suiteRoot: process.env.ECC_VIDEO_RELEASE_SUITE_ROOT || '', + skipProbe: false, + summary: false, + }; + + 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 === '--skip-probe') { + parsed.skipProbe = true; + continue; + } + + if (arg === '--summary') { + parsed.summary = true; + 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; + } + + if (arg === '--source-root') { + parsed.sourceRoot = path.resolve(readArgValue(args, index, arg)); + index += 1; + continue; + } + + if (arg.startsWith('--source-root=')) { + parsed.sourceRoot = path.resolve(arg.slice('--source-root='.length)); + continue; + } + + if (arg === '--suite-root') { + parsed.suiteRoot = path.resolve(readArgValue(args, index, arg)); + index += 1; + continue; + } + + if (arg.startsWith('--suite-root=')) { + parsed.suiteRoot = path.resolve(arg.slice('--suite-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 safeParseJson(text) { + if (!text.trim()) { + return null; + } + + try { + return JSON.parse(text); + } catch (_error) { + return null; + } +} + +function lineNumberForIndex(text, index) { + return text.slice(0, index).split('\n').length; +} + +function scanForbiddenPaths(rootDir, relativePaths) { + const offenders = []; + const privatePathPattern = /\/Users\/(?!\.\.\.)[A-Za-z0-9._-]+|\/home\/(?!user|runner)[A-Za-z0-9._-]+/g; + + for (const relativePath of relativePaths) { + const text = readText(rootDir, relativePath); + if (!text) { + continue; + } + + for (const match of text.matchAll(privatePathPattern)) { + offenders.push({ + path: relativePath, + line: lineNumberForIndex(text, match.index), + marker: match[0], + }); + } + } + + return offenders; +} + +function makeCheck(id, status, summary, fix, details = {}) { + return { + id, + status, + summary, + fix: status === 'pass' ? '' : fix, + ...details, + }; +} + +function formatBytes(bytes) { + if (!Number.isFinite(bytes)) { + return null; + } + + return Number((bytes / 1024 / 1024).toFixed(2)); +} + +function probeMedia(filePath, skipProbe) { + const stat = fs.statSync(filePath); + const result = { + sizeBytes: stat.size, + sizeMb: formatBytes(stat.size), + durationSeconds: null, + probe: skipProbe ? 'skipped' : 'unavailable', + }; + + if (skipProbe) { + return result; + } + + const probe = spawnSync('ffprobe', [ + '-v', + 'error', + '-show_entries', + 'format=duration', + '-of', + 'json', + filePath, + ], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 15000, + }); + + if (probe.error) { + result.probe = `error: ${probe.error.message}`; + return result; + } + + if (probe.status !== 0) { + result.probe = `failed: ${(probe.stderr || '').trim() || `exit ${probe.status}`}`; + return result; + } + + const parsed = safeParseJson(probe.stdout); + const duration = Number(parsed && parsed.format && parsed.format.duration); + if (Number.isFinite(duration)) { + result.durationSeconds = Number(duration.toFixed(3)); + result.probe = 'ok'; + } + + return result; +} + +function resolveSourceAssetPath(sourceRoot, fileName) { + const candidates = [ + path.join(sourceRoot, fileName), + path.join(sourceRoot, '_edited', fileName), + ]; + + return candidates.find(candidate => fs.existsSync(candidate)) || candidates[0]; +} + +function inspectSourceAssets(sourceRoot, skipProbe) { + return REQUIRED_SOURCE_ASSETS.map(asset => { + if (!sourceRoot) { + return { + ...asset, + status: 'missing', + configured: false, + }; + } + + const filePath = resolveSourceAssetPath(sourceRoot, asset.file); + if (!fs.existsSync(filePath)) { + return { + ...asset, + status: 'missing', + configured: true, + }; + } + + const media = asset.file.endsWith('.mp4') ? probeMedia(filePath, skipProbe) : { + sizeBytes: fs.statSync(filePath).size, + sizeMb: formatBytes(fs.statSync(filePath).size), + durationSeconds: null, + probe: 'not-media', + }; + + return { + ...asset, + status: 'present', + configured: true, + ...media, + }; + }); +} + +function inspectSuiteArtifacts(suiteRoot, skipProbe) { + return REQUIRED_SUITE_ARTIFACTS.map(artifact => { + if (!suiteRoot) { + return { + ...artifact, + status: 'missing', + configured: false, + }; + } + + const filePath = path.join(suiteRoot, artifact.relativePath); + if (!fs.existsSync(filePath)) { + return { + ...artifact, + status: 'missing', + configured: true, + }; + } + + const media = artifact.kind === 'video' ? probeMedia(filePath, skipProbe) : { + sizeBytes: fs.statSync(filePath).size, + sizeMb: formatBytes(fs.statSync(filePath).size), + durationSeconds: null, + probe: 'not-media', + }; + + let durationStatus = 'pass'; + if ( + artifact.kind === 'video' + && Number.isFinite(artifact.minDurationSeconds) + && Number.isFinite(media.durationSeconds) + && media.durationSeconds < artifact.minDurationSeconds + ) { + durationStatus = 'fail'; + } + + if ( + artifact.kind === 'video' + && Number.isFinite(artifact.maxDurationSeconds) + && Number.isFinite(media.durationSeconds) + && media.durationSeconds > artifact.maxDurationSeconds + ) { + durationStatus = 'fail'; + } + + if ( + artifact.kind === 'video' + && Number.isFinite(artifact.minDurationSeconds) + && !skipProbe + && media.durationSeconds === null + ) { + durationStatus = 'fail'; + } + + return { + ...artifact, + status: durationStatus === 'pass' ? 'present' : 'invalid', + configured: true, + ...media, + }; + }); +} + +function buildReport(options = {}) { + const rootDir = path.resolve(options.root || process.cwd()); + const sourceRoot = options.sourceRoot ? path.resolve(options.sourceRoot) : ''; + const suiteRoot = options.suiteRoot ? path.resolve(options.suiteRoot) : ''; + const skipProbe = Boolean(options.skipProbe); + const packageJson = safeParseJson(readText(rootDir, 'package.json')) || {}; + const packageScripts = packageJson.scripts || {}; + const packageFiles = Array.isArray(packageJson.files) ? packageJson.files : []; + const manifest = readText(rootDir, VIDEO_MANIFEST_PATH); + const hypergrowth = readText(rootDir, HYPERGROWTH_DOC_PATH); + + const missingDocMarkers = REQUIRED_DOC_MARKERS.filter(marker => !manifest.includes(marker)); + const forbiddenPaths = scanForbiddenPaths(rootDir, [ + VIDEO_MANIFEST_PATH, + HYPERGROWTH_DOC_PATH, + `docs/releases/${RELEASE}/preview-pack-manifest.md`, + `docs/releases/${RELEASE}/launch-checklist.md`, + ]); + const sourceAssets = inspectSourceAssets(sourceRoot, skipProbe); + const suiteArtifacts = inspectSuiteArtifacts(suiteRoot, skipProbe); + const missingSourceAssets = sourceAssets.filter(asset => asset.status !== 'present'); + const missingSuiteArtifacts = suiteArtifacts.filter(artifact => artifact.status !== 'present'); + + const checks = [ + makeCheck( + 'video-suite-command-registered', + packageScripts['release:video-suite'] === 'node scripts/release-video-suite.js' + && packageFiles.includes('scripts/release-video-suite.js') + ? 'pass' + : 'fail', + 'package script and npm package entry for the release video suite validator', + 'Add release:video-suite to package scripts and include scripts/release-video-suite.js in package files.' + ), + makeCheck( + 'video-suite-manifest-present', + manifest && missingDocMarkers.length === 0 ? 'pass' : 'fail', + manifest && missingDocMarkers.length === 0 + ? `${VIDEO_MANIFEST_PATH} includes the required production markers` + : `missing markers: ${missingDocMarkers.join(', ') || 'manifest file missing'}`, + 'Restore the video production manifest and required production markers.' + ), + makeCheck( + 'video-suite-public-sanitization', + forbiddenPaths.length === 0 + && manifest.includes('Do not commit raw footage, transcript JSON, or timeline exports') + && /Keep raw\s+absolute paths out of public docs/.test(hypergrowth) + ? 'pass' + : 'fail', + forbiddenPaths.length === 0 + ? 'public launch docs avoid private media paths and keep raw assets local' + : `private path markers: ${forbiddenPaths.map(item => `${item.path}:${item.line}`).join(', ')}`, + 'Remove private absolute paths from public release docs and keep raw media in the local production workspace.', + { forbiddenPaths } + ), + makeCheck( + 'video-source-assets-present', + missingSourceAssets.length === 0 ? 'pass' : 'fail', + missingSourceAssets.length === 0 + ? `${sourceAssets.length} source assets are present` + : `missing source assets: ${missingSourceAssets.map(asset => asset.file).join(', ')}`, + 'Set ECC_VIDEO_SOURCE_ROOT or pass --source-root to the edited ECC 2 media directory.', + { + configured: Boolean(sourceRoot), + missing: missingSourceAssets.map(asset => asset.file), + } + ), + makeCheck( + 'video-release-artifacts-present', + missingSuiteArtifacts.length === 0 ? 'pass' : 'fail', + missingSuiteArtifacts.length === 0 + ? `${suiteArtifacts.length} render, timeline, caption, EDL, and segment artifacts are present` + : `missing or invalid suite artifacts: ${missingSuiteArtifacts.map(artifact => artifact.relativePath).join(', ')}`, + 'Set ECC_VIDEO_RELEASE_SUITE_ROOT or pass --suite-root to the ECC 2 release suite workspace.', + { + configured: Boolean(suiteRoot), + missing: missingSuiteArtifacts.map(artifact => artifact.relativePath), + } + ), + ]; + + const failed = checks.filter(check => check.status !== 'pass'); + const topActions = []; + + if (!sourceRoot) { + topActions.push('Set ECC_VIDEO_SOURCE_ROOT to the edited ECC 2 media directory.'); + } + + if (!suiteRoot) { + topActions.push('Set ECC_VIDEO_RELEASE_SUITE_ROOT to the local release suite workspace.'); + } + + for (const check of failed) { + if (check.fix && !topActions.includes(check.fix)) { + topActions.push(check.fix); + } + } + + return { + schema_version: SCHEMA_VERSION, + release: RELEASE, + generatedAt: options.generatedAt || new Date().toISOString(), + root: rootDir, + sourceRootConfigured: Boolean(sourceRoot), + suiteRootConfigured: Boolean(suiteRoot), + mediaPathsRedacted: true, + ready: failed.length === 0, + checks, + sourceAssets, + suiteArtifacts, + top_actions: topActions, + }; +} + +function summarizeItems(items) { + const present = items.filter(item => item.status === 'present'); + const missing = items.filter(item => item.status !== 'present'); + + return { + total: items.length, + present: present.length, + missing: missing.map(item => item.file || item.relativePath), + }; +} + +function summarizeReport(report) { + const primaryRender = report.suiteArtifacts.find(item => item.id === 'primary-render-v1') || null; + + return { + schema_version: report.schema_version, + release: report.release, + generatedAt: report.generatedAt, + root: report.root, + sourceRootConfigured: report.sourceRootConfigured, + suiteRootConfigured: report.suiteRootConfigured, + mediaPathsRedacted: report.mediaPathsRedacted, + ready: report.ready, + checks: report.checks.map(check => ({ + id: check.id, + status: check.status, + summary: check.summary, + fix: check.fix, + })), + sourceAssetSummary: summarizeItems(report.sourceAssets), + suiteArtifactSummary: summarizeItems(report.suiteArtifacts), + primaryRender: primaryRender ? { + status: primaryRender.status, + durationSeconds: primaryRender.durationSeconds, + sizeMb: primaryRender.sizeMb, + } : null, + top_actions: report.top_actions, + }; +} + +function renderText(report) { + const lines = [ + `ECC ${report.release} release video suite`, + `Ready: ${report.ready ? 'yes' : 'no'}`, + `Source root configured: ${report.sourceRootConfigured ? 'yes' : 'no'}`, + `Suite root configured: ${report.suiteRootConfigured ? 'yes' : 'no'}`, + '', + 'Checks:', + ]; + + for (const check of report.checks) { + lines.push(`- ${check.status.toUpperCase()} ${check.id}: ${check.summary}`); + } + + const primaryRender = report.suiteArtifacts.find(item => item.id === 'primary-render-v1'); + if (primaryRender && primaryRender.status === 'present') { + lines.push(''); + lines.push( + `Primary rough render: ${primaryRender.relativePath}` + + (Number.isFinite(primaryRender.durationSeconds) ? ` (${primaryRender.durationSeconds}s)` : '') + ); + } + + if (report.top_actions.length > 0) { + lines.push(''); + lines.push('Top actions:'); + for (const action of report.top_actions) { + lines.push(`- ${action}`); + } + } + + return `${lines.join('\n')}\n`; +} + +function main() { + let options; + try { + options = parseArgs(process.argv); + } catch (error) { + console.error(error.message); + process.exit(2); + } + + if (options.help) { + usage(); + return; + } + + const report = buildReport(options); + const outputReport = options.summary ? summarizeReport(report) : report; + + if (options.format === 'json') { + console.log(JSON.stringify(outputReport, null, 2)); + } else { + process.stdout.write(renderText(report)); + } + + process.exit(report.ready ? 0 : 1); +} + +if (require.main === module) { + main(); +} + +module.exports = { + REQUIRED_SOURCE_ASSETS, + REQUIRED_SUITE_ARTIFACTS, + buildReport, + parseArgs, + renderText, + summarizeReport, +}; diff --git a/tests/docs/ecc2-release-surface.test.js b/tests/docs/ecc2-release-surface.test.js index dc3f4222..3991504b 100644 --- a/tests/docs/ecc2-release-surface.test.js +++ b/tests/docs/ecc2-release-surface.test.js @@ -52,6 +52,7 @@ const expectedReleaseFiles = [ 'quickstart.md', 'preview-pack-manifest.md', 'publication-readiness.md', + 'video-suite-production.md', 'release-name-plugin-publication-checklist-2026-05-18.md', ]; @@ -176,6 +177,7 @@ test('preview pack manifest assembles release, Hermes, and publication gates', ( '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', + 'docs/releases/2.0.0-rc.1/video-suite-production.md', 'docs/releases/2.0.0-rc.1/release-name-plugin-publication-checklist-2026-05-18.md', ]) { assert.ok(manifest.includes(artifact), `preview pack manifest missing ${artifact}`); @@ -194,6 +196,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:video-suite -- --format json')); assert.ok(manifest.includes('Reference-Inspired Adapter Direction')); }); @@ -231,6 +234,60 @@ test('launch checklist records the ecc2 alpha version policy', () => { assert.ok(!launchChecklist.includes('confirm whether `ecc2/Cargo.toml` moves')); }); +test('release video suite manifest gates the content launch lane', () => { + const videoManifest = read('docs/releases/2.0.0-rc.1/video-suite-production.md'); + const launchChecklist = read('docs/releases/2.0.0-rc.1/launch-checklist.md'); + const hypergrowth = read('docs/releases/2.0.0/ecc-2-hypergrowth-release-command-center.md'); + const packageJson = JSON.parse(read('package.json')); + + for (const marker of [ + 'ECC 2.0 Video Suite Production Manifest', + 'ECC_VIDEO_SOURCE_ROOT', + 'ECC_VIDEO_RELEASE_SUITE_ROOT', + 'video-use compatible workflow', + 'Self-Eval Gate', + 'Do Not Publish If', + 'renders/ecc-2-primary-launch-rough-v1.mp4', + 'timelines/primary-launch-v1.timeline.json', + 'Primary launch video', + ]) { + assert.ok(videoManifest.includes(marker), `video suite manifest missing ${marker}`); + } + + for (const asset of [ + 'longform-full-wide.mp4', + 'sf-thread-2-whatisecc.mp4', + 'thread-2-ghapp-money.mp4', + 'coverage-montage-wide.mp4', + 'star_history.png', + 'x_analytics.png', + ]) { + assert.ok(videoManifest.includes(asset), `video suite manifest missing asset ${asset}`); + } + + assert.ok(launchChecklist.includes('npm run release:video-suite -- --format json')); + assert.ok(hypergrowth.includes('Validate `video-suite-production.md`')); + assert.strictEqual(packageJson.scripts['release:video-suite'], 'node scripts/release-video-suite.js'); + assert.ok(packageJson.files.includes('scripts/release-video-suite.js')); +}); + +test('release video suite public docs do not expose private media paths', () => { + const releaseVideoDocs = [ + 'docs/releases/2.0.0-rc.1/video-suite-production.md', + 'docs/releases/2.0.0/ecc-2-hypergrowth-release-command-center.md', + ]; + + const offenders = []; + for (const relativePath of releaseVideoDocs) { + const source = read(relativePath); + if (/\/Users\/[A-Za-z0-9._-]+|\/home\/(?!user|runner)[A-Za-z0-9._-]+/.test(source)) { + offenders.push(relativePath); + } + } + + assert.deepStrictEqual(offenders, []); +}); + test('publication readiness checklist gates public release actions on evidence', () => { const source = read('docs/releases/2.0.0-rc.1/publication-readiness.md'); const may15Evidence = read('docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md'); diff --git a/tests/scripts/npm-publish-surface.test.js b/tests/scripts/npm-publish-surface.test.js index 7cb3857d..51df54c7 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-video-suite.js", "scripts/skill-create-output.js", "scripts/repair.js", "scripts/harness-adapter-compliance.js", @@ -131,6 +132,7 @@ function main() { "scripts/discussion-audit.js", "scripts/operator-readiness-dashboard.js", "scripts/preview-pack-smoke.js", + "scripts/release-video-suite.js", "scripts/work-items.js", "scripts/platform-audit.js", ".gemini/GEMINI.md", diff --git a/tests/scripts/release-video-suite.test.js b/tests/scripts/release-video-suite.test.js new file mode 100644 index 00000000..4cab6be2 --- /dev/null +++ b/tests/scripts/release-video-suite.test.js @@ -0,0 +1,303 @@ +/** + * Tests for scripts/release-video-suite.js + */ + +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-video-suite.js'); +const { + REQUIRED_SOURCE_ASSETS, + REQUIRED_SUITE_ARTIFACTS, + buildReport, + parseArgs, + renderText, + summarizeReport, +} = require(SCRIPT); + +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 = 'fixture') { + const targetPath = path.join(rootDir, relativePath); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, content); +} + +function seedRepo(rootDir, overrides = {}) { + const files = { + 'package.json': JSON.stringify({ + name: 'ecc-universal', + files: ['scripts/release-video-suite.js'], + scripts: { + 'release:video-suite': 'node scripts/release-video-suite.js', + }, + }, null, 2), + 'docs/releases/2.0.0-rc.1/video-suite-production.md': [ + '# ECC 2.0 Video Suite Production Manifest', + 'ECC_VIDEO_SOURCE_ROOT', + 'ECC_VIDEO_RELEASE_SUITE_ROOT', + 'Primary launch video', + 'video-use compatible workflow', + 'Self-Eval Gate', + 'Do Not Publish If', + 'Do not commit raw footage, transcript JSON, or timeline exports', + ].join('\n'), + 'docs/releases/2.0.0/ecc-2-hypergrowth-release-command-center.md': [ + 'Keep raw absolute paths out of public docs', + 'Validate `video-suite-production.md`', + ].join('\n'), + 'docs/releases/2.0.0-rc.1/preview-pack-manifest.md': 'video-suite-production.md', + 'docs/releases/2.0.0-rc.1/launch-checklist.md': 'release video suite', + }; + + for (const [relativePath, content] of Object.entries({ ...files, ...overrides })) { + if (content === null) { + continue; + } + writeFile(rootDir, relativePath, content); + } +} + +function seedMedia(sourceRoot, suiteRoot) { + for (const asset of REQUIRED_SOURCE_ASSETS) { + writeFile(sourceRoot, asset.file, `source ${asset.id}`); + } + + for (const artifact of REQUIRED_SUITE_ARTIFACTS) { + writeFile(suiteRoot, artifact.relativePath, `artifact ${artifact.id}`); + } +} + +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-video-suite.js ===\n'); + + let passed = 0; + let failed = 0; + + if (test('parseArgs accepts release video flags and rejects invalid values', () => { + const rootDir = createTempDir('release-video-args-'); + const sourceRoot = createTempDir('release-video-source-'); + const suiteRoot = createTempDir('release-video-suite-'); + + try { + const parsed = parseArgs([ + 'node', + 'script', + '--json', + `--root=${rootDir}`, + '--source-root', + sourceRoot, + `--suite-root=${suiteRoot}`, + '--skip-probe', + '--summary', + ]); + + assert.strictEqual(parsed.format, 'json'); + assert.strictEqual(parsed.root, path.resolve(rootDir)); + assert.strictEqual(parsed.sourceRoot, path.resolve(sourceRoot)); + assert.strictEqual(parsed.suiteRoot, path.resolve(suiteRoot)); + assert.strictEqual(parsed.skipProbe, true); + assert.strictEqual(parsed.summary, true); + + assert.throws(() => parseArgs(['node', 'script', '--format', 'xml']), /Invalid format/); + assert.throws(() => parseArgs(['node', 'script', '--source-root']), /--source-root requires a value/); + assert.throws(() => parseArgs(['node', 'script', '--unknown']), /Unknown argument/); + } finally { + cleanup(rootDir); + cleanup(sourceRoot); + cleanup(suiteRoot); + } + })) passed++; else failed++; + + if (test('buildReport passes with a sanitized manifest and complete local media fixture', () => { + const rootDir = createTempDir('release-video-report-'); + const sourceRoot = createTempDir('release-video-source-'); + const suiteRoot = createTempDir('release-video-suite-'); + + try { + seedRepo(rootDir); + seedMedia(sourceRoot, suiteRoot); + + const report = buildReport({ + root: rootDir, + sourceRoot, + suiteRoot, + skipProbe: true, + generatedAt: '2026-05-19T00:00:00.000Z', + }); + + assert.strictEqual(report.schema_version, 'ecc.release-video-suite.v1'); + assert.strictEqual(report.ready, true); + assert.strictEqual(report.mediaPathsRedacted, true); + assert.ok(report.checks.every(check => check.status === 'pass')); + assert.strictEqual(report.sourceAssets.length, REQUIRED_SOURCE_ASSETS.length); + assert.strictEqual(report.suiteArtifacts.length, REQUIRED_SUITE_ARTIFACTS.length); + assert.ok(renderText(report).includes('Ready: yes')); + assert.strictEqual(summarizeReport(report).sourceAssetSummary.present, REQUIRED_SOURCE_ASSETS.length); + } finally { + cleanup(rootDir); + cleanup(sourceRoot); + cleanup(suiteRoot); + } + })) passed++; else failed++; + + if (test('missing local roots keep the release video gate blocked', () => { + const rootDir = createTempDir('release-video-missing-roots-'); + + try { + seedRepo(rootDir); + + const report = buildReport({ + root: rootDir, + skipProbe: true, + generatedAt: '2026-05-19T00:00:00.000Z', + }); + + assert.strictEqual(report.ready, false); + assert.ok(report.top_actions.some(action => action.includes('ECC_VIDEO_SOURCE_ROOT'))); + assert.ok(report.top_actions.some(action => action.includes('ECC_VIDEO_RELEASE_SUITE_ROOT'))); + assert.ok(report.checks.some(check => check.id === 'video-source-assets-present' && check.status === 'fail')); + assert.ok(report.checks.some(check => check.id === 'video-release-artifacts-present' && check.status === 'fail')); + } finally { + cleanup(rootDir); + } + })) passed++; else failed++; + + if (test('private media paths in public docs fail sanitization', () => { + const rootDir = createTempDir('release-video-private-path-'); + const sourceRoot = createTempDir('release-video-source-'); + const suiteRoot = createTempDir('release-video-suite-'); + + try { + seedRepo(rootDir, { + 'docs/releases/2.0.0-rc.1/video-suite-production.md': [ + '# ECC 2.0 Video Suite Production Manifest', + 'ECC_VIDEO_SOURCE_ROOT', + 'ECC_VIDEO_RELEASE_SUITE_ROOT', + 'Primary launch video', + 'video-use compatible workflow', + 'Self-Eval Gate', + 'Do Not Publish If', + 'Do not commit raw footage, transcript JSON, or timeline exports', + '/Users/affoon/private-media', + ].join('\n'), + }); + seedMedia(sourceRoot, suiteRoot); + + const report = buildReport({ + root: rootDir, + sourceRoot, + suiteRoot, + skipProbe: true, + generatedAt: '2026-05-19T00:00:00.000Z', + }); + + assert.strictEqual(report.ready, false); + assert.ok(report.checks.some(check => check.id === 'video-suite-public-sanitization' && check.status === 'fail')); + } finally { + cleanup(rootDir); + cleanup(sourceRoot); + cleanup(suiteRoot); + } + })) passed++; else failed++; + + if (test('CLI emits JSON and exits successfully for complete fixture', () => { + const rootDir = createTempDir('release-video-cli-'); + const sourceRoot = createTempDir('release-video-source-'); + const suiteRoot = createTempDir('release-video-suite-'); + + try { + seedRepo(rootDir); + seedMedia(sourceRoot, suiteRoot); + + const output = run([ + '--format=json', + `--root=${rootDir}`, + `--source-root=${sourceRoot}`, + `--suite-root=${suiteRoot}`, + '--skip-probe', + '--summary', + ], { cwd: rootDir }); + const parsed = JSON.parse(output); + + assert.strictEqual(parsed.ready, true); + assert.strictEqual(parsed.sourceRootConfigured, true); + assert.strictEqual(parsed.suiteRootConfigured, true); + assert.strictEqual(parsed.sourceAssetSummary.present, REQUIRED_SOURCE_ASSETS.length); + assert.strictEqual(parsed.suiteArtifactSummary.present, REQUIRED_SUITE_ARTIFACTS.length); + } finally { + cleanup(rootDir); + cleanup(sourceRoot); + cleanup(suiteRoot); + } + })) passed++; else failed++; + + if (test('CLI exits nonzero when media roots are missing', () => { + const rootDir = createTempDir('release-video-cli-blocked-'); + + try { + seedRepo(rootDir); + + const result = runProcess([ + '--format=json', + `--root=${rootDir}`, + '--skip-probe', + '--summary', + ], { cwd: rootDir }); + + assert.strictEqual(result.status, 1); + const parsed = JSON.parse(result.stdout); + assert.strictEqual(parsed.ready, false); + } finally { + cleanup(rootDir); + } + })) passed++; else failed++; + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + + process.exit(failed > 0 ? 1 : 0); +} + +if (require.main === module) { + runTests(); +}