diff --git a/CHANGELOG.md b/CHANGELOG.md index b1e722ea..4596a4c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to `github-delivery` are documented here. +## [1.10.0] - 2026-11-08 + +### Added + +- An explicit, per-repository local CI evidence mode. User configuration schema 2 can select `ciEvidenceMode: local` for one repository with a recorded user waiver, while the default stays remote. The merge gate then requires a user-scoped local receipt bound to the repository, PR, exact head and base SHA, command/suite scope, exit codes, timestamps, runner host, tool versions, and the complete log hash. Missing, foreign, red, stale, pre-head, tampered, or non-ancestor receipts stay blocked; failed remote checks stay visible as observations and never become green. Reviews, authority, draft handling, branch protection, and all other gates are unchanged. New commands: `github-delivery-config --ci-evidence-mode` and `local-ci-receipt`. + +### Changed + +- Bumped the package version from `1.9.1` to `1.10.0`. + ## [1.9.1] - 2026-11-08 ### Fixed diff --git a/package.json b/package.json index 6b849c1b..f59275a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "github-delivery", - "version": "1.9.1", + "version": "1.10.0", "description": "Guided installer and safety tooling for GitHub Delivery workflows", "type": "module", "engines": { @@ -22,6 +22,7 @@ "scripts/codex-with-watchdog.mjs", "scripts/install-codex-watchdog-hooks.mjs", "scripts/install-skill.mjs", + "scripts/local-ci-receipt.mjs", "scripts/windows-install-locks.ps1", "scripts/lib/agent-debug-trace.mjs", "scripts/lib/agent-trace-launcher.mjs", @@ -44,6 +45,7 @@ "scripts/lib/grok-trace-launcher.mjs", "scripts/lib/install-lock.mjs", "scripts/lib/installation-backups.mjs", + "scripts/lib/local-ci-evidence.mjs", "scripts/lib/release-path-identity.mjs", "scripts/lib/release-self-update.mjs", "scripts/lib/subprocess-policy.mjs", diff --git a/references/configuration.md b/references/configuration.md index 43520aa3..c5cb54e7 100644 --- a/references/configuration.md +++ b/references/configuration.md @@ -30,6 +30,29 @@ On Windows, the same stored preference is available in **Control Center > Settin The config is global for the user's github-delivery installation and lives outside the installed skill directory. +## Local CI evidence mode (explicit opt-in, per repository) + +The default required-check source is remote GitHub check evidence. If a repository cannot produce usable remote check runs (for example, an exhausted Actions quota), the user may explicitly select local evidence for that repository only: + +```bash +node scripts/github-delivery-config.mjs --ci-evidence-mode local --repository OWNER/REPO --waiver "user authorization reference" +node scripts/github-delivery-config.mjs --ci-evidence-mode remote --repository OWNER/REPO +node scripts/github-delivery-config.mjs --show --repository OWNER/REPO +``` + +The local mode is stored in user configuration schema 2 and never read from repository files. It records the repository identity, the waiver reference, and the time of the change. Without an entry for the repository, remote checks remain authoritative. + +A merge with local mode active requires a matching receipt: + +```bash +node scripts/local-ci-receipt.mjs OWNER/REPO PR --log PATH\to\run.log \ + --command "npm run ci:local -- --full" --scope "full local CI" +``` + +The receipt binds the repository, pull request, exact head SHA, base SHA, command and suite scope, exit codes, timestamps, runner host, Node/npm/git versions, and the SHA-256 of the complete log. Receipts live under the user's github-delivery data directory, not in the repository. The merge gate stays blocked when the receipt is missing, belongs to another repository or PR, targets another head, records a non-zero exit, has a tampered log hash, was recorded before the head commit, is older than seven days, or its base is no longer part of the current base history. Remote check results remain visible as observations and advisories; they are not presented as green. + +Reviews, authority grants, draft handling, branch protection, conversation resolution, and every other gate keep working unchanged. The mode only substitutes the required-check evidence source for the named repository. + ## Windows Authority host For normal stable installations, use the managed lifecycle: diff --git a/scripts/lib/config-command.mjs b/scripts/lib/config-command.mjs index 9c404c2a..c73d47d9 100644 --- a/scripts/lib/config-command.mjs +++ b/scripts/lib/config-command.mjs @@ -1,20 +1,42 @@ import { + resolveCiEvidenceMode, readUserConfig, resolveAuthorityMode, + withCiEvidenceRepository, writeUserConfig, } from "./user-config.mjs"; export function parseConfigArgs(argv = []) { - const options = { show: false, authorityMode: null }; + const options = { + show: false, + authorityMode: null, + ciEvidenceMode: null, + repository: null, + waiver: null, + }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--show") options.show = true; else if (arg === "--authority-mode") { options.authorityMode = argv[++index]; if (!options.authorityMode) throw new Error("--authority-mode requires a value"); + } else if (arg === "--ci-evidence-mode") { + options.ciEvidenceMode = argv[++index]; + if (!options.ciEvidenceMode) throw new Error("--ci-evidence-mode requires a value"); + } else if (arg === "--repository") { + options.repository = argv[++index]; + if (!options.repository) throw new Error("--repository requires a value"); + } else if (arg === "--waiver") { + options.waiver = argv[++index]; + if (!options.waiver) throw new Error("--waiver requires a value"); } else throw new Error(`unknown argument: ${arg}`); } - if (!options.show && !options.authorityMode) options.show = true; + if (options.ciEvidenceMode && !options.repository) { + throw new Error("--ci-evidence-mode requires --repository OWNER/REPO"); + } + if (!options.show && !options.authorityMode && !options.ciEvidenceMode) { + options.show = true; + } return options; } @@ -24,18 +46,32 @@ export function runConfigCommand({ argv = [], env = process.env, dependencies = const options = parseConfigArgs(argv); const current = read({ env }); let stored = current; - if (options.authorityMode) { - stored = write({ - ...current.config, - authorityMode: String(options.authorityMode).trim().toLowerCase(), - }); + if (options.authorityMode || options.ciEvidenceMode) { + let next = current.config; + if (options.authorityMode) { + next = { + ...next, + authorityMode: String(options.authorityMode).trim().toLowerCase(), + }; + } + if (options.ciEvidenceMode) { + next = withCiEvidenceRepository(next, { + repository: options.repository, + mode: String(options.ciEvidenceMode).trim().toLowerCase(), + waiver: options.waiver, + }); + } + stored = write(next); } return { schemaVersion: 1, kind: "github-delivery/user-configuration", path: stored.path, - source: options.authorityMode ? "updated" : current.source, + source: options.authorityMode || options.ciEvidenceMode ? "updated" : current.source, config: stored.config, effectiveAuthorityMode: resolveAuthorityMode({ config: stored.config, env }), + effectiveCiEvidenceMode: options.repository + ? resolveCiEvidenceMode({ config: stored.config, repo: options.repository }).mode + : null, }; } diff --git a/scripts/lib/delivery-workflow-controller.mjs b/scripts/lib/delivery-workflow-controller.mjs index aa3fc698..083c1b2a 100644 --- a/scripts/lib/delivery-workflow-controller.mjs +++ b/scripts/lib/delivery-workflow-controller.mjs @@ -232,9 +232,14 @@ export function assertPreOpenPublicationEvidence(snapshot, request = null) { const requestHead = String(request?.head || ""); const separator = requestHead.indexOf(":"); const requestBranch = separator < 0 ? requestHead : requestHead.slice(separator + 1); + const gateHeadRef = String(gate.headRef || ""); + // Local-work gates bind headRef to the exact commit SHA; issue-work gates + // bind it to the head branch. Only compare branch names when that is what + // the gate recorded; the SHA identity is already checked above. + const gateHeadRefIsSha = /^[0-9a-f]{40,64}$/i.test(gateHeadRef); if ( (request?.base && gate.baseRef && String(request.base) !== String(gate.baseRef)) || - (requestBranch && gate.headRef && requestBranch !== String(gate.headRef)) || + (requestBranch && gateHeadRef && !gateHeadRefIsSha && requestBranch !== gateHeadRef) || (request?.publishedHead && !sameIdentity(request.publishedHead.sha, gate.headSha)) ) { throw new Error("pre_open_evidence_scope_mismatch"); diff --git a/scripts/lib/local-ci-evidence.mjs b/scripts/lib/local-ci-evidence.mjs new file mode 100644 index 00000000..71b4516a --- /dev/null +++ b/scripts/lib/local-ci-evidence.mjs @@ -0,0 +1,318 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { dirname, isAbsolute, join } from "node:path"; + +import { boundedSpawnSync } from "./subprocess-policy.mjs"; +import { + normalizeRepositoryKey, + readUserConfig, + resolveCiEvidenceMode, + userConfigPath, +} from "./user-config.mjs"; + +export const LOCAL_CI_RECEIPT_SCHEMA_VERSION = 1; +export const LOCAL_CI_RECEIPT_KIND = "github-delivery/local-ci-evidence"; +export const MAX_LOCAL_EVIDENCE_AGE_MS = 7 * 24 * 60 * 60 * 1000; + +export function localEvidenceRoot(options = {}) { + return join(dirname(userConfigPath(options)), "local-ci-evidence"); +} + +export function localEvidenceReceiptPath({ repo, pr, headSha, ...options } = {}) { + const [owner, name] = normalizeRepositoryKey(repo).split("/"); + const number = Number(pr); + const head = String(headSha || "").trim().toLowerCase(); + if (!Number.isInteger(number) || number <= 0) { + throw new Error("local_evidence_pr_required"); + } + if (!/^[0-9a-f]{40}$/.test(head)) { + throw new Error("local_evidence_head_sha_required"); + } + return join( + localEvidenceRoot(options), + owner, + name, + `pr-${number}`, + `${head}.json`, + ); +} + +export function canonicalReceiptHash(receipt) { + const { receiptHash, ...body } = receipt || {}; + return createHash("sha256") + .update(JSON.stringify(body), "utf8") + .digest("hex"); +} + +function fileDigest(readFile, path) { + const body = readFile(path); + return createHash("sha256") + .update(Buffer.isBuffer(body) ? body : Buffer.from(String(body), "utf8")) + .digest("hex"); +} + +function parseTimestamp(value) { + const parsed = Date.parse(String(value || "")); + return Number.isFinite(parsed) ? parsed : null; +} + +const ancestryCache = new Map(); + +export function ghBaseAncestry( + { repo, ancestor, descendant }, + runner = boundedSpawnSync, +) { + const key = `${normalizeRepositoryKey(repo)}:${ancestor}:${descendant}`; + if (ancestryCache.has(key)) return ancestryCache.get(key); + const result = runner( + "gh", + [ + "api", + `repos/${repo}/compare/${ancestor}...${descendant}`, + "--jq", + ".status", + ], + { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }, + ); + if (result.status !== 0) return null; + const status = String(result.stdout || "").trim().toLowerCase(); + let answer = null; + if (status === "ahead" || status === "identical") answer = true; + else if (status === "behind" || status === "diverged") answer = false; + ancestryCache.set(key, answer); + return answer; +} + +function publicReceipt(receipt) { + return { + headSha: receipt.headSha, + baseSha: receipt.baseSha, + pullRequest: receipt.pullRequest, + repository: receipt.repository, + waiver: receipt.waiver, + recordedAt: receipt.recordedAt, + completedAt: receipt.completedAt, + runner: receipt.runner || null, + commands: Array.isArray(receipt.commands) ? receipt.commands : [], + log: receipt.log ? { path: receipt.log.path, sha256: receipt.log.sha256 } : null, + receiptHash: receipt.receiptHash || null, + }; +} + +export function evaluateLocalCiEvidence({ + config, + repo, + pr, + headSha, + baseSha = null, + headCommittedAt = null, + receiptPath = undefined, + configError = null, + exists = existsSync, + readFile = readFileSync, + stat = statSync, + now = Date.now(), + maxAgeMs = MAX_LOCAL_EVIDENCE_AGE_MS, + isBaseAncestor = ghBaseAncestry, +} = {}) { + const mode = resolveCiEvidenceMode({ config, repo }); + if (mode.mode !== "local") { + return { + mode: "remote", + active: false, + applicable: false, + valid: false, + reasons: [], + path: null, + receipt: null, + }; + } + + const reasons = []; + const fail = (reason) => { + if (!reasons.includes(reason)) reasons.push(reason); + }; + if (configError) fail("local_evidence_config_unreadable"); + + const head = String(headSha || "").trim().toLowerCase(); + if (!/^[0-9a-f]{40}$/.test(head)) fail("local_evidence_head_sha_invalid"); + const path = receiptPath || localEvidenceReceiptPath({ repo, pr, headSha: head }); + + let receipt = null; + if (!exists(path)) { + fail("local_evidence_missing"); + } else { + try { + receipt = JSON.parse(readFile(path, "utf8")); + } catch { + fail("local_evidence_invalid_json"); + } + } + + if (receipt !== null && (typeof receipt !== "object" || Array.isArray(receipt))) { + fail("local_evidence_invalid_receipt"); + receipt = null; + } + + if (receipt) { + if (receipt.schemaVersion !== LOCAL_CI_RECEIPT_SCHEMA_VERSION) { + fail("local_evidence_schema_unsupported"); + } + if (receipt.kind !== LOCAL_CI_RECEIPT_KIND) { + fail("local_evidence_kind_invalid"); + } + if (receipt.receiptHash !== canonicalReceiptHash(receipt)) { + fail("local_evidence_receipt_tampered"); + } + if ( + String(receipt.repository || "").toLowerCase() !== + String(repo || "").toLowerCase() + ) { + fail("local_evidence_foreign_repository"); + } + if (Number(receipt.pullRequest) !== Number(pr)) { + fail("local_evidence_pull_request_mismatch"); + } + if (String(receipt.headSha || "").toLowerCase() !== head) { + fail("local_evidence_head_mismatch"); + } + + const receiptBase = String(receipt.baseSha || "").toLowerCase(); + const currentBase = String(baseSha || "").toLowerCase(); + if (!/^[0-9a-f]{40}$/.test(receiptBase)) { + fail("local_evidence_base_missing"); + } else if (!/^[0-9a-f]{40}$/.test(currentBase)) { + fail("local_evidence_base_unavailable"); + } else if (receiptBase !== currentBase) { + let ancestry = null; + try { + ancestry = isBaseAncestor({ + repo, + ancestor: receiptBase, + descendant: currentBase, + }); + } catch { + ancestry = null; + } + if (ancestry === false) fail("local_evidence_base_not_ancestor"); + else if (ancestry !== true) fail("local_evidence_base_ancestry_unknown"); + } + + if (String(receipt.waiver || "").trim() !== String(mode.waiver || "").trim()) { + fail("local_evidence_waiver_mismatch"); + } + + const commands = Array.isArray(receipt.commands) ? receipt.commands : []; + if (!commands.length) fail("local_evidence_commands_missing"); + for (const entry of commands) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + fail("local_evidence_command_invalid"); + continue; + } + if (!String(entry.command || "").trim()) fail("local_evidence_command_missing"); + if (!String(entry.scope || "").trim()) fail("local_evidence_scope_missing"); + if (Number(entry.exitCode) !== 0) fail("local_evidence_command_failed"); + } + if (Number(receipt.exitCode) !== 0) fail("local_evidence_exit_code_failed"); + + const completedAt = parseTimestamp(receipt.completedAt); + const recordedAt = parseTimestamp(receipt.recordedAt); + if (completedAt === null) fail("local_evidence_completed_at_invalid"); + if (recordedAt === null) fail("local_evidence_recorded_at_invalid"); + if (completedAt !== null) { + if (completedAt > now + 5 * 60 * 1000) fail("local_evidence_completed_at_in_future"); + if (now - completedAt > maxAgeMs) fail("local_evidence_stale"); + const headTime = parseTimestamp(headCommittedAt); + if (headTime !== null && completedAt < headTime) { + fail("local_evidence_predates_head"); + } + } + + if (!String(receipt.runner?.host || "").trim()) fail("local_evidence_runner_missing"); + for (const tool of ["node", "npm", "git"]) { + if (!String(receipt.tools?.[tool] || "").trim()) { + fail("local_evidence_tool_versions_incomplete"); + } + } + + const log = receipt.log; + if (!log || typeof log !== "object" || Array.isArray(log)) { + fail("local_evidence_log_missing"); + } else if (!String(log.path || "").trim() || !isAbsolute(String(log.path))) { + fail("local_evidence_log_path_invalid"); + } else if (!exists(log.path)) { + fail("local_evidence_log_unreadable"); + } else { + if (!Number.isSafeInteger(Number(log.bytes)) || Number(log.bytes) < 0) { + fail("local_evidence_log_size_invalid"); + } + let digest = null; + let info = null; + try { + digest = fileDigest(readFile, log.path); + info = stat(log.path); + } catch { + fail("local_evidence_log_unreadable"); + } + if (digest && String(log.sha256 || "").toLowerCase() !== digest) { + fail("local_evidence_log_hash_mismatch"); + } + if (info && Number.isSafeInteger(Number(log.bytes)) && Number(log.bytes) !== info.size) { + fail("local_evidence_log_size_mismatch"); + } + } + } + + return { + mode: "local", + active: true, + applicable: true, + valid: reasons.length === 0, + reasons, + path, + receipt: receipt ? publicReceipt(receipt) : null, + }; +} + +export function resolveLocalCiEvidenceForSnapshot( + snapshot, + { readConfig = readUserConfig, ...options } = {}, +) { + const repo = snapshot?.repo; + if (!repo) { + return { + mode: "remote", + active: false, + applicable: false, + valid: false, + reasons: [], + path: null, + receipt: null, + }; + } + let config; + let configError = null; + try { + config = readConfig().config; + } catch (error) { + config = undefined; + configError = String(error?.message || error); + } + const pullRequest = snapshot?.evidence?.pullRequest || {}; + const commits = Array.isArray(pullRequest.commits) ? pullRequest.commits : []; + const headCommit = commits.length ? commits[commits.length - 1] : null; + return evaluateLocalCiEvidence({ + config, + repo, + pr: snapshot?.pr, + headSha: snapshot?.headOid, + baseSha: + snapshot?.evidence?.captureBoundary?.baseOid || + snapshot?.evidence?.baseHealth?.baseOid || + null, + headCommittedAt: + headCommit?.committedDate || headCommit?.committedAt || null, + configError, + ...options, + }); +} diff --git a/scripts/lib/mutation-checkpoint.mjs b/scripts/lib/mutation-checkpoint.mjs index 7be5f30d..75a6d14f 100644 --- a/scripts/lib/mutation-checkpoint.mjs +++ b/scripts/lib/mutation-checkpoint.mjs @@ -29,6 +29,9 @@ const ROUTED_WORKFLOW_INTENT = Object.freeze({ }), "create-pr-from-local-work": Object.freeze({ OPEN_PR: Object.freeze(new Set(["create_pr"])), + REVIEW_FEEDBACK: Object.freeze(new Set(["change_draft_state"])), + CI: Object.freeze(new Set(["change_draft_state"])), + FINAL_GATE: Object.freeze(new Set(["change_draft_state"])), }), }); const PRE_OPEN_WORKFLOWS = new Set([ diff --git a/scripts/lib/ship-gate-policy.mjs b/scripts/lib/ship-gate-policy.mjs index 98a04662..c202f2ce 100644 --- a/scripts/lib/ship-gate-policy.mjs +++ b/scripts/lib/ship-gate-policy.mjs @@ -12,12 +12,14 @@ const UNDERSTOOD_ACTIVE_RULE_TYPES = new Set([ const MERGE_STATES_READY = new Set(["CLEAN", "UNSTABLE", "HAS_HOOKS"]); const MERGE_STATES_WAKE = new Set(["DIRTY", "BEHIND", "CONFLICTING"]); -function applyMergeStateUnknowns(snapshot, unknowns) { +function applyMergeStateUnknowns(snapshot, unknowns, { localEvidenceActive = false } = {}) { const mergeStateStatus = String( snapshot?.evidence?.pullRequest?.mergeStateStatus || "", ).toUpperCase(); if (mergeStateStatus === "BLOCKED") { - unknowns.push("policy:github_merge_state_blocked"); + // With valid local evidence the remote check conclusion is intentionally + // waived, so GitHub's derived BLOCKED state is not independent evidence. + if (!localEvidenceActive) unknowns.push("policy:github_merge_state_blocked"); return; } if ( @@ -86,8 +88,12 @@ export function combineShipGateResults({ }; const blockers = []; const unknowns = []; + const localEvidenceActive = + requiredChecks?.evidenceMode === "local" && + requiredChecks?.localEvidence?.valid === true; for (const [name, component] of Object.entries(authoritative)) { + if (localEvidenceActive && name === "baseHealth") continue; for (const blocker of component?.blockers || []) { blockers.push(blockerValue(name, blocker)); } @@ -113,7 +119,7 @@ export function combineShipGateResults({ unknowns.push(unknown); } - applyMergeStateUnknowns(snapshot, unknowns); + applyMergeStateUnknowns(snapshot, unknowns, { localEvidenceActive }); unknowns.push(...nativeStackShipGateUnknowns(snapshot)); const mergeQueueEnabled = reviewPolicy?.mergeQueue?.enabled === true; @@ -172,6 +178,15 @@ export function combineShipGateResults({ "The same failures are present on the base tip. They may block merging, but they do not automatically belong in this PR's implementation scope.", }); } + if (localEvidenceActive) { + advisories.push({ + code: "remote_checks_waived_by_local_evidence", + message: + "Remote required checks were waived by an explicit per-repository local evidence mode; the bound local receipt is the authoritative check evidence.", + remoteDecision: requiredChecks?.remoteObserved?.decision || "unknown", + remoteBlockerCount: (requiredChecks?.remoteObserved?.blockers || []).length, + }); + } const uniqueBlockers = [...new Set(blockers)]; const uniqueUnknowns = [...new Set(unknowns)]; @@ -201,12 +216,16 @@ export function combineShipGateResults({ complete: uniqueUnknowns.length === 0, blockers: uniqueBlockers, unknowns: uniqueUnknowns, + evidenceMode: requiredChecks?.evidenceMode || "remote", + remoteChecksWaived: localEvidenceActive, + localEvidence: requiredChecks?.localEvidence || null, advisories, components: { requiredChecks: { ...componentSummary(requiredChecks), sha: requiredChecks?.authoritativeCheckSha || requiredChecks?.sha || null, reason: requiredChecks?.authoritativeCheckReason || null, + evidenceMode: requiredChecks?.evidenceMode || "remote", }, baseHealth: { ...componentSummary(baseHealth), diff --git a/scripts/lib/snapshot-evaluators.mjs b/scripts/lib/snapshot-evaluators.mjs index d1559e04..7c2575c3 100644 --- a/scripts/lib/snapshot-evaluators.mjs +++ b/scripts/lib/snapshot-evaluators.mjs @@ -1,4 +1,5 @@ import { ownersForPath, parseCodeowners } from "./codeowners.mjs"; +import { resolveLocalCiEvidenceForSnapshot } from "./local-ci-evidence.mjs"; import { protectionRefName } from "./native-stack-policy.mjs"; import { validateRulesBoundaryEvidence } from "./rules-boundary.mjs"; import { @@ -229,7 +230,10 @@ function decisionFrom(blockers, unknowns) { return "ready"; } -export function evaluateRequiredChecksSnapshot(snapshot) { +export function evaluateRequiredChecksSnapshot( + snapshot, + { localEvidence = undefined } = {}, +) { const pr = pullRequest(snapshot); const base = pr.baseRefName; const branchRules = policyEvidence(snapshot).branchProtectionRules || {}; @@ -264,7 +268,16 @@ export function evaluateRequiredChecksSnapshot(snapshot) { mergeStateStatus: pr.mergeStateStatus, }); - return { + const local = + localEvidence === undefined + ? resolveLocalCiEvidenceForSnapshot(snapshot) + : localEvidence; + const remoteObserved = { + decision: evaluation.decision, + blockers: evaluation.blockers, + unknowns: evaluation.unknowns, + }; + const output = { schemaVersion: 1, snapshotId: snapshot.snapshotId, repo: snapshot.repo, @@ -286,7 +299,28 @@ export function evaluateRequiredChecksSnapshot(snapshot) { requiredStatus: evaluation.requiredStatus, observedStatus: evaluation.observedStatus, allLive: evaluation.allLive, + remoteObserved, }; + if (local?.active === true && local.mode === "local") { + output.mode = "local_evidence"; + output.evidenceMode = "local"; + output.localEvidence = { + valid: local.valid === true, + reasons: local.reasons || [], + path: local.path || null, + receipt: local.receipt || null, + }; + output.decision = local.valid === true ? "ready" : "blocked"; + output.ready = local.valid === true; + output.blocked = local.valid !== true; + output.unknown = false; + output.complete = true; + output.blockers = local.valid === true ? [] : [...(local.reasons || [])]; + output.unknowns = []; + } else { + output.evidenceMode = "remote"; + } + return output; } export function evaluateReviewPolicySnapshot(snapshot) { diff --git a/scripts/lib/user-config.mjs b/scripts/lib/user-config.mjs index a882d537..ba9227c0 100644 --- a/scripts/lib/user-config.mjs +++ b/scripts/lib/user-config.mjs @@ -15,9 +15,15 @@ export const AUTHORITY_MODES = Object.freeze([ "all", ]); +export const CI_EVIDENCE_MODES = Object.freeze(["remote", "local"]); + export const DEFAULT_USER_CONFIG = Object.freeze({ - schemaVersion: 1, + schemaVersion: 2, authorityMode: "high-assurance", + ciEvidence: Object.freeze({ + defaultMode: "remote", + repositories: Object.freeze({}), + }), }); function codedError(code, cause = undefined) { @@ -31,19 +37,76 @@ function authorityMode(value, code) { return value; } +export function normalizeRepositoryKey(value) { + const match = String(value || "") + .trim() + .match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/); + if (!match) { + throw codedError("github_delivery_config_ci_evidence_repository_invalid"); + } + return `${match[1].toLowerCase()}/${match[2].toLowerCase()}`; +} + +function normalizeCiEvidence(value) { + if (value === null || value === undefined) { + return { defaultMode: "remote", repositories: {} }; + } + if (typeof value !== "object" || Array.isArray(value)) { + throw codedError("github_delivery_config_ci_evidence_invalid"); + } + const defaultMode = value.defaultMode ?? "remote"; + if (defaultMode !== "remote") { + throw codedError("github_delivery_config_ci_evidence_default_mode_invalid"); + } + const source = value.repositories ?? {}; + if (typeof source !== "object" || Array.isArray(source)) { + throw codedError("github_delivery_config_ci_evidence_repositories_invalid"); + } + const repositories = {}; + for (const [rawKey, entry] of Object.entries(source)) { + const key = normalizeRepositoryKey(rawKey); + if ( + entry === null || + typeof entry !== "object" || + Array.isArray(entry) || + entry.mode !== "local" + ) { + throw codedError("github_delivery_config_ci_evidence_repository_mode_invalid"); + } + const waiver = String(entry.waiver || "").trim(); + if (!waiver || waiver.length > 200) { + throw codedError("github_delivery_config_ci_evidence_waiver_required"); + } + const normalized = { mode: "local", waiver }; + if (entry.recordedAt !== undefined) { + const parsed = Date.parse(String(entry.recordedAt)); + if (!Number.isFinite(parsed)) { + throw codedError("github_delivery_config_ci_evidence_recorded_at_invalid"); + } + normalized.recordedAt = new Date(parsed).toISOString(); + } + repositories[key] = normalized; + } + return { defaultMode, repositories }; +} + export function normalizeUserConfig(value) { if (value === null || typeof value !== "object" || Array.isArray(value)) { throw codedError("github_delivery_config_invalid"); } - if (value.schemaVersion !== 1) { + if (value.schemaVersion !== 1 && value.schemaVersion !== 2) { throw codedError("github_delivery_config_schema_version_unsupported"); } return { - schemaVersion: 1, + schemaVersion: 2, authorityMode: authorityMode( value.authorityMode, "github_delivery_config_authority_mode_invalid", ), + ciEvidence: + value.schemaVersion === 1 + ? { defaultMode: "remote", repositories: {} } + : normalizeCiEvidence(value.ciEvidence), }; } @@ -150,3 +213,45 @@ export function resolveAuthorityMode({ } return normalizeUserConfig(config).authorityMode; } + +export function resolveCiEvidenceMode({ + config = DEFAULT_USER_CONFIG, + repo, +} = {}) { + const normalized = normalizeUserConfig(config); + const key = normalizeRepositoryKey(repo); + const entry = normalized.ciEvidence.repositories[key] || null; + if (entry?.mode === "local") { + return { + mode: "local", + waiver: entry.waiver, + recordedAt: entry.recordedAt ?? null, + }; + } + return { mode: "remote", waiver: null, recordedAt: null }; +} + +export function withCiEvidenceRepository( + config, + { repository, mode, waiver = null, recordedAt = new Date().toISOString() } = {}, +) { + const normalized = normalizeUserConfig(config); + if (!CI_EVIDENCE_MODES.includes(mode)) { + throw codedError("github_delivery_config_ci_evidence_mode_invalid"); + } + const key = normalizeRepositoryKey(repository); + const repositories = { ...normalized.ciEvidence.repositories }; + if (mode === "local") { + const text = String(waiver || "").trim(); + if (!text) { + throw codedError("github_delivery_config_ci_evidence_waiver_required"); + } + repositories[key] = { mode: "local", waiver: text, recordedAt }; + } else { + delete repositories[key]; + } + return normalizeUserConfig({ + ...normalized, + ciEvidence: { ...normalized.ciEvidence, repositories }, + }); +} diff --git a/scripts/local-ci-receipt.mjs b/scripts/local-ci-receipt.mjs new file mode 100644 index 00000000..6e9d327b --- /dev/null +++ b/scripts/local-ci-receipt.mjs @@ -0,0 +1,249 @@ +#!/usr/bin/env node +/** + * Record a user-scoped local CI evidence receipt for one exact PR head. + * + * Usage: + * node scripts/local-ci-receipt.mjs OWNER/REPO PR --log FILE --command CMD + * [--command CMD ...] [--scope SCOPE ...] [--exit-code N] [--waiver TEXT] + * [--started-at ISO] [--replace] + * + * The receipt binds the repository, PR number, exact head and base SHA, + * command/suite scope, exit codes, runner host, tool versions, timestamps and + * a hash of the complete log file. It is written outside the repository, so + * repository content cannot forge it. + */ +import { createHash } from "node:crypto"; +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + statSync, + writeFileSync, +} from "node:fs"; +import { arch, hostname, platform, release } from "node:os"; +import { dirname, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { boundedSpawnSync } from "./lib/subprocess-policy.mjs"; +import { + canonicalReceiptHash, + localEvidenceReceiptPath, + LOCAL_CI_RECEIPT_KIND, + LOCAL_CI_RECEIPT_SCHEMA_VERSION, +} from "./lib/local-ci-evidence.mjs"; +import { + readUserConfig, + resolveCiEvidenceMode, +} from "./lib/user-config.mjs"; + +const USAGE = + "Usage: node scripts/local-ci-receipt.mjs OWNER/REPO PR --log FILE --command CMD [--command CMD ...] [--scope SCOPE ...] [--exit-code N] [--waiver TEXT] [--started-at ISO] [--replace]"; + +function parseArgs(argv) { + const positionals = []; + const options = { + log: null, + commands: [], + scopes: [], + exitCode: 0, + waiver: null, + startedAt: null, + replace: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "--log") options.log = argv[++index]; + else if (value === "--command") options.commands.push(argv[++index]); + else if (value === "--scope") options.scopes.push(argv[++index]); + else if (value === "--exit-code") options.exitCode = Number(argv[++index]); + else if (value === "--waiver") options.waiver = argv[++index]; + else if (value === "--started-at") options.startedAt = argv[++index]; + else if (value === "--replace") options.replace = true; + else positionals.push(value); + } + const [repo, prRaw] = positionals; + const pr = Number(prRaw); + if ( + positionals.length !== 2 || + !repo?.includes("/") || + !Number.isInteger(pr) || + pr <= 0 || + !options.log || + !options.commands.length || + options.commands.some((command) => !command || !String(command).trim()) || + !Number.isInteger(options.exitCode) + ) { + throw new Error(USAGE); + } + if ( + options.scopes.length && + options.scopes.length !== 1 && + options.scopes.length !== options.commands.length + ) { + throw new Error("--scope must appear once or once per --command"); + } + return { repo, pr, ...options }; +} + +function run(command, args) { + const result = boundedSpawnSync(command, args, { + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + }); + return { + ok: result.status === 0, + body: String(result.stdout || "").trim(), + error: String(result.stderr || result.stdout || "").trim(), + }; +} + +function toolVersion(label, command, args) { + const result = run(command, args); + const value = (result.body || result.error).split(/\r?\n/)[0].trim(); + if (!result.ok || !value) { + throw new Error(`tool_version_unavailable:${label}`); + } + return value; +} + +export function npmVersion(execPath = process.env.ComSpec || "cmd.exe") { + if (process.platform === "win32") { + return toolVersion("npm", execPath, ["/d", "/s", "/c", "npm --version"]); + } + return toolVersion("npm", "npm", ["--version"]); +} + +export function recordLocalCiReceipt(argv, dependencies = {}) { + const args = parseArgs(argv); + const readConfig = dependencies.readUserConfig || readUserConfig; + const now = dependencies.now || new Date(); + const logPath = resolve(args.log); + const config = readConfig().config; + const mode = resolveCiEvidenceMode({ config, repo: args.repo }); + if (mode.mode !== "local") { + throw new Error("local_evidence_mode_not_configured"); + } + const waiver = String(args.waiver || mode.waiver || "").trim(); + if (waiver !== String(mode.waiver || "").trim()) { + throw new Error("local_evidence_waiver_mismatch"); + } + if (!existsSync(logPath)) throw new Error("local_evidence_log_missing"); + const logInfo = statSync(logPath); + if (!logInfo.isFile() || logInfo.size <= 0) { + throw new Error("local_evidence_log_empty"); + } + + const prInfo = run("gh", [ + "pr", + "view", + String(args.pr), + "--repo", + args.repo, + "--json", + "number,headRefOid,baseRefOid,commits", + ]); + if (!prInfo.ok) { + throw new Error(`local_evidence_pr_unreadable:${prInfo.error || "gh failed"}`); + } + const pr = JSON.parse(prInfo.body); + const headSha = String(pr.headRefOid || "").toLowerCase(); + const baseSha = String(pr.baseRefOid || "").toLowerCase(); + if (!/^[0-9a-f]{40}$/.test(headSha) || !/^[0-9a-f]{40}$/.test(baseSha)) { + throw new Error("local_evidence_pr_shas_unavailable"); + } + const commits = Array.isArray(pr.commits) ? pr.commits : []; + const headCommit = commits.length ? commits[commits.length - 1] : null; + const headCommittedAt = + headCommit?.committedDate || headCommit?.committedAt || null; + + const commands = args.commands.map((command, index) => ({ + command: String(command).trim(), + scope: String( + args.scopes.length === 1 + ? args.scopes[0] + : args.scopes[index] || "targeted", + ).trim(), + exitCode: args.exitCode, + })); + if (commands.some((entry) => !entry.scope)) { + throw new Error("local_evidence_scope_missing"); + } + + const body = { + schemaVersion: LOCAL_CI_RECEIPT_SCHEMA_VERSION, + kind: LOCAL_CI_RECEIPT_KIND, + repository: args.repo, + pullRequest: args.pr, + headSha, + baseSha, + headCommittedAt, + recordedAt: now.toISOString(), + startedAt: args.startedAt + ? new Date(args.startedAt).toISOString() + : new Date(logInfo.mtimeMs).toISOString(), + completedAt: new Date(logInfo.mtimeMs).toISOString(), + waiver, + exitCode: args.exitCode, + commands, + runner: { + host: hostname(), + platform: platform(), + release: release(), + arch: arch(), + }, + tools: { + node: process.version, + npm: dependencies.npmVersion + ? dependencies.npmVersion() + : npmVersion(), + git: toolVersion("git", "git", ["--version"]), + }, + log: { + path: logPath, + sha256: createHash("sha256") + .update(readFileSync(logPath)) + .digest("hex"), + bytes: logInfo.size, + }, + }; + const receipt = { ...body, receiptHash: canonicalReceiptHash(body) }; + const receiptPath = + dependencies.receiptPath || + localEvidenceReceiptPath({ + repo: args.repo, + pr: args.pr, + headSha, + }); + if (existsSync(receiptPath) && !args.replace) { + throw new Error("local_evidence_receipt_exists"); + } + mkdirSync(dirname(receiptPath), { recursive: true }); + const tempPath = `${receiptPath}.${process.pid}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(receipt, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + renameSync(tempPath, receiptPath); + return { + schemaVersion: 1, + kind: "github-delivery/local-ci-receipt-recording", + repository: args.repo, + pullRequest: args.pr, + headSha, + baseSha, + waiver, + receiptPath, + receiptHash: receipt.receiptHash, + }; +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const result = recordLocalCiReceipt(process.argv.slice(2)); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } catch (error) { + process.stderr.write(`${String(error?.message || error)}\n`); + process.exitCode = 2; + } +} diff --git a/scripts/validate-npm-package.mjs b/scripts/validate-npm-package.mjs index e8723913..10ed893b 100755 --- a/scripts/validate-npm-package.mjs +++ b/scripts/validate-npm-package.mjs @@ -20,6 +20,7 @@ const RUNTIME_FILES = [ "scripts/codex-with-watchdog.mjs", "scripts/install-codex-watchdog-hooks.mjs", "scripts/install-skill.mjs", + "scripts/local-ci-receipt.mjs", "scripts/windows-install-locks.ps1", "scripts/lib/agent-debug-trace.mjs", "scripts/lib/agent-trace-launcher.mjs", @@ -42,6 +43,7 @@ const RUNTIME_FILES = [ "scripts/lib/grok-trace-launcher.mjs", "scripts/lib/install-lock.mjs", "scripts/lib/installation-backups.mjs", + "scripts/lib/local-ci-evidence.mjs", "scripts/lib/release-path-identity.mjs", "scripts/lib/release-self-update.mjs", "scripts/lib/subprocess-policy.mjs", diff --git a/tests/unit/audit-remediation.test.mjs b/tests/unit/audit-remediation.test.mjs index d16f21c2..99d3a88c 100644 --- a/tests/unit/audit-remediation.test.mjs +++ b/tests/unit/audit-remediation.test.mjs @@ -85,8 +85,9 @@ function mergeSnapshot(feedback = {}) { test("secure authority is the default when the user has not opted out", () => { assert.deepEqual(DEFAULT_USER_CONFIG, { - schemaVersion: 1, + schemaVersion: 2, authorityMode: "high-assurance", + ciEvidence: { defaultMode: "remote", repositories: {} }, }); }); diff --git a/tests/unit/local-ci-evidence.test.mjs b/tests/unit/local-ci-evidence.test.mjs new file mode 100644 index 00000000..eb7e24af --- /dev/null +++ b/tests/unit/local-ci-evidence.test.mjs @@ -0,0 +1,372 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + canonicalReceiptHash, + evaluateLocalCiEvidence, +} from "../../scripts/lib/local-ci-evidence.mjs"; +import { evaluateRequiredChecksSnapshot } from "../../scripts/lib/snapshot-evaluators.mjs"; +import { combineShipGateResults } from "../../scripts/lib/ship-gate-policy.mjs"; + +const REPO = "Wibias/streamer-content"; +const PR = 873; +const HEAD = "a".repeat(40); +const BASE = "b".repeat(40); +const NOW = Date.parse("2026-11-08T10:00:00.000Z"); + +function localConfig() { + return { + schemaVersion: 2, + authorityMode: "high-assurance", + ciEvidence: { + defaultMode: "remote", + repositories: { + "wibias/streamer-content": { + mode: "local", + waiver: "t851s5532", + recordedAt: "2026-11-08T09:31:00.000Z", + }, + }, + }, + }; +} + +function fixture() { + const dir = mkdtempSync(join(tmpdir(), "gd-local-ci-")); + const logPath = join(dir, "ci.log"); + writeFileSync(logPath, "all suites green\n", "utf8"); + return { dir, logPath, receiptPath: join(dir, "receipt.json") }; +} + +function receiptBody({ logPath }) { + return { + schemaVersion: 1, + kind: "github-delivery/local-ci-evidence", + repository: REPO, + pullRequest: PR, + headSha: HEAD, + baseSha: BASE, + headCommittedAt: "2026-11-08T09:00:00.000Z", + recordedAt: "2026-11-08T09:45:00.000Z", + startedAt: "2026-11-08T09:30:00.000Z", + completedAt: "2026-11-08T09:45:00.000Z", + waiver: "t851s5532", + exitCode: 0, + commands: [ + { + command: "npm run ci:local -- --full", + scope: "full local CI", + exitCode: 0, + }, + ], + runner: { host: "runner-1", platform: "win32", release: "10.0", arch: "x64" }, + tools: { + node: "v22.20.0", + npm: "10.9.0", + git: "git version 2.47.0", + }, + log: { + path: logPath, + sha256: "", + bytes: 18, + }, + }; +} + +function writeReceipt(fixtureValue, overrides = {}) { + const body = { + ...receiptBody(fixtureValue), + ...overrides, + }; + body.log = { + ...body.log, + sha256: createHash("sha256").update(readFileSync(fixtureValue.logPath)).digest("hex"), + bytes: statSync(fixtureValue.logPath).size, + }; + const receipt = { ...body, receiptHash: canonicalReceiptHash(body) }; + writeFileSync(fixtureValue.receiptPath, JSON.stringify(receipt, null, 2)); + return receipt; +} + +function evaluate(fixtureValue, overrides = {}) { + return evaluateLocalCiEvidence({ + config: localConfig(), + repo: REPO, + pr: PR, + headSha: HEAD, + baseSha: BASE, + receiptPath: fixtureValue.receiptPath, + now: NOW, + isBaseAncestor: () => true, + ...overrides, + }); +} + +test("valid same-head receipt passes and remote mode stays inactive", () => { + const value = fixture(); + writeReceipt(value); + const result = evaluate(value); + assert.equal(result.mode, "local"); + assert.equal(result.active, true); + assert.equal(result.valid, true); + assert.deepEqual(result.reasons, []); + + const remote = evaluateLocalCiEvidence({ + config: { + schemaVersion: 2, + authorityMode: "high-assurance", + ciEvidence: { defaultMode: "remote", repositories: {} }, + }, + repo: REPO, + pr: PR, + headSha: HEAD, + baseSha: BASE, + receiptPath: value.receiptPath, + now: NOW, + }); + assert.equal(remote.mode, "remote"); + assert.equal(remote.active, false); + assert.equal(remote.valid, false); +}); + +test("missing or foreign receipt stays blocked", () => { + const value = fixture(); + assert.ok(evaluate(value).reasons.includes("local_evidence_missing")); + + writeReceipt(value, { repository: "other/repo" }); + const foreign = evaluate(value); + assert.equal(foreign.valid, false); + assert.ok(foreign.reasons.includes("local_evidence_foreign_repository")); +}); + +test("head, PR, waiver, red exit code, and tampered receipt fail closed", () => { + const value = fixture(); + writeReceipt(value, { headSha: "c".repeat(40) }); + assert.ok(evaluate(value).reasons.includes("local_evidence_head_mismatch")); + + writeReceipt(value, { pullRequest: 1 }); + assert.ok(evaluate(value).reasons.includes("local_evidence_pull_request_mismatch")); + + writeReceipt(value, { waiver: "other" }); + assert.ok(evaluate(value).reasons.includes("local_evidence_waiver_mismatch")); + + writeReceipt(value, { + exitCode: 1, + commands: [{ command: "npm test", scope: "unit", exitCode: 1 }], + }); + const red = evaluate(value); + assert.ok(red.reasons.includes("local_evidence_exit_code_failed")); + assert.ok(red.reasons.includes("local_evidence_command_failed")); + + const tampered = writeReceipt(value); + tampered.runner.host = "other-host"; + writeFileSync(value.receiptPath, JSON.stringify(tampered, null, 2)); + assert.ok(evaluate(value).reasons.includes("local_evidence_receipt_tampered")); +}); + +test("stale and pre-head receipts stay blocked", () => { + const value = fixture(); + writeReceipt(value, { completedAt: "2026-10-01T09:45:00.000Z" }); + assert.ok(evaluate(value).reasons.includes("local_evidence_stale")); + + writeReceipt(value, { completedAt: "2026-11-08T08:30:00.000Z" }); + assert.ok( + evaluate(value, { headCommittedAt: "2026-11-08T09:00:00.000Z" }).reasons.includes( + "local_evidence_predates_head", + ), + ); +}); + +test("base divergence and unreadable ancestry fail closed", () => { + const value = fixture(); + writeReceipt(value); + assert.ok( + evaluate(value, { baseSha: "d".repeat(40), isBaseAncestor: () => false }).reasons.includes( + "local_evidence_base_not_ancestor", + ), + ); + assert.ok( + evaluate(value, { baseSha: "d".repeat(40), isBaseAncestor: () => null }).reasons.includes( + "local_evidence_base_ancestry_unknown", + ), + ); +}); + +test("log tampering and missing provenance fail closed", () => { + const value = fixture(); + writeReceipt(value); + writeFileSync(value.logPath, "tampered\n"); + assert.ok(evaluate(value).reasons.includes("local_evidence_log_hash_mismatch")); + + const noRunner = fixture(); + writeReceipt(noRunner, { runner: { host: "" } }); + assert.ok(evaluate(noRunner).reasons.includes("local_evidence_runner_missing")); +}); + +function snapshotWithFailingRemoteCheck() { + const source = () => ({ required: true, readable: true, complete: true, error: null }); + return { + schemaVersion: 1, + kind: "github-delivery/evidence-snapshot", + snapshotId: "snap-local", + capturedAt: "2026-11-08T09:59:30.000Z", + repo: REPO, + pr: PR, + headOid: HEAD, + complete: true, + incompleteReasons: [], + sources: { + pr: source(), + changedFiles: source(), + activeRules: source(), + checkRuns: source(), + statuses: source(), + issueComments: source(), + reviewComments: source(), + reviews: source(), + reviewThreads: source(), + branchProtection: source(false), + codeowners: source(false), + codeownersErrors: source(false), + policyGraphql: source(), + workflowCoverage: source(), + viewer: source(), + baseRef: source(), + }, + evidence: { + pullRequest: { + number: PR, + url: "https://github.com/Wibias/streamer-content/pull/873", + baseRefName: "main", + headRefOid: HEAD, + mergeStateStatus: "BLOCKED", + mergeable: "MERGEABLE", + reviewDecision: "APPROVED", + isDraft: false, + reviewRequests: [], + stack: null, + commits: [ + { + oid: HEAD, + committedDate: "2026-11-08T09:00:00.000Z", + }, + ], + }, + changedFiles: [{ filename: "src/index.js" }], + branchProtection: null, + activeRules: [], + checks: { + authoritative: { sha: HEAD, complete: true, reason: "head_only" }, + checkRuns: [ + { + id: 1, + name: "Affected tests", + status: "completed", + conclusion: "failure", + completed_at: "2026-11-08T09:10:00.000Z", + app: { id: 15368, slug: "github-actions" }, + }, + ], + statuses: [], + }, + baseHealth: { + baseRefName: "main", + baseOid: BASE, + checks: { checkRuns: [], statuses: [] }, + }, + feedback: { + issueComments: [], + reviewComments: [], + reviews: [], + reviewThreads: [], + }, + codeowners: { path: ".github/CODEOWNERS", text: "", errors: [] }, + policy: { + branchProtectionRules: { pageInfo: { hasNextPage: false }, nodes: [] }, + latestOpinionatedReviews: { + pageInfo: { hasNextPage: false }, + nodes: [ + { + author: { login: "reviewer" }, + state: "APPROVED", + submittedAt: "2026-11-08T09:20:00.000Z", + commit: { oid: HEAD }, + }, + ], + }, + mergeQueue: { enabled: false, inQueue: false, entry: null }, + }, + workflowCoverage: { + complete: true, + scannedRef: "main", + workflowFiles: 1, + hasMergeGroupTrigger: false, + hasPullRequestTrigger: true, + warning: null, + }, + viewer: { login: "Wibias" }, + }, + }; +} + +test("snapshot gate uses valid local evidence and keeps remote failure visible", () => { + const value = fixture(); + writeReceipt(value); + const snapshot = snapshotWithFailingRemoteCheck(); + const result = evaluateRequiredChecksSnapshot(snapshot, { + localEvidence: evaluate(value, { baseSha: BASE }), + }); + assert.equal(result.decision, "ready"); + assert.equal(result.evidenceMode, "local"); + assert.equal(result.localEvidence.valid, true); + assert.equal(result.remoteObserved.decision, "blocked"); + assert.ok(result.remoteObserved.blockers.some((row) => row.includes("Affected tests"))); + + const blocked = evaluateRequiredChecksSnapshot(snapshot, { + localEvidence: evaluate(value, { + baseSha: "d".repeat(40), + isBaseAncestor: () => false, + }), + }); + assert.equal(blocked.decision, "blocked"); + assert.ok(blocked.blockers.includes("local_evidence_base_not_ancestor")); +}); + +test("ship gate treats waived base health as advisory, not as a blocker", () => { + const snapshot = snapshotWithFailingRemoteCheck(); + const requiredChecks = { + decision: "ready", + ready: true, + complete: true, + blockers: [], + unknowns: [], + evidenceMode: "local", + localEvidence: { valid: true, reasons: [] }, + remoteObserved: { decision: "blocked", blockers: ["fail:Affected tests"], unknowns: [] }, + }; + const output = combineShipGateResults({ + snapshot, + mutationProfile: { mode: "maintainer" }, + requiredChecks, + baseHealth: { + decision: "blocked", + complete: true, + blockers: ["pr_only_failure:Affected tests"], + unknowns: [], + sharedFailures: [], + }, + reviewPolicy: { decision: "ready", complete: true, blockers: [], unknowns: [] }, + reviewThreads: { decision: "ready", complete: true, blockers: [], unknowns: [] }, + wake: { decision: "ready", complete: true, blockers: [], unknowns: [] }, + codeowners: { complete: true }, + }); + assert.equal(output.decision, "ready"); + assert.equal(output.remoteChecksWaived, true); + assert.ok( + output.advisories.some((row) => row.code === "remote_checks_waived_by_local_evidence"), + ); +}); diff --git a/tests/unit/npm-package.test.mjs b/tests/unit/npm-package.test.mjs index 17629e9e..83b7560e 100644 --- a/tests/unit/npm-package.test.mjs +++ b/tests/unit/npm-package.test.mjs @@ -20,6 +20,7 @@ const REQUIRED_RUNTIME = new Set([ "scripts/codex-with-watchdog.mjs", "scripts/install-codex-watchdog-hooks.mjs", "scripts/install-skill.mjs", + "scripts/local-ci-receipt.mjs", "scripts/windows-install-locks.ps1", "scripts/lib/agent-debug-trace.mjs", "scripts/lib/agent-trace-launcher.mjs", @@ -42,6 +43,7 @@ const REQUIRED_RUNTIME = new Set([ "scripts/lib/grok-trace-launcher.mjs", "scripts/lib/install-lock.mjs", "scripts/lib/installation-backups.mjs", + "scripts/lib/local-ci-evidence.mjs", "scripts/lib/release-path-identity.mjs", "scripts/lib/release-self-update.mjs", "scripts/lib/subprocess-policy.mjs", @@ -215,4 +217,4 @@ test("a real packed tarball runs supported entrypoints after an offline local in } finally { rmSync(root, { recursive: true, force: true }); } -}); \ No newline at end of file +}); diff --git a/tests/unit/pre-open-publication-binding.test.mjs b/tests/unit/pre-open-publication-binding.test.mjs index e58448f6..0324bd83 100644 --- a/tests/unit/pre-open-publication-binding.test.mjs +++ b/tests/unit/pre-open-publication-binding.test.mjs @@ -8,6 +8,7 @@ import test from "node:test"; import { buildCreatePrPublicationPlan } from "../../scripts/lib/create-pr-publication-plan.mjs"; import { + assertPreOpenPublicationEvidence, createDeliveryWorkflowController, readDeliveryWorkflowCheckpoint, writeDeliveryWorkflowCheckpoint, @@ -126,6 +127,90 @@ test("blocked and mismatched pre-open evidence remain publication blockers", () assert.equal(current.transition("OPEN_PR").phase, "OPEN_PR"); }); +test("local-work create_pr binds the exact gate head when headRef is a SHA", () => { + const snapshot = { + schemaVersion: 1, + kind: "github-delivery/workflow-controller", + workflow: "create-pr-from-local-work", + repo: "acme/widgets", + baseSha: BASE, + headSha: HEAD, + preOpenGate: { + decision: "ready", + repo: "acme/widgets", + baseRef: "dev", + headRef: HEAD, + baseSha: BASE, + headSha: HEAD, + diffIdentity: `sha256:${"d".repeat(64)}`, + fileCount: 6, + recordedAt: 1, + }, + }; + const plan = publicationPlan("checkpoint.json"); + assert.doesNotThrow(() => assertPreOpenPublicationEvidence(snapshot, plan.requests[1])); + + const wrongBranchGate = { + ...snapshot, + preOpenGate: { ...snapshot.preOpenGate, headRef: "other-branch" }, + }; + assert.throws( + () => assertPreOpenPublicationEvidence(wrongBranchGate, plan.requests[1]), + /pre_open_evidence_scope_mismatch/, + ); +}); + +test("local-work post-publication phases authorize only the draft-state change", () => { + const directory = mkdtempSync(join(tmpdir(), "github-delivery-draft-intent-")); + const checkpoint = join(directory, "controller.json"); + try { + for (const phase of ["REVIEW_FEEDBACK", "CI", "FINAL_GATE"]) { + const initial = createDeliveryWorkflowController({ + workflow: "create-pr-from-local-work", + repo: "acme/widgets", + baseSha: BASE, + headSha: HEAD, + graph: { + REVIEW_FEEDBACK: ["CI", "FINAL_GATE"], + CI: ["FINAL_GATE"], + FINAL_GATE: ["DONE"], + DONE: [], + }, + startPhase: phase, + }); + writeDeliveryWorkflowCheckpoint(checkpoint, initial.snapshot()); + const draft = { + schemaVersion: 1, + action: "change_draft_state", + mutationMode: "maintainer", + repo: "acme/widgets", + pr: 42, + expectedHead: HEAD, + ready: true, + }; + assert.deepEqual( + mutationExecutionContextFromCheckpoint({ path: checkpoint, request: draft }), + { + trustedWorkflowIntent: true, + trustedExactTextConfirmation: false, + }, + ); + assert.deepEqual( + mutationExecutionContextFromCheckpoint({ + path: checkpoint, + request: { ...draft, action: "close_pr" }, + }), + { + trustedWorkflowIntent: false, + trustedExactTextConfirmation: false, + }, + ); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + test("mutation boundary independently rejects missing, changed, or stale pre-open scope", () => { const directory = mkdtempSync(join(tmpdir(), "github-delivery-preopen-boundary-")); const checkpoint = join(directory, "controller.json"); diff --git a/tests/unit/user-config.test.mjs b/tests/unit/user-config.test.mjs index ddab3a77..8ab325f6 100644 --- a/tests/unit/user-config.test.mjs +++ b/tests/unit/user-config.test.mjs @@ -6,31 +6,92 @@ import { DEFAULT_USER_CONFIG, normalizeUserConfig, readUserConfig, + resolveCiEvidenceMode, resolveAuthorityMode, userConfigPath, + withCiEvidenceRepository, writeUserConfig, } from "../../scripts/lib/user-config.mjs"; test("authority mode schema is exact and defaults to high assurance", () => { assert.deepEqual(AUTHORITY_MODES, ["off", "high-assurance", "all"]); assert.deepEqual(DEFAULT_USER_CONFIG, { - schemaVersion: 1, + schemaVersion: 2, authorityMode: "high-assurance", + ciEvidence: { defaultMode: "remote", repositories: {} }, }); assert.deepEqual(normalizeUserConfig({ schemaVersion: 1, authorityMode: "high-assurance" }), { - schemaVersion: 1, + schemaVersion: 2, authorityMode: "high-assurance", + ciEvidence: { defaultMode: "remote", repositories: {} }, }); assert.throws( () => normalizeUserConfig({ schemaVersion: 1, authorityMode: "sometimes" }), /github_delivery_config_authority_mode_invalid/, ); assert.throws( - () => normalizeUserConfig({ schemaVersion: 2, authorityMode: "off" }), + () => normalizeUserConfig({ schemaVersion: 3, authorityMode: "off" }), /github_delivery_config_schema_version_unsupported/, ); }); +test("local CI evidence is explicit, per repository, and requires a waiver", () => { + const configured = normalizeUserConfig({ + schemaVersion: 2, + authorityMode: "high-assurance", + ciEvidence: { + defaultMode: "remote", + repositories: { + "Wibias/Streamer-Content": { + mode: "local", + waiver: "t851s5532", + recordedAt: "2026-11-08T09:31:00+01:00", + }, + }, + }, + }); + assert.equal(configured.schemaVersion, 2); + assert.deepEqual(configured.ciEvidence.repositories["wibias/streamer-content"], { + mode: "local", + waiver: "t851s5532", + recordedAt: "2026-11-08T08:31:00.000Z", + }); + assert.deepEqual(resolveCiEvidenceMode({ config: configured, repo: "wibias/streamer-content" }), { + mode: "local", + waiver: "t851s5532", + recordedAt: "2026-11-08T08:31:00.000Z", + }); + assert.equal( + resolveCiEvidenceMode({ config: configured, repo: "Wibias/github-delivery" }).mode, + "remote", + ); + + assert.throws( + () => normalizeUserConfig({ + schemaVersion: 2, + authorityMode: "high-assurance", + ciEvidence: { + defaultMode: "remote", + repositories: { "a/b": { mode: "local", waiver: " " } }, + }, + }), + /github_delivery_config_ci_evidence_waiver_required/, + ); + + const added = withCiEvidenceRepository(configured, { + repository: "wibias/streamer-content", + mode: "remote", + }); + assert.deepEqual(added.ciEvidence.repositories, {}); + + const local = withCiEvidenceRepository(added, { + repository: "wibias/streamer-content", + mode: "local", + waiver: "t851s5532", + }); + assert.equal(resolveCiEvidenceMode({ config: local, repo: "wibias/streamer-content" }).mode, "local"); +}); + test("config path is outside the skill install and follows each OS convention", () => { assert.equal( userConfigPath({