From 7574f88d45f5a3010cccf8fce1c98d634e272755 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Thu, 10 Sep 2026 01:21:11 +0100 Subject: [PATCH 1/2] feat(ci): add protected-base repository adapters and onboarding CLI Add conservative Node/.NET/Python manifest starters, immutable-base policy inspection, additive Taskdeck bridge, and guarded workflow proposal tool. All reports remain observation-only; 253 local regression tests pass. Refs #2329, #2336 --- docs/ci/continuation/ADAPTERS.md | 62 ++++++++++ scripts/ci/smart-ci/continuation.test.mjs | 2 + .../continuation/adapters/repository.mjs | 98 ++++++++++++++++ .../continuation/adapters/taskdeck.mjs | 109 ++++++++++++++++++ scripts/ci/smart-ci/continuation/cli.mjs | 45 ++++++++ .../continuation/tests/adapter.test.mjs | 105 +++++++++++++++++ .../continuation/tests/repository.test.mjs | 84 ++++++++++++++ .../continuation/tools/stage-taskdeck.mjs | 96 +++++++++++++++ 8 files changed, 601 insertions(+) create mode 100644 docs/ci/continuation/ADAPTERS.md create mode 100644 scripts/ci/smart-ci/continuation/adapters/repository.mjs create mode 100644 scripts/ci/smart-ci/continuation/adapters/taskdeck.mjs create mode 100644 scripts/ci/smart-ci/continuation/cli.mjs create mode 100644 scripts/ci/smart-ci/continuation/tests/adapter.test.mjs create mode 100644 scripts/ci/smart-ci/continuation/tests/repository.test.mjs create mode 100644 scripts/ci/smart-ci/continuation/tools/stage-taskdeck.mjs diff --git a/docs/ci/continuation/ADAPTERS.md b/docs/ci/continuation/ADAPTERS.md new file mode 100644 index 000000000..200399895 --- /dev/null +++ b/docs/ci/continuation/ADAPTERS.md @@ -0,0 +1,62 @@ +# Repository adapters and conservative onboarding + +Date: 2026-09-10. Parent: [engineering contract](README.md). The portable CLI and Taskdeck adapter produce observations only; neither executes the task commands it models, writes GitHub checks, edits workflows, or enables result reuse. + +## Portable CLI + +Node 22+ and Git are required. There are no external package dependencies. Run a reviewed copy of this directory from trusted tooling; `--repo` is an object database to inspect, not a source of executable plugins. + +```sh +node scripts/ci/smart-ci/continuation/cli.mjs init --kind node --repository-id 123456 --root web --out candidate-config.json +node scripts/ci/smart-ci/continuation/cli.mjs validate --manifest candidate-config.json +``` + +Supported starter ecosystems are `node`, `dotnet` and `python`. The root is a literal repository-relative directory, or `.`. Starters deliberately model a complete component suite with `reviewed: false`. The sample command is a reviewable identity, not a promise that every repository exposes that script. Adapt working directory, exact command, runtime and dependency resolution to the actual workflow. Add transitive shared-code/configuration/fixture dependencies before narrowing anything. + +After review, commit the configuration at `.ci/continuation.json` on the trusted base. Obtain the immutable numeric repository ID from authenticated provider metadata, not from candidate-authored configuration. Then: + +```sh +node scripts/ci/smart-ci/continuation/cli.mjs plan --repo /path/to/repository --base FULL_BASE_COMMIT_SHA --candidate FULL_CANDIDATE_COMMIT_SHA --repository-id 123456 --out advisory.json +``` + +`--config` may select another file only under a protected control path. The CLI reads configuration bytes from the **base commit**, never the candidate or dirty working tree. Candidate edits to the control policy select full qualification. Missing Git objects, invalid JSON, repository mismatches and incomplete inventories fail visibly. The output is a new file; existing output files are never overwritten. + +The source API `adviseRepository` accepts an additive canonical selection floor, exact environments/context and event type. `inspectRepository` is the immutable-object, observation-only command path. Non-PR events, explicit full qualification, unknown ownership or changed control paths escalate. Default output has `authority: none` and all executable actions remain `run`. + +## Manifest protocol + +`ci.repository-adapter.v1` contains an immutable repository ID, human description, declarative policy, and the core input-contract graph. It contains no JavaScript plugin or shell hook. + +The policy declares `alwaysTasks`, `controlPaths`, and ownership `rules`. The minimum `.github/**`, `.ci/**`, `ci/**` and `scripts/ci/**` control coverage cannot be removed. Rules may add checks; the canonical floor cannot be reduced. Input closure may add further dependent consumers. Unknown top-level/policy fields, duplicate task references, unsupported patterns and cyclic dependencies are rejected. + +The contract graph declares components, transitive dependencies, task input patterns, required files, command identity, platform, exact environment/context keys, TTL and review/reuse state. See the parent engineering document for fingerprint and evidence semantics. The adapter policy and complete contract content affect the fingerprint; editing configuration invalidates applicable observations rather than silently retaining earlier approval. + +For a monorepo, create distinct components for shared libraries and each deployable/service, then declare tests and integration tasks over their transitive inputs. For a single application, keep one complete task until measured costs justify a split. Never assume that folder names alone capture test harnesses, generated clients, containers, process launchers, imported build targets or external data. + +## Taskdeck-specific bridge + +```sh +node scripts/ci/smart-ci/continuation/adapters/taskdeck.mjs --repo /path/to/Taskdeck --plan ci-plan.json --out taskdeck-advisory.json +``` + +Run the bridge from reviewed Taskdeck tooling. It imports canonical validators relative to its own trusted module, **not** from the `--repo` candidate checkout. It reads `ci/policy.v1.json` from the plan's immutable control-base SHA. Canonical plan/schema validation happens before the auxiliary report. The comparison is bound to the observed merge first parent and exact merge commit/tree. + +Every Taskdeck task contract is still explicitly unreviewed. The adapter derives all canonical lane IDs instead of inventing a parallel policy. It only adds affected lanes; risk/trust escalation remains full. The initial broad contracts account for the Linux frontend job's backend-launcher dependency. Unknown future lane IDs receive a whole-repository input boundary, not an optimistic empty contract. + +This bridge is not evidence of safe omission. A local invocation can inspect a supplied plan, but production acceptance still requires authenticated canonical provenance. No report produced here is fed into the canonical gate as successful task evidence. + +## Dependency-only workflow proposal + +```sh +node scripts/ci/smart-ci/continuation/tools/stage-taskdeck.mjs --repo /path/to/Taskdeck --out ci-required.proposed.yml --mode minimal +``` + +The transformer handles the reviewed 13-job block-mapping shape only. It changes `needs`, retaining commands, matrices, names, permissions, immutable pins and event triggers. It refuses unfamiliar structures, anchors, cycles, duplicate dependencies and unconditional dependence on the PR-only secret scan. Apply only the reviewed diff in a separate PR. The optional `compute` mode waits for the entire backend unit matrix before API integration; this is not the default because it may worsen healthy-candidate latency. + +The transformer is intentionally **Taskdeck-specific**. Do not use it as a generic YAML migration engine. Other repositories should use their own reviewed workflow adapter over the provider-neutral contracts/core. + +## Validation and limitations + +Combined core + original Taskdeck/staging + new repository adapter/CLI tests: **253 passed, zero failed/skipped/cancelled**, local Node 22.16.0/Linux. A real temporary Git repository proves candidate and dirty-worktree policy changes cannot replace the base policy; output overwrite and configuration-budget checks are exercised. These fixtures do not constitute adoption in a second real repository, hosted configured-Node qualification, Windows testing or a full Taskdeck governance/product pass. + +Porting requires a second real-repository shadow trial before claiming cross-repository effectiveness. No new repository, external service, registry publication or license grant is created. The kit inherits the repository license; separately review licensing before distributing it as a standalone product. diff --git a/scripts/ci/smart-ci/continuation.test.mjs b/scripts/ci/smart-ci/continuation.test.mjs index 7522cdce3..86da78294 100644 --- a/scripts/ci/smart-ci/continuation.test.mjs +++ b/scripts/ci/smart-ci/continuation.test.mjs @@ -4,3 +4,5 @@ import './continuation/tests/core.test.mjs'; import './continuation/tests/evidence.test.mjs'; import './continuation/tests/execution.test.mjs'; import './continuation/tests/planner.test.mjs'; +import './continuation/tests/adapter.test.mjs'; +import './continuation/tests/repository.test.mjs'; diff --git a/scripts/ci/smart-ci/continuation/adapters/repository.mjs b/scripts/ci/smart-ci/continuation/adapters/repository.mjs new file mode 100644 index 000000000..076044855 --- /dev/null +++ b/scripts/ci/smart-ci/continuation/adapters/repository.mjs @@ -0,0 +1,98 @@ +import { execFileSync } from 'node:child_process'; +import { TextDecoder } from 'node:util'; +import { validateContracts } from '../core/contracts.mjs'; +import { changedPaths, snapshot } from '../core/snapshot.mjs'; +import { planContinuation } from '../core/planner.mjs'; +import { glob, hash, invariant, isGitId, matches, validPath } from '../core/primitives.mjs'; + +export const CONTROL_FLOOR = ['.github/**', '.ci/**', 'ci/**', 'scripts/ci/**']; +const uniqueStrings = value => Array.isArray(value) && value.every(x => typeof x === 'string' && x.length > 0) && new Set(value).size === value.length; + +/** A small declarative adapter protocol, not an executable plugin loaded from the PR. */ +export function validateManifest(manifest) { + invariant(manifest?.format === 'ci.repository-adapter.v1', 'unsupported repository adapter'); + invariant(typeof manifest.repositoryId === 'string' && /^[1-9]\d*$/.test(manifest.repositoryId), 'numeric immutable repository ID required'); + invariant(typeof manifest.description === 'string', 'adapter description required'); + invariant(Object.keys(manifest).every(k => ['format', 'repositoryId', 'description', 'policy', 'contracts'].includes(k)), 'unknown adapter field'); + validateContracts(manifest.contracts); + const tasks = Object.keys(manifest.contracts.tasks), policy = manifest.policy; + invariant(policy && Object.keys(policy).every(k => ['alwaysTasks', 'controlPaths', 'rules'].includes(k)), 'invalid adapter policy'); + invariant(uniqueStrings(policy.alwaysTasks) && policy.alwaysTasks.every(id => tasks.includes(id)), 'invalid always tasks'); + invariant(uniqueStrings(policy.controlPaths) && CONTROL_FLOOR.every(p => policy.controlPaths.includes(p)), 'protected control floor cannot be removed'); + policy.controlPaths.forEach(glob); + invariant(Array.isArray(policy.rules) && policy.rules.length > 0, 'explicit ownership rules required'); + for (const rule of policy.rules) { + invariant(rule && Object.keys(rule).every(k => ['patterns', 'tasks'].includes(k)), 'invalid ownership rule'); + invariant(uniqueStrings(rule.patterns) && rule.patterns.length > 0, 'invalid rule patterns'); + rule.patterns.forEach(glob); + invariant(uniqueStrings(rule.tasks) && rule.tasks.every(id => tasks.includes(id)), 'unknown or duplicate rule task'); + } + return manifest; +} + +/** Read exact blob bytes from a complete Git tree. No checkout, hooks, project commands or imports. */ +export function readJsonBlob(repo, commit, path, maxBytes = 1024 * 1024) { + invariant(isGitId(commit) && validPath(path), 'exact commit and safe repository-relative path required'); + invariant(Number.isSafeInteger(maxBytes) && maxBytes > 0 && maxBytes <= 8 * 1024 * 1024, 'invalid blob budget'); + const state = snapshot(repo, commit), entry = state.entries.find(e => e.path === path); + invariant(entry && ['100644', '100755'].includes(entry.mode), 'configuration must be a regular tracked blob'); + const bytes = execFileSync('git', ['--no-replace-objects', '-C', repo, 'cat-file', 'blob', entry.oid], + { timeout: 30000, maxBuffer: maxBytes, env: { ...process.env, GIT_NO_REPLACE_OBJECTS: '1' } }); + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + return { value: JSON.parse(text), text, oid: entry.oid, commit, path }; +} + +/** Always observation. An existing protected selector may supply additional mandatory tasks. */ +export function adviseRepository({ manifest, baseState, candidateState, canonicalSelected = [], environments = {}, context = {}, event = 'pull_request', forceFull = false }) { + validateManifest(manifest); + const universe = Object.keys(manifest.contracts.tasks).sort(), paths = changedPaths(baseState, candidateState); + invariant(uniqueStrings(canonicalSelected) && canonicalSelected.every(id => universe.includes(id)), 'invalid canonical floor'); + const selected = new Set([...manifest.policy.alwaysTasks, ...canonicalSelected]); + const reasons = []; + if (forceFull) reasons.push('explicit-full'); + if (event !== 'pull_request') reasons.push('non-pr-event'); + for (const path of paths) { + if (matches(path, manifest.policy.controlPaths)) reasons.push('control-path'); + const rules = manifest.policy.rules.filter(rule => matches(path, rule.patterns)); + if (!rules.length && !matches(path, manifest.policy.controlPaths)) reasons.push('unmapped-path'); + for (const rule of rules) for (const id of rule.tasks) selected.add(id); + } + const fullQualification = reasons.length > 0; + const plan = planContinuation({ universe, graph: manifest.contracts, + canonicalSelected: fullQualification ? universe : [...selected].sort(), baseState, candidateState, + mode: 'observe', qualifiedTasks: [], environments, context, repositoryId: manifest.repositoryId, + policyDigest: hash({ domain: 'ci.repository-adapter.v1', manifest }), fullQualification }); + return { format: 'ci.repository-advisory.v1', authority: 'none', configPolicyDigest: hash(manifest), + escalationReasons: [...new Set(reasons)].sort(), changedPathCount: paths.length, plan }; +} + +/** The policy is always read from the caller's trusted base, NEVER the candidate or worktree. */ +export function inspectRepository({ repo, base, candidate, configPath = '.ci/continuation.json', repositoryId }) { + invariant(validPath(configPath) && matches(configPath, CONTROL_FLOOR), 'config must live under a protected control path'); + const config = readJsonBlob(repo, base, configPath); + invariant(config.value.repositoryId === repositoryId, 'repository binding mismatch'); + const report = adviseRepository({ manifest: config.value, baseState: snapshot(repo, base), candidateState: snapshot(repo, candidate) }); + return { ...report, configSource: { commit: base, path: configPath, blob: config.oid } }; +} + +/** Conservative starters: commands are identities for review, not executed by this kit. */ +export function starterManifest({ repositoryId, kind, root = '.' }) { + invariant(['node', 'dotnet', 'python'].includes(kind), 'kind must be node, dotnet or python'); + invariant(root === '.' || validPath(root) && !/[*?\[\]{}!]/.test(root), 'invalid project root'); + const prefix = root === '.' ? '' : `${root}/`; + const specs = { + node: { command: ['npm', 'test', '--', '--run'], required: [`${prefix}package.json`], runtime: 'node' }, + dotnet: { command: ['dotnet', 'test', '--configuration', 'Release'], required: [`${prefix}**/*.csproj`], runtime: 'dotnet' }, + python: { command: ['python', '-m', 'pytest'], required: [`${prefix}pyproject.toml`], runtime: 'python' } + }; + const spec = specs[kind]; + return validateManifest({ format: 'ci.repository-adapter.v1', repositoryId, + description: `Review this ${kind} starter against actual workflows, working directory, fixtures and dependencies before narrowing it.`, + policy: { alwaysTasks: [], controlPaths: [...CONTROL_FLOOR], rules: [{ patterns: [`${prefix}**`], tasks: ['tests'] }, { patterns: ['docs/**', '**/*.md'], tasks: [] }] }, + contracts: { version: 1, globalInputs: [...CONTROL_FLOOR, '**/package.json', '**/package-lock.json', '**/pnpm-lock.yaml', '**/yarn.lock', '**/global.json', '**/Directory.Build.*', '**/Directory.Packages.props', '**/NuGet.Config', '**/nuget.config', '**/pyproject.toml', '**/requirements*.txt', '**/uv.lock', '**/poetry.lock', '.gitattributes', '.gitmodules'], + components: { product: { inputs: [`${prefix}**`], deps: [] } }, + tasks: { tests: { components: ['product'], inputs: [], requiredInputs: spec.required, + command: ['working-directory', root, ...spec.command], platform: 'linux-x64', + environmentKeys: ['os', 'arch', 'image', spec.runtime, 'dependencies'], contextKeys: ['configuration'], + reuse: 'eligible', reviewed: false, ttlSeconds: 86400 } } } }); +} diff --git a/scripts/ci/smart-ci/continuation/adapters/taskdeck.mjs b/scripts/ci/smart-ci/continuation/adapters/taskdeck.mjs new file mode 100644 index 000000000..e48f923cb --- /dev/null +++ b/scripts/ci/smart-ci/continuation/adapters/taskdeck.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto'; +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { validateContracts, fingerprint, expandAffected } from '../core/contracts.mjs'; +import { invariant } from '../core/primitives.mjs'; +import { snapshot, changedPaths } from '../core/snapshot.mjs'; +import { readJsonBlob } from './repository.mjs'; + +export const AUDITED_MAIN = '6c51b09bcdcefbc7a852a7047aa9ab8ee5eb10b7'; +// Match the canonical existing implementation exactly (raw text, CRLF normalized). +export const canonicalPolicyDigest = text => `sha256:${createHash('sha256').update(text.replace(/\r\n/g, '\n')).digest('hex')}`; + +/** Coarse initial input contracts. No supplied Taskdeck lane is approved for result reuse. */ +export function taskdeckContracts(policy) { + invariant(policy?.schemaVersion === 1 && policy.policyId === 'taskdeck.smart-ci.v1', 'canonical Taskdeck v1 policy required'); + invariant(policy.lanes && Object.keys(policy.lanes).length > 0, 'canonical lane inventory required'); + const graph = { + version: 1, + globalInputs: ['.github/**', 'ci/**', 'scripts/ci/**', '.config/**', 'global.json', '**/global.json', + '**/Directory.Build.*', '**/Directory.Packages.props', '**/NuGet.Config', '**/nuget.config', + '**/package.json', '**/package-lock.json', '**/.npmrc', '**/.nvmrc', '.gitattributes', '.gitmodules', + '.dockerignore', '**/.dockerignore', '**/Dockerfile*', 'package.json', 'package-lock.json'], + components: { + repository: { inputs: ['**'], deps: [] }, + backend: { inputs: ['backend/**'], deps: [] }, + frontend: { inputs: ['frontend/**'], deps: [] }, + scripts: { inputs: ['scripts/**'], deps: [] }, + launcher: { inputs: ['scripts/**'], deps: ['backend', 'frontend'] }, + journeys: { inputs: ['tests/**', 'deploy/**'], deps: ['backend', 'frontend', 'scripts'] } + }, tasks: {} + }; + for (const [id, lane] of Object.entries(policy.lanes)) { + let components = ['repository']; + if (id.startsWith('backend-') || id.startsWith('api-integration') || id === 'migration-validation') components = ['backend', 'scripts']; + if (id === 'frontend-unit-windows' || id === 'paper-color-audit') components = ['frontend', 'scripts']; + // Linux Frontend currently executes dev-up.test.mjs, including the API process. + if (id === 'frontend-unit-linux') components = ['launcher']; + if (id === 'e2e-smoke' || id === 'container-images') components = ['journeys']; + const isWindows = id.endsWith('-windows') || id === 'worktree-helper-windows'; + graph.tasks[id] = { + components, inputs: [], requiredInputs: components.includes('backend') ? ['backend/Taskdeck.sln'] : [], + // Opaque canonical lane identity: NOT a shell command, and not executable by this adapter. + // Workflow file bytes are in globalInputs. Production integration must bind resolved inputs, callee and job IDs. + command: ['taskdeck-canonical-lane', id, lane.checkName], + platform: isWindows ? 'windows-x64' : 'linux-x64', + environmentKeys: ['os', 'arch', 'image', 'node', 'dotnet', 'shell', 'dependencyResolution', 'networkPolicy'], + contextKeys: ['testConfiguration', 'environmentContractVersion'], + reuse: ['security', 'control'].includes(lane.family) || id === 'smart-ci-self-test' ? 'never' : 'eligible', + reviewed: false, + ttlSeconds: 24 * 60 * 60 + }; + } + return validateContracts(graph); +} + +/** Read-only auxiliary report. It can ADD affected lanes, but never writes executable job outputs. */ +export function adviseTaskdeck({ policyText, plan, baseState, candidateState, environments = {}, context = {} }) { + const policy = JSON.parse(policyText), digest = canonicalPolicyDigest(policyText), graph = taskdeckContracts(policy); + const universe = Object.keys(policy.lanes).sort(); + invariant(plan.policyDigest === digest && plan.policyId === policy.policyId && plan.schemaVersion === 1, 'canonical plan/policy binding mismatch'); + const selected = plan.selected.map(x => x.lane), skipped = plan.skipped.map(x => x.lane); + const all = [...selected, ...skipped]; + invariant(all.length === universe.length && new Set(all).size === all.length && all.every(x => universe.includes(x)), 'canonical lane inventory mismatch'); + invariant(plan.mergeSha === candidateState.commit && plan.mergeTreeSha === candidateState.tree, 'candidate must be the planned merge commit/tree'); + invariant(baseState.commit === (plan.mergeBaseSha ?? plan.baseSha), 'comparison must use actual planned merge first parent'); + const paths = changedPaths(baseState, candidateState); + const expanded = expandAffected(graph, selected, paths); + const full = plan.escalated || plan.plannerError || plan.risk === 'R4' || plan.trust !== 'T1'; + const suggested = full ? universe : expanded.selected; + const report = { + format: 'taskdeck-continuation-advisory.v1', authority: 'none: canonical ci-plan.v1 and ci-run.v1 remain authoritative', + auditedMain: AUDITED_MAIN, evaluatedCommit: candidateState.commit, evaluatedTree: candidateState.tree, + policyDigest: digest, canonicalMode: policy.mode, canonicalSelected: selected, + advisorySelected: suggested, addedOnly: suggested.filter(id => !selected.includes(id)), + fallback: full ? 'canonical-conservative-plan' : expanded.fallback, + changedPathCount: paths.length, candidates: [] + }; + for (const taskId of suggested) report.candidates.push(fingerprint({ graph, taskId, state: candidateState, + environment: environments[taskId] ?? {}, context, policyDigest: digest, repositoryId: '1098648347' })); + invariant(report.candidates.every(x => !x.reusable), 'shipped adapter must never authorize reuse'); + return report; +} + +async function main() { + const args = process.argv.slice(2), options = {}; + for (let i = 0; i < args.length; i += 2) { + invariant(['--repo', '--plan', '--out'].includes(args[i]) && args[i + 1], 'usage: node adapters/taskdeck.mjs --repo REPO --plan ci-plan.json --out NEW_REPORT.json'); + options[args[i].slice(2)] = args[i + 1]; + } + invariant(options.repo && options.plan && options.out, '--repo, --plan and --out required'); + const repo = resolve(options.repo), out = resolve(options.out); + invariant(!existsSync(out), 'output already exists'); + const plan = JSON.parse(readFileSync(options.plan, 'utf8')); + const { text: policyText, value: policy } = readJsonBlob(repo, plan.baseSha, 'ci/policy.v1.json'); + // Run ONLY from a reviewed/protected tooling checkout. The CLI does not execute PR code. + // Validation uses the existing canonical module instead of cloning its full schema/logic. + const canonical = await import(new URL('../../lib/plan.mjs', import.meta.url).href); + const errors = [...canonical.validatePolicy(policy), ...canonical.validatePlan(plan, policy)]; + invariant(errors.length === 0, `canonical validation failed: ${errors.join('; ')}`); + const report = adviseTaskdeck({ policyText, plan, + baseState: snapshot(repo, plan.mergeBaseSha ?? plan.baseSha), candidateState: snapshot(repo, plan.mergeSha) }); + writeFileSync(out, JSON.stringify(report, null, 2) + '\n', { flag: 'wx' }); + console.log(`Read-only report written to ${out}. Reuse is disabled for all supplied Taskdeck contracts.`); +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main().catch(error => { console.error(error.message); process.exitCode = 1; }); +} diff --git a/scripts/ci/smart-ci/continuation/cli.mjs b/scripts/ci/smart-ci/continuation/cli.mjs new file mode 100644 index 000000000..04f896b51 --- /dev/null +++ b/scripts/ci/smart-ci/continuation/cli.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync, statSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { invariant } from './core/primitives.mjs'; +import { inspectRepository, starterManifest, validateManifest } from './adapters/repository.mjs'; + +export function options(argv, allowed) { + const out = {}; + invariant(argv.length % 2 === 0, 'options require explicit values'); + for (let i = 0; i < argv.length; i += 2) { + const key = argv[i]; + invariant(allowed.includes(key) && !Object.hasOwn(out, key) && argv[i + 1] && !argv[i + 1].startsWith('--'), `unknown, duplicate or missing option: ${key}`); + out[key] = argv[i + 1]; + } + return out; +} +function required(o, keys) { for (const k of keys) invariant(o[k], `missing ${k}`); } +export function runCli(argv) { + const [command, ...args] = argv; + if (command === '--help') return { usage: ['init --kind node|dotnet|python --repository-id ID --root PATH --out NEW_FILE', + 'validate --manifest FILE', 'plan --repo REPO --base FULL_SHA --candidate FULL_SHA --repository-id ID --config .ci/continuation.json --out NEW_FILE'], + authority: 'Observation only. No task commands, workflow edits, network or status writes.' }; + let result, output; + if (command === 'init') { + const o = options(args, ['--kind', '--repository-id', '--root', '--out']); required(o, ['--kind', '--repository-id', '--out']); + result = starterManifest({ kind: o['--kind'], repositoryId: o['--repository-id'], root: o['--root'] ?? '.' }); output = o['--out']; + } else if (command === 'validate') { + const o = options(args, ['--manifest']); required(o, ['--manifest']); + invariant(statSync(o['--manifest']).size <= 1024 * 1024, 'manifest exceeds budget'); + const text = readFileSync(o['--manifest'], 'utf8'); invariant(Buffer.byteLength(text) <= 1024 * 1024, 'manifest exceeds budget'); + const manifest = validateManifest(JSON.parse(text)); result = { valid: true, repositoryId: manifest.repositoryId, tasks: Object.keys(manifest.contracts.tasks), authority: 'none' }; + } else if (command === 'plan') { + const o = options(args, ['--repo', '--base', '--candidate', '--repository-id', '--config', '--out']); + required(o, ['--repo', '--base', '--candidate', '--repository-id', '--out']); + result = inspectRepository({ repo: resolve(o['--repo']), base: o['--base'], candidate: o['--candidate'], repositoryId: o['--repository-id'], configPath: o['--config'] ?? '.ci/continuation.json' }); + output = o['--out']; + } else throw new Error('unknown command; use --help'); + if (output) writeFileSync(resolve(output), JSON.stringify(result, null, 2) + '\n', { flag: 'wx' }); + return result; +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { console.log(JSON.stringify(runCli(process.argv.slice(2)), null, 2)); } + catch (error) { console.error(error.message); process.exitCode = 1; } +} diff --git a/scripts/ci/smart-ci/continuation/tests/adapter.test.mjs b/scripts/ci/smart-ci/continuation/tests/adapter.test.mjs new file mode 100644 index 000000000..78d0fbd20 --- /dev/null +++ b/scripts/ci/smart-ci/continuation/tests/adapter.test.mjs @@ -0,0 +1,105 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { taskdeckContracts, adviseTaskdeck, canonicalPolicyDigest } from '../adapters/taskdeck.mjs'; +import { inputPatterns } from '../core/contracts.mjs'; +import { matches } from '../core/primitives.mjs'; +import { stageWorkflow } from '../tools/stage-taskdeck.mjs'; +import { state, oid, entry } from './fixtures.mjs'; + +// Synthetic fixtures modeled on the reviewed topology, NOT copies of a live product test result. +const LANES = ['smart-ci-plan', 'smart-ci-self-test', 'docs-governance', 'worktree-helper-windows', + 'release-workflow-contract', 'backend-architecture', 'backend-unit-linux', 'backend-unit-windows', + 'api-integration-linux', 'api-integration-windows', 'migration-validation', 'frontend-unit-linux', + 'frontend-unit-windows', 'paper-color-audit', 'container-images', 'secret-scan', 'dependency-security', 'sast-scan', 'e2e-smoke']; +function policy() { + return { schemaVersion: 1, policyId: 'taskdeck.smart-ci.v1', mode: 'shadow', lanes: Object.fromEntries(LANES.map(id => [id, { + checkName: `Fixture / ${id}`, family: ['secret-scan', 'dependency-security', 'sast-scan'].includes(id) ? 'security' : 'product' + }])) }; +} +function advisory() { + const p = policy(), policyText = JSON.stringify(p), baseState = state(), candidateState = state(); + candidateState.commit = oid('3'); candidateState.tree = oid('4'); candidateState.entries[0].oid = oid('b'); + baseState.entries.push(entry('backend/Taskdeck.sln')); candidateState.entries.push(entry('backend/Taskdeck.sln')); + return { policyText, baseState, candidateState, plan: { schemaVersion: 1, policyId: p.policyId, policyDigest: canonicalPolicyDigest(policyText), + baseSha: baseState.commit, mergeBaseSha: baseState.commit, mergeSha: candidateState.commit, mergeTreeSha: candidateState.tree, + selected: [{ lane: 'secret-scan' }], skipped: LANES.filter(x => x !== 'secret-scan').map(lane => ({ lane })), trust: 'T1', risk: 'R2', escalated: false } }; +} +test('adapter derives all 19 canonical lanes without defining a second selection policy', () => assert.equal(Object.keys(taskdeckContracts(policy()).tasks).length, 19)); +test('Linux frontend depends on backend launcher inputs; Windows frontend does not', () => { + const g = taskdeckContracts(policy()); assert.ok(matches('backend/src/Taskdeck.Api/Program.cs', inputPatterns(g, 'frontend-unit-linux'))); + assert.equal(matches('backend/src/Taskdeck.Api/Program.cs', inputPatterns(g, 'frontend-unit-windows')), false); +}); +test('security is always fresh', () => assert.equal(taskdeckContracts(policy()).tasks['secret-scan'].reuse, 'never')); +test('every shipped Taskdeck contract is explicitly unreviewed', () => assert.ok(Object.values(taskdeckContracts(policy()).tasks).every(t => t.reviewed === false))); +test('canonical digest normalizes CRLF, not arbitrary whitespace', () => { + assert.equal(canonicalPolicyDigest('a\r\nb'), canonicalPolicyDigest('a\nb')); assert.notEqual(canonicalPolicyDigest('a b'), canonicalPolicyDigest('a b')); +}); +test('advisory is additive, source-bound and cannot authorize reuse', () => { + const a = adviseTaskdeck(advisory()); assert.ok(a.advisorySelected.includes('secret-scan')); + assert.ok(a.addedOnly.includes('frontend-unit-linux')); assert.ok(a.candidates.every(c => !c.reusable)); + assert.equal(a.authority.startsWith('none'), true); +}); +for (const [field, value] of [['mergeSha', oid('9')], ['mergeTreeSha', oid('9')], ['mergeBaseSha', oid('9')], ['policyDigest', 'bad'], ['schemaVersion', 2]]) { + test(`advisory rejects mismatched ${field}`, () => { const a = advisory(); a.plan[field] = value; assert.throws(() => adviseTaskdeck(a)); }); +} +test('advisory rejects duplicate/incomplete lane universe', () => { const a = advisory(); a.plan.skipped[0].lane = 'secret-scan'; assert.throws(() => adviseTaskdeck(a)); }); +for (const [field, value] of [['risk', 'R4'], ['escalated', true], ['trust', 'T3'], ['plannerError', { message: 'bad' }]]) { + test(`${field} requires advisory full plan`, () => { const a = advisory(); a.plan[field] = value; assert.equal(adviseTaskdeck(a).advisorySelected.length, LANES.length); }); +} + +const JOBS = ['docs-governance', 'release-workflow-contract', 'backend-architecture', 'backend-unit', + 'api-integration', 'migration-validation', 'frontend-unit', 'paper-color-audit', 'container-images', + 'secret-scan', 'dependency-security', 'sast-scan', 'e2e-smoke']; +function workflow() { + return `name: CI\non:\n pull_request:\n push:\n branches: [main]\npermissions:\n contents: read\njobs:\n` + JOBS.map(id => { + let needs = id === 'e2e-smoke' ? ' needs:\n - docs-governance\n - backend-architecture\n - backend-unit\n - api-integration\n - migration-validation\n' : ''; + let guard = id === 'secret-scan' ? " if: ${{ github.event_name == 'pull_request' }}\n" : ''; + return ` ${id}:\n name: ${id}\n${needs}${guard} uses: ./.github/workflows/reusable-${id}.yml\n`; + }).join(''); +} +test('staging adds frontend barrier to E2E and retains all original dependencies', () => { + const a = stageWorkflow(workflow()); assert.ok(a.changes.find(c => c.job === 'e2e-smoke').added.includes('frontend-unit')); + assert.ok(a.text.includes(' - migration-validation')); assert.equal((a.text.split('\njobs:\n')[1].match(/^ [a-z0-9-]+:\n/gm) ?? []).length, JOBS.length); +}); +test('minimal and compute modes differ only in API waiting for full backend matrix', () => { + assert.equal(stageWorkflow(workflow()).changes.find(c => c.job === 'api-integration').added.includes('backend-unit'), false); + assert.equal(stageWorkflow(workflow(), 'compute').changes.find(c => c.job === 'api-integration').added.includes('backend-unit'), true); +}); +test('staging is idempotent', () => { const a = stageWorkflow(workflow()); assert.equal(stageWorkflow(a.text).text, a.text); assert.deepEqual(stageWorkflow(a.text).changes, []); }); +test('staging never creates an unconditional dependency on PR-only secret scan', () => assert.equal(stageWorkflow(workflow()).text.includes(' - secret-scan'), false)); +test('existing step changes survive the dependency transform', () => { + const w = workflow().replace(' name: backend-unit\n', ' name: backend-unit\n # concurrent agent credential fix stays untouched\n'); + assert.ok(stageWorkflow(w).text.includes(' # concurrent agent credential fix stays untouched')); +}); +test('all non-needs lines preserved byte-for-byte', () => { + const strip = s => s.replace(/^ needs:\n(?: - [a-z0-9-]+\n)+/gm, ''); + assert.equal(strip(stageWorkflow(workflow()).text), strip(workflow())); +}); +for (const [name, mutate] of Object.entries({ crlf: w => w.replaceAll('\n', '\r\n'), missingJob: w => w.replace(' backend-unit:', ' renamed-backend:'), + newJob: w => w + ' new-job:\n runs-on: ubuntu-latest\n', flowNeeds: w => w.replace(' needs:\n', ' needs: []\n'), + anchors: w => w.replace(' backend-unit:\n', ' backend-unit: &backend\n'), cycle: w => w.replace(' backend-architecture:\n', ' backend-architecture:\n needs:\n - backend-unit\n'), + secretBarrier: w => w.replace(' - docs-governance\n', ' - secret-scan\n') })) { + test(`staging rejects ${name} rather than overwriting unknown workflow shape`, () => assert.throws(() => stageWorkflow(mutate(workflow())))); +} + +test('staging rejects duplicated dependency instead of silently normalizing it', () => assert.throws(() => stageWorkflow(workflow().replace(' - docs-governance\n', ' - docs-governance\n - docs-governance\n')))); + +test('staging CLI produces a separate file and refuses overwrite of source or output', () => { + const dir = mkdtempSync(join(tmpdir(), 'ci-staging-')); + try { + mkdirSync(join(dir, '.github', 'workflows'), { recursive: true }); + const source = join(dir, '.github', 'workflows', 'ci-required.yml'), out = join(dir, 'proposed.yml'); + writeFileSync(source, workflow()); + const cli = fileURLToPath(new URL('../tools/stage-taskdeck.mjs', import.meta.url)); + const run = output => execFileSync(process.execPath, [cli, '--repo', dir, '--out', output], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + const result = JSON.parse(run(out)); assert.ok(result.changes.length > 0); + assert.equal(readFileSync(source, 'utf8'), workflow()); assert.equal(readFileSync(out, 'utf8'), stageWorkflow(workflow()).text); + assert.throws(() => run(out)); assert.throws(() => run(source)); + assert.equal(readFileSync(source, 'utf8'), workflow()); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); diff --git a/scripts/ci/smart-ci/continuation/tests/repository.test.mjs b/scripts/ci/smart-ci/continuation/tests/repository.test.mjs new file mode 100644 index 000000000..c3ffa6592 --- /dev/null +++ b/scripts/ci/smart-ci/continuation/tests/repository.test.mjs @@ -0,0 +1,84 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { CONTROL_FLOOR, validateManifest, starterManifest, adviseRepository, inspectRepository, readJsonBlob } from '../adapters/repository.mjs'; +import { options, runCli } from '../cli.mjs'; +import { graph, state, oid } from './fixtures.mjs'; +function manifest() { + return { format: 'ci.repository-adapter.v1', repositoryId: '1234', description: 'Fictional polyglot fixture', contracts: graph(), + policy: { alwaysTasks: ['docs'], controlPaths: [...CONTROL_FLOOR], rules: [ + { patterns: ['backend/**'], tasks: ['backend'] }, { patterns: ['frontend/**'], tasks: ['frontend'] }, + { patterns: ['shared/**'], tasks: [] }, { patterns: ['docs/**'], tasks: ['docs'] }, { patterns: ['lock.json'], tasks: ['backend', 'frontend', 'e2e'] } + ] } }; +} +function request() { return { manifest: manifest(), baseState: state(), candidateState: state() }; } +for (const kind of ['node', 'dotnet', 'python']) test(`${kind} starter is conservative and unreviewed`, () => { + const m = starterManifest({ repositoryId: '1234', kind, root: 'apps/product' }); + assert.equal(validateManifest(m), m); assert.equal(m.contracts.tasks.tests.reviewed, false); + assert.ok(m.contracts.tasks.tests.command.includes('apps/product')); assert.ok(CONTROL_FLOOR.every(p => m.policy.controlPaths.includes(p))); +}); +for (const [name, mutate] of Object.entries({ wrongVersion: m => m.format = 'v2', wrongRepository: m => m.repositoryId = 'owner/name', + missingFloor: m => m.policy.controlPaths.pop(), unknownField: m => m.eval = 'untrusted()', + unknownTask: m => m.policy.rules[0].tasks.push('other'), duplicateTask: m => m.policy.rules[0].tasks.push('backend'), + unsupportedPattern: m => m.policy.rules[0].patterns = ['!secret/**'], duplicateAlways: m => m.policy.alwaysTasks.push('docs'), + cyclicInputs: m => m.contracts.components.shared.deps = ['backend'] })) { + test(`manifest rejects ${name}`, () => { const m = manifest(); mutate(m); assert.throws(() => validateManifest(m)); }); +} +test('related-test floor and transitive integration checks are additive', () => { + const r = request(); r.candidateState.entries[0].oid = oid('b'); const a = adviseRepository(r); + assert.equal(a.authority, 'none'); assert.ok(a.plan.tasks.every(t => t.action === 'run')); + assert.equal(a.plan.tasks.find(t => t.taskId === 'e2e').proposed, 'run'); + assert.equal(a.plan.tasks.find(t => t.taskId === 'frontend').proposed, 'unaffected'); +}); +test('external canonical floor cannot be reduced', () => { + const r = request(); r.canonicalSelected = ['frontend']; const a = adviseRepository(r); + assert.equal(a.plan.tasks.find(t => t.taskId === 'frontend').proposed, 'run'); +}); +for (const [name, change] of Object.entries({ control: r => r.candidateState.entries[3].oid = oid('b'), + unknown: r => r.candidateState.entries.push({ ...r.candidateState.entries[0], path: 'unknown/new' }), + release: r => r.event = 'release', force: r => r.forceFull = true })) { + test(`${name} escalates rather than guessing`, () => { + const r = request(); change(r); const a = adviseRepository(r); + assert.ok(a.plan.fullQualification); assert.ok(a.plan.tasks.every(t => t.proposed === 'run')); + }); +} +for (const root of ['../x', '/x', 'a/**', 'a\\b']) test(`unsafe starter root ${root}`, () => assert.throws(() => starterManifest({ repositoryId: '1234', kind: 'node', root }))); +test('unsupported kind fails', () => assert.throws(() => starterManifest({ repositoryId: '1234', kind: 'auto' }))); +for (const args of [['--a'], ['--a', '1', '--a', '2'], ['--unknown', 'x'], ['--a', '--b']]) { + test(`strict CLI options ${args}`, () => assert.throws(() => options(args, ['--a']))); +} +test('CLI has no activation path', () => assert.throws(() => runCli(['enforce']))); +test('CLI writes a new starter, validates it and refuses overwrite', () => { + const dir = mkdtempSync(join(tmpdir(), 'ci-adapter-cli-')), path = join(dir, 'config.json'); + try { + const args = ['init', '--kind', 'node', '--repository-id', '1234', '--out', path]; + runCli(args); assert.equal(runCli(['validate', '--manifest', path]).valid, true); + const before = readFileSync(path, 'utf8'); assert.throws(() => runCli(args)); assert.equal(readFileSync(path, 'utf8'), before); + writeFileSync(path, ' '.repeat(1024 * 1024 + 1)); assert.throws(() => runCli(['validate', '--manifest', path]), /budget/); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); +test('immutable base policy cannot be weakened by candidate or dirty working tree', () => { + const dir = mkdtempSync(join(tmpdir(), 'ci-adapter-git-')); + const git = (...args) => execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + try { + git('init'); git('config', 'user.name', 'CI Fixture'); git('config', 'user.email', 'fixture@example.invalid'); + mkdirSync(join(dir, '.ci')); mkdirSync(join(dir, 'backend')); + writeFileSync(join(dir, '.ci', 'continuation.json'), JSON.stringify(manifest())); writeFileSync(join(dir, 'backend', 'a.cs'), 'first'); + git('add', '.'); git('commit', '-m', 'trusted policy'); const base = git('rev-parse', 'HEAD'); + writeFileSync(join(dir, '.ci', 'continuation.json'), '{"format":"malicious"}'); + git('add', '.'); git('commit', '-m', 'candidate policy'); const candidate = git('rev-parse', 'HEAD'); + writeFileSync(join(dir, '.ci', 'continuation.json'), 'not JSON'); + const a = inspectRepository({ repo: dir, base, candidate, repositoryId: '1234' }); + assert.equal(a.configSource.commit, base); assert.ok(a.escalationReasons.includes('control-path')); + assert.ok(a.plan.tasks.every(t => t.action === 'run')); assert.equal(a.authority, 'none'); + assert.throws(() => inspectRepository({ repo: dir, base, candidate, repositoryId: '9999' }), /binding/); + assert.throws(() => readJsonBlob(dir, base, '.ci/continuation.json', 10)); + assert.throws(() => readJsonBlob(dir, 'HEAD', '.ci/continuation.json')); + assert.throws(() => inspectRepository({ repo: dir, base, candidate, repositoryId: '1234', configPath: 'backend/config.json' }), /protected/); + const out = join(dir, 'advisory.json'); runCli(['plan', '--repo', dir, '--base', base, '--candidate', candidate, '--repository-id', '1234', '--out', out]); + assert.equal(JSON.parse(readFileSync(out)).configSource.commit, base); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); diff --git a/scripts/ci/smart-ci/continuation/tools/stage-taskdeck.mjs b/scripts/ci/smart-ci/continuation/tools/stage-taskdeck.mjs new file mode 100644 index 000000000..94472b7e9 --- /dev/null +++ b/scripts/ci/smart-ci/continuation/tools/stage-taskdeck.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { closure, invariant } from '../core/primitives.mjs'; + +// Intentionally a narrow transform for Taskdeck's block-mapping workflow shape, +// NOT a general YAML parser. Unknown shapes fail instead of guessing. +const EXPECTED = ['docs-governance', 'release-workflow-contract', 'backend-architecture', 'backend-unit', + 'api-integration', 'migration-validation', 'frontend-unit', 'paper-color-audit', 'container-images', + 'secret-scan', 'dependency-security', 'sast-scan', 'e2e-smoke']; +const MINIMAL = { + 'backend-unit': ['backend-architecture', 'release-workflow-contract'], + 'frontend-unit': ['release-workflow-contract', 'paper-color-audit'], + 'api-integration': ['backend-architecture', 'release-workflow-contract'], + 'migration-validation': ['backend-architecture'], + 'container-images': ['release-workflow-contract'], + 'e2e-smoke': ['frontend-unit', 'release-workflow-contract', 'paper-color-audit'] +}; +function splitJobs(text) { + invariant(!text.includes('\r'), 'normalize CRLF explicitly before staging'); + invariant(!/^[^#\n]*[&*][A-Za-z_][\w-]*/m.test(text), 'YAML anchors/aliases are not supported'); + const offset = text.indexOf('\njobs:\n'); + invariant(offset >= 0 && text.indexOf('\njobs:\n', offset + 1) < 0, 'one block-mapping jobs section required'); + const header = text.slice(0, offset + 7), rest = text.slice(offset + 7); + invariant(!/^\S/m.test(rest), 'unexpected top-level content after jobs'); + const starts = [...rest.matchAll(/^ ([a-z][a-z0-9-]*):\n/gm)]; + const jobs = new Map(); + for (let i = 0; i < starts.length; i++) { + const match = starts[i]; + invariant(!jobs.has(match[1]), 'duplicate job id'); + jobs.set(match[1], rest.slice(match.index, starts[i + 1]?.index ?? rest.length)); + } + invariant(starts[0]?.index === 0, 'unsupported jobs preamble'); + invariant(EXPECTED.every(id => jobs.has(id)) && jobs.size === EXPECTED.length, 'job inventory changed; re-review staging contract'); + return { header, jobs }; +} +function parseNeeds(block) { + const occurrences = [...block.matchAll(/^ needs:.*$/gm)]; + invariant(occurrences.length <= 1, 'duplicate needs mapping'); + if (!occurrences.length) return { needs: [], range: null }; + invariant(occurrences[0][0] === ' needs:', 'only block-list needs is supported'); + const start = occurrences[0].index; + const match = block.slice(start).match(/^ needs:\n((?: - [a-z][a-z0-9-]*\n)+)/); + invariant(match, 'unsupported needs list'); + const end = start + match[0].length; + invariant(!/^ {5,}\S/.test(block.slice(end)), 'unsupported trailing needs content'); + const needs = [...match[1].matchAll(/- ([a-z][a-z0-9-]*)/g)].map(x => x[1]); + invariant(new Set(needs).size === needs.length, 'duplicate dependency'); + return { needs, range: [start, end] }; +} +function withoutNeeds(block) { + const { range } = parseNeeds(block); + return range ? block.slice(0, range[0]) + block.slice(range[1]) : block; +} +export function stageWorkflow(text, mode = 'minimal') { + invariant(['minimal', 'compute'].includes(mode), 'mode must be minimal or compute'); + const { header, jobs } = splitJobs(text); + const additions = structuredClone(MINIMAL); + if (mode === 'compute') additions['api-integration'].push('backend-unit'); + const changes = []; + const result = new Map(jobs); + for (const [id, extra] of Object.entries(additions)) { + const block = jobs.get(id), parsed = parseNeeds(block); + const needs = [...new Set([...parsed.needs, ...extra])]; + const added = needs.filter(x => !parsed.needs.includes(x)); + if (added.length === 0) continue; + const replacement = ` needs:\n${needs.map(x => ` - ${x}\n`).join('')}`; + const position = parsed.range ?? [block.indexOf('\n') + 1, block.indexOf('\n') + 1]; + const updated = block.slice(0, position[0]) + replacement + block.slice(position[1]); + invariant(withoutNeeds(updated) === withoutNeeds(block), 'non-dependency workflow content changed'); + result.set(id, updated); changes.push({ job: id, added }); + } + const nodes = Object.fromEntries([...result].map(([id, block]) => [id, { deps: parseNeeds(block).needs }])); + for (const id of Object.keys(nodes)) closure(id, nodes); + invariant(!Object.values(nodes).some(x => x.deps.includes('secret-scan')), 'PR-only secret job cannot be an unconditional barrier'); + return { text: header + [...result.values()].join(''), changes, mode }; +} + +function main() { + const args = process.argv.slice(2), options = {}; + for (let i = 0; i < args.length; i += 2) { + invariant(['--repo', '--out', '--mode'].includes(args[i]) && args[i + 1], 'usage: node tools/stage-taskdeck.mjs --repo REPO --out NEW_FILE [--mode minimal|compute]'); + options[args[i].slice(2)] = args[i + 1]; + } + invariant(options.repo && options.out, '--repo and --out required; original file is never overwritten'); + const source = resolve(options.repo, '.github/workflows/ci-required.yml'), out = resolve(options.out); + invariant(source !== out && !existsSync(out), 'output must be a new, separate file'); + const result = stageWorkflow(readFileSync(source, 'utf8'), options.mode ?? 'minimal'); + writeFileSync(out, result.text, { flag: 'wx' }); + console.log(JSON.stringify({ output: out, changes: result.changes, mode: result.mode, + warning: 'Local proposed file only. Lint, run contracts, review diff, then open a PR. Every existing test remains selected.' }, null, 2)); +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { main(); } catch (error) { console.error(error.message); process.exitCode = 1; } +} From 0a800907a1efc450100467cb9f0cdc7ec21b7f63 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 10 Sep 2026 03:40:20 +0100 Subject: [PATCH 2/2] Reject missing policy blobs without executing promisor transports --- .../continuation/adapters/repository.mjs | 2 +- .../continuation/tests/repository.test.mjs | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/scripts/ci/smart-ci/continuation/adapters/repository.mjs b/scripts/ci/smart-ci/continuation/adapters/repository.mjs index 076044855..0dbc682b6 100644 --- a/scripts/ci/smart-ci/continuation/adapters/repository.mjs +++ b/scripts/ci/smart-ci/continuation/adapters/repository.mjs @@ -37,7 +37,7 @@ export function readJsonBlob(repo, commit, path, maxBytes = 1024 * 1024) { const state = snapshot(repo, commit), entry = state.entries.find(e => e.path === path); invariant(entry && ['100644', '100755'].includes(entry.mode), 'configuration must be a regular tracked blob'); const bytes = execFileSync('git', ['--no-replace-objects', '-C', repo, 'cat-file', 'blob', entry.oid], - { timeout: 30000, maxBuffer: maxBytes, env: { ...process.env, GIT_NO_REPLACE_OBJECTS: '1' } }); + { timeout: 30000, maxBuffer: maxBytes, env: { ...process.env, GIT_NO_REPLACE_OBJECTS: '1', GIT_NO_LAZY_FETCH: '1', GIT_ALLOW_PROTOCOL: '' } }); const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); return { value: JSON.parse(text), text, oid: entry.oid, commit, path }; } diff --git a/scripts/ci/smart-ci/continuation/tests/repository.test.mjs b/scripts/ci/smart-ci/continuation/tests/repository.test.mjs index c3ffa6592..19b960925 100644 --- a/scripts/ci/smart-ci/continuation/tests/repository.test.mjs +++ b/scripts/ci/smart-ci/continuation/tests/repository.test.mjs @@ -3,10 +3,29 @@ import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { execFileSync } from 'node:child_process'; import { CONTROL_FLOOR, validateManifest, starterManifest, adviseRepository, inspectRepository, readJsonBlob } from '../adapters/repository.mjs'; import { options, runCli } from '../cli.mjs'; import { graph, state, oid } from './fixtures.mjs'; + +test('missing policy blobs never trigger a promisor fetch', () => { + const dir = mkdtempSync(join(tmpdir(), 'ci-promisor-')), source = join(dir, 'source'), repo = join(dir, 'partial'); + const git = (cwd, ...args) => execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + try { + mkdirSync(source); git(source, 'init'); git(source, 'config', 'user.name', 'Fixture'); git(source, 'config', 'user.email', 'fixture@example.invalid'); + writeFileSync(join(source, 'policy.json'), '{"fixture":true}'); git(source, 'add', '.'); git(source, 'commit', '-m', 'fixture'); + git(source, 'config', 'uploadpack.allowFilter', 'true'); + execFileSync('git', ['clone', '--no-checkout', '--filter=blob:none', pathToFileURL(source).href, repo], { stdio: 'pipe' }); + const sha = git(repo, 'rev-parse', 'HEAD'), blob = git(source, 'rev-parse', 'HEAD:policy.json'); + assert.ok(git(repo, 'rev-list', '--objects', '--missing=print', 'HEAD').includes(`?${blob}`)); + assert.throws(() => readJsonBlob(repo, sha, 'policy.json')); + assert.ok(git(repo, 'rev-list', '--objects', '--missing=print', 'HEAD').includes(`?${blob}`)); + // Successful control proves the local promisor can provide the object. + assert.equal(git(repo, 'cat-file', 'blob', blob), '{"fixture":true}'); + assert.deepEqual(readJsonBlob(repo, sha, 'policy.json').value, { fixture: true }); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); function manifest() { return { format: 'ci.repository-adapter.v1', repositoryId: '1234', description: 'Fictional polyglot fixture', contracts: graph(), policy: { alwaysTasks: ['docs'], controlPaths: [...CONTROL_FLOOR], rules: [