Skip to content

ci: detection proof - poison corpus counter-evidence for the matrix #1

ci: detection proof - poison corpus counter-evidence for the matrix

ci: detection proof - poison corpus counter-evidence for the matrix #1

# PR gate: execute every registry pack's frontend JS against this commit's
# frontend runtime and gate on the population result.
#
# The corpus is a mirror of the frontend JS of EVERY registry pack (~5,100
# packs). Only the executable surface is kept - .js/.mjs plus the text assets
# a module can import - and binary assets are staged as empty placeholders so
# their imports still resolve. That is what makes it cacheable: ~0.3GB
# compressed against the repo's shared 10GB budget. Keeping every asset byte
# put it at 6.0GB, which evicted itself mid-run (run 31753242605: three of
# four shards found no corpus at all).
#
# Corpus freshness is a push-to-main concern, not a PR concern. main
# revalidates every pack by ETag and saves the cache; a PR restores that
# corpus and uses it as-is. Both sides of the comparison then measure the
# same packs, so a red delta is attributable to the diff rather than to a
# pack that moved overnight.
#
# The upstream static idiom scan (L1) is deliberately absent: its
# false-positive/negative rate was judged too high to act on. Packs are
# measured by executing their code - the ecosystem matrix - instead.
#
# Provenance: scripts/registry-census/ is vendored from
# Comfy-Org/ComfyUI_ECS_Compat_Check (private, migration-phase instrument
# slated for archival) - see scripts/registry-census/README.md.
name: 'CI: Ecosystem Matrix'
on:
# A frontend change is the thing this gate exists to catch, so src/** runs
# it. The corpus is restored, not refetched, so a PR run is bounded by
# execution rather than by the network.
pull_request:
paths:
- 'src/**'
- 'scripts/registry-census/**'
- 'vitest.matrix.config.mts'
- '.github/workflows/ci-ecosystem-matrix.yaml'
# Refreshes the corpus and writes the baseline every PR is measured against.
# Actions cache scoping means only a default-branch run can produce an entry
# that other branches restore.
push:
branches: [main]
paths:
- 'src/**'
- 'scripts/registry-census/**'
- 'vitest.matrix.config.mts'
- '.github/workflows/ci-ecosystem-matrix.yaml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ecosystem-matrix-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
PYTHON_VERSION: '3.11'
CORPUS_CACHE_PREFIX: registry-corpus-v3-
jobs:
corpus:
runs-on: ubuntu-latest
timeout-minutes: 30
# contents: read only. The fetch step below holds a token (for the
# codeload rate limit) while extracting untrusted archives, so this job
# must not also carry actions: write - cache pruning runs as its own job.
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
# An ubuntu-latest image bump must not silently change the interpreter
# under a verdict whose whole value is run-to-run comparability.
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
# Restore the newest corpus regardless of key: the primary key never
# hits (run-scoped), so restore-keys always serves the latest save.
- name: Restore corpus cache
id: restore
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
with:
path: .census
key: ${{ env.CORPUS_CACHE_PREFIX }}${{ github.run_id }}
restore-keys: ${{ env.CORPUS_CACHE_PREFIX }}
# Re-pin the registry snapshot every run: it is the corpus's target
# list, and new packs only enter the corpus through it.
- name: Refresh registry snapshot
run: python3 scripts/registry-census/refresh_registry.py
# Authenticated codeload: anonymous fetches are rate-limited per IP, and
# a run where many packs drift at once exhausts that budget partway
# through. Run 31738365496 failed 145 of 150 attempted fetches this way
# and reported it as an ecosystem-wide outage. This job only downloads
# and unpacks archives; pack code executes in ecosystem-matrix, which
# checks out with persist-credentials: false.
#
# --revalidate only off main. On a PR the restored corpus is the
# measurement's control: refetching drifted packs there would move the
# population out from under the baseline it is being compared to.
- name: Fetch corpus
id: fetch
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python3 scripts/registry-census/fetch_corpus.py \
${{ github.event_name == 'pull_request' && ' ' || '--revalidate' }}
# corpus.lock.json records the ETag and resolved tree of every pack this
# run measured - the identity a published figure has to be cited with.
# registry-stale.json is present only when the snapshot is a fallback.
- name: Upload corpus provenance
if: always()
uses: actions/upload-artifact@v6
with:
name: corpus-lock
path: .census/*.json
retention-days: 30
if-no-files-found: warn
# Key derived from the lockfile: unchanged corpus finds its key taken
# and the server rejects the duplicate; any pack drift writes a new
# entry. Gated on the FETCH step and a present lockfile: an early
# failure must not save a partial .census - least of all under the
# empty-hash key, which equals the restore-keys prefix and would
# become the best match for every later run.
#
# Default branch only. A PR that saved would spend the repo's shared
# cache budget on an entry no other branch can restore.
- name: Save corpus cache
if: |
github.event_name != 'pull_request'
&& github.ref_name == github.event.repository.default_branch
&& steps.fetch.outcome == 'success'
&& hashFiles('.census/corpus.lock.json') != ''
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
with:
path: .census
key: ${{ env.CORPUS_CACHE_PREFIX }}${{ hashFiles('.census/corpus.lock.json') }}
# Each drift writes a new entry against the repo's shared 10GB budget; keep
# the newest only. Its own job so `corpus`, which holds a token while
# extracting untrusted archives, stays at contents: read.
prune-corpus-cache:
needs: corpus
if: |
needs.corpus.result == 'success'
&& github.event_name != 'pull_request'
&& github.repository == 'Comfy-Org/ComfyUI_frontend'
&& github.ref_name == github.event.repository.default_branch
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
actions: write
steps:
# --ref, or this enumerates every branch's entries and deletes a PR's
# corpus out from under it. --limit, because the default of 30 leaves a
# larger backlog permanently unpruned. pipefail, because the exit status
# otherwise comes from the loop, which succeeds over empty input - so a
# failing `gh cache list` would let pruning stop while the step reads
# green.
- name: Prune superseded corpus caches
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh cache list --repo "$GITHUB_REPOSITORY" \
--key "$CORPUS_CACHE_PREFIX" \
--ref "refs/heads/${GITHUB_REF_NAME}" \
--limit 100 --sort created_at --order desc \
--json id --jq '.[1:][].id' > ids.txt
while read -r id; do
gh cache delete "$id" --repo "$GITHUB_REPOSITORY" || true
done < ids.txt
# The execution rung between the corpus (every pack fetched) and the E2E
# gates (browser + backend): every JS-shipping pack's real extension code
# is imported and driven against the real frontend runtime under vitest -
# registration lifecycle, a user-operation battery, serialize/reload.
# Sharded 4 ways; vitest additionally parallelizes per core within a
# shard (~4.7s of test time per pack, measured).
#
# A shard checks harness integrity only: every built spec must write its
# per-pack row. vitest's own exit code is ignored here because pack code
# leaks unhandled rejections. The ecosystem PASS/FAIL verdict is applied
# once, over all shards combined, by matrix-verdict below.
ecosystem-matrix:
needs: corpus
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
# This job EXECUTES unreviewed third-party pack code under vitest;
# the checkout must not leave a usable git credential behind for it.
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
# The corpus job above saved the cache this run; same-run caches are
# visible to later jobs. Restore-only - the matrix never writes it.
- name: Restore corpus cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
with:
path: .census
key: ${{ env.CORPUS_CACHE_PREFIX }}${{ github.run_id }}-matrix
restore-keys: ${{ env.CORPUS_CACHE_PREFIX }}
# node_cache: false - setup-node's post step writes the pnpm store
# AFTER this job has executed ~1,900 packs' code, and the publish
# workflows restore that same lockfile-derived key while holding
# NPM_TOKEN and id-token: write. Slower installs here; no write path
# out of the job that runs unreviewed code.
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: false
node_cache: 'false'
- name: Build shard fixture
run: python3 scripts/registry-census/build_matrix.py --shard ${{ matrix.shard }}/4
# Every built spec gets its stub row BEFORE vitest starts: no failure
# mode - worker crash, collection kill, hang - can produce a missing
# row, only an incomplete one, which the verdict counts and reports.
- name: Pre-create stub rows
run: |
python3 - <<'PY'
import json, os
manifest = json.load(open('src/__ecs_matrix__/manifest.json'))
os.makedirs('matrix-rows', exist_ok=True)
for pack, entry in manifest.items():
if 'entries' in entry:
json.dump(
{'pack': pack, 'incomplete': True},
open(os.path.join('matrix-rows', entry['safe'] + '.json'), 'w'),
)
PY
- name: Run the matrix
run: |
set -euo pipefail
shopt -s nullglob
specs=(src/__ecs_matrix__/*.matrix.test.ts)
built=${#specs[@]}
if [ "$built" -eq 0 ]; then
echo "::error::no pack specs built - corpus missing or empty"
exit 1
fi
MATRIX_OUT="$PWD/matrix-rows" pnpm exec vitest run \
--config vitest.matrix.config.mts > vitest-shard.log 2>&1 \
|| echo "vitest exited nonzero - tolerated, pack code may leak unhandled errors"
tail -25 vitest-shard.log
cp src/__ecs_matrix__/manifest.json "matrix-rows/_manifest-shard-${{ matrix.shard }}.json"
# nullglob makes an empty match an empty array rather than the
# literal pattern, so these counts stay honest when nothing ran.
spec_names=()
for f in "${specs[@]}"; do
spec_names+=("$(basename "$f" .matrix.test.ts)")
done
row_names=()
for f in matrix-rows/*.json; do
name=$(basename "$f" .json)
case "$name" in _manifest*) continue ;; esac
row_names+=("$name")
done
echo "specs=$built rows=${#row_names[@]}"
if [ "${#row_names[@]}" -ne "$built" ]; then
echo "::error::matrix wrote ${#row_names[@]} rows for $built specs - harness failure, not pack noise"
comm -23 \
<(printf '%s\n' "${spec_names[@]}" | sort) \
<(printf '%s\n' "${row_names[@]}" | sort) \
| sed 's/^/::error::missing row: /'
exit 1
fi
# The shard log is the only record of WHY a shard died. Without it a
# 06:30 failure leaves the last 25 lines of a truncated tail and nothing
# to go back to.
- name: Upload rows
if: always()
uses: actions/upload-artifact@v6
with:
name: ecosystem-matrix-shard-${{ matrix.shard }}
path: |
matrix-rows/
vitest-shard.log
retention-days: 30
if-no-files-found: warn
# The single combined result: all four shards' rows merge into one table
# and one gating verdict - this job's exit code IS the ecosystem verdict
# (0 PASS, 1 FAIL, 2 harness failure / no rows / short population).
#
# MATRIX_EXPECT_SHARDS makes a dead shard withhold rather than PASS. Every
# criterion is a ratio, so three shards produce a perfectly plausible
# number, and on PASS that partial population would be saved as the
# baseline the next run is measured against.
matrix-verdict:
needs: ecosystem-matrix
# A single failed shard must degrade to a withheld verdict over the rows
# that exist, not to a skipped check that reads as nothing happened. Not
# always(): a cancelled run would find no rows, exit 2, and manufacture a
# red out of someone pressing the stop button.
if: '!cancelled()'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
# continue-on-error: when the corpus job fails, no shard ran and no
# artifact exists - the verdict step must still run so the summarizer
# reports the documented no-rows exit (2) instead of an opaque
# download failure.
- name: Merge shard rows
continue-on-error: true
uses: actions/download-artifact@v8
with:
pattern: ecosystem-matrix-shard-*
path: matrix-rows
merge-multiple: true
# A stale registry means a different denominator, and that denominator
# feeds the delta gate. refresh_registry.py records the fallback in
# registry-stale.json; the summarizer surfaces it in the report and
# stores it in metrics.json rather than leaving it in a job log.
- name: Fetch corpus provenance
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: corpus-lock
path: corpus-lock
# The baseline this PR is measured against: main's last green run. The
# primary key never hits (saves carry a run_attempt suffix), so
# restore-keys serves the newest save. A re-run can restore this same
# run's earlier attempt - metrics carry runId and the summarizer skips
# the delta rather than compare a run against itself.
- name: Restore baseline metrics
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
with:
path: matrix-metrics
key: matrix-metrics-${{ github.run_id }}
restore-keys: matrix-metrics-
- name: Verdict
id: verdict
env:
MATRIX_RUN_ID: ${{ github.run_id }}
MATRIX_EXPECT_SHARDS: '4'
run: |
MATRIX_OUT="$PWD/matrix-rows" \
MATRIX_PREV="$PWD/matrix-metrics/metrics.json" \
MATRIX_METRICS_OUT="$PWD/matrix-metrics/metrics.json" \
MATRIX_STALE_MARKER="$PWD/corpus-lock/registry-stale.json" \
python3 scripts/registry-census/summarize_matrix.py
# Baseline-from-health, and only from main: a PR must not publish its
# own numbers as the baseline every later PR is measured against, and a
# FAILing run must not ratchet the baseline down to its own eroded
# numbers.
- name: Save baseline metrics
if: |
github.event_name != 'pull_request'
&& github.ref_name == github.event.repository.default_branch
&& steps.verdict.outcome == 'success'
&& hashFiles('matrix-metrics/metrics.json') != ''
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
with:
path: matrix-metrics
key: matrix-metrics-${{ github.run_id }}-${{ github.run_attempt }}
- name: Upload combined result
if: always()
uses: actions/upload-artifact@v6
with:
name: ecosystem-matrix-combined
path: matrix-rows/
retention-days: 30
if-no-files-found: warn
# Counter-evidence: the matrix runs over a synthetic corpus where every
# pack breaks exactly one measurement channel (detection-proof/corpus/),
# then this job requires BOTH that every channel fired with its poison
# message AND that each poison pack breaches the criterion it targets
# (verify_detection.py). A green matrix is only meaningful while this stays
# green: a runner or frontend change that blinds a channel reds THIS job
# instead of silently hollowing out the gate. No corpus/cache dependency -
# the poison fixture is checked in, so this runs on every PR that touches
# src/** even when the corpus is cold.
matrix-detection-proof:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
# Runs the poison packs (and the same harness that runs real pack
# code); same rule as the shard jobs - no persisted credential.
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: false
node_cache: 'false'
- name: Gate unit tests
run: python3 -m unittest discover -s scripts/registry-census -p 'test_*.py' -v
- name: Build poison fixture
run: CENSUS_ROOT=scripts/registry-census/detection-proof python3 scripts/registry-census/build_matrix.py
- name: Run the matrix over the poison corpus
run: |
MATRIX_OUT="$PWD/matrix-proof" pnpm exec vitest run \
--config vitest.matrix.config.mts 2>&1 | tail -15 \
|| echo "vitest exited nonzero - tolerated, poison packs leak by design"
- name: Every channel must fire and must gate
run: MATRIX_OUT="$PWD/matrix-proof" python3 scripts/registry-census/verify_detection.py
# Exit code must be exactly 1 (criteria FAIL): exit 2 would mean the
# harness gate withheld the verdict, which on this corpus is itself a
# detection failure.
- name: Verdict must FAIL on poison
run: |
set +e
MATRIX_OUT="$PWD/matrix-proof" python3 scripts/registry-census/summarize_matrix.py > proof-verdict.txt 2>&1
rc=$?
set -e
tail -8 proof-verdict.txt
if [ "$rc" -ne 1 ]; then
echo "::error::expected verdict FAIL (exit 1) on the poison corpus, got exit $rc"
exit 1
fi
echo "verdict correctly FAILED on the poison corpus"