ci: remove invalid repair runner #3
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Apply strict content promotion fix | |
| on: | |
| push: | |
| branches: | |
| - fix/strict-content-promotion-gate | |
| permissions: | |
| contents: write | |
| concurrency: | |
| group: strict-content-promotion-fix | |
| cancel-in-progress: false | |
| jobs: | |
| patch-test-commit: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd | |
| with: | |
| ref: fix/strict-content-promotion-gate | |
| fetch-depth: 0 | |
| - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 | |
| with: | |
| python-version: '3.12' | |
| - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e | |
| with: | |
| node-version: '24' | |
| package-manager-cache: false | |
| - name: Install dependencies | |
| run: npm ci --ignore-scripts --no-audit --no-fund | |
| - name: Apply strict identity and duration gate | |
| shell: bash | |
| run: | | |
| python - <<'PY' | |
| from pathlib import Path | |
| def replace_once(path, old, new): | |
| p = Path(path) | |
| text = p.read_text(encoding='utf-8') | |
| if old not in text: | |
| raise SystemExit(f'anchor missing in {path}: {old[:120]!r}') | |
| p.write_text(text.replace(old, new, 1), encoding='utf-8') | |
| # Use every useful metadata field. A generic title such as "Inconnue" | |
| # must not hide a correct description/filename, while provider-only | |
| # labels remain insufficient on their own. | |
| old_meta = """ const metadataLabel = String(stream?.title || stream?.description || stream?.filename || stream?.name || '').trim(); | |
| const mediaFilename = humanMediaFilename(stream?.url); | |
| const label = [metadataLabel, mediaFilename].filter(Boolean).join(' '); | |
| """ | |
| new_meta = """ const metadataParts = [stream?.title, stream?.description, stream?.filename] | |
| .map((value) => String(value || '').trim()) | |
| .filter(Boolean); | |
| if (!metadataParts.length && stream?.name) metadataParts.push(String(stream.name).trim()); | |
| const metadataLabel = metadataParts.join(' '); | |
| const mediaFilename = humanMediaFilename(stream?.url); | |
| const label = [metadataLabel, mediaFilename].filter(Boolean).join(' '); | |
| """ | |
| replace_once('scripts/nuvio_client_lab.cjs', old_meta, new_meta) | |
| replace_once('scripts/nuvio_tv_probe_v2.cjs', old_meta, new_meta) | |
| # Teach the native/TV probe to measure bounded HLS VOD duration. | |
| replace_once( | |
| 'scripts/nuvio_tv_probe_v2.cjs', | |
| """ const variants = []; | |
| const externalAudio = []; | |
| let audioGroups = 0; | |
| for (let index = 0; index < lines.length; index += 1) { | |
| const line = lines[index]; | |
| if (/^#EXT-X-STREAM-INF\\s*:/i.test(line)) { | |
| """, | |
| """ const variants = []; | |
| const externalAudio = []; | |
| let audioGroups = 0; | |
| let durationSeconds = 0; | |
| let durationEntryCount = 0; | |
| let isVod = false; | |
| for (let index = 0; index < lines.length; index += 1) { | |
| const line = lines[index]; | |
| if (/^#EXTINF\\s*:/i.test(line)) { | |
| const duration = Number(line.slice(line.indexOf(':') + 1).split(',')[0]); | |
| if (Number.isFinite(duration) && duration >= 0) { | |
| durationSeconds += duration; | |
| durationEntryCount += 1; | |
| } | |
| } else if (/^#EXT-X-ENDLIST\\s*$/i.test(line)) { | |
| isVod = true; | |
| } else if (/^#EXT-X-STREAM-INF\\s*:/i.test(line)) { | |
| """, | |
| ) | |
| replace_once( | |
| 'scripts/nuvio_tv_probe_v2.cjs', | |
| ' return { variants, externalAudio, audioGroups };\n', | |
| ' return { variants, externalAudio, audioGroups, durationSeconds: durationEntryCount ? durationSeconds : null, isVod };\n', | |
| ) | |
| replace_once( | |
| 'scripts/nuvio_tv_probe_v2.cjs', | |
| """ const hasMedia = /#EXTINF\\s*:/i.test(text) || /#EXT-X-PART\\s*:/i.test(text) || /#EXT-X-STREAM-INF\\s*:/i.test(text) || /#EXT-X-MAP\\s*:/i.test(text); | |
| return { playable: hasMedia, status: response.status, error: hasMedia ? null : 'child_header_only' }; | |
| """, | |
| """ const hasMedia = /#EXTINF\\s*:/i.test(text) || /#EXT-X-PART\\s*:/i.test(text) || /#EXT-X-STREAM-INF\\s*:/i.test(text) || /#EXT-X-MAP\\s*:/i.test(text); | |
| const graph = hlsGraph(text, response.url || url); | |
| return { | |
| playable: hasMedia, | |
| status: response.status, | |
| error: hasMedia ? null : 'child_header_only', | |
| media_duration_seconds: graph.durationSeconds, | |
| is_vod: graph.isVod, | |
| }; | |
| """, | |
| ) | |
| replace_once( | |
| 'scripts/nuvio_tv_probe_v2.cjs', | |
| """ hls_external_audio_playable: null, | |
| error: null, | |
| """, | |
| """ hls_external_audio_playable: null, | |
| media_duration_seconds: null, | |
| error: null, | |
| """, | |
| ) | |
| replace_once( | |
| 'scripts/nuvio_tv_probe_v2.cjs', | |
| """ const graph = hlsGraph(text, response.url || url); | |
| result.hls_master = graph.variants.length > 0 || /#EXT-X-STREAM-INF\\s*:/i.test(text); | |
| """, | |
| """ const graph = hlsGraph(text, response.url || url); | |
| result.media_duration_seconds = graph.durationSeconds; | |
| result.hls_master = graph.variants.length > 0 || /#EXT-X-STREAM-INF\\s*:/i.test(text); | |
| """, | |
| ) | |
| replace_once( | |
| 'scripts/nuvio_tv_probe_v2.cjs', | |
| """ const variant = await inspectHlsChild(graph.variants[0], headers); | |
| result.hls_variant_playable = variant.playable; | |
| """, | |
| """ const variant = await inspectHlsChild(graph.variants[0], headers); | |
| result.hls_variant_playable = variant.playable; | |
| if (Number.isFinite(variant.media_duration_seconds) && variant.media_duration_seconds > 0) { | |
| result.media_duration_seconds = variant.media_duration_seconds; | |
| } | |
| """, | |
| ) | |
| old_main = """ const inspected = rows.map((row, index) => ({ row, media: media[index], identity: streamIdentity(row, fixture) })); | |
| const playable = inspected.filter((item) => item.media.playable); | |
| const identityContradictions = playable.filter((item) => item.identity.status === 'contradiction'); | |
| const identityVerified = playable.filter((item) => item.identity.status === 'match'); | |
| process.stdout.write(JSON.stringify({ | |
| ok: !runtimeError && playable.length > 0 && identityContradictions.length === 0, | |
| duration_ms: Date.now() - started, | |
| runtime_error: runtimeError, | |
| raw_stream_count: rows.length, | |
| playable_stream_count: playable.length, | |
| identity_verified_count: identityVerified.length, | |
| identity_contradiction_count: identityContradictions.length, | |
| streams: inspected, | |
| }) + '\\n'); | |
| process.exitCode = playable.length && identityContradictions.length === 0 ? 0 : 2; | |
| """ | |
| new_main = """ const inspected = rows.map((row, index) => { | |
| const metadataIdentity = streamIdentity(row, fixture); | |
| const mediaResult = media[index]; | |
| const expectedMinutes = Number(fixture?.expectedDurationMinutes || 0); | |
| const expectedSeconds = expectedMinutes > 0 ? expectedMinutes * 60 : null; | |
| const measuredSeconds = Number(mediaResult?.media_duration_seconds || 0); | |
| let durationIdentity = { status: 'unknown', reason: 'duration_unavailable', ratio: null }; | |
| if (expectedSeconds && Number.isFinite(measuredSeconds) && measuredSeconds > 0) { | |
| const ratio = measuredSeconds / expectedSeconds; | |
| durationIdentity = (ratio < 0.55 || ratio > 1.8) | |
| ? { status: 'contradiction', reason: 'fixture_duration_mismatch', ratio } | |
| : { status: 'match', reason: 'fixture_duration_match', ratio }; | |
| } | |
| let identity = metadataIdentity; | |
| if (metadataIdentity.status !== 'contradiction' && durationIdentity.status === 'contradiction') { | |
| identity = durationIdentity; | |
| } else if (metadataIdentity.status === 'unknown' && durationIdentity.status === 'match') { | |
| identity = durationIdentity; | |
| } | |
| return { | |
| row, | |
| media: mediaResult, | |
| identity, | |
| metadata_identity: metadataIdentity, | |
| duration_identity: durationIdentity, | |
| }; | |
| }); | |
| const playable = inspected.filter((item) => item.media.playable); | |
| const identityContradictions = playable.filter((item) => item.identity.status === 'contradiction'); | |
| const identityVerified = playable.filter((item) => item.identity.status === 'match'); | |
| const identityUnknown = playable.filter((item) => item.identity.status === 'unknown'); | |
| const strictComplete = playable.length > 0 | |
| && identityVerified.length === playable.length | |
| && identityContradictions.length === 0 | |
| && identityUnknown.length === 0; | |
| process.stdout.write(JSON.stringify({ | |
| ok: !runtimeError && strictComplete, | |
| duration_ms: Date.now() - started, | |
| runtime_error: runtimeError, | |
| raw_stream_count: rows.length, | |
| playable_stream_count: playable.length, | |
| content_verified_count: identityVerified.length, | |
| identity_verified_count: identityVerified.length, | |
| identity_unverified_count: identityUnknown.length, | |
| identity_contradiction_count: identityContradictions.length, | |
| streams: inspected, | |
| }) + '\\n'); | |
| process.exitCode = !runtimeError && strictComplete ? 0 : 2; | |
| """ | |
| replace_once('scripts/nuvio_tv_probe_v2.cjs', old_main, new_main) | |
| strict_probe_old = '"ok": bool(parsed and int(parsed.get("playable_stream_count") or 0) > 0),' | |
| strict_probe_new = '"ok": bool(parsed and parsed.get("ok") and int(parsed.get("content_verified_count") or 0) > 0 and int(parsed.get("content_verified_count") or 0) == int(parsed.get("playable_stream_count") or 0) and int(parsed.get("identity_contradiction_count") or 0) == 0),' | |
| for path in ( | |
| 'scripts/promote_global_nuvio_tv_candidates.py', | |
| 'scripts/promote_target_media_v3.py', | |
| 'scripts/reactivate_strict_main_providers.py', | |
| ): | |
| replace_once(path, strict_probe_old, strict_probe_new) | |
| for path in ('scripts/promote_global_nuvio_tv_candidates.py', 'scripts/promote_target_media_v3.py'): | |
| replace_once( | |
| path, | |
| """ count = int(value.get("playable_stream_count") or 0) | |
| return (1 if count else 0, count) | |
| """, | |
| """ playable = int(value.get("playable_stream_count") or 0) | |
| verified = int(value.get("content_verified_count") or value.get("identity_verified_count") or 0) | |
| contradictions = int(value.get("identity_contradiction_count") or 0) | |
| strict = playable > 0 and verified == playable and contradictions == 0 | |
| return (1 if strict else 0, verified if strict else 0) | |
| """, | |
| ) | |
| # Compatibility/transformation publishers must never turn a disabled | |
| # provider on by themselves. Activation belongs to the identity-safe | |
| # repair/native evidence gate. | |
| for path in ( | |
| 'scripts/promote_global_nuvio_tv_candidates.py', | |
| 'scripts/promote_target_media_v3.py', | |
| 'scripts/publish_nuvio_tv_compat_v2.py', | |
| 'scripts/publish_desktop_runtime_compat.py', | |
| ): | |
| replace_once(path, ' row["enabled"] = True\n', ' row["enabled"] = row.get("enabled") is True\n') | |
| # v2 compatibility proof gets the same duration sentinel as deep health. | |
| replace_once( | |
| 'scripts/publish_nuvio_tv_compat_v2.py', | |
| ' "category": "movie",\n}', | |
| ' "category": "movie",\n "expectedDurationMinutes": 169,\n}', | |
| ) | |
| # Explicit reactivation is allowed only when the strict probe itself passed. | |
| replace_once( | |
| 'scripts/reactivate_strict_main_providers.py', | |
| """ parsed = result.get("result") or {} | |
| playable = [item for item in parsed.get("streams") or [] if strict_media(item.get("media") or {})] | |
| """, | |
| """ parsed = result.get("result") or {} | |
| if not result.get("ok"): | |
| return False, ["strict content identity/duration probe failed"] | |
| playable = [item for item in parsed.get("streams") or [] if strict_media(item.get("media") or {})] | |
| """, | |
| ) | |
| # Report identity-unverified playable media as inconclusive, not healthy. | |
| replace_once( | |
| 'scripts/audit_catalogue_identity_media.py', | |
| """ identity_verified_count = int(probe.get("identity_verified_count") or 0) | |
| identity_contradiction_count = int(probe.get("identity_contradiction_count") or 0) | |
| summary = summarize_media(probe) | |
| status = "wrong_content" if identity_contradiction_count > 0 else ("playable" if playable_count > 0 else ("returned_unplayable" if raw_count > 0 else "no_streams")) | |
| """, | |
| """ identity_verified_count = int(probe.get("identity_verified_count") or 0) | |
| content_verified_count = int(probe.get("content_verified_count") or identity_verified_count) | |
| identity_contradiction_count = int(probe.get("identity_contradiction_count") or 0) | |
| summary = summarize_media(probe) | |
| status = "wrong_content" if identity_contradiction_count > 0 else ("playable" if playable_count > 0 and content_verified_count == playable_count else ("identity_unverified" if playable_count > 0 else ("returned_unplayable" if raw_count > 0 else "no_streams"))) | |
| """, | |
| ) | |
| replace_once( | |
| 'scripts/audit_catalogue_identity_media.py', | |
| ' "identity_verified_count": identity_verified_count,\n "identity_contradiction_count": identity_contradiction_count,', | |
| ' "identity_verified_count": identity_verified_count,\n "content_verified_count": content_verified_count,\n "identity_contradiction_count": identity_contradiction_count,', | |
| ) | |
| # Regression: an "Inconnue" display title may still be valid when the | |
| # description carries the requested identity. | |
| test_path = Path('tests/nuvio_client_lab.test.cjs') | |
| test_text = test_path.read_text(encoding='utf-8') | |
| anchor = """assert.deepEqual(streamIdentity({ name: 'Purstream 1080p Dual Audio - Inconnue', url: 'https://cdn.example/hls2/03/00026/master.m3u8' }, { title: 'Revenant', mediaType: 'tv', season: 1, episode: 1 }), { status: 'unknown', reason: 'insufficient_identity_metadata' }); | |
| """ | |
| extra = anchor + """assert.deepEqual(streamIdentity({ title: 'Purstream 1080p Dual Audio - Inconnue', description: 'Revenant S01E01', url: 'https://cdn.example/hls2/03/00026/master.m3u8' }, { title: 'Revenant', mediaType: 'tv', season: 1, episode: 1 }), { status: 'match', reason: 'expected_title_alias' }); | |
| """ | |
| if anchor not in test_text: | |
| raise SystemExit('Purstream identity regression anchor missing') | |
| test_path.write_text(test_text.replace(anchor, extra, 1), encoding='utf-8') | |
| # Keep a durable integration regression for the historical 7-minute | |
| # wrong-cartoon family and for valid generic labels. | |
| media_test = Path('tests/media_duration_identity_test.py') | |
| text = media_test.read_text(encoding='utf-8') | |
| print_anchor = "print('global media duration identity tests passed')\n" | |
| block = r''' | |
| # The NuvioTV probe used by promotion scripts must enforce the same | |
| # identity+duration contract as deep health. | |
| import http.server | |
| import subprocess | |
| import tempfile | |
| import threading | |
| class _ProbeHandler(http.server.BaseHTTPRequestHandler): | |
| def do_GET(self): | |
| duration = 420 if self.path.startswith('/wrong') else 3480 | |
| body = f"#EXTM3U\n#EXT-X-VERSION:3\n#EXTINF:{duration},\nsegment.ts\n#EXT-X-ENDLIST\n".encode() | |
| self.send_response(200) | |
| self.send_header('Content-Type', 'application/vnd.apple.mpegurl') | |
| self.send_header('Content-Length', str(len(body))) | |
| self.end_headers() | |
| self.wfile.write(body) | |
| def log_message(self, *_args): | |
| pass | |
| server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), _ProbeHandler) | |
| thread = threading.Thread(target=server.serve_forever, daemon=True) | |
| thread.start() | |
| fixture = { | |
| 'tmdbId': '1396', 'mediaType': 'tv', 'season': 1, 'episode': 1, | |
| 'title': 'Breaking Bad', 'year': 2008, 'expectedDurationMinutes': 58, | |
| } | |
| try: | |
| with tempfile.TemporaryDirectory() as tmp: | |
| tmp = Path(tmp) | |
| def run_provider(name, stream): | |
| provider = tmp / f'{name}.cjs' | |
| provider.write_text('module.exports={getStreams:async()=>[' + json.dumps(stream) + ']};\n', encoding='utf-8') | |
| proc = subprocess.run( | |
| ['node', str(ROOT / 'scripts/nuvio_tv_probe_v2.cjs'), str(provider), json.dumps(fixture), '{}'], | |
| cwd=ROOT, text=True, capture_output=True, timeout=25, | |
| ) | |
| parsed = None | |
| for line in reversed(proc.stdout.splitlines()): | |
| try: | |
| candidate = json.loads(line) | |
| except Exception: | |
| continue | |
| if isinstance(candidate, dict) and 'playable_stream_count' in candidate: | |
| parsed = candidate | |
| break | |
| assert parsed is not None, (proc.returncode, proc.stdout, proc.stderr) | |
| return proc, parsed | |
| base = f'http://127.0.0.1:{server.server_port}' | |
| bad_proc, bad = run_provider('wrong_cartoon', { | |
| 'url': base + '/wrong.m3u8', | |
| 'title': 'TopCartoons - Unknown', | |
| 'description': 'Ben 10 Ultimate Alien', | |
| }) | |
| assert bad_proc.returncode != 0, bad | |
| assert bad['identity_contradiction_count'] == 1, bad | |
| assert bad['content_verified_count'] == 0, bad | |
| good_proc, good = run_provider('generic_but_described', { | |
| 'url': base + '/good.m3u8', | |
| 'title': 'Purstream 1080p Dual Audio - Inconnue', | |
| 'description': 'Breaking Bad S01E01', | |
| }) | |
| assert good_proc.returncode == 0, (good, good_proc.stderr) | |
| assert good['content_verified_count'] == 1, good | |
| assert good['identity_contradiction_count'] == 0, good | |
| duration_proc, duration_only = run_provider('duration_only', { | |
| 'url': base + '/good.m3u8', | |
| 'title': 'Purstream 1080p Dual Audio - Inconnue', | |
| }) | |
| assert duration_proc.returncode == 0, (duration_only, duration_proc.stderr) | |
| assert duration_only['content_verified_count'] == 1, duration_only | |
| assert duration_only['streams'][0]['identity']['reason'] == 'fixture_duration_match', duration_only | |
| finally: | |
| server.shutdown() | |
| server.server_close() | |
| thread.join(timeout=2) | |
| probe_source = (ROOT / 'scripts' / 'nuvio_tv_probe_v2.cjs').read_text(encoding='utf-8') | |
| assert 'content_verified_count' in probe_source | |
| assert 'fixture_duration_mismatch' in probe_source | |
| for publisher in ( | |
| 'scripts/promote_global_nuvio_tv_candidates.py', | |
| 'scripts/promote_target_media_v3.py', | |
| 'scripts/publish_nuvio_tv_compat_v2.py', | |
| 'scripts/reactivate_strict_main_providers.py', | |
| ): | |
| publisher_source = (ROOT / publisher).read_text(encoding='utf-8') | |
| assert 'content_verified_count' in publisher_source or 'parsed.get("ok")' in publisher_source, publisher | |
| print('global media duration identity tests passed') | |
| ''' | |
| if print_anchor not in text: | |
| raise SystemExit('media duration test print anchor missing') | |
| media_test.write_text(text.replace(print_anchor, block, 1), encoding='utf-8') | |
| # Remove obsolete unreferenced v1 publisher/probe; v2 is the only TV | |
| # probe path retained. | |
| for obsolete in ('scripts/publish_nuvio_tv_compat.py', 'scripts/nuvio_tv_probe.cjs'): | |
| p = Path(obsolete) | |
| if p.exists(): | |
| p.unlink() | |
| # This temporary workflow must not survive in the resulting branch. | |
| Path('.github/workflows/tmp-apply-strict-content-promotion-fix.yml').unlink() | |
| PY | |
| - name: Syntax and targeted regression tests | |
| run: | | |
| node --check scripts/nuvio_tv_probe_v2.cjs | |
| node --check scripts/nuvio_client_lab.cjs | |
| python -m py_compile \ | |
| scripts/promote_global_nuvio_tv_candidates.py \ | |
| scripts/promote_target_media_v3.py \ | |
| scripts/publish_nuvio_tv_compat_v2.py \ | |
| scripts/reactivate_strict_main_providers.py \ | |
| scripts/audit_catalogue_identity_media.py \ | |
| scripts/publish_desktop_runtime_compat.py \ | |
| tests/media_duration_identity_test.py | |
| node tests/nuvio_client_lab.test.cjs | |
| python tests/media_duration_identity_test.py | |
| python tests/repair_identity_gate_test.py | |
| - name: Refresh integrity metadata and run full tests | |
| run: | | |
| python scripts/generate_release_hashes.py | |
| npm test --ignore-scripts | |
| python scripts/validate_release_integrity.py | |
| - name: Commit tested fix to branch | |
| run: | | |
| git config user.name 'niakvio-ci' | |
| git config user.email 'actions@users.noreply.github.com' | |
| git add -A | |
| git status --short | |
| git commit -m 'fix: enforce strict content proof before provider promotion' | |
| git push origin HEAD:fix/strict-content-promotion-gate |