From 765a9ca2440f3748d3d27519c202393ba5342e24 Mon Sep 17 00:00:00 2001 From: Mathias Tock Date: Fri, 1 May 2026 21:17:31 +0200 Subject: [PATCH 1/5] feat(live-test): structured diagnostic JSON for lag attribution The HUD shows `Bitrate` and `Target` both at 6 000 because pulsar:GetAdaptiveState.current_kbps stays equal to target_kbps as long as no drops are sensed -- normal for a healthy CBR run, but it gives the impression the value is stale. Lag attribution also needs data the HUD doesn't surface : actual encoded fps, render time per frame, render-skipped frames, output-skipped frames, effective network bitrate. The probe now captures a comprehensive perf snapshot on every POLL_INTERVAL_SEC tick : active_fps -- live encoder fps avg_render_ms -- ms per render+encode (>16 = bad at 60fps) render_total / skipped -- compositor lag output_total / skipped -- encoder/network drops output_bytes -- cumulative, derives effective kbps drop_ratio, current/target_kbps, cpu, memory At end-of-run it computes summary stats (avg / max / p95 / final), ffprobes the recorded MP4 (codec / actual encoded bitrate / fps / duration -- the gold-standard answer to "is the encoder really hitting target"), and writes everything as `diagnostic.json` to LIVE_VOD_DIR. The path is echoed via a `LIVE_DIAGNOSTIC_PATH=` sentinel for the workflow to upload as an artefact. A one-line summary lands on stdout for at-a-glance CI readability : avg_fps=59.98 render_avg=4.1ms render_p95=6.8ms render_skipped=0 output_skipped=0 effective_kbps=5980 target=6000 If render_p95 > 16 ms or render_skipped > 0 the lag came from the compositor ; if effective_kbps drifts way below target with no output_skipped, blame the network ; if output_skipped grows, the encoder dropped frames. Every case is now visible. The workflow uploads `artefacts/diagnostic.json` alongside the MP4 under the `pulsar-live-broadcast-proof` artefact name. --- .github/workflows/live-test.yml | 24 +++- scripts/probe-twitch-live.py | 196 +++++++++++++++++++++++++++++++- 2 files changed, 213 insertions(+), 7 deletions(-) diff --git a/.github/workflows/live-test.yml b/.github/workflows/live-test.yml index 560b15b..6757475 100644 --- a/.github/workflows/live-test.yml +++ b/.github/workflows/live-test.yml @@ -213,12 +213,32 @@ jobs: echo "VOD_STABLE_PATH=${stable}" >> "$GITHUB_ENV" echo "VOD_VERSIONED_PATH=${versioned}" >> "$GITHUB_ENV" - - name: Upload broadcast VOD as workflow artefact + - name: Stage diagnostic JSON + # The probe writes diagnostic.json next to the raw MP4 with + # per-poll perf samples + summary + ffprobe of the recording. + # Useful for attributing lag (high render time, dropped frames) + # to pulsar vs network vs Twitch ingest. + if: success() + shell: bash + run: | + set -euo pipefail + diag="$(grep '^LIVE_DIAGNOSTIC_PATH=' probe-output.log | tail -1 | cut -d= -f2-)" + diag="${diag%$'\r'}" + if [ -n "${diag}" ] && [ -f "${diag}" ]; then + cp "${diag}" artefacts/diagnostic.json + echo "::notice::diagnostic.json staged ($(stat -c%s artefacts/diagnostic.json) bytes)" + else + echo "::warning::no diagnostic.json sentinel found -- probe may be older" + fi + + - name: Upload broadcast VOD + diagnostic as workflow artefact if: success() uses: actions/upload-artifact@v4 with: name: pulsar-live-broadcast-proof - path: artefacts/pulsar-live-broadcast-proof-*.mp4 + path: | + artefacts/pulsar-live-broadcast-proof-*.mp4 + artefacts/diagnostic.json retention-days: 90 if-no-files-found: error diff --git a/scripts/probe-twitch-live.py b/scripts/probe-twitch-live.py index 5092cfa..51d0a83 100644 --- a/scripts/probe-twitch-live.py +++ b/scripts/probe-twitch-live.py @@ -337,6 +337,137 @@ def fail_log(label: str, msg: str) -> None: print(f"::error::live-test {label}: {msg}", file=sys.stderr) +# ── Diagnostic JSON dump ──────────────────────────────────────────────────── +# Writes a structured snapshot at end-of-run so reviewers can attribute lag +# to pulsar (high render time, dropped frames) vs network (low effective +# bitrate vs target) vs upstream (e.g. Twitch ingest stalls). Two halves : +# - the per-poll sample series (raw signal) +# - a summary block (avg / max / p95 / total skipped) for at-a-glance +# Plus ffprobe stats on the local MP4 (the encoded-on-disk truth). + +def _percentile(values: list[float], p: float) -> float | None: + """p-th percentile of a numeric list. p in [0, 100].""" + vs = sorted(v for v in values if v is not None) + if not vs: + return None + k = (len(vs) - 1) * (p / 100.0) + lo = int(k) + hi = min(lo + 1, len(vs) - 1) + if lo == hi: + return float(vs[lo]) + return float(vs[lo] + (vs[hi] - vs[lo]) * (k - lo)) + + +def _ffprobe_summary(mp4_path: str) -> dict: + """Return codec / bitrate / fps / duration for the recorded MP4. Empty + dict on failure -- a missing diagnostic is non-fatal, the workflow + upload still happens.""" + try: + proc = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", + "stream=codec_type,codec_name,width,height,r_frame_rate,bit_rate,duration,sample_rate,channels", + "-of", "json", mp4_path], + capture_output=True, text=True, timeout=30, + ) + if proc.returncode != 0: + return {"_ffprobe_error": (proc.stderr or "")[:500]} + data = json.loads(proc.stdout or "{}") + out = {"streams": []} + for s in data.get("streams", []): + entry = {k: s.get(k) for k in ( + "codec_type", "codec_name", "width", "height", + "r_frame_rate", "bit_rate", "duration", + "sample_rate", "channels", + ) if s.get(k) is not None} + # Convert bit_rate to kbps for readability. + if "bit_rate" in entry: + try: + entry["bit_rate_kbps"] = round(int(entry["bit_rate"]) / 1000) + except (TypeError, ValueError): + pass + out["streams"].append(entry) + return out + except Exception as e: + return {"_ffprobe_error": str(e)[:500]} + + +def write_diagnostic(samples: list[dict], vod_path: str | None, duration_sec: int) -> str | None: + """Compute end-of-run summary stats + ffprobe the MP4, write JSON to + LIVE_VOD_DIR. Returns the absolute path on success, None on failure + (failure here is non-fatal -- the broadcast proof MP4 is the gate).""" + if not samples: + return None + + def _avg(key): + vals = [s.get(key) for s in samples if s.get(key) is not None] + return (sum(vals) / len(vals)) if vals else None + def _max(key): + vals = [s.get(key) for s in samples if s.get(key) is not None] + return max(vals) if vals else None + def _p95(key): + return _percentile([s.get(key) for s in samples], 95.0) + def _last(key): + for s in reversed(samples): + if s.get(key) is not None: + return s[key] + return None + + # outputBytes is cumulative ; effective bitrate = (last - first) over span. + bytes_first = next((s["output_bytes"] for s in samples if s.get("output_bytes") is not None), 0) or 0 + bytes_last = _last("output_bytes") or 0 + span_sec = max(1, samples[-1]["t"] - samples[0]["t"]) + effective_kbps = round((bytes_last - bytes_first) * 8 / span_sec / 1000) if bytes_last > bytes_first else None + + summary = { + "duration_sec": duration_sec, + "samples": len(samples), + "active_fps_avg": _avg("active_fps"), + "active_fps_min": min((s["active_fps"] for s in samples if s.get("active_fps") is not None), default=None), + "render_ms_avg": _avg("avg_render_ms"), + "render_ms_p95": _p95("avg_render_ms"), + "render_ms_max": _max("avg_render_ms"), + "render_skipped": _last("render_skipped") or 0, + "render_total": _last("render_total") or 0, + "output_skipped": _last("output_skipped") or 0, + "output_total": _last("output_total") or 0, + "drop_ratio_max": _max("drop_ratio") or 0.0, + "current_kbps_min": min((s["current_kbps"] for s in samples if s.get("current_kbps") is not None), default=None), + "current_kbps_max": _max("current_kbps"), + "target_kbps": _last("target_kbps"), + "effective_kbps": effective_kbps, # derived from outputBytes delta + "cpu_pct_avg": _avg("cpu_pct"), + "cpu_pct_max": _max("cpu_pct"), + "memory_mb_final": _last("memory_mb"), + "adaptive_samples": _last("adaptive_samples") or 0, + } + + diagnostic = { + "schema": "pulsar-live-test-diagnostic/v1", + "summary": summary, + "samples": samples, + "mp4_ffprobe": _ffprobe_summary(vod_path) if vod_path else None, + } + + try: + LIVE_VOD_DIR.mkdir(parents=True, exist_ok=True) + out = LIVE_VOD_DIR / "diagnostic.json" + out.write_text(json.dumps(diagnostic, indent=2, default=str), encoding="utf-8") + except OSError as e: + print(f"::warning::could not write diagnostic.json: {e}", file=sys.stderr) + return None + + # One-line summary on stdout so a CI run is at-a-glance diagnosable. + print(f"[live-test] diagnostic : " + f"avg_fps={summary['active_fps_avg']} " + f"render_avg={summary['render_ms_avg']}ms " + f"render_p95={summary['render_ms_p95']}ms " + f"render_skipped={summary['render_skipped']} " + f"output_skipped={summary['output_skipped']} " + f"effective_kbps={summary['effective_kbps']} " + f"target={summary['target_kbps']}") + return str(out) + + async def probe(stream_key: str, duration_sec: int, fps: int) -> int: if not stream_key: fail_log("config", "TWITCH_STREAM_KEY env var is empty") @@ -509,12 +640,19 @@ async def probe(stream_key: str, duration_sec: int, fps: int) -> int: print(f"[live-test] local recording started (writing under {LIVE_VOD_DIR})") # 4. Poll metrics every POLL_INTERVAL_SEC for `duration_sec`. + # Per-poll samples accumulate in `perf_samples` ; at end-of-run + # the probe computes a structured diagnostic JSON (avg / max / + # p95) plus an ffprobe summary of the recorded MP4. This is + # the gold-standard answer to "is the lag coming from pulsar" + # -- everything libobs collects internally is captured. start_t = time.time() poll_count = 0 adaptive_samples_seen = 0 + perf_samples: list[dict] = [] while time.time() - start_t < duration_sec: await asyncio.sleep(POLL_INTERVAL_SEC) poll_count += 1 + elapsed = int(time.time() - start_t) # GetDestinations — assert active=true on our id. r = await vendor_call(ws, inbox, f"get-dest-{poll_count}", @@ -533,13 +671,54 @@ async def probe(stream_key: str, duration_sec: int, fps: int) -> int: if samples > adaptive_samples_seen: adaptive_samples_seen = samples drop_ratio = float(adapt.get("last_drop_ratio", 0.0)) - cur_bitrate = adapt.get("current_bitrate") - - elapsed = int(time.time() - start_t) + cur_bitrate = adapt.get("current_kbps") + target_bitrate = adapt.get("target_kbps") + + # GetStats — comprehensive perf snapshot (the "is it + # pulsar" tooling). Standard obs-websocket v5 request, + # NOT a vendor call. The fields that matter for lag + # attribution : + # activeFps -- live encoder fps. <60 = bad. + # averageFrameRenderTime -- ms per render+encode. >16 = bad + # at 60fps. + # renderSkippedFrames -- compositor lagged. + # outputSkippedFrames -- encoder/network dropped. + stats_r = await request(ws, inbox, "GetStats", f"stats-{poll_count}") + stats = stats_r.get("responseData", {}) or {} + + # GetStreamStatus — outputBytes lets us derive the actual + # encoded bitrate. Multi-stream destinations don't update + # the legacy outputs but obs's stats may still tick. + ss_r = await request(ws, inbox, "GetStreamStatus", f"stream-{poll_count}") + ss = ss_r.get("responseData", {}) or {} + + sample = { + "t": elapsed, + "poll": poll_count, + "adaptive_samples": samples, + "drop_ratio": drop_ratio, + "current_kbps": cur_bitrate, + "target_kbps": target_bitrate, + "active_fps": stats.get("activeFps"), + "avg_render_ms": stats.get("averageFrameRenderTime"), + "render_total": stats.get("renderTotalFrames"), + "render_skipped": stats.get("renderSkippedFrames"), + "output_total": stats.get("outputTotalFrames"), + "output_skipped": stats.get("outputSkippedFrames"), + "output_bytes": ss.get("outputBytes"), + "cpu_pct": stats.get("cpuUsage"), + "memory_mb": stats.get("memoryUsage"), + "destination_active": bool(ours and ours.get("active")), + } + perf_samples.append(sample) + + fps_str = f"{sample['active_fps']:.1f}" if sample['active_fps'] is not None else "—" + rt_str = f"{sample['avg_render_ms']:.1f}ms" if sample['avg_render_ms'] is not None else "—" print(f"[live-test] poll #{poll_count} t={elapsed}s " f"active=true samples={samples} " f"drop_ratio={drop_ratio:.4f} " - f"bitrate={cur_bitrate}") + f"bitrate={cur_bitrate} " + f"fps={fps_str} render={rt_str}") if drop_ratio > FRAME_DROP_RATIO_MAX: fail_log("poll", @@ -577,7 +756,14 @@ async def probe(stream_key: str, duration_sec: int, fps: int) -> int: return 1 print(f"[live-test] adaptive samples seen total : {adaptive_samples_seen}") - # 7. RemoveDestination. + # 7. Write diagnostic JSON so reviewers can attribute lag. + diag_path = write_diagnostic(perf_samples, vod_path, duration_sec) + if diag_path: + # Sentinel parsed by live-test.yml to upload the JSON + # as a workflow artefact alongside the MP4. + print(f"LIVE_DIAGNOSTIC_PATH={diag_path}") + + # 8. RemoveDestination. await vendor_call(ws, inbox, "remove-dest", "pulsar", "RemoveDestination", {"id": dest_id}) From bf5bd791e92778a855d6a4ebca07250877cc1953 Mon Sep 17 00:00:00 2001 From: Mathias Tock Date: Fri, 1 May 2026 21:31:52 +0200 Subject: [PATCH 2/5] ci: collapse the six-workflow split into a single pipeline.yml Rebuild-once architecture. The previous setup spawned five separate GitHub-Actions workflows on a tag push (build.yml, license-isolation.yml, live-test.yml, release.yml, publish-npm.yml -- ci.yml runs only on PR/main but spawns its own runner too), and three of them did a full Windows MSVC build of pulsar.exe in parallel doing the same work. Frustrating to watch, slow on tag pushes, no shared state between runners. We never needed five workflows -- one would do. Replace the six YAMLs (871 lines) with a single pipeline.yml (403 lines) carrying ONE job on windows-2022 with 30 sequential steps : - checkout + git identity (1-2) - lint : license-isolation grep, patch-lint, plugin metadata (3-5) - THE BUILD : cached obs-deps + Qt6 + CEF, MSVC, build-win.ps1 -Full (6-9) - binary-export gate (10) - offline probe suite via CTest (11-13) - live Twitch broadcast + ffmpeg compress + diagnostic JSON (14-18) - gh-pages publish, gated to main/tag (19) - package light + full distros, gated to tag (20-21) - GitHub Release attach, gated to tag (22) - npm publish in topo order (client -> bundle -> bundle-full), gated to tag (23-29) - failure forensics, runs only on failure (30) Trigger / duration matrix carried over verbatim : push branch : full pipeline minus tag-only stages, broadcast 60 s push main : adds gh-pages publish, broadcast 1800 s push tag v*.*.* : adds package + release attach + npm, broadcast 1800 s PR to main : full pipeline minus all release-grade steps workflow_dispatch : manual duration / fps paths-ignore at the workflow level skips the whole pipeline on docs / changelog / .gitignore / LICENSE -- those don't affect what pulsar.exe builds. Concurrency `pipeline-` with cancel-in-progress means a fresh push to the same branch cancels the previous run, so broken intermediate states don't queue up. End-to-end timing : push branch : ~15 min (lint + build + gates + 60 s broadcast + cleanup) push main : ~45 min (the above + 30 min broadcast + gh-pages) push tag : ~50 min (the above + package + release + npm) Versus the previous parallel-but-redundant layout : tag push went from ~30 min wall (3 parallel builds wasting 2 of them) to ~50 min wall sequential -- but never duplicates work. PR push goes from ~25 min (parallel build.yml + live-test.yml each rebuilding) to ~15 min sequential since the build is shared. Six workflow files deleted, one added : net -468 lines. --- .github/workflows/build.yml | 114 ------- .github/workflows/ci.yml | 110 ------- .github/workflows/license-isolation.yml | 57 ---- .github/workflows/live-test.yml | 291 ----------------- .github/workflows/pipeline.yml | 403 ++++++++++++++++++++++++ .github/workflows/publish-npm.yml | 146 --------- .github/workflows/release.yml | 153 --------- 7 files changed, 403 insertions(+), 871 deletions(-) delete mode 100644 .github/workflows/build.yml delete mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/license-isolation.yml delete mode 100644 .github/workflows/live-test.yml create mode 100644 .github/workflows/pipeline.yml delete mode 100644 .github/workflows/publish-npm.yml delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 12cd04e..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,114 +0,0 @@ -# Pulsar — Windows build gate. -# -# Runs the same build pipeline release.yml uses, minus packaging, -# minus -Full (no CEF / no obs-browser — keeps the run under -# ~20 min instead of ~40). Produces `pulsar.exe` so the binary-export -# license invariant (#3) can be verified before any code lands on -# main. -# -# Why every PR pays a full Windows build : the binary-export check is -# the only mechanism that catches a plugin starting to publish symbols -# (a __declspec or EXPORT_SYMBOL is the source-side fingerprint, but -# upstream patches or future linker flags could in principle slip a -# symbol through other paths). Static grep + binary dumpbin is the -# defence-in-depth this license posture needs. -# -# Mac (Phase 7) and Linux (Phase 8) join the matrix when those -# targets light up. They will share the same gate structure. - -name: build - -on: - pull_request: - branches: [main] - push: - branches: [main] - -jobs: - windows-x64: - name: build + license gate (windows-x64) - runs-on: windows-2022 - timeout-minutes: 60 - - steps: - - name: Checkout (with submodules) - uses: actions/checkout@v4 - with: - submodules: recursive - fetch-depth: 0 - - - name: License isolation source-tree audit - # Same gate license-isolation.yml runs, repeated here so the - # build job fails fast on a sourcecode-level violation before - # paying the build cost. - shell: bash - run: bash scripts/check-license-isolation.sh - - - name: Cache obs-deps + Qt6 - # build-win.ps1 fetches obs-deps, Qt6, and (with -Full) CEF - # into upstream/.deps/. The downloads dominate cold-cache - # build time. Key on the build-win.ps1 hash (so a deps-list - # change invalidates) + the upstream submodule pointer (so a - # libobs bump invalidates). - uses: actions/cache@v4 - with: - path: upstream/.deps - key: obs-deps-${{ runner.os }}-${{ hashFiles('scripts/build-win.ps1', '.gitmodules') }} - restore-keys: | - obs-deps-${{ runner.os }}- - - - name: Configure git identity - # build-win.ps1 calls `git am` to apply patches/ onto the - # upstream/ submodule, which writes a commit and needs a - # configured user.name / user.email — absent on a fresh runner. - shell: pwsh - run: | - git config --global user.email "ci@pulsar.zablaboratory" - git config --global user.name "Pulsar CI" - - - name: Install CMake 3.28+ - uses: lukka/get-cmake@latest - with: - cmakeVersion: '3.30.0' - - - name: Setup MSVC toolchain - uses: ilammy/msvc-dev-cmd@v1 - with: - arch: x64 - - - name: Build (no -Full, headless only) - # Skipping -Full means no CEF / no obs-browser ; that codepath - # is exercised by release.yml on every tag. For PR validation - # we only need pulsar.exe + the four Pulsar plugins to confirm - # the source compiles + nothing exports. - shell: pwsh - run: ./scripts/build-win.ps1 - - - name: Verify binary export tables (#3) - # LICENSE-INVARIANTS.md (#3) gate. Scans pulsar.exe (must be - # empty), pulsar-browser-page.exe (must be empty), and every - # Pulsar-owned plugin DLL (only OBS module ABI symbols allowed). - # See scripts/check-binary-exports.ps1 for the full policy. - shell: pwsh - run: ./scripts/check-binary-exports.ps1 - - - name: Setup Python (for offline probes) - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install probe deps - shell: bash - run: | - python -m pip install --upgrade pip - pip install websockets - - - name: Run offline probe suite (CTest) - # Spawns pulsar.exe, waits for the PULSAR_READY sentinel, runs - # probe-{websocket,events,multi-stream,adaptive,record}.py - # against it, asserts exit 0. The live Twitch broadcast probe - # is excluded -- it lives in live-test.yml. - # See scripts/run-probes.ps1 + top-level CMakeLists.txt for the - # CTest registration. - shell: pwsh - run: ctest --test-dir build --output-on-failure -C RelWithDebInfo diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index ac84ffe..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,110 +0,0 @@ -# Pulsar — lightweight per-PR checks. -# -# Fast Linux-only sanity checks that don't need the full Windows -# build. License isolation has its own workflow ; these are the -# non-license invariants of repo hygiene. -# -# Two jobs : -# - patch-lint : every patches/*.patch must apply cleanly on -# upstream/ at the pinned submodule SHA. A -# patch that doesn't apply means the next -# release is broken. -# - plugin-metadata : every plugins// must carry CMakeLists.txt -# + README.md. Catches a half-added plugin -# that the build silently ignored. -# -# Mac / Linux build matrices live in build.yml when they light up. - -name: ci - -on: - pull_request: - branches: [main] - push: - branches: [main] - -jobs: - patch-lint: - name: patches/ apply cleanly on upstream pinned SHA - runs-on: ubuntu-latest - steps: - - name: Checkout (with submodules) - uses: actions/checkout@v4 - with: - submodules: recursive - fetch-depth: 0 - - - name: Configure git identity - # `git am` writes commits ; without a configured user identity - # it aborts. Local-only — nothing is pushed. - run: | - git config --global user.email "ci@pulsar.zablaboratory" - git config --global user.name "Pulsar CI" - - - name: Reset upstream to pinned SHA - # build-win.ps1 does this same reset before applying patches. - # We replicate the deterministic state here so a developer - # working in upstream/ with stray commits doesn't poison the - # lint result. - run: | - set -e - pinned=$(git submodule status --cached upstream | awk '{print $1}' | sed 's/^[+-]*//') - if [ -z "$pinned" ]; then - echo "::error::Could not read pinned upstream SHA." - exit 1 - fi - echo "Pinned upstream SHA : $pinned" - cd upstream - git reset --hard "$pinned" - git clean -fdx - - - name: Apply each patch in order - run: | - set -e - shopt -s nullglob - patches=(patches/*.patch) - if [ ${#patches[@]} -eq 0 ]; then - echo "::notice::No patches under patches/ — nothing to apply." - exit 0 - fi - cd upstream - for p in "${patches[@]}"; do - full="../$p" - echo "::group::git am ../$p" - if ! git am --3way "$full"; then - echo "::error::Patch $p does not apply on upstream pinned SHA." - git am --abort || true - exit 1 - fi - echo "::endgroup::" - done - echo "::notice::All ${#patches[@]} patch(es) apply cleanly." - - plugin-metadata: - name: every plugins/* carries CMakeLists.txt + README.md - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Verify metadata - run: | - set -e - fail=0 - shopt -s nullglob - for dir in plugins/*/; do - name=$(basename "$dir") - if [ ! -f "$dir/CMakeLists.txt" ]; then - echo "::error file=$dir::Missing CMakeLists.txt under plugins/$name." - fail=1 - fi - if [ ! -f "$dir/README.md" ]; then - echo "::error file=$dir::Missing README.md under plugins/$name." - fail=1 - fi - done - if [ $fail -ne 0 ]; then - echo "::error::Plugin metadata sanity failed." - exit 1 - fi - count=$(ls -1d plugins/*/ 2>/dev/null | wc -l) - echo "::notice::Plugin metadata sanity passed for $count plugin(s)." diff --git a/.github/workflows/license-isolation.yml b/.github/workflows/license-isolation.yml deleted file mode 100644 index 743414d..0000000 --- a/.github/workflows/license-isolation.yml +++ /dev/null @@ -1,57 +0,0 @@ -# License isolation gate. -# -# Pulsar is GPL-2.0-or-later. Consumers (Prism, future apps) keep their -# own license only because of the four invariants in -# LICENSE-INVARIANTS.md (process boundary, WS-only IPC, no FFI on the -# consumer side, no copy-paste of source). This workflow enforces what -# can be enforced statically on the Pulsar source tree. -# -# Two jobs : -# - source-grep : forbidden FFI / NAPI / node-gyp / consumer-stage -# patterns in plugins/ + packages/ + scripts/ -# - npm-pack-audit : every workspace's `npm pack --dry-run` content -# must not include C/C++ source or node-gyp -# manifests -# -# Both jobs delegate to scripts under scripts/check-*.sh so the same -# logic runs in release.yml + publish-npm.yml on the path that -# actually ships artefacts. Logic lives in one place, multiple -# trigger points. -# -# The harder check (binary `dumpbin /exports pulsar.exe` empty) lives -# in release.yml because it requires a full Windows build — too heavy -# for every PR. release.yml fails early if the binary exports anything, -# blocking the GitHub Release before artefacts are uploaded. - -name: license-isolation - -on: - pull_request: - branches: [main] - push: - branches: [main] - -jobs: - source-grep: - name: source-tree audit (#1, #3, #4) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Run source-tree audit - run: bash scripts/check-license-isolation.sh - - npm-pack-audit: - name: npm tarball audit (#3, #4) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - name: Install workspaces (skip pulsar.exe download) - env: - PULSAR_BUNDLE_SKIP_POSTINSTALL: '1' - run: npm install --no-audit --no-fund --force - - name: Run npm tarball audit - run: bash scripts/check-npm-pack-audit.sh diff --git a/.github/workflows/live-test.yml b/.github/workflows/live-test.yml deleted file mode 100644 index 6757475..0000000 --- a/.github/workflows/live-test.yml +++ /dev/null @@ -1,291 +0,0 @@ -# Pulsar — Twitch live broadcast end-to-end test. -# -# Spawns pulsar.exe, points its CEF browser_source at a locally-served -# test scene, pushes a real broadcast to Twitch using -# secrets.TWITCH_STREAM_KEY, polls metrics, asserts thresholds, cleans -# up, then re-encodes the recording into a smaller MP4 for the README -# `