openvibers/openvibe.shared Commits

CI: pin-only pull requests take a short path (#427)

OP OpenVibers committed 3 days ago · 15b927b7d9 · 9 files, +683 −6

* CI: pin-only pull requests take a short path (plan ask 307)

A pin round opens ~59 pull requests that each only move an openvibe-*
pin (package.json, lockfile, STATUS/README version lines, sometimes a
size budget), and each runs the full suite plus Docker. The reusable
test workflow now starts with a cheap changes job: scripts/pin-only.js
decides from the pull request's diff whether ONLY pin files changed
(package.json pin lines, lockfile pin/integrity lines in an openvibe-*
hunk, STATUS.json, README version lines, budget numbers with their
comments). When yes, the test job runs the install, the page/frame/
size-budget/browser test files and pin drift, and skips syntax,
contracts, docs, floating promises and extra; anything the subset does
not fit (custom runner, pnpm, no test/run.js) runs the full suite
exactly as before. Shared's own Windows/macOS legs are skipped the same
way (their steps, not the jobs, so the check names still report
success). test-runner gains --allow-empty for repositories with no such
files. Detection failures and pushes to main always run everything.

* CI: the pin-only subset needs a test/run.js that requires test-runner

A comment mentioning test-runner (Contracts' and Tools' custom runners
copy its skip rule) must not select the subset: only a test/run.js that
requires openvibe-shared/test-runner understands the word filters and
--allow-empty. The custom runners fall through to the full suite.

* CI: the pin-only test step is a case, not if/elif

With no pin-only-test input the override branch held only the empty
interpolation, and if/then/elif with no command is a shell syntax
error (the trial's test job failed at once). An empty branch is legal
under case. Every run block now parses under bash -n with empty and
with filled expressions.

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex bd8a949..b1e2b7a 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -5,6 +5,25 @@ on:
tags: ['v*']
pull_request:
jobs:
++ # Cheap first job: a pin-only pull request (an openvibe-* pin bump with its version lines and
++ # budget raises, nothing else) needs no Windows/macOS leg; Linux still runs the whole suite.
++ changes:
++ runs-on: ubuntu-latest
++ outputs:
++ pin_only: ${{ steps.decide.outputs.pin_only }}
++ steps:
++ - uses: actions/checkout@v4
++ if: ${{ github.event_name == 'pull_request' }}
++ with:
++ fetch-depth: 1
++ - id: decide
++ env:
++ BASE: ${{ github.event.pull_request.base.sha }}
++ EVENT: ${{ github.event_name }}
++ run: |
++ if [ "$EVENT" != "pull_request" ]; then echo "pin_only=false" >> "$GITHUB_OUTPUT"; exit 0; fi
++ git fetch -q --depth 1 origin "$BASE" || { echo "pin_only=false" >> "$GITHUB_OUTPUT"; exit 0; }
++ echo "pin_only=$(node scripts/pin-only.js --base "$BASE")" >> "$GITHUB_OUTPUT"
test:
runs-on: ubuntu-latest
steps:
@@@ -32,9 +51,12 @@ jobs:
npm install --no-audit --no-fund "$RUNNER_TEMP/openvibe-shared.tgz"
node -e "require('openvibe-shared'); require('openvibe-shared/navbar'); require('openvibe-shared/legal'); require('openvibe-shared/metrics'); require('openvibe-shared/ready'); require('openvibe-shared/release'); require('openvibe-shared/release-compat'); require('openvibe-shared/analytics'); require('openvibe-shared/analytics/pg'); require('openvibe-shared/analytics/event.v1.json'); const f = require('openvibe-shared/files'); for (const n of f.BROWSER) require('fs').accessSync(f.path(n)); console.log('ok', require('openvibe-shared/package.json').version)"
# Sites built on these files are developed on every OS (owner, 2026-10-27: "the shared SDK and API … work well across
-- # multi-platform"): the whole suite on Windows and macOS too.
++ # multi-platform"): the whole suite on Windows and macOS too — except on a pin-only pull request,
++ # where the Linux suite is enough. The legs still run and report success (their steps are skipped,
++ # not the jobs), so the check names stay green and no merge guard hangs on a missing status.
other-os:
name: test (${{ matrix.os }})
++ needs: [changes]
strategy:
fail-fast: false
matrix:
@@@ -42,11 +64,17 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
++ if: ${{ needs.changes.outputs.pin_only != 'true' }}
- uses: actions/setup-node@v4
++ if: ${{ needs.changes.outputs.pin_only != 'true' }}
with:
node-version: 22.22.1
- run: npm ci --no-audit --no-fund
++ if: ${{ needs.changes.outputs.pin_only != 'true' }}
- run: npm test
++ if: ${{ needs.changes.outputs.pin_only != 'true' }}
++ - run: echo "pin-only short path; the Linux suite ran, this leg is not needed."
++ if: ${{ needs.changes.outputs.pin_only == 'true' }}
# Secret scan (gitleaks) and dependency audit, the workflow every OpenVibers repo calls (Track Q).
security:
uses: ./.github/workflows/security.yml
ddiff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
iindex 928e5fa..e019cce 100644
---- a/.github/workflows/test.yml
++++ b/.github/workflows/test.yml
@@@ -36,6 +36,15 @@ name: test
# every repository is clean (acorn installed first; plan T0)
# extra `extra`, when set: any further commands, one per line (e.g. a budget check), with a
# read-only GITHUB_TOKEN
++#
++# Pin-only pull requests (an openvibe-* pin bump with its STATUS.json / README version lines and
++# size-budget raises, nothing else) take a short path: the `changes` job decides from the pull
++# request's diff (scripts/pin-only.js), and the `test` job then runs the install, the page, frame,
++# size-budget and browser test files (`pin-only-test`, or the test/run.js subset when the repository
++# uses openvibe-shared/test-runner) and pin drift, skipping syntax, contracts, docs, floating
++# promises and extra. Anything the subset does not fit (no test/run.js, a custom runner, pnpm)
++# runs the full suite exactly as before, and so does every push to main. The job and check names
++# never change: a skipped step reports success, so the merge guard sees the green check it knows.
on:
workflow_call:
inputs:
@@@ -95,13 +104,48 @@ on:
description: Git history to check out (0 = everything, e.g. a compat check against the previous release tag).
type: number
default: 1
++ pin-only-test:
++ description: Test command for a pin-only pull request ("" = the page/frame/size/browser subset through test/run.js when the repository uses openvibe-shared/test-runner, else the full `test`).
++ type: string
++ default: ''
++ pin-script-ref:
++ description: OpenVibe.Shared ref scripts/pin-only.js is fetched from (a branch name for workflow development; consumers leave it at main).
++ type: string
++ default: main
permissions:
contents: read
jobs:
++ # Cheap first job: is this pull request pin-only? scripts/pin-only.js decides from the pull
++ # request's diff (the base commit is fetched shallowly; no history, no install). Pushes to main
++ # and any detection failure run the full suite.
++ changes:
++ runs-on: ubuntu-latest
++ outputs:
++ pin_only: ${{ steps.decide.outputs.pin_only }}
++ steps:
++ - uses: actions/checkout@v4
++ if: ${{ github.event_name == 'pull_request' }}
++ with:
++ fetch-depth: 1
++ - id: decide
++ env:
++ BASE: ${{ github.event.pull_request.base.sha }}
++ EVENT: ${{ github.event_name }}
++ REF: ${{ inputs.pin-script-ref }}
++ run: |
++ if [ "$EVENT" != "pull_request" ]; then echo "pin_only=false" >> "$GITHUB_OUTPUT"; exit 0; fi
++ git fetch -q --depth 1 origin "$BASE" || { echo "::warning::pin-only detection could not fetch the base; running the full suite"; echo "pin_only=false" >> "$GITHUB_OUTPUT"; exit 0; }
++ if curl -fsSL "https://raw.githubusercontent.com/OpenVibers/OpenVibe.Shared/${REF}/scripts/pin-only.js" -o "$RUNNER_TEMP/pin-only.js"; then
++ echo "pin_only=$(node "$RUNNER_TEMP/pin-only.js" --base "$BASE")" >> "$GITHUB_OUTPUT"
++ else
++ echo "::warning::scripts/pin-only.js is not at OpenVibe.Shared ${REF}; running the full suite"
++ echo "pin_only=false" >> "$GITHUB_OUTPUT"
++ fi
test:
runs-on: ubuntu-latest
++ needs: [changes]
defaults:
run:
working-directory: ${{ inputs.working-directory }}
@@@ -109,6 +153,18 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: ${{ inputs.fetch-depth }}
++ - id: plan
++ name: pin-only plan
++ run: |
++ # mode=override: the repository's own pin-only-test. mode=subset: the page, frame,
++ # size-budget and browser files through test/run.js (npm + a test/run.js that requires
++ # openvibe-shared/test-runner only; a custom runner or pnpm falls through to full).
++ # mode=full: the suite exactly as before.
++ if [ "${{ needs.changes.outputs.pin_only }}" = "true" ]; then
++ if [ -n "${{ inputs.pin-only-test }}" ]; then echo "mode=override" >> "$GITHUB_OUTPUT"
++ elif [ "${{ inputs.package-manager }}" = "npm" ] && [ -f test/run.js ] && grep -q "require(.*test-runner" test/run.js && grep -q '"test": *"node test/run.js' package.json; then echo "mode=subset" >> "$GITHUB_OUTPUT"
++ else echo "mode=full" >> "$GITHUB_OUTPUT"; fi
++ else echo "mode=full" >> "$GITHUB_OUTPUT"; fi
- uses: pnpm/action-setup@v4
if: ${{ inputs.package-manager == 'pnpm' }}
- uses: actions/setup-node@v4
@@@ -124,7 +180,7 @@ jobs:
- name: install
run: ${{ inputs.install }}
- name: syntax
-- if: ${{ inputs.syntax-dirs != '' }}
++ if: ${{ inputs.syntax-dirs != '' && steps.plan.outputs.mode == 'full' }}
env:
DIRS: ${{ inputs.syntax-dirs }}
run: |
@@@ -139,9 +195,31 @@ jobs:
echo "node --check: $n files, $failed failed"
[ "$failed" -eq 0 ]
- name: test
-- run: ${{ inputs.test }}
++ # A case, not if/elif: the override branch holds only the pin-only-test input, and an empty
++ # branch is a syntax error under if (`then` + `elif` with no command) but legal under case.
++ run: |
++ case "${{ steps.plan.outputs.mode }}" in
++ override)
++ ${{ inputs.pin-only-test }}
++ ;;
++ subset)
++ echo "pin-only: the page, frame, size-budget and browser files only (pin drift still runs below)"
++ set +e
++ out=$(npm test -- perf budget page frame layout home smoke chrome --allow-empty 2>&1); rc=$?
++ set -e
++ printf '%s\n' "$out"
++ if [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -q 'no test files matched'; then
++ echo "pin-only: this repository has no page/frame/budget/browser files; the subset is empty"
++ else
++ exit "$rc"
++ fi
++ ;;
++ *)
++ ${{ inputs.test }}
++ ;;
++ esac
- name: contracts
-- if: ${{ inputs.contracts-service != '' }}
++ if: ${{ inputs.contracts-service != '' && steps.plan.outputs.mode == 'full' }}
env:
SERVICE: ${{ inputs.contracts-service }}
SRC: ${{ inputs.contracts-src }}
@@@ -153,17 +231,19 @@ jobs:
working-directory: .
run: curl -fsSL https://raw.githubusercontent.com/OpenVibers/OpenVibe.Shared/main/scripts/pin-drift.js | node - "$GITHUB_WORKSPACE"
- name: docs currency
-- if: ${{ inputs.docs-status }}
++ if: ${{ inputs.docs-status && steps.plan.outputs.mode == 'full' }}
working-directory: .
run: curl -fsSL https://raw.githubusercontent.com/OpenVibers/OpenVibe.Shared/main/scripts/docs-status.js | node - "$GITHUB_WORKSPACE" --check
- name: install acorn (floating promises check)
++ if: ${{ steps.plan.outputs.mode == 'full' }}
working-directory: .
run: npm i --no-save acorn@8 acorn-walk@8
- name: floating promises (report-only until every repository is clean; plan T0)
++ if: ${{ steps.plan.outputs.mode == 'full' }}
working-directory: .
run: curl -fsSL https://raw.githubusercontent.com/OpenVibers/OpenVibe.Shared/main/scripts/floating-promises.js | node - "$GITHUB_WORKSPACE" || echo "::warning title=floating promises::unawaited async calls found (see the list above); this step fails the build once the estate is clean"
- name: extra
-- if: ${{ inputs.extra != '' }}
++ if: ${{ inputs.extra != '' && steps.plan.outputs.mode == 'full' }}
env:
GITHUB_TOKEN: ${{ github.token }} # read-only (permissions: contents: read), for a step that asks GitHub
run: ${{ inputs.extra }}
ddiff --git a/scripts/pin-only.js b/scripts/pin-only.js
nnew file mode 100644
iindex 0000000..e7ef38d
---- /dev/null
++++ b/scripts/pin-only.js
@@@ -0,0 +1,173 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Pin-only detection: is a pull request's diff nothing but a library pin bump (and the version
++ * lines and size budgets that follow it)? The reusable test workflow runs a short path (install,
++ * the page/frame/size/browser subset, pin drift) when this says yes, and the full suite otherwise.
++ *
++ * Only these may change, and only in these ways:
++ * package.json (any depth) every changed line is an openvibe-* dependency line naming a
++ * codeload tag tarball (the pin this round moves)
++ * lockfiles every hunk touches an openvibe-* entry, and every changed line is an
++ * openvibe-* line, a codeload URL, or a resolved/resolution/integrity/
++ * version line (npm's version/resolved/integrity triple sits below the
++ * entry's key line, so the key line is context, not part of the hunk)
++ * STATUS.json (root) any change: metadata, never code
++ * README.md (root) only version lines (an openvibe-* name and a version number) and the
++ * versions-block markers
++ * perf-budget.test.js / only budget numbers and their comments (a budget key with a colon, or
++ * check-budgets.js (any depth) a comment line)
++ * Anything else — code, tests, workflows (including a security.yml SHA move), CHANGELOG.md, a new,
++ * deleted or renamed file, a mode-only change — is not pin-only. When in doubt this says no: no
++ * means the full suite, which is always safe.
++ *
++ * node scripts/pin-only.js --base <sha> [--head <rev>] [--dir <path>] [--context <n>]
++ * curl -fsSL https://raw.githubusercontent.com/OpenVibers/OpenVibe.Shared/main/scripts/pin-only.js | node - --base <sha>
++ *
++ * Prints `true` or `false` on stdout (the caller writes it to $GITHUB_OUTPUT) and the reason on
++ * stderr. Exit 0 always: a detection failure must run the full suite, not fail the job.
++ */
++const cp = require('child_process');
++
++const LIB_RE = /openvibe-(contracts|shared|sdk|publishing)/;
++const PIN_URL_RE = /codeload\.github\.com\/OpenVibers\/OpenVibe\.(Contracts|Shared|SDK|Publishing)\/tar\.gz\/refs\/tags\/v\d+\.\d+\.\d+/;
++const PKG_KEY_RE = /"openvibe-(contracts|shared|sdk|publishing)"\s*:\s*"/;
++const LOCK_VALUE_RE = /^\s*"?(resolved|resolution|integrity|version)"?\s*:/;
++const CODELOAD_RE = /codeload\.github\.com\/OpenVibers\/OpenVibe\./;
++const VERSION_RE = /\d+\.\d+\.\d+/;
++const BUDGET_KEY_RE = /(^|[^A-Za-z])(htmlRawKB|htmlBrotliKB|jsFiles|jsRawKB|jsBrotliKB|cssFiles|cssRawKB|cssBrotliKB|externalFiles|lcpMs|inpMs|cls)\s*:/;
++
++function classify(file) {
++ if (/(^|\/)package\.json$/.test(file)) return 'package';
++ if (/(^|\/)(package-lock\.json|pnpm-lock\.yaml|npm-shrinkwrap\.json|yarn\.lock)$/.test(file)) return 'lock';
++ if (file === 'STATUS.json') return 'status';
++ if (file === 'README.md') return 'readme';
++ if (/(^|\/)(perf-budget\.test\.js|check-budgets\.js)$/.test(file)) return 'budget';
++ return 'other';
++}
++
++const pkgLine = (l) => LIB_RE.test(l) && PIN_URL_RE.test(l) && PKG_KEY_RE.test(l);
++const lockLine = (l) => LIB_RE.test(l) || CODELOAD_RE.test(l) || LOCK_VALUE_RE.test(l);
++const readmeLine = (l) => {
++ const s = l.trim();
++ if (s === '<!-- versions:start -->' || s === '<!-- versions:end -->') return true;
++ return LIB_RE.test(s) && VERSION_RE.test(s);
++};
++const budgetLine = (l) => {
++ const s = l.trim();
++ if (s.startsWith('//') || s.startsWith('/*') || s.startsWith('*')) return true;
++ return BUDGET_KEY_RE.test(s);
++};
++
++/**
++ * Parse unified diff text into [{ file, renamed, binary, added, deleted, hunks: [[{ type, text }]] }].
++ * `type` is ' ' (context), '+' or '-'. File headers (---/+++), index lines and newline markers are
++ * skipped; a hunk's context lines are kept because the lockfile rule reads them.
++ */
++function parseDiff(diffText) {
++ const files = [];
++ let cur = null;
++ let hunk = null;
++ const pushHunk = () => { if (cur && hunk) cur.hunks.push(hunk); hunk = null; };
++ for (const raw of String(diffText || '').split('\n')) {
++ const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
++ let m = /^diff --git a\/(.*) b\/(.*)$/.exec(line);
++ if (m) {
++ pushHunk();
++ cur = { file: m[2], renamed: false, binary: false, added: false, deleted: false, hunks: [] };
++ files.push(cur);
++ hunk = null;
++ continue;
++ }
++ if (!cur) continue;
++ if (/^(rename from|rename to|similarity index)/.test(line)) { cur.renamed = true; continue; }
++ if (/^new file mode/.test(line)) { cur.added = true; continue; }
++ if (/^deleted file mode/.test(line)) { cur.deleted = true; continue; }
++ if (/^Binary files /.test(line)) { cur.binary = true; continue; }
++ if (/^@@ /.test(line)) { pushHunk(); hunk = []; continue; }
++ if (/^(--- |\+\+\+ |index |\\ No newline)/.test(line)) continue;
++ if (hunk && (line.startsWith('+') || line.startsWith('-') || line.startsWith(' '))) {
++ hunk.push({ type: line[0], text: line.slice(1) });
++ }
++ }
++ pushHunk();
++ return files;
++}
++
++/** One file's verdict: { allowed, reason }. */
++function decideFile(f) {
++ if (f.renamed) return { allowed: false, reason: `${f.file}: renamed` };
++ if (f.binary) return { allowed: false, reason: `${f.file}: binary change` };
++ if (f.added || f.deleted) return { allowed: false, reason: `${f.file}: ${f.added ? 'added' : 'deleted'}` };
++ const kind = classify(f.file);
++ if (kind === 'other') return { allowed: false, reason: `${f.file}: not a pin file` };
++ const changed = f.hunks.flat().filter((l) => l.type === '+' || l.type === '-');
++ if (!changed.length) return { allowed: false, reason: `${f.file}: no content change (mode only?)` };
++ if (kind === 'status') return { allowed: true, reason: '' };
++ if (kind === 'package') {
++ const bad = changed.find((l) => !pkgLine(l.text));
++ return bad
++ ? { allowed: false, reason: `${f.file}: not a pin line: ${bad.text.trim().slice(0, 100)}` }
++ : { allowed: true, reason: '' };
++ }
++ if (kind === 'lock') {
++ for (const h of f.hunks) {
++ if (!h.some((l) => LIB_RE.test(l.text))) return { allowed: false, reason: `${f.file}: a hunk touches no openvibe-* entry` };
++ const bad = h.find((l) => (l.type === '+' || l.type === '-') && !lockLine(l.text));
++ if (bad) return { allowed: false, reason: `${f.file}: not a pin/integrity line: ${bad.text.trim().slice(0, 100)}` };
++ }
++ return { allowed: true, reason: '' };
++ }
++ if (kind === 'readme') {
++ const bad = changed.find((l) => !readmeLine(l.text));
++ return bad
++ ? { allowed: false, reason: `${f.file}: not a version line: ${bad.text.trim().slice(0, 100)}` }
++ : { allowed: true, reason: '' };
++ }
++ const bad = changed.find((l) => !budgetLine(l.text));
++ return bad
++ ? { allowed: false, reason: `${f.file}: not a budget number or comment: ${bad.text.trim().slice(0, 100)}` }
++ : { allowed: true, reason: '' };
++}
++
++/** Whole-diff verdict: { pinOnly, reasons }. An empty diff is not pin-only (nothing to shortcut). */
++function decide(diffText) {
++ const files = parseDiff(diffText);
++ if (!files.length) return { pinOnly: false, reasons: ['empty diff'] };
++ const reasons = [];
++ for (const f of files) {
++ const v = decideFile(f);
++ if (!v.allowed) reasons.push(v.reason);
++ }
++ return { pinOnly: reasons.length === 0, reasons };
++}
++
++function decideRange({ base, head = 'HEAD', dir = '.', context = 5 }) {
++ const diff = cp.execFileSync('git', ['-C', dir, 'diff', '--no-color', '--no-ext-diff', `-U${context}`, base, head, '--'],
++ { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
++ return decide(diff);
++}
++
++function main(argv) {
++ const opts = { head: 'HEAD', dir: '.', context: 5 };
++ for (let i = 0; i < argv.length; i++) {
++ if (argv[i] === '--base') opts.base = argv[++i];
++ else if (argv[i] === '--head') opts.head = argv[++i];
++ else if (argv[i] === '--dir') opts.dir = argv[++i];
++ else if (argv[i] === '--context') opts.context = Number(argv[++i]);
++ else { console.error(`pin-only: unknown argument ${argv[i]}`); console.log('false'); return; }
++ }
++ if (!opts.base) { console.error('pin-only: --base <sha> required'); console.log('false'); return; }
++ try {
++ const v = decideRange(opts);
++ for (const r of v.reasons) console.error(`pin-only: ${r}`);
++ console.log(v.pinOnly ? 'true' : 'false');
++ } catch (err) {
++ console.error(`pin-only: could not diff (${err.message.split('\n')[0]}); running the full suite`);
++ console.log('false');
++ }
++}
++
++if (require.main === module) main(process.argv.slice(2));
++
++module.exports = { parseDiff, decideFile, decide, decideRange, classify };
ddiff --git a/test-runner.js b/test-runner.js
iindex 208dbaf..130f4fe 100644
---- a/test-runner.js
++++ b/test-runner.js
@@@ -10,6 +10,8 @@
// npm test # every *.test.js in dir, each in its own process, a few at a time
// npm test -- quota cache # only files whose name contains one of the words
// npm test -- --strict # a skip fails the run too (or OV_TEST_STRICT=1)
++// npm test -- page --allow-empty # exit 0 when no file matches (or OV_TEST_ALLOW_EMPTY=1):
++// # the pin-only CI subset on a repository without those files
//
// Each test file runs in its own process and owns its database: openvibe-sdk/testing `createTestDb` gives it its
// own PGlite, or under `test:pg` its own PostgreSQL schema, roles and port. Files therefore parallelize safely and
@@@ -81,11 +83,16 @@ async function run(opts = {}) {
const argv = opts.argv || process.argv.slice(2);
const log = opts.log || console.log;
const strict = argv.includes('--strict') || process.env.OV_TEST_STRICT === '1';
++ const allowEmpty = argv.includes('--allow-empty') || process.env.OV_TEST_ALLOW_EMPTY === '1';
const filters = argv.filter((a) => !a.startsWith('-'));
const match = opts.match || /\.test\.js$/;
const files = fs.readdirSync(dir).filter((f) => match.test(f))
.filter((f) => !filters.length || filters.some((w) => f.includes(w))).sort();
if (!files.length) {
++ if (allowEmpty) {
++ log('no test files matched (allowed)');
++ return { results: [], passed: 0, skipped: 0, failed: 0, strict, exitCode: 0, summary: 'no test files matched (allowed)' };
++ }
log('no test files matched');
return { results: [], passed: 0, skipped: 0, failed: 0, strict, exitCode: 1, summary: 'no test files matched' };
}
ddiff --git a/test/fixtures/pin-only/pin-with-budget.diff b/test/fixtures/pin-only/pin-with-budget.diff
nnew file mode 100644
iindex 0000000..5a50d08
---- /dev/null
++++ b/test/fixtures/pin-only/pin-with-budget.diff
@@@ -0,0 +1,94 @@
++diff --git a/README.md b/README.md
++index 1395115..b9873b4 100644
++--- a/README.md
+++++ b/README.md
++@@ -301,7 +301,7 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
++
++ <!-- versions:start -->
++ - openvibe-contracts: v0.384.0
++-- openvibe-sdk: v0.125.0
++-- openvibe-shared: v4.186.0
+++- openvibe-sdk: v0.136.0
+++- openvibe-shared: v4.190.0
++ - openvibe-publishing: v1.7.0
++ <!-- versions:end -->
++diff --git a/STATUS.json b/STATUS.json
++index 5204641..80a4146 100644
++--- a/STATUS.json
+++++ b/STATUS.json
++@@ -8,12 +8,12 @@
++ "port": "4980",
++ "domain": "openvibe.quest",
++ "contracts": "openvibe-contracts v0.384.0",
++- "sdk": "openvibe-sdk v0.125.0",
++- "shared": "openvibe-shared v4.186.0",
+++ "sdk": "openvibe-sdk v0.136.0",
+++ "shared": "openvibe-shared v4.190.0",
++ "packages": [
++ "openvibe-contracts v0.384.0",
++- "openvibe-sdk v0.125.0",
++- "openvibe-shared v4.186.0",
+++ "openvibe-sdk v0.136.0",
+++ "openvibe-shared v4.190.0",
++ "openvibe-publishing v1.7.0"
++ ],
++ "summary": "OpenVibe.Quest: one quest log across every OpenVibe site. OpenVibe.Events delivers the domain events the sites already emit to POST /internal/events (signed v2, once-only through the openvibe-sdk inbox), the person taken from the field each event's own schema defines, and a progress count, a completion and a badge move because of it. 12 first-party quests in code, grouped by the site whose events each counts. Rewards are badges and OpenCoins — credited through OpenVibe.Network's wallet, off by default (QUEST_COINS), with per-person and network-wide daily caps, failures recorded and retried. Public API and server-rendered pages, complete without JavaScript. OpenCoins are off until Network grants the two capabilities.",
++diff --git a/package-lock.json b/package-lock.json
++index d3fc5fa..7e15af0 100644
++--- a/package-lock.json
+++++ b/package-lock.json
++@@ -18,7 +18,7 @@
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.136.0",
++- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.186.0",
+++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.190.0",
++ "pg": "^8.23.0"
++ },
++ "devDependencies": {
++@@ -1209,9 +1209,9 @@
++ }
++ },
++ "node_modules/openvibe-shared": {
++- "version": "4.186.0",
++- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.186.0",
++- "integrity": "sha512-wD4net+UjO0a6OG73W+RR1z5MIWtPgj407xLEWphiVWgLTQ4V1r76+DtLOfSf6RfEda/ALizRu1zmxbvuGGZOw==",
+++ "version": "4.190.0",
+++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.190.0",
+++ "integrity": "sha512-NnmQ+mJLTyM4mbYDyq/Nmxpjxo1ehmMcltz6NqcJJ/AhOTuZw7/QBl1CbJeWcGyXyL5VI/Ugu9/fIPe0UKvNNQ==",
++ "license": "MIT",
++ "dependencies": {
++ "jsonwebtoken": "^9.0.0",
++diff --git a/package.json b/package.json
++index 1c4042d..5f52d62 100644
++--- a/package.json
+++++ b/package.json
++@@ -25,7 +25,7 @@
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.136.0",
++- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.186.0",
+++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.190.0",
++ "pg": "^8.23.0"
++ },
++ "engines": {
++diff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
++index f4cb34c..6cb52de 100644
++--- a/test/perf-budget.test.js
+++++ b/test/perf-budget.test.js
++@@ -21,11 +21,11 @@ const BUDGETS = {
++ // Raised 2026-10-22 with the rich home: figures under the hero, the most-earned badges, each card's "earned by",
++ // the badges nobody holds yet, the sites Quest follows, a FAQ and the quests as ItemList + FAQPage JSON-LD.
++ // Measured 54.3 KB raw / 10.6 KB brotli on a fresh database.
++- htmlRawKB: 83.5, // 83.2 with the daily quest as a card of three rows and its glyphs drawn inline (2026-11-10); before: 82.6 with the daily quest hero (2026-11-09); 81.3 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 80.6 with 2026-11-01: the hero orbit (15 sites, inline icons) (2026-11-01); 58.6 the home a taste and a door (2026-10-29): rails of kit cards and folds instead of every quest card and the sites list; 67.5 the hero's and the numbers' icons (inline ring icons, 2026-10-28); 61.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; measured 54.3 (the home page: hero, figures, badges, features, the quests, sites, FAQ, cta, JSON-LD)
++- htmlBrotliKB: 16.8, // 16.8: measured 16.3 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
+++ htmlRawKB: 87, // 87: measured 86.5 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 83.2 with the daily quest as a card of three rows and its glyphs drawn inline (2026-11-10); before: 82.6 with the daily quest hero (2026-11-09); 81.3 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 80.6 with 2026-11-01: the hero orbit (15 sites, inline icons) (2026-11-01); 58.6 the home a taste and a door (2026-10-29): rails of kit cards and folds instead of every quest card and the sites list; 67.5 the hero's and the numbers' icons (inline ring icons, 2026-10-28); 61.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; measured 54.3 (the home page: hero, figures, badges, features, the quests, sites, FAQ, cta, JSON-LD)
+++ htmlBrotliKB: 17, // 17: measured 16.9 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 16.8: measured 16.3 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
++ jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
++- jsRawKB: 376.7, // 376.7: measured 368.7 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
++- jsBrotliKB: 89.8, // 89.8: measured 89.7 KB brotli with openvibe-shared 4.186.0 (2026-11-10: the mark's Working and Thinking states, the Dock and the menu as one sheet, bare controls styled by the loader); 89.5: measured 87.5 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
+++ jsRawKB: 393, // 393: measured 392.5 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 376.7: measured 368.7 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
+++ jsBrotliKB: 94, // 94: measured 93.7 KB brotli with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 89.8: measured 89.7 KB brotli with openvibe-shared 4.186.0 (2026-11-10: the mark's Working and Thinking states, the Dock and the menu as one sheet, bare controls styled by the loader); 89.5: measured 87.5 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
++ cssFiles: 4, // 4 (2026-10-29: + /shared/kit.css and /shared/room.css, the kit's numbers, filters, folds, cards and rails); 2 (app.css + the cached /shared/showcase.css)
++ cssRawKB: 127.3, // 127.0 with the catalog's phone cards (2026-11-10); before: 125.9 with the daily card and the phone folds (2026-11-10); before: 122.2: measured 117.2 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0,
++ cssBrotliKB: 27.6, // 27.4 with the catalog's phone cards (2026-11-10); before: 27.1 with the daily card and the phone folds (2026-11-10); before: 26.6: measured 25.4 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 20
ddiff --git a/test/fixtures/pin-only/pin-with-code.diff b/test/fixtures/pin-only/pin-with-code.diff
nnew file mode 100644
iindex 0000000..9826654
---- /dev/null
++++ b/test/fixtures/pin-only/pin-with-code.diff
@@@ -0,0 +1,100 @@
++diff --git a/README.md b/README.md
++index acb2132..1395115 100644
++--- a/README.md
+++++ b/README.md
++@@ -302,6 +302,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
++ <!-- versions:start -->
++ - openvibe-contracts: v0.384.0
++ - openvibe-sdk: v0.125.0
++-- openvibe-shared: v4.183.0
+++- openvibe-shared: v4.186.0
++ - openvibe-publishing: v1.7.0
++ <!-- versions:end -->
++diff --git a/STATUS.json b/STATUS.json
++index 38debf9..5204641 100644
++--- a/STATUS.json
+++++ b/STATUS.json
++@@ -9,11 +9,11 @@
++ "domain": "openvibe.quest",
++ "contracts": "openvibe-contracts v0.384.0",
++ "sdk": "openvibe-sdk v0.125.0",
++- "shared": "openvibe-shared v4.183.0",
+++ "shared": "openvibe-shared v4.186.0",
++ "packages": [
++ "openvibe-contracts v0.384.0",
++ "openvibe-sdk v0.125.0",
++- "openvibe-shared v4.179.0",
+++ "openvibe-shared v4.186.0",
++ "openvibe-publishing v1.7.0"
++ ],
++ "summary": "OpenVibe.Quest: one quest log across every OpenVibe site. OpenVibe.Events delivers the domain events the sites already emit to POST /internal/events (signed v2, once-only through the openvibe-sdk inbox), the person taken from the field each event's own schema defines, and a progress count, a completion and a badge move because of it. 12 first-party quests in code, grouped by the site whose events each counts. Rewards are badges and OpenCoins — credited through OpenVibe.Network's wallet, off by default (QUEST_COINS), with per-person and network-wide daily caps, failures recorded and retried. Public API and server-rendered pages, complete without JavaScript. OpenCoins are off until Network grants the two capabilities.",
++diff --git a/package-lock.json b/package-lock.json
++index 1c11f06..66e28df 100644
++--- a/package-lock.json
+++++ b/package-lock.json
++@@ -18,7 +18,7 @@
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
++- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.183.0",
+++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.186.0",
++ "pg": "^8.23.0"
++ },
++ "devDependencies": {
++@@ -1209,9 +1209,9 @@
++ }
++ },
++ "node_modules/openvibe-shared": {
++- "version": "4.183.0",
++- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.183.0",
++- "integrity": "sha512-vp9gg++Dhyj3h0cwvBjujM6dSFYoeXdh1UXdZ6OXP/NjG672Fs7uRANV4MjT467JtFDN7r7qLI3OJAwGQxOuQw==",
+++ "version": "4.186.0",
+++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.186.0",
+++ "integrity": "sha512-wD4net+UjO0a6OG73W+RR1z5MIWtPgj407xLEWphiVWgLTQ4V1r76+DtLOfSf6RfEda/ALizRu1zmxbvuGGZOw==",
++ "license": "MIT",
++ "dependencies": {
++ "jsonwebtoken": "^9.0.0",
++diff --git a/package.json b/package.json
++index aa28de6..a412150 100644
++--- a/package.json
+++++ b/package.json
++@@ -25,7 +25,7 @@
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
++- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.183.0",
+++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.186.0",
++ "pg": "^8.23.0"
++ },
++ "engines": {
++diff --git a/test/auth-ops.test.js b/test/auth-ops.test.js
++index 6446813..eb4bb60 100644
++--- a/test/auth-ops.test.js
+++++ b/test/auth-ops.test.js
++@@ -108,11 +108,11 @@ const cookieHeader = (res) => res.headers.getSetCookie().map((c) => c.split(';')
++ assert.notStrictEqual(proxied.status, 200, 'a proxied request is not loopback');
++ });
++
++- await check('public pages are complete without JavaScript (noscript nav, SSR footer)', async () => {
+++ await check('public pages are complete without JavaScript (server-rendered nav, SSR footer)', async () => {
++ for (const p of ['/', '/updates']) {
++ const r = await t.get(p);
++ assert.strictEqual(r.status, 200, p);
++- assert.match(r.text, /<noscript><nav/);
+++ assert.match(r.text, /<div id="navbar-mount"><nav class="openvibe-navbar"[^>]*data-ov-server-nav="true"/, `${p}: the server-rendered navbar is in the first HTML`);
++ assert.match(r.text, /<footer id="ov-footer"/);
++ assert.match(r.text, /<main id="main"/);
++ }
++diff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
++index dd508ac..f4cb34c 100644
++--- a/test/perf-budget.test.js
+++++ b/test/perf-budget.test.js
++@@ -25,7 +25,7 @@ const BUDGETS = {
++ htmlBrotliKB: 16.8, // 16.8: measured 16.3 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
++ jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
++ jsRawKB: 376.7, // 376.7: measured 368.7 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
++- jsBrotliKB: 89.5, // 89.5: measured 87.5 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
+++ jsBrotliKB: 89.8, // 89.8: measured 89.7 KB brotli with openvibe-shared 4.186.0 (2026-11-10: the mark's Working and Thinking states, the Dock and the menu as one sheet, bare controls styled by the loader); 89.5: measured 87.5 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
++ cssFiles: 4, // 4 (2026-10-29: + /shared/kit.css and /shared/room.css, the kit's numbers, filters, folds, cards and rails); 2 (app.css + the cached /shared/showcase.css)
++ cssRawKB: 127.3, // 127.0 with the catalog's phone cards (2026-11-10); before: 125.9 with the daily card and the phone folds (2026-11-10); before: 122.2: measured 117.2 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0,
++ cssBrotliKB: 27.6, // 27.4 with the catalog's phone cards (2026-11-10); before: 27.1 with the daily card and the phone folds (2026-11-10); before: 26.6: measured 25.4 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 20
ddiff --git a/test/fixtures/pin-only/pure-pin.diff b/test/fixtures/pin-only/pure-pin.diff
nnew file mode 100644
iindex 0000000..ac0353b
---- /dev/null
++++ b/test/fixtures/pin-only/pure-pin.diff
@@@ -0,0 +1,72 @@
++diff --git a/README.md b/README.md
++index dae90aa..3d34bb7 100644
++--- a/README.md
+++++ b/README.md
++@@ -69,7 +69,7 @@ promo write changes both in one transaction.
++ - OpenVibe.Network (JWKS for service tokens; SSO with PKCE for the staff console; `identity.subject.resolve` for the Live import)
++ - OpenVibe.Events (the SDK outbox relay, when `EVENTS_URL` and the OAuth client secret are set)
++ - the payment providers whose secrets are configured (none in production today)
++-- `openvibe-contracts` v0.384.0, `openvibe-sdk` v0.133.0 (service tokens, per-person limits, `openvibe-sdk/db`, `openvibe-sdk/service`, `openvibe-sdk/vibes`), `openvibe-shared` v4.186.0, pinned by release tarball
+++- `openvibe-contracts` v0.384.0, `openvibe-sdk` v0.133.0 (service tokens, per-person limits, `openvibe-sdk/db`, `openvibe-sdk/service`, `openvibe-sdk/vibes`), `openvibe-shared` v4.190.0, pinned by release tarball
++ - **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
++ `openvibe-sdk/db`; Valkey holds the per-person limit counters (optional: without `VALKEY_URL` they count in the process)
++
++@@ -518,5 +518,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
++ <!-- versions:start -->
++ - openvibe-contracts: v0.384.0
++ - openvibe-sdk: v0.133.0
++-- openvibe-shared: v4.186.0
+++- openvibe-shared: v4.190.0
++ <!-- versions:end -->
++diff --git a/STATUS.json b/STATUS.json
++index 39fc2cc..d821e84 100644
++--- a/STATUS.json
+++++ b/STATUS.json
++@@ -10,7 +10,7 @@
++ "deployment": "The money ledger since the cutover of 2026-10-17 04:50-04:56 UTC (BILLING_AUTHORITY=billing in billing.env and live.env): deployed with `sudo ovhost deploy billing`, /opt/openvibe.billing, unit openvibe-billing, API on 127.0.0.1:4600 (loopback only; /api/v1 is denied on the public host). Public at https://billing.openvibe.network: the staff console (Network SSO + PKCE, BILLING_STAFF_SUBJECTS = the owner only), the billing policy (/policy), a person's own Vibes (/api/me/vibes, CORS for OpenVibe's sites), /api/health, /api/ready, /release.json and /webhooks/*. PowerChat deliveries reach /webhooks/powerchat through openvibe.live's /api/powerchat/webhook (proxied by Live's vhost, OpenVibe.Live#172).",
++ "contracts": "openvibe-contracts v0.384.0",
++ "sdk": "openvibe-sdk v0.133.0",
++- "shared": "openvibe-shared v4.186.0",
+++ "shared": "openvibe-shared v4.190.0",
++ "notes": "Runtime, journal, adapters, import, reconciliation and the staff console (cashout approve/deny, review queue, reconciliation, freeze, audit log) are built, tested and deployed, with /metrics (loopback only), hourly scheduled reconciliation, EXTERNAL PowerChat receipts (billing.receipt.external, announced for OpenVibe.Tips), overflow guards and serializable settlement (PostgreSQL, ADR-035). The cutover (README runbook, docs/live-cutover.md) ran on 2026-10-17: final import 0 new rows, 0 holds, 0 anomalies; reconciliation OK before and after; Live's money tables are read-only behind a database tripwire. OpenVibe.Tips and OpenVibe.VIP launched right after (VIP checkout open). Still due: a small real PowerChat purchase settling end to end after the cutover. Live's payouts, refunds and plans have no tables of their own; where they map is docs/live-mapping.md.",
++ "features": [
++ "append-only double-entry journal in bits and cents, balanced per currency",
++diff --git a/package-lock.json b/package-lock.json
++index abbec7f..8205305 100644
++--- a/package-lock.json
+++++ b/package-lock.json
++@@ -14,7 +14,7 @@
++ "iovalkey": "^0.4.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.133.0",
++- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.186.0",
+++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.190.0",
++ "pg": "^8.23.0"
++ },
++ "devDependencies": {
++@@ -1147,9 +1147,9 @@
++ }
++ },
++ "node_modules/openvibe-shared": {
++- "version": "4.186.0",
++- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.186.0",
++- "integrity": "sha512-wD4net+UjO0a6OG73W+RR1z5MIWtPgj407xLEWphiVWgLTQ4V1r76+DtLOfSf6RfEda/ALizRu1zmxbvuGGZOw==",
+++ "version": "4.190.0",
+++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.190.0",
+++ "integrity": "sha512-NnmQ+mJLTyM4mbYDyq/Nmxpjxo1ehmMcltz6NqcJJ/AhOTuZw7/QBl1CbJeWcGyXyL5VI/Ugu9/fIPe0UKvNNQ==",
++ "license": "MIT",
++ "dependencies": {
++ "jsonwebtoken": "^9.0.0",
++diff --git a/package.json b/package.json
++index 4347318..06ac961 100644
++--- a/package.json
+++++ b/package.json
++@@ -28,7 +28,7 @@
++ "iovalkey": "^0.4.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.133.0",
++- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.186.0",
+++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.190.0",
++ "pg": "^8.23.0"
++ },
++ "engines": {
ddiff --git a/test/pin-only.test.js b/test/pin-only.test.js
nnew file mode 100644
iindex 0000000..a3c6636
---- /dev/null
++++ b/test/pin-only.test.js
@@@ -0,0 +1,115 @@
++'use strict';
++// scripts/pin-only.js: a pull request is pin-only when its diff touches nothing but an openvibe-* pin
++// (package.json, lockfiles), STATUS.json, README version lines, and budget numbers with their comments.
++// The three fixtures are real merged pin diffs (gh pr diff); the inline cases are the ways a code change
++// must not be mistaken for one.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const cp = require('child_process');
++const { decide } = require('../scripts/pin-only');
++
++const fixture = (n) => fs.readFileSync(path.join(__dirname, 'fixtures/pin-only', n), 'utf8');
++
++// The real diffs: a pure pin (Billing#252), a pin with a budget raise (Quest#263), and a pin that also
++// changes a test's assertions (Quest#260, test/auth-ops.test.js).
++assert.deepStrictEqual(decide(fixture('pure-pin.diff')).pinOnly, true, 'a pure pin is pin-only');
++assert.deepStrictEqual(decide(fixture('pin-with-budget.diff')).pinOnly, true, 'a pin with a budget raise is pin-only');
++{
++ const v = decide(fixture('pin-with-code.diff'));
++ assert.strictEqual(v.pinOnly, false, 'a pin that also changes a test is not pin-only');
++ assert.ok(v.reasons.some((r) => r.includes('auth-ops')), `the reason names the code file: ${v.reasons}`);
++}
++
++const file = (p, body) => `diff --git a/${p} b/${p}\nindex 1111111..2222222 100644\n--- a/${p}\n+++ b/${p}\n${body}`;
++const PKG_OLD = ` "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.186.0",`;
++const PKG_NEW = ` "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.190.0",`;
++
++// package.json: only openvibe-* pin lines.
++assert.strictEqual(decide(file('package.json', `@@ -1,2 +1,2 @@\n "a": "b",\n-${PKG_OLD}\n+${PKG_NEW}\n`)).pinOnly, true);
++assert.strictEqual(decide(file('apps/gateway/package.json', `@@ -1,2 +1,2 @@\n "a": "b",\n-${PKG_OLD}\n+${PKG_NEW}\n`)).pinOnly, true, 'nested package.json (Tools keeps one package per app)');
++{
++ const v = decide(file('package.json', `@@ -1,3 +1,3 @@\n-${PKG_OLD}\n+${PKG_NEW}\n- "left-pad": "^1.3.0",\n+ "left-pad": "^1.4.0",\n`));
++ assert.strictEqual(v.pinOnly, false, 'a second dependency moving with the pin is not pin-only');
++}
++{
++ const v = decide(file('package.json', `@@ -1,2 +1,2 @@\n- "test": "node test/run.js",\n+ "test": "node test/run.js --strict",\n`));
++ assert.strictEqual(v.pinOnly, false, 'a scripts change is not pin-only');
++}
++
++// Lockfiles: openvibe-* lines and the version/resolved/integrity lines below them, in a hunk that touches
++// an openvibe-* entry.
++const lockHunk = `@@ -10,7 +10,7 @@\n "node_modules/openvibe-shared": {\n- "version": "4.186.0",\n- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.186.0",\n- "integrity": "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",\n+ "version": "4.190.0",\n+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.190.0",\n+ "integrity": "sha512-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB==",\n "license": "MIT",\n`;
++assert.strictEqual(decide(file('package-lock.json', lockHunk)).pinOnly, true, "npm's version/resolved/integrity triple");
++{
++ const v = decide(file('package-lock.json', `@@ -50,5 +50,5 @@\n "node_modules/lodash": {\n- "version": "4.17.20",\n+ "version": "4.17.21",\n "license": "MIT",\n`));
++ assert.strictEqual(v.pinOnly, false, 'another package moving in the lockfile is not pin-only');
++ assert.ok(v.reasons[0].includes('no openvibe-* entry'), `the reason says why: ${v.reasons}`);
++}
++{
++ // The resolved URL of another package is the supply-chain shape: it must never ride a pin hunk.
++ const v = decide(file('package-lock.json', lockHunk + `@@ -60,5 +60,5 @@\n "node_modules/left-pad": {\n- "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",\n+ "resolved": "https://evil.example.com/left-pad.tgz",\n "license": "MIT",\n`));
++ assert.strictEqual(v.pinOnly, false, 'a foreign resolved URL is not pin-only');
++}
++assert.strictEqual(decide(file('pnpm-lock.yaml', `@@ -8,5 +8,5 @@\n- openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.186.0:\n+ openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.190.0:\n resolution: {integrity: sha512-AAAA}\n- version: 4.186.0\n+ version: 4.190.0\n`)).pinOnly, true, 'pnpm lock entry');
++
++// STATUS.json is metadata: any change rides along.
++assert.strictEqual(decide(file('STATUS.json', `@@ -1,3 +1,3 @@\n- "shared": "openvibe-shared v4.186.0",\n+ "shared": "openvibe-shared v4.190.0",\n "summary": "x"\n`)).pinOnly, true);
++
++// README.md: only version lines.
++assert.strictEqual(decide(file('README.md', `@@ -1,3 +1,3 @@\n - openvibe-contracts: v0.384.0\n-- openvibe-shared: v4.186.0\n+- openvibe-shared: v4.190.0\n <!-- versions:end -->\n`)).pinOnly, true, 'the versions block');
++assert.strictEqual(decide(file('README.md', `@@ -1 +1 @@\n-The widget needs \`openvibe-sdk\` v0.26.0 or later.\n+The widget needs \`openvibe-sdk\` v0.27.0 or later.\n`)).pinOnly, true, 'a prose version mention');
++{
++ const v = decide(file('README.md', `@@ -1 +1 @@\n-Old install words.\n+New install words.\n`));
++ assert.strictEqual(v.pinOnly, false, 'prose without a version is not pin-only');
++}
++
++// Budget files: budget numbers and their comments, nothing else.
++assert.strictEqual(decide(file('test/perf-budget.test.js', `@@ -20,3 +20,3 @@\n- jsBrotliKB: 89.8, // 89.8: measured 89.7 with openvibe-shared 4.186.0\n+ jsBrotliKB: 94, // 94: measured 93.7 with openvibe-shared 4.190.0\n cssFiles: 4,\n`)).pinOnly, true);
++assert.strictEqual(decide(file('scripts/perf/check-budgets.js', `@@ -20,3 +20,3 @@\n- jsRawKB: 958.3, // with openvibe-shared 4.137.0\n+ jsRawKB: 960, // 960: measured 959.1 with openvibe-shared 4.190.0\n jsFiles: 25,\n`)).pinOnly, true, "Live's budget script");
++assert.strictEqual(decide(file('apps/gateway/test/perf-budget.test.js', `@@ -20,3 +20,3 @@\n- jsBrotliKB: 80, // measured 79.4\n+ jsBrotliKB: 80.5, // 80.5: measured 80.1 with openvibe-shared 4.190.0\n`)).pinOnly, true, "Tools' gateway budget");
++{
++ const v = decide(file('test/perf-budget.test.js', `@@ -20,3 +20,3 @@\n- jsBrotliKB: 89.8, // measured 89.7\n+ jsBrotliKB: 89.8, // measured 89.7\n+ const extra = 1;\n`));
++ assert.strictEqual(v.pinOnly, false, 'a logic line in a budget file is not pin-only');
++}
++{
++ const v = decide(file('scripts/perf/check-budgets.js', `@@ -40,3 +40,3 @@\n-const FORBIDDEN_ON_HOME = ['/js/chat.js'];\n+const FORBIDDEN_ON_HOME = [];\n`));
++ assert.strictEqual(v.pinOnly, false, 'emptying the forbidden list is not pin-only');
++}
++
++// Everything else fails closed: workflows, changelogs, code, new/deleted/renamed files, empty diffs.
++for (const [name, d] of [
++ ['a security.yml SHA move', file('.github/workflows/ci.yml', `@@ -1,2 +1,2 @@\n- uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@${'a'.repeat(40)}\n+ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@${'b'.repeat(40)}\n`)],
++ ['CHANGELOG.md', file('CHANGELOG.md', `@@ -1,2 +1,2 @@\n-## 1.0.0\n+## 1.0.1\n`)],
++ ['server code', file('server/index.js', `@@ -1,2 +1,2 @@\n-const a = 1;\n+const a = 2;\n`)],
++ ['a nested README', file('apps/a/README.md', `@@ -1 +1 @@\n-old\n+new\n`)],
++ ['empty diff', ''],
++]) {
++ assert.strictEqual(decide(d).pinOnly, false, `${name} is not pin-only`);
++}
++assert.strictEqual(decide(`diff --git a/new.js b/new.js\nnew file mode 100644\nindex 0000000..1111111\n--- /dev/null\n+++ b/new.js\n@@ -0,0 +1 @@\n+console.log(1);\n`).pinOnly, false, 'an added file is not pin-only');
++assert.strictEqual(decide(`diff --git a/old.js b/old.js\ndeleted file mode 100644\nindex 1111111..0000000\n--- a/old.js\n+++ /dev/null\n@@ -1 +0,0 @@\n-console.log(1);\n`).pinOnly, false, 'a deleted file is not pin-only');
++
++// End to end through git: a tiny repository, a pin-shaped commit, then a code commit.
++{
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pin-only-'));
++ const git = (args) => cp.execFileSync('git', args, { cwd: dir, encoding: 'utf8' });
++ const run = (args) => cp.execFileSync(process.execPath, [path.join(__dirname, '..', 'scripts/pin-only.js'), ...args], { encoding: 'utf8' }).trim();
++ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ dependencies: { 'openvibe-shared': `https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.186.0` } }, null, 2) + '\n');
++ fs.writeFileSync(path.join(dir, 'server.js'), 'console.log(1);\n');
++ git(['init', '-q']);
++ git(['-c', 'user.email=t@t', '-c', 'user.name=t', 'add', '.']);
++ git(['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'base']);
++ const base = git(['rev-parse', 'HEAD']).trim();
++ fs.writeFileSync(path.join(dir, 'package.json'), fs.readFileSync(path.join(dir, 'package.json'), 'utf8').replace('v4.186.0', 'v4.190.0'));
++ git(['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qam', 'pin']);
++ assert.strictEqual(run(['--base', base, '--dir', dir]), 'true', 'the CLI says true for a pin commit');
++ fs.writeFileSync(path.join(dir, 'server.js'), 'console.log(2);\n');
++ git(['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qam', 'code']);
++ assert.strictEqual(run(['--base', base, '--dir', dir]), 'false', 'the CLI says false once code moves too');
++ assert.strictEqual(run(['--dir', dir]), 'false', 'a missing --base fails closed to the full suite');
++ fs.rmSync(dir, { recursive: true, force: true });
++}
++
++console.log('pin-only: all checks passed');
ddiff --git a/test/test-runner.test.js b/test/test-runner.test.js
iindex bb83587..50792f9 100644
---- a/test/test-runner.test.js
++++ b/test/test-runner.test.js
@@@ -58,6 +58,14 @@ const { run, skipsIn, resolveParallel } = require('../test-runner');
r = await run({ dir, argv: ['nothing-matches'], log });
assert.strictEqual(r.exitCode, 1);
++ // --allow-empty (the pin-only CI subset): no match is a pass, not a failure.
++ r = await run({ dir, argv: ['nothing-matches', '--allow-empty'], log });
++ assert.deepStrictEqual([r.summary, r.exitCode], ['no test files matched (allowed)', 0]);
++ process.env.OV_TEST_ALLOW_EMPTY = '1';
++ try {
++ r = await run({ dir, argv: ['nothing-matches'], log });
++ assert.strictEqual(r.exitCode, 0);
++ } finally { delete process.env.OV_TEST_ALLOW_EMPTY; }
assert.deepStrictEqual(skipsIn('x\n ACK test: skipped (ffmpeg not installed) \nrollback with newer writes: skipped (no git history)\nOK B: skipped — old style'),
['ACK test: skipped (ffmpeg not installed)', 'rollback with newer writes: skipped (no git history)']);
// retry: a file that matches and fails is run once more. One that passes the second time counts as passed and is