diff --git a/.github/scripts/npm-ci.md b/.github/scripts/npm-ci.md new file mode 100644 index 00000000000..d4462019441 --- /dev/null +++ b/.github/scripts/npm-ci.md @@ -0,0 +1,72 @@ +# CI npm install retry + +Run `node .github/scripts/npm-ci.mjs` from the repository root on Linux x64. +It executes ordinary `npm ci` and permits **one retry only** when exit 1 and +the contiguous npm stderr block identify the exact release download of the C2PA +version locked in the root `package-lock.json` (`packages["node_modules/@contentauth/c2pa-node"].version`), +the Rust fallback, and the missing workspace manifest. The release URL and +filename are built from that one exact `x.y.z` version. A missing, unreadable, +malformed or non-exact (range, prerelease, leading-zero) lockfile version fails +closed: no retry, and npm's original exit status is preserved. Other versions +(including a stderr version that differs from the lock, or a URL whose tag and +filename disagree), platforms, partial signatures, additional npm error +codes/paths, and signals do not retry. +A second failure remains a job failure with its own exit status or signal. +Before the sole retry, wait a uniformly randomized integer delay of 1,000–5,000 +milliseconds. Cancellation aborts that wait and prevents the second install. +Tests inject randomness and a clock through the module API; the CLI always uses +the bounded production jitter and timer, with no environment override. + +The matcher comes from [main job 104032687845](https://github.com/adcontextprotocol/adcp/actions/runs/34860948128/job/104032687845) +on 2026-09-14. The same release installed in other jobs of that run, and +[#7511's failed install](https://github.com/adcontextprotocol/adcp/actions/runs/34857319525/job/104020125889) +[passed unchanged](https://github.com/adcontextprotocol/adcp/actions/runs/34857319525/job/104028425924). +Upstream returns false on a non-2xx download without logging the HTTP status; +the fallback then fails because the published Cargo manifest inherits workspace +fields without a workspace root. The logs cannot establish the exact HTTP code. +The same block, followed by the installer's unhandled-rejection trace, failed +four shards of [#8804's first Build Check attempt](https://github.com/adcontextprotocol/adcp/actions/runs/42472661157/attempts/1) +on 2026-11-08 with 0.9.7 locked; the rerun passed. +The published 0.9.7, 0.9.9 and 0.10.0 Cargo manifests still inherit workspace +fields, and 0.10.0 ships the same `postinstall.cjs` as 0.9.7, so the underlying +fallback bug is not fixed upstream; the matcher follows the locked version +instead of a hardcoded one. Keep the match narrow; reassess/remove +this workaround when upstream fixes the installer. This is not a general npm +retry policy. + +Both attempts stream stdout/stderr live, with backpressure, and retain separate +complete files in a unique `adcp-npm-ci-*` directory under `RUNNER_TEMP` (or the +OS temporary directory). The path is printed, directory permissions are 0700, +and files are 0600. Capture or live-forwarding failure disables retry. If a live +log consumer disconnects, the wrapper continues draining npm into the private +logs and preserves npm's outcome. No environment/config dump, +shell evaluation, log replay, or artifact upload is added. GitHub's existing +masking applies to the live streams; runner-local files are raw npm output and +must not be uploaded as public artifacts. They live until runner cleanup. +SIGINT/SIGTERM/SIGHUP are forwarded to npm's process group, including lifecycle +children, and the wrapper preserves signal termination even if npm traps it. + +The helper covers 12 root installs: Build Check (4 job definitions), migration +smoke (2), training storyboards (2), broken links, external smoke, schema PR +bundle construction, and the untrusted runtime-attestation build. Existing workflow gates, environment +variables, permissions and timeouts remain in force. `release.yml` and +`deploy.yml` stay outside this CI-only change, including release verification. +The separate `apps/web` and checked-out `sdk` packages are excluded, as is the +existing `sdk-response-conformance.yml` install with scripts disabled (which +cannot execute this postinstall). No dependency, lockfile or install policy changes. + +Offline tests run before Build Check's install and can also be run locally: + +```sh +node --test .github/scripts/npm-ci.test.cjs +node tests/changeset-protocol-scope.test.cjs +``` + +No protocol changeset is appropriate for this operational change. The scope +check exempts only the exact install-command substitution in the otherwise +protocol-scoped storyboard workflow; any other edit there remains scoped. + +External smoke isolation has landed on `main`; its install step adopts this +helper with its `PUPPETEER_SKIP_DOWNLOAD` environment and permissions unchanged. +Draft #7514 owns PostgreSQL test barriers. This helper only addresses the shared +dependency-install failure. diff --git a/.github/scripts/npm-ci.mjs b/.github/scripts/npm-ci.mjs new file mode 100644 index 00000000000..4a9ea69c889 --- /dev/null +++ b/.github/scripts/npm-ci.mjs @@ -0,0 +1,168 @@ +#!/usr/bin/env node +// CI-only, Linux x64: one normal npm ci retry for the C2PA release-download/workspace fallback failure of the lockfile's version. +import { spawn } from 'node:child_process'; +import { randomInt } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +import { createReadStream, mkdtempSync, openSync, closeSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { createInterface } from 'node:readline'; +import { Writable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +// The only version that may match is the one the root lockfile installs. Any +// missing, unreadable or non-exact-semver metadata yields null (no retry). +function lockedC2paVersion() { + try { + const lock = JSON.parse(readFileSync(join(process.cwd(), 'package-lock.json'), 'utf8')); + const version = lock?.packages?.['node_modules/@contentauth/c2pa-node']?.version; + return typeof version === 'string' && /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/.test(version) ? version : null; + } catch { + return null; + } +} + +async function matchesFailure(log) { + const version = lockedC2paVersion(); + if (!version) return false; + const root = join(process.cwd(), 'node_modules/@contentauth/c2pa-node'); + // Match npm's contiguous stderr error block, including the package path, + // exact release/version/platform, download-to-build transition and manifest. + // Checking for a release alone does NOT establish a failed download. + const expected = [ + 'npm error code 1', + `npm error path ${root}`, + 'npm error command failed', + 'npm error command sh -c node scripts/postinstall.cjs', + "npm error Detected { arch: 'x64', platform: 'linux' }", + `npm error Checking for a release at: https://github.com/contentauth/c2pa-js/releases/download/%40contentauth%2Fc2pa-node%40${version}/c2pa-node_x86_64-unknown-linux-gnu-v${version}.zip`, + 'npm error 🦀 Building Rust...', + `npm error ERROR: Error: Command failed: npx cargo-cp-artifact -nc "${root}/dist/index.node" -- cargo build --message-format=json-render-diagnostics --release --manifest-path="${root}/Cargo.toml"`, + `npm error error: failed to parse manifest at \`${root}/Cargo.toml\``, + 'npm error', + 'npm error Caused by:', + "npm error error inheriting `rust-version` from workspace root manifest's `workspace.package.rust-version`", + 'npm error', + 'npm error Caused by:', + 'npm error failed to find a workspace root', + 'npm error Did not copy "cdylib:c2pa-node"', + ]; + let index = 0; + let codes = 0; + let paths = 0; + for await (const raw of createInterface({ input: createReadStream(log), crlfDelay: Infinity })) { + const line = raw.trimEnd(); + if (line.startsWith('npm error code ')) codes++; + if (line.startsWith('npm error path ')) paths++; + if (index === 0 && line !== expected[0]) continue; + if (index < expected.length) { + if (line !== expected[index]) return false; + index++; + } + } + return index === expected.length && codes === 1 && paths === 1; +} + +// Injectable clock/randomness for offline tests; the CLI always uses these defaults. +export async function runNpmCi({ jitter = randomInt, sleep = delay } = {}) { + const outputs = [process.stdout, process.stderr]; + const failedOutputs = new Set(); + const outputHandlers = outputs.map(output => () => failedOutputs.add(output)); + // A disconnected live-log consumer must not crash the wrapper or stop capture. + outputs.forEach((output, index) => output.on('error', outputHandlers[index])); + const logDir = mkdtempSync(join(process.env.RUNNER_TEMP || tmpdir(), 'adcp-npm-ci-')); + console.error(`npm ci logs (runner-local): ${JSON.stringify(logDir)}`); + const backoff = new AbortController(); + let child; + let interrupted; + const signals = ['SIGINT', 'SIGTERM', 'SIGHUP']; + function forwardSignal(signal) { + interrupted ??= signal; + backoff.abort(); + if (child?.pid) { + try { + // npm's lifecycle shell and Cargo must receive cancellation too. + process.kill(-child.pid, signal); + } catch (error) { + if (error.code !== 'ESRCH') throw error; + } + } + } + const handlers = signals.map(signal => () => forwardSignal(signal)); + signals.forEach((signal, index) => process.on(signal, handlers[index])); + + try { + for (let attempt = 1; attempt <= 2; attempt++) { + if (interrupted) break; + let captureFailed = false; + const files = ['stdout', 'stderr'].map(stream => join(logDir, `attempt-${attempt}.${stream}.log`)); + const fds = files.map(file => openSync(file, 'wx', 0o600)); + // Fixed executable/arguments, no shell or flags; inherit ordinary npm config. + child = spawn('npm', ['ci'], { detached: true, stdio: ['inherit', 'pipe', 'pipe'] }); + const result = new Promise(resolve => { + child.once('error', error => resolve({ code: error.code === 'ENOENT' ? 127 : 126 })); + child.once('close', (code, signal) => resolve({ code, signal })); + }); + const copies = [child.stdout, child.stderr].map((input, index) => pipeline(input, new Writable({ + write(chunk, encoding, callback) { + try { + writeFileSync(fds[index], chunk); + } catch { + captureFailed = true; + } + // Backpressure and completion callbacks preserve live, untruncated output. + const output = outputs[index]; + if (failedOutputs.has(output)) return callback(); + output.write(chunk, encoding, error => { + if (error) failedOutputs.add(output); + // Always keep draining npm into the private log, even after EPIPE. + callback(); + }); + }, + }))); + const copying = Promise.allSettled(copies); + const { code, signal } = await result; + const copied = await copying; + child = undefined; + fds.forEach(fd => closeSync(fd)); + captureFailed ||= copied.some(copy => copy.status === 'rejected'); + if (captureFailed || failedOutputs.size) console.error('npm ci log capture/forwarding failed; retry disabled.'); + interrupted ??= signal; + process.exitCode = code ?? 1; + if (interrupted || code !== 1 || attempt === 2 || captureFailed || failedOutputs.size || !(await matchesFailure(files[1]))) break; + const milliseconds = jitter(1000, 5001); + if (!Number.isInteger(milliseconds) || milliseconds < 1000 || milliseconds > 5000) { + throw new RangeError('CI install retry delay must be between 1000 and 5000 ms.'); + } + console.error(`Known locked C2PA download/workspace fallback failure; waiting ${milliseconds} ms before retry.`); + try { + await sleep(milliseconds, undefined, { signal: backoff.signal }); + } catch (error) { + if (!interrupted || error.name !== 'AbortError') throw error; + } + if (interrupted || failedOutputs.size) break; + // Observe an async write failure before starting another detached install. + await new Promise(resolve => process.stderr.write('retrying normal npm ci once (attempt 2/2).\n', error => { + if (error) failedOutputs.add(process.stderr); + resolve(); + })); + if (interrupted || failedOutputs.size) break; + } + } finally { + // Drain diagnostics while error/signal handlers are still installed. The + // next event-loop turn also lets write callbacks' pending error events fire. + await Promise.all(outputs.map(output => new Promise(resolve => { + if (output.destroyed) resolve(); + else output.write('', resolve); + }))); + await new Promise(resolve => setImmediate(resolve)); + outputs.forEach((output, index) => output.off('error', outputHandlers[index])); + signals.forEach((signal, index) => process.off(signal, handlers[index])); + } + if (interrupted) process.kill(process.pid, interrupted); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await runNpmCi(); +} diff --git a/.github/scripts/npm-ci.test.cjs b/.github/scripts/npm-ci.test.cjs new file mode 100644 index 00000000000..880f0ac679f --- /dev/null +++ b/.github/scripts/npm-ci.test.cjs @@ -0,0 +1,446 @@ +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { once } = require('node:events'); +const { pathToFileURL } = require('node:url'); +const test = require('node:test'); + +const script = path.join(__dirname, 'npm-ci.mjs'); +// Verbatim npm stderr from main job 104032687845 (2026-09-14), with the +// checkout path replaced per fixture. No network, installed dependencies or Rust. +// The 0.9.4 observation is the verbatim fixture; other versions substitute the +// version in the release URL/filename, as the published installer does. +function signatureFor(version) { + return signature.replaceAll('0.9.4', version); +} +const signature = `npm error code 1 +npm error path ROOT/node_modules/@contentauth/c2pa-node +npm error command failed +npm error command sh -c node scripts/postinstall.cjs +npm error Detected { arch: 'x64', platform: 'linux' } +npm error Checking for a release at: https://github.com/contentauth/c2pa-js/releases/download/%40contentauth%2Fc2pa-node%400.9.4/c2pa-node_x86_64-unknown-linux-gnu-v0.9.4.zip +npm error 🦀 Building Rust... +npm error ERROR: Error: Command failed: npx cargo-cp-artifact -nc "ROOT/node_modules/@contentauth/c2pa-node/dist/index.node" -- cargo build --message-format=json-render-diagnostics --release --manifest-path="ROOT/node_modules/@contentauth/c2pa-node/Cargo.toml" +npm error error: failed to parse manifest at \`ROOT/node_modules/@contentauth/c2pa-node/Cargo.toml\` +npm error +npm error Caused by: +npm error error inheriting \`rust-version\` from workspace root manifest's \`workspace.package.rust-version\` +npm error +npm error Caused by: +npm error failed to find a workspace root +npm error Did not copy "cdylib:c2pa-node" +`; + +const fakeNpm = ` +const fs = require('node:fs'); +const { spawn } = require('node:child_process'); +const plan = JSON.parse(fs.readFileSync('plan.json')); +const count = fs.existsSync('calls.json') ? JSON.parse(fs.readFileSync('calls.json')) : []; +count.push({ args: process.argv.slice(2), pid: process.pid }); +fs.writeFileSync('calls.json', JSON.stringify(count)); +const step = plan[count.length - 1]; +if (!step) process.exit(99); +fs.writeFileSync(1, (step.out || '').replaceAll('ROOT', process.cwd())); +fs.writeFileSync(2, (step.err || '').replaceAll('ROOT', process.cwd())); +if (step.signal) process.kill(process.pid, step.signal); +else if (step.family) { + const grandchild = spawn(process.execPath, ['-e', \` + const fs = require('node:fs'); + for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, () => { + fs.writeFileSync('grandchild-signal', signal); + process.exit(0); + }); + setInterval(() => {}, 1000); + process.send('ready'); + \`], { stdio: ['ignore', 'inherit', 'inherit', 'ipc'] }); + for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, () => { + fs.writeFileSync('npm-signal', signal); + process.exit(0); + }); + grandchild.on('message', () => fs.writeFileSync(1, 'ready\\n')); +} else if (step.wait) { + fs.writeFileSync(1, 'ready\\n'); + const timer = setInterval(() => { + if (fs.existsSync('continue')) { clearInterval(timer); process.exit(step.code || 0); } + }, 10); +} else process.exit(step.code || 0); +`; + +function fixture(t, plan, options = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'npm-ci-test-')); + // Shell metacharacters are data in cwd and RUNNER_TEMP, never evaluated. + const cwd = path.join(dir, 'space $(touch INJECTED) `touch INJECTED`'); + fs.mkdirSync(cwd); + const bin = path.join(dir, 'bin'); + fs.mkdirSync(bin); + fs.writeFileSync(path.join(bin, 'npm'), `#!${process.execPath}\n${fakeNpm}`, { mode: 0o700 }); + fs.writeFileSync(path.join(cwd, 'plan.json'), JSON.stringify(plan)); + // The expected C2PA version comes only from the root lockfile's installed + // package entry; fixtures state it explicitly (default 0.9.4, the fixture's). + if (options.lockRaw !== undefined) { + fs.writeFileSync(path.join(cwd, 'package-lock.json'), options.lockRaw); + } else if (options.lock !== null) { + fs.writeFileSync(path.join(cwd, 'package-lock.json'), JSON.stringify({ + packages: { 'node_modules/@contentauth/c2pa-node': { version: options.lock ?? '0.9.4' } }, + })); + } + // No production environment switch: inject a fake clock through the module API. + const driver = path.join(cwd, 'driver.mjs'); + fs.writeFileSync(driver, ` + import fs from 'node:fs'; + import { runNpmCi } from ${JSON.stringify(pathToFileURL(script).href)}; + await runNpmCi({ + jitter(min, max) { + fs.appendFileSync('jitter.jsonl', JSON.stringify([min, max]) + '\\n'); + return ${JSON.stringify(options.delayMs ?? 3000)}; + }, + sleep(milliseconds, value, { signal }) { + fs.appendFileSync('delays.jsonl', JSON.stringify(milliseconds) + '\\n'); + if (!${Boolean(options.waitDelay)}) return Promise.resolve(); + process.stdout.write('backoff-ready\\n'); + return new Promise((resolve, reject) => { + const finish = () => { clearInterval(timer); signal.removeEventListener('abort', abort); }; + const abort = () => { finish(); reject(Object.assign(new Error(), { name: 'AbortError' })); }; + const timer = setInterval(() => { + if (fs.existsSync('continue-delay')) { finish(); resolve(); } + }, 10); + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) abort(); + }); + }, + }); + `); + const child = spawn(process.execPath, [options.direct ? script : driver], { + cwd, + env: { PATH: bin, RUNNER_TEMP: cwd, SENTINEL_SECRET: 'never-print-this-secret', ...options.env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => { stdout += chunk; }); + child.stderr.on('data', chunk => { stderr += chunk; }); + const done = once(child, 'close').then(([code, signal]) => ({ code, signal })); + const calls = () => JSON.parse(fs.readFileSync(path.join(cwd, 'calls.json'))); + t.after(() => { + if (child.exitCode === null && child.signalCode === null) { + for (const call of fs.existsSync(path.join(cwd, 'calls.json')) ? calls() : []) { + try { process.kill(-call.pid, 'SIGKILL'); } catch {} + } + child.kill('SIGKILL'); + } + fs.rmSync(dir, { recursive: true, force: true }); + }); + return { + child, cwd, done, calls, + stdout: () => stdout, + stderr: () => stderr, + logs: () => path.join(cwd, fs.readdirSync(cwd).find(name => name.startsWith('adcp-npm-ci-'))), + delays: () => fs.existsSync(path.join(cwd, 'delays.jsonl')) + ? fs.readFileSync(path.join(cwd, 'delays.jsonl'), 'utf8').trim().split('\n').map(JSON.parse) : [], + async waiting() { + while (!stdout.includes('backoff-ready\n')) await once(child.stdout, 'data'); + assert.equal(child.exitCode, null); + }, + async ready() { + while (!stdout.includes('ready\n')) await once(child.stdout, 'data'); + assert.equal(child.exitCode, null, 'output must arrive while npm is still running'); + }, + }; +} + +function checkCalls(f, count) { + assert.equal(f.calls().length, count); + for (const call of f.calls()) assert.deepEqual(call.args, ['ci']); + assert.equal((f.stderr().match(/retrying normal npm ci once/g) || []).length, count - 1); + assert.equal(f.delays().length, count - 1, 'exactly one delay per retry'); + assert.ok(!f.stderr().includes('never-print-this-secret')); + assert.ok(!f.stdout().includes('never-print-this-secret')); + assert.ok(!fs.existsSync(path.join(f.cwd, 'INJECTED'))); +} + +test('first success: live stdout/stderr and exact normal npm arguments', { timeout: 10000 }, async t => { + const f = fixture(t, [{ out: 'stdout\n', err: 'stderr\n', wait: true }], { direct: true }); + await f.ready(); + assert.ok(f.stdout().includes('stdout\n')); + // Stderr uses its own pipe, so wait for its independent arrival. + while (!f.stderr().includes('stderr\n')) await once(f.child.stderr, 'data'); + fs.writeFileSync(path.join(f.cwd, 'continue'), ''); + assert.deepEqual(await f.done, { code: 0, signal: null }); + checkCalls(f, 1); +}); + +test('exact signature then success: one retry, both complete logs retained privately', { timeout: 10000 }, async t => { + const large = 'x'.repeat(2 * 1024 * 1024) + '\n'; + const f = fixture(t, [ + { code: 1, out: large, err: large + signature }, + { out: 'installed\n', err: 'second attempt\n' }, + ], { lock: '0.9.4' }); + assert.deepEqual(await f.done, { code: 0, signal: null }); + checkCalls(f, 2); + const logs = f.logs(); + assert.deepEqual(fs.readdirSync(logs).sort(), [ + 'attempt-1.stderr.log', 'attempt-1.stdout.log', 'attempt-2.stderr.log', 'attempt-2.stdout.log', + ]); + assert.equal(fs.statSync(logs).mode & 0o777, 0o700); + for (const file of fs.readdirSync(logs)) assert.equal(fs.statSync(path.join(logs, file)).mode & 0o777, 0o600); + assert.equal(fs.readFileSync(path.join(logs, 'attempt-1.stdout.log'), 'utf8'), large); + assert.equal(fs.readFileSync(path.join(logs, 'attempt-1.stderr.log'), 'utf8'), large + signature.replaceAll('ROOT', f.cwd)); + assert.equal(fs.readFileSync(path.join(logs, 'attempt-2.stdout.log'), 'utf8'), 'installed\n'); + assert.equal(fs.readFileSync(path.join(logs, 'attempt-2.stderr.log'), 'utf8'), 'second attempt\n'); + assert.equal(f.stdout(), large + 'installed\n'); + assert.ok(f.stderr().includes(signature.replaceAll('ROOT', f.cwd))); +}); + +test('exact signature twice: fail after exactly two npm ci invocations', { timeout: 10000 }, async t => { + const f = fixture(t, [{ code: 1, err: signature }, { code: 1, err: signature }, { code: 0 }]); + assert.deepEqual(await f.done, { code: 1, signal: null }); + checkCalls(f, 2); + assert.equal(fs.readFileSync(path.join(f.logs(), 'attempt-2.stderr.log'), 'utf8'), signature.replaceAll('ROOT', f.cwd)); +}); + +for (const stream of ['stdout', 'stderr']) { + for (const code of [0, 1, 42]) { + test(`disconnected ${stream}: retain full logs and npm exit ${code}, no retry`, { timeout: 10000 }, async t => { + const large = 'x'.repeat(2 * 1024 * 1024) + '\n'; + const f = fixture(t, [{ code, out: large, err: large + signature }, { code: 0 }]); + f.child[stream].destroy(); + assert.deepEqual(await f.done, { code, signal: null }); + checkCalls(f, 1); + assert.equal(fs.readFileSync(path.join(f.logs(), 'attempt-1.stdout.log'), 'utf8'), large); + assert.equal(fs.readFileSync(path.join(f.logs(), 'attempt-1.stderr.log'), 'utf8'), large + signature.replaceAll('ROOT', f.cwd)); + }); + } +} + +test('disconnected stderr still forwards cancellation to npm and descendants', { timeout: 10000 }, async t => { + const f = fixture(t, [{ family: true, err: signature }]); + f.child.stderr.destroy(); + await f.ready(); + f.child.kill('SIGTERM'); + assert.deepEqual(await f.done, { code: null, signal: 'SIGTERM' }); + checkCalls(f, 1); + assert.equal(fs.readFileSync(path.join(f.cwd, 'npm-signal'), 'utf8'), 'SIGTERM'); + assert.equal(fs.readFileSync(path.join(f.cwd, 'grandchild-signal'), 'utf8'), 'SIGTERM'); +}); + +for (const milliseconds of [1000, 2753, 5000]) { + test(`jitter ${milliseconds} ms: wait before the sole retry`, { timeout: 10000 }, async t => { + const f = fixture(t, [{ code: 1, err: signature }, { code: 0 }], { delayMs: milliseconds, waitDelay: true }); + await f.waiting(); + assert.equal(f.calls().length, 1, 'the second install cannot start before the delay completes'); + assert.deepEqual(f.delays(), [milliseconds]); + assert.equal(fs.readFileSync(path.join(f.cwd, 'jitter.jsonl'), 'utf8'), '[1000,5001]\n'); + assert.ok(!f.stderr().includes('retrying normal npm ci once')); + fs.writeFileSync(path.join(f.cwd, 'continue-delay'), ''); + assert.deepEqual(await f.done, { code: 0, signal: null }); + checkCalls(f, 2); + assert.equal(fs.readFileSync(path.join(f.cwd, 'jitter.jsonl'), 'utf8'), '[1000,5001]\n'); + }); +} + +for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { + test(`${signal} during backoff: preserve signal and never start retry`, { timeout: 10000 }, async t => { + const f = fixture(t, [{ code: 1, err: signature }, { code: 0 }], { waitDelay: true }); + await f.waiting(); + f.child.kill(signal); + assert.deepEqual(await f.done, { code: null, signal }); + assert.equal(f.calls().length, 1); + assert.deepEqual(f.calls()[0].args, ['ci']); + assert.deepEqual(f.delays(), [3000]); + assert.ok(!f.stderr().includes('retrying normal npm ci once')); + assert.equal(fs.readFileSync(path.join(f.logs(), 'attempt-1.stderr.log'), 'utf8'), signature.replaceAll('ROOT', f.cwd)); + assert.ok(!fs.existsSync(path.join(f.logs(), 'attempt-2.stdout.log'))); + }); +} + +test('stderr disconnects during backoff: await retry diagnostic failure before another install', { timeout: 10000 }, async t => { + const f = fixture(t, [{ code: 1, err: signature }, { code: 0 }], { waitDelay: true }); + await f.waiting(); + f.child.stderr.destroy(); + fs.writeFileSync(path.join(f.cwd, 'continue-delay'), ''); + assert.deepEqual(await f.done, { code: 1, signal: null }); + assert.equal(f.calls().length, 1); + assert.deepEqual(f.delays(), [3000]); + assert.equal(fs.readFileSync(path.join(f.logs(), 'attempt-1.stderr.log'), 'utf8'), signature.replaceAll('ROOT', f.cwd)); + assert.ok(!fs.existsSync(path.join(f.logs(), 'attempt-2.stdout.log'))); +}); + +for (const milliseconds of [999, 5001, 1500.5]) { + test(`reject invalid injected delay ${milliseconds} without another install`, { timeout: 10000 }, async t => { + const f = fixture(t, [{ code: 1, err: signature }, { code: 0 }], { delayMs: milliseconds }); + assert.deepEqual(await f.done, { code: 1, signal: null }); + checkCalls(f, 1); + assert.ok(f.stderr().includes('delay must be between 1000 and 5000 ms')); + }); +} + +for (const [name, err] of [ + ['download symptom without workspace fallback', signature.split('npm error 🦀')[0]], + ['workspace fallback without C2PA/download', 'npm error code 1\nnpm error failed to find a workspace root\n'], + ['C2PA workspace fallback without download', signature.split('\n').filter(line => !line.includes('Checking for a release')).join('\n')], + ['different package', signature.replaceAll('@contentauth/c2pa-node', '@example/other')], + ['different C2PA version than the 0.9.4 lock', signature.replaceAll('0.9.4', '0.9.5')], + ['successful download then unrelated build error', signature.replace('npm error 🦀', 'npm error Downloaded to ROOT/index.node\nnpm error 🦀')], + ['additional npm integrity failure', signature + 'npm error code EINTEGRITY\n'], + ['additional failing package', signature + 'npm error path ROOT/node_modules/other\n'], + ['arbitrary npm failure', 'npm error code EUSAGE\nnpm error package-lock.json is out of sync\n'], +]) { + test(`${name}: no retry`, { timeout: 10000 }, async t => { + const f = fixture(t, [{ code: 1, err }, { code: 0 }]); + assert.deepEqual(await f.done, { code: 1, signal: null }); + checkCalls(f, 1); + assert.equal(fs.readFileSync(path.join(f.logs(), 'attempt-1.stderr.log'), 'utf8'), err.replaceAll('ROOT', f.cwd)); + }); +} + +// Verbatim tail npm printed after the signature in #8804's failed install +// (job 128706873969, 2026-11-08): the installer's unhandled rejection. +const observedTail = `npm error +npm error node:internal/process/promises:392 +npm error new UnhandledPromiseRejection(reason); +npm error ^ +npm error +npm error UnhandledPromiseRejection: This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason "undefined". +npm error at throwUnhandledRejectionsMode (node:internal/process/promises:392:7) +npm error at processPromiseRejections (node:internal/process/promises:475:17) +npm error at process.processTicksAndRejections (node:internal/process/task_queues:106:37) { +npm error code: 'ERR_UNHANDLED_REJECTION' +npm error } +npm error +npm error Node.js v24.22.2 +npm error A complete log of this run can be found in: /home/runner/.npm/_logs/2026-11-08T10_47_43_203Z-debug-0.log +`; + +test('observed signature with its unhandled-rejection tail retries once', { timeout: 10000 }, async t => { + const err = signatureFor('0.10.0') + observedTail; + const f = fixture(t, [{ code: 1, err }, { out: 'installed\n' }], { lock: '0.10.0' }); + assert.deepEqual(await f.done, { code: 0, signal: null }); + checkCalls(f, 2); + assert.equal(fs.readFileSync(path.join(f.logs(), 'attempt-1.stderr.log'), 'utf8'), err.replaceAll('ROOT', f.cwd)); +}); + +for (const version of ['0.9.4', '0.9.7', '0.9.9', '0.10.0']) { + test(`locked C2PA ${version}: exact ${version} signature retries once and logs remain complete`, { timeout: 10000 }, async t => { + const sig = signatureFor(version); + const f = fixture(t, [{ code: 1, err: sig }, { out: 'installed\n' }], { lock: version }); + assert.deepEqual(await f.done, { code: 0, signal: null }); + checkCalls(f, 2); + assert.ok(sig.includes(`%40${version}/c2pa-node_x86_64-unknown-linux-gnu-v${version}.zip`)); + assert.equal(fs.readFileSync(path.join(f.logs(), 'attempt-1.stderr.log'), 'utf8'), sig.replaceAll('ROOT', f.cwd)); + assert.equal(fs.readFileSync(path.join(f.logs(), 'attempt-2.stdout.log'), 'utf8'), 'installed\n'); + }); + test(`locked C2PA ${version}: exact signature twice fails after two installs`, { timeout: 10000 }, async t => { + const sig = signatureFor(version); + const f = fixture(t, [{ code: 1, err: sig }, { code: 1, err: sig }, { code: 0 }], { lock: version }); + assert.deepEqual(await f.done, { code: 1, signal: null }); + checkCalls(f, 2); + }); +} + +for (const [locked, observed] of [ + ['0.9.7', '0.9.4'], + ['0.9.9', '0.9.7'], + ['0.9.7', '0.9.9'], + ['0.9.4', '0.9.7'], + ['0.10.0', '0.9.7'], + ['0.9.7', '0.10.0'], +]) { + test(`mixed versions: lock ${locked} with ${observed} failure: no retry`, { timeout: 10000 }, async t => { + const err = signatureFor(observed); + const f = fixture(t, [{ code: 1, err }, { code: 0 }], { lock: locked }); + assert.deepEqual(await f.done, { code: 1, signal: null }); + checkCalls(f, 1); + assert.equal(fs.readFileSync(path.join(f.logs(), 'attempt-1.stderr.log'), 'utf8'), err.replaceAll('ROOT', f.cwd)); + }); +} + +for (const [name, err] of [ + ['tag from lock, filename from another version', signatureFor('0.9.7').replace('-v0.9.7.zip', '-v0.9.9.zip')], + ['filename from lock, tag from another version', signatureFor('0.9.7').replace('%400.9.7/', '%400.9.9/')], +]) { + test(`mixed URL parts (${name}): no retry`, { timeout: 10000 }, async t => { + const f = fixture(t, [{ code: 1, err }, { code: 0 }], { lock: '0.9.7' }); + assert.deepEqual(await f.done, { code: 1, signal: null }); + checkCalls(f, 1); + }); +} + +for (const [name, options] of [ + ['missing lockfile', { lock: null }], + ['malformed lockfile JSON', { lockRaw: '{ not json' }], + ['lockfile without packages', { lockRaw: '{}' }], + ['no installed C2PA entry', { lockRaw: JSON.stringify({ packages: { '': { dependencies: { '@contentauth/c2pa-node': '^0.9.7' } } } }) }], + ['nested C2PA entry only', { lockRaw: JSON.stringify({ packages: { 'node_modules/other/node_modules/@contentauth/c2pa-node': { version: '0.9.7' } } }) }], + ['non-string version', { lockRaw: JSON.stringify({ packages: { 'node_modules/@contentauth/c2pa-node': { version: 907 } } }) }], + ['missing version', { lockRaw: JSON.stringify({ packages: { 'node_modules/@contentauth/c2pa-node': {} } }) }], + ['range instead of exact version', { lock: '^0.9.7' }], + ['prerelease version', { lock: '0.9.7-beta.1' }], + ['leading-zero version', { lock: '0.9.07' }], + ['empty version', { lock: '' }], + ['version with URL-significant text', { lock: '0.9.7/../../x' }], +]) { + test(`invalid lock metadata (${name}): fail closed, no retry, npm status preserved`, { timeout: 10000 }, async t => { + const err = signatureFor('0.9.7'); + const f = fixture(t, [{ code: 1, err }, { code: 0 }], options); + assert.deepEqual(await f.done, { code: 1, signal: null }); + checkCalls(f, 1); + assert.equal(fs.readFileSync(path.join(f.logs(), 'attempt-1.stderr.log'), 'utf8'), err.replaceAll('ROOT', f.cwd)); + }); +} + +test('invalid lock metadata does not alter a non-1 npm exit', { timeout: 10000 }, async t => { + const f = fixture(t, [{ code: 42, err: signatureFor('0.9.7') }], { lock: null }); + assert.deepEqual(await f.done, { code: 42, signal: null }); + checkCalls(f, 1); +}); + +test('matching text on stdout cannot classify a different npm stderr failure', { timeout: 10000 }, async t => { + const f = fixture(t, [{ code: 1, out: signature, err: 'npm error code EINTEGRITY\n' }]); + assert.deepEqual(await f.done, { code: 1, signal: null }); + checkCalls(f, 1); +}); + +for (const code of [2, 42, 127, 130, 143]) { + test(`preserve exit ${code} even with matching text`, { timeout: 10000 }, async t => { + const f = fixture(t, [{ code, err: signature }]); + assert.deepEqual(await f.done, { code, signal: null }); + checkCalls(f, 1); + }); +} + +test('preserve arbitrary second-attempt exit', { timeout: 10000 }, async t => { + const f = fixture(t, [{ code: 1, err: signature }, { code: 42, err: 'npm error code EOTHER\n' }]); + assert.deepEqual(await f.done, { code: 42, signal: null }); + checkCalls(f, 2); +}); + +test('missing npm propagates command-not-found without retry', { timeout: 10000 }, async t => { + const f = fixture(t, [], { env: { PATH: '/does-not-exist' } }); + assert.deepEqual(await f.done, { code: 127, signal: null }); + assert.ok(!fs.existsSync(path.join(f.cwd, 'calls.json'))); + assert.ok(!f.stderr().includes('retrying')); +}); + +test('second-attempt signal propagates without a third attempt', { timeout: 10000 }, async t => { + const f = fixture(t, [{ code: 1, err: signature }, { signal: 'SIGTERM', err: signature }]); + assert.deepEqual(await f.done, { code: null, signal: 'SIGTERM' }); + checkCalls(f, 2); +}); + +for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { + test(`npm terminated by ${signal}: same signal, no retry`, { timeout: 10000 }, async t => { + const f = fixture(t, [{ signal, err: signature }]); + assert.deepEqual(await f.done, { code: null, signal }); + checkCalls(f, 1); + }); + test(`forward ${signal} to npm and descendants; don't retry even if npm handles it with exit 0`, { timeout: 10000 }, async t => { + const f = fixture(t, [{ family: true, err: signature }]); + await f.ready(); + f.child.kill(signal); + assert.deepEqual(await f.done, { code: null, signal }); + checkCalls(f, 1); + for (const file of ['npm-signal', 'grandchild-signal']) assert.equal(fs.readFileSync(path.join(f.cwd, file), 'utf8'), signal); + }); +} diff --git a/.github/workflows/broken-links.yml b/.github/workflows/broken-links.yml index 4ff6509b524..632d433c145 100644 --- a/.github/workflows/broken-links.yml +++ b/.github/workflows/broken-links.yml @@ -88,7 +88,7 @@ jobs: cache: 'npm' - name: Install dependencies - run: npm ci + run: node .github/scripts/npm-ci.mjs env: PUPPETEER_SKIP_DOWNLOAD: 'true' diff --git a/.github/workflows/build-check.yml b/.github/workflows/build-check.yml index 31840fc0eb1..a37ea44d65d 100644 --- a/.github/workflows/build-check.yml +++ b/.github/workflows/build-check.yml @@ -39,8 +39,13 @@ jobs: node-version: "24" cache: "npm" + - name: Test CI install retry + run: | + node --test .github/scripts/npm-ci.test.cjs + node tests/changeset-protocol-scope.test.cjs + - name: Install dependencies - run: npm ci + run: node .github/scripts/npm-ci.mjs env: PUPPETEER_SKIP_DOWNLOAD: "true" @@ -197,7 +202,7 @@ jobs: cache: "npm" - name: Install dependencies - run: npm ci + run: node .github/scripts/npm-ci.mjs env: PUPPETEER_SKIP_DOWNLOAD: "true" @@ -303,7 +308,7 @@ jobs: cache: "npm" - name: Install dependencies - run: npm ci + run: node .github/scripts/npm-ci.mjs env: PUPPETEER_SKIP_DOWNLOAD: "true" @@ -381,7 +386,7 @@ jobs: cache: "npm" - name: Install dependencies - run: npm ci + run: node .github/scripts/npm-ci.mjs env: PUPPETEER_SKIP_DOWNLOAD: "true" diff --git a/.github/workflows/external-smoke.yml b/.github/workflows/external-smoke.yml index d6e39977200..dc9a8f4a7dd 100644 --- a/.github/workflows/external-smoke.yml +++ b/.github/workflows/external-smoke.yml @@ -24,7 +24,7 @@ jobs: with: node-version: '24' cache: npm - - run: npm ci + - run: node .github/scripts/npm-ci.mjs env: PUPPETEER_SKIP_DOWNLOAD: 'true' - name: Check deployed public agent diff --git a/.github/workflows/migration-smoke-test.yml b/.github/workflows/migration-smoke-test.yml index 2bcc4cd944f..12c4ec06c70 100644 --- a/.github/workflows/migration-smoke-test.yml +++ b/.github/workflows/migration-smoke-test.yml @@ -65,7 +65,7 @@ jobs: cache: "npm" - name: Install dependencies - run: npm ci + run: node .github/scripts/npm-ci.mjs env: PUPPETEER_SKIP_DOWNLOAD: "true" @@ -984,7 +984,7 @@ jobs: node-version: "24" cache: "npm" - - run: npm ci + - run: node .github/scripts/npm-ci.mjs env: PUPPETEER_SKIP_DOWNLOAD: "true" diff --git a/.github/workflows/training-agent-storyboards.yml b/.github/workflows/training-agent-storyboards.yml index 62abb0983ae..9404e655a42 100644 --- a/.github/workflows/training-agent-storyboards.yml +++ b/.github/workflows/training-agent-storyboards.yml @@ -204,7 +204,7 @@ jobs: cache: 'npm' - name: Install dependencies - run: npm ci + run: node .github/scripts/npm-ci.mjs env: PUPPETEER_SKIP_DOWNLOAD: 'true' @@ -593,7 +593,7 @@ jobs: cache: 'npm' - name: Install dependencies - run: npm ci + run: node .github/scripts/npm-ci.mjs env: PUPPETEER_SKIP_DOWNLOAD: 'true' diff --git a/.github/workflows/validate-schema-bundle.yml b/.github/workflows/validate-schema-bundle.yml index 319b1298bde..372c0b77884 100644 --- a/.github/workflows/validate-schema-bundle.yml +++ b/.github/workflows/validate-schema-bundle.yml @@ -36,7 +36,7 @@ jobs: node-version: '24' cache: npm - - run: npm ci + - run: node .github/scripts/npm-ci.mjs - name: Build schemas, compliance, and protocol bundle env: diff --git a/.github/workflows/verify-matched-v4-runtime-attestation.yml b/.github/workflows/verify-matched-v4-runtime-attestation.yml index bb3381138bb..ce686d5cdfd 100644 --- a/.github/workflows/verify-matched-v4-runtime-attestation.yml +++ b/.github/workflows/verify-matched-v4-runtime-attestation.yml @@ -41,7 +41,7 @@ jobs: - name: Build an untrusted runnable evaluator bundle run: | set -euo pipefail - npm ci + node .github/scripts/npm-ci.mjs npm run build printf '%s\n' "${{ steps.verify.outputs.approved_main_sha }}" > /tmp/matched-v4-runtime-commit # This job deliberately has no id-token permission. The protected diff --git a/scripts/check-changeset-protocol-scope.cjs b/scripts/check-changeset-protocol-scope.cjs index d0456965341..97327d76eae 100644 --- a/scripts/check-changeset-protocol-scope.cjs +++ b/scripts/check-changeset-protocol-scope.cjs @@ -103,14 +103,37 @@ function isStoryboardBranchRegistrationOnly(headContent, baseContent) { && headContent === baseContent.replaceAll(oldBranches, newBranches); } +function isCiInstallOnlyChange(change, readFileAtHead, readFileAtBase) { + // This workflow also controls compliance execution, so keep its default + // protocol classification. Only the exact CI install substitution is exempt. + if (change.status !== 'M' || change.paths?.length !== 1 || + change.paths[0] !== '.github/workflows/training-agent-storyboards.yml') return false; + try { + const head = readFileAtHead(change.paths[0]); + const base = readFileAtBase(change.paths[0]); + if (typeof head !== 'string' || typeof base !== 'string') return false; + const restored = head.replace(/^([ \t]*run: )node \.github\/scripts\/npm-ci\.mjs$/gm, '$1npm ci'); + return restored !== head && restored === base; + } catch { + // Missing content must never exempt a potentially protocol-scoped change. + return false; + } +} + function isProtocolScopedChange(change, readFileAtHead, readFileAtBase) { return (change.paths || []).some(filePath => { if (!isProtocolScopedPath(filePath)) return false; if (normalizePath(filePath) === '.github/workflows/training-agent-storyboards.yml' - && change.status === 'M' - && isStoryboardBranchRegistrationOnly( - readFileAtHead(filePath), readFileAtBase(filePath) - )) return false; + && change.status === 'M') { + if (isCiInstallOnlyChange(change, readFileAtHead, readFileAtBase)) return false; + try { + if (isStoryboardBranchRegistrationOnly( + readFileAtHead(filePath), readFileAtBase(filePath) + )) return false; + } catch { + // Unreadable content keeps the default protocol classification. + } + } return true; }); } @@ -308,6 +331,9 @@ function run(argv = process.argv.slice(2)) { const baseRef = argv[0] || defaultBaseRef(); const diffOutput = git(['diff', '--name-status', '--find-renames', `${baseRef}...HEAD`]); const changes = parseNameStatus(diffOutput); + // Read the same baseline as the three-dot diff, even if the target advances. + const mergeBase = git(['merge-base', baseRef, 'HEAD']).trim(); + const readFileAtBase = filePath => readFileAtRef(mergeBase, filePath); if (argv.includes('--is-delete-only-cleanup')) { const isCleanup = isChangesetDeleteOnlyCleanup(changes); @@ -330,11 +356,7 @@ function run(argv = process.argv.slice(2)) { } if (argv.includes('--has-protocol-scoped-changes')) { - const hasProtocol = hasProtocolScopedChanges( - changes, - readFileAtHead, - filePath => readFileAtRef(baseRef, filePath) - ); + const hasProtocol = hasProtocolScopedChanges(changes, readFileAtHead, readFileAtBase); if (hasProtocol) { console.log('Protocol-scoped changes detected.'); return 0; @@ -346,7 +368,7 @@ function run(argv = process.argv.slice(2)) { const violations = findChangesetProtocolScopeViolations( changes, readFileAtHead, - filePath => readFileAtRef(baseRef, filePath) + readFileAtBase ); if (violations.length > 0) { diff --git a/server/tests/unit/addie/matched-v4-provider-billing-reconciliation.test.ts b/server/tests/unit/addie/matched-v4-provider-billing-reconciliation.test.ts index 159e3e1b7ab..59a0de5cc2f 100644 --- a/server/tests/unit/addie/matched-v4-provider-billing-reconciliation.test.ts +++ b/server/tests/unit/addie/matched-v4-provider-billing-reconciliation.test.ts @@ -410,11 +410,12 @@ describe("matched-v4 provider-authoritative aggregate billing", () => { "--certificate-oidc-issuer https://token.actions.githubusercontent.com", ); expect(untrustedBuild).not.toContain("id-token: write"); - expect(untrustedBuild).toContain("npm ci"); + expect(untrustedBuild).toContain("node .github/scripts/npm-ci.mjs"); expect(untrustedBuild).toContain("npm run build"); expect(protectedAttestation).toContain("id-token: write"); expect(protectedAttestation).not.toContain("actions/checkout@"); expect(protectedAttestation).not.toContain("npm ci"); + expect(protectedAttestation).not.toContain("npm-ci.mjs"); expect(protectedAttestation).not.toContain("npm run build"); expect(protectedAttestation).toContain( "matched-v4-runtime-attestation.json", diff --git a/server/tests/unit/external-smoke.test.ts b/server/tests/unit/external-smoke.test.ts index a839d674061..1ba1b593a52 100644 --- a/server/tests/unit/external-smoke.test.ts +++ b/server/tests/unit/external-smoke.test.ts @@ -317,5 +317,7 @@ describe('external smoke CI isolation', () => { expect(pkg.scripts['test:external-smoke']).toBe('tsx scripts/external-smoke/run.ts'); expect(workflow.jobs.smoke.steps.filter((step: { run?: string }) => step.run === 'npm run test:external-smoke')).toHaveLength(1); expect(workflow.jobs.smoke['timeout-minutes']).toBe(5); + const installs = workflow.jobs.smoke.steps.filter((step: { run?: string }) => /\bnpm ci\b|npm-ci\.mjs/.test(step.run ?? '')); + expect(installs).toEqual([{ run: 'node .github/scripts/npm-ci.mjs', env: { PUPPETEER_SKIP_DOWNLOAD: 'true' } }]); }); }); diff --git a/tests/changeset-protocol-scope.test.cjs b/tests/changeset-protocol-scope.test.cjs index 191be5c1a4d..df714e163a9 100644 --- a/tests/changeset-protocol-scope.test.cjs +++ b/tests/changeset-protocol-scope.test.cjs @@ -158,6 +158,64 @@ assert.strictEqual( 'Normative reference docs require changesets status' ); +const storyboardWorkflow = '.github/workflows/training-agent-storyboards.yml'; +const installOnlyChange = { status: 'M', paths: [storyboardWorkflow] }; +const beforeInstall = 'jobs:\n check:\n steps:\n - name: Install\n run: npm ci\n - name: Install again\n run: npm ci\n'; +const afterInstall = beforeInstall.replaceAll('run: npm ci', 'run: node .github/scripts/npm-ci.mjs'); +const readBeforeInstall = readFilesStrict({ [storyboardWorkflow]: beforeInstall }); +const readAfterInstall = readFilesStrict({ [storyboardWorkflow]: afterInstall }); +assert.strictEqual(isProtocolScopedPath(storyboardWorkflow), true, 'Keep default storyboard protocol classification'); +assert.strictEqual(hasProtocolScopedChanges([installOnlyChange], readAfterInstall, readBeforeInstall), false, + 'Exact install-only substitution does not change the protocol surface'); +assert.strictEqual(hasProtocolScopedChanges([installOnlyChange]), true, 'Missing readers fail closed'); +assert.strictEqual(hasProtocolScopedChanges([installOnlyChange], readAfterInstall, readFilesStrict({})), true, + 'Unreadable base fails closed'); +assert.strictEqual(hasProtocolScopedChanges([installOnlyChange], readFilesStrict({}), readBeforeInstall), true, + 'Unreadable head fails closed'); +for (const head of [ + beforeInstall, + afterInstall + ' if: false\n', + afterInstall.replace('check:', 'changed:'), + afterInstall.replaceAll('npm-ci.mjs', 'npm-ci.mjs --ignore-scripts'), + afterInstall.replaceAll('npm-ci.mjs', 'npm-ci.mjs; true'), + afterInstall.replaceAll('npm-ci.mjs', 'other.mjs'), +]) { + assert.strictEqual(hasProtocolScopedChanges([installOnlyChange], readFiles({ [storyboardWorkflow]: head }), readBeforeInstall), true, + 'Any other workflow delta retains protocol classification'); +} +// The branch-registration guard stays in force alongside the install exemption. +assert.strictEqual(hasProtocolScopedChanges(storyboardChange, + readFiles({ [storyboardWorkflowPath]: storyboardWorkflowRegistered + ' run: node .github/scripts/npm-ci.mjs\n' }), + readFiles({ [storyboardWorkflowPath]: storyboardWorkflowBase })), true, + 'Branch registration plus install substitution in one edit is not exempt'); +for (const status of ['A', 'D', 'T', 'R100', 'C100']) { + assert.strictEqual(hasProtocolScopedChanges([{ status, paths: [storyboardWorkflow] }], readAfterInstall, readBeforeInstall), true, + `${status} is not an install-only modification`); +} +assert.strictEqual(hasProtocolScopedChanges([ + { status: 'R100', paths: [storyboardWorkflow, '.github/workflows/renamed.yml'] }, +], readAfterInstall, readBeforeInstall), true, 'Workflow rename remains protocol-scoped'); +assert.strictEqual(hasProtocolScopedChanges([ + { status: 'M', paths: ['.github/workflows/release.yml'] }, +], () => afterInstall, () => beforeInstall), false, 'Release workflow remains operational, never classified by the install exemption'); +assert.strictEqual(hasProtocolScopedChanges([ + { status: 'M', paths: ['.github/workflows/release.yml'] }, + installOnlyChange, + { status: 'M', paths: ['static/schemas/source/core/example.json'] }, +], readAfterInstall, readBeforeInstall), true, 'Release plus install-only work cannot hide protocol content'); +for (const filePath of ['static/schemas/source/core/example.json', 'docs/reference/versioning.mdx', 'scripts/run-storyboards-isolated.mjs']) { + assert.strictEqual(hasProtocolScopedChanges([installOnlyChange, { status: 'M', paths: [filePath] }], readAfterInstall, readBeforeInstall), true, + 'Mixed protocol changes still require changesets status'); +} +for (const content of [protocolChangeset, emptyChangeset]) { + assert.strictEqual(findChangesetProtocolScopeViolations([ + installOnlyChange, { status: 'A', paths: ['.changeset/ci-only.md'] }, + ], readFilesStrict({ [storyboardWorkflow]: afterInstall, '.changeset/ci-only.md': content }), readBeforeInstall).length, 1, + 'Install-only CI changes reject both protocol and empty changesets'); +} +assert.deepStrictEqual(findChangesetProtocolScopeViolations([installOnlyChange], readAfterInstall, readBeforeInstall), [], + 'Install-only CI work passes scope without a changeset'); + assert.deepStrictEqual( parseNameStatus('M\tserver/src/billing/subscription-sync.ts\nA\t.changeset/billing-fix.md\n'), [