Files
pbakaus_impeccable/scripts/ci-test-plan.mjs
T
Paul BakausandClaude Fable 5.1 e355ebf714 reorg: public plumbing for the in-repo Rust workspace and the two-release flow
The engine binaries move from the impeccable-dist channel to this repo's own
GitHub Releases (tag engine-v<ENGINE_VERSION>), and the closed detector the
engine links arrives as detector-v<DETECTOR_VERSION> releases on the same
repo. This commit wires the public side for that; the crates themselves land
in the next commit.

- Launcher (sh + cmd), npm shim, fetch-engine and check-engine-release now
  download from github.com/pbakaus/impeccable/releases/download/engine-v<X>/.
- release.mjs gains `engine`: verifies ENGINE_VERSION against the platform
  package pins and the detector release, tags, pushes; release-engine.yml
  builds the five targets and publishes. check-detector-release.mjs is the
  matching release-order guard (with tests).
- Root Cargo.toml (workspace, lto = false with the reason), rust-toolchain.toml
  (exact pin), DETECTOR_VERSION, /target ignored.
- CI: rust + rust-windows jobs and an oracle job that replays the goldens
  against a source build, warn-only until the first detector release exists;
  ci-test-plan exposes a `rust` output.
- docs/ENGINE.md (the crate map and the closed-detector mechanism) and the
  CLAUDE.md engine, release-order and rules sections.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
2026-09-01 14:05:39 -07:00

126 lines
4.4 KiB
JavaScript

#!/usr/bin/env node
import fs from 'node:fs';
import { execFileSync } from 'node:child_process';
import { DEFAULT_SUITES, matchesSuiteTriggers } from './test-suites.mjs';
const eventName = process.env.GITHUB_EVENT_NAME || '';
const localNoChanges = !eventName && !process.env.CI_CHANGED_FILES;
// The nightly schedule exists for exactly one thing: the full live-e2e
// matrix. A schedule event has no diff base, so the change-detection path
// degenerates to "everything changed"; without this guard that would flip on
// every file-triggered opt-in suite, including the ones that bill LLM APIs
// (skill-behavior, accept-cleanup, deepseek), every single night.
const isSchedule = eventName === 'schedule';
const changedFiles = localNoChanges || isSchedule ? [] : getChangedFiles();
const forceDeterministic = localNoChanges || isSchedule || eventName === 'push' || eventName === 'workflow_dispatch';
const forceOptIn = eventName === 'workflow_dispatch';
// The Rust workspace (the engine) builds and tests when its own inputs move.
// tests/oracle is included: the goldens are the engine's behavior gate and
// the oracle job replays them against a source build.
const RUST_PATTERNS = [
/^crates\//,
/^Cargo\.(toml|lock)$/,
/^rust-toolchain\.toml$/,
/^DETECTOR_VERSION$/,
/^tests\/oracle\//,
/^\.github\/workflows\/ci\.yml$/,
];
const rustChanged = changedFiles.some((file) => RUST_PATTERNS.some((re) => re.test(file)));
const plan = isSchedule
? {
core: true,
oracle: true,
rust: true,
detector: true,
live: true,
framework: true,
cli_remote_e2e: false,
live_e2e: true,
live_e2e_accept_cleanup: false,
skill_behavior: false,
live_svelte_adapter_deepseek: false,
}
: {
core: true,
oracle: forceDeterministic || matchesSuiteTriggers('oracle', changedFiles),
rust: forceDeterministic || rustChanged,
detector: forceDeterministic || matchesSuiteTriggers('detector', changedFiles),
live: forceDeterministic || matchesSuiteTriggers('live', changedFiles),
framework: forceDeterministic || matchesSuiteTriggers('framework', changedFiles),
cli_remote_e2e: forceOptIn,
live_e2e: forceOptIn || matchesSuiteTriggers('live-e2e', changedFiles),
live_e2e_accept_cleanup: forceOptIn || matchesSuiteTriggers('live-e2e-accept-cleanup', changedFiles),
skill_behavior: forceOptIn || matchesSuiteTriggers('skill-behavior', changedFiles),
live_svelte_adapter_deepseek: forceOptIn || matchesSuiteTriggers('live-svelte-adapter-deepseek', changedFiles),
};
writeGithubOutputs(plan);
printSummary(plan, changedFiles);
function getChangedFiles() {
if (process.env.CI_CHANGED_FILES) {
return process.env.CI_CHANGED_FILES
.split(/\r?\n/)
.map((file) => file.trim())
.filter(Boolean);
}
const event = process.env.GITHUB_EVENT_NAME || '';
const sha = process.env.GITHUB_SHA || 'HEAD';
if (event === 'pull_request' && process.env.GITHUB_BASE_REF) {
const base = `origin/${process.env.GITHUB_BASE_REF}`;
return gitDiffNames(`${base}...${sha}`) || gitDiffNames(`${base}...HEAD`) || allChanged();
}
const before = process.env.GITHUB_EVENT_BEFORE;
if (before && !/^0+$/.test(before)) {
return gitDiffNames(`${before}..${sha}`) || allChanged();
}
return allChanged();
}
function allChanged() {
return git(['ls-files']).split(/\r?\n/).filter(Boolean);
}
function gitDiffNames(range) {
try {
return git(['diff', '--name-only', range]).split(/\r?\n/).filter(Boolean);
} catch {
return null;
}
}
function git(args) {
return execFileSync('git', args, { encoding: 'utf-8' });
}
function writeGithubOutputs(outputs) {
const outputPath = process.env.GITHUB_OUTPUT;
if (!outputPath) return;
const lines = [];
for (const [key, value] of Object.entries(outputs)) {
lines.push(`${key}=${value ? 'true' : 'false'}`);
}
fs.appendFileSync(outputPath, lines.join('\n') + '\n');
}
function printSummary(outputs, files) {
const deterministic = DEFAULT_SUITES.map((name) => `${name}=${outputs[name]}`).join(' ');
console.log(`Event: ${eventName || 'local'}`);
console.log(`Changed files: ${files.length}`);
console.log(`Deterministic suites: ${deterministic} rust=${outputs.rust}`);
console.log(
[
`cli_remote_e2e=${outputs.cli_remote_e2e}`,
`live_e2e=${outputs.live_e2e}`,
`live_e2e_accept_cleanup=${outputs.live_e2e_accept_cleanup}`,
`skill_behavior=${outputs.skill_behavior}`,
`deepseek=${outputs.live_svelte_adapter_deepseek}`,
].join(' '),
);
}