diff --git a/scripts/workflow-registry-audit.mjs b/scripts/workflow-registry-audit.mjs index b861786e6..86babb466 100644 --- a/scripts/workflow-registry-audit.mjs +++ b/scripts/workflow-registry-audit.mjs @@ -217,6 +217,36 @@ function classifyRecord( return { ...base, classification: "active_pr_owned", failure: null }; } + const activePullRequestCaseCollision = [...activePullRequestWorkflowPaths].find( + (activePath) => activePath.toLowerCase() === lowerPath, + ); + if (activePullRequestCaseCollision) { + return { + ...base, + classification: "unresolved_registry_record", + failure: { + code: "active_pr_workflow_path_case_mismatch", + workflow_id: record.id, + detail: `Workflow path ${path} differs by case from active-PR path ${activePullRequestCaseCollision}.`, + }, + }; + } + + const activePullRequestNormalizationCollision = [...activePullRequestWorkflowPaths].find( + (activePath) => activePath.normalize("NFC").toLowerCase() === normalizedPath, + ); + if (activePullRequestNormalizationCollision) { + return { + ...base, + classification: "unresolved_registry_record", + failure: { + code: "active_pr_workflow_path_normalization_mismatch", + workflow_id: record.id, + detail: `Workflow path ${path} differs by Unicode normalization from active-PR path ${activePullRequestNormalizationCollision}.`, + }, + }; + } + if (record.state === "disabled_manually" || record.state === "disabled_inactivity") { return { ...base, classification: "disabled_registry_record", failure: null }; } diff --git a/scripts/workflow-registry-live-audit.mjs b/scripts/workflow-registry-live-audit.mjs index 9d3579736..847bfc758 100644 --- a/scripts/workflow-registry-live-audit.mjs +++ b/scripts/workflow-registry-live-audit.mjs @@ -12,6 +12,7 @@ import { const EXPECTED_REPOSITORY = "ContextualWisdomLab/noema"; const EXPECTED_DEFAULT_BRANCH = "main"; const REPOSITORY_WORKFLOW_PREFIX = ".github/workflows/"; +const LOWERCASE_SHA_40 = /^[0-9a-f]{40}$/; const MAX_GH_OUTPUT_BYTES = 8 * 1024 * 1024; const MAX_GH_REQUEST_MILLISECONDS = 20_000; const MAX_PAGES = 1_000; @@ -77,30 +78,49 @@ export function workflowPageFromResponse(payload, page, perPage) { }; } -/** - * Extract exact repository workflow blobs from one complete recursive Git tree. - * A truncated tree cannot prove absence and therefore fails closed. - * @param {unknown} payload GitHub recursive tree response. - * @returns {string[]} Exact tracked workflow paths sorted for deterministic evidence. - */ -export function repositoryWorkflowPathsFromTree(payload) { +function repositoryWorkflowEntryMap(payload) { if (!payload || payload.truncated === true) { - throw new Error("Protected-main recursive Git tree is truncated; workflow absence is not provable."); + throw new Error("Recursive Git tree is truncated; workflow absence is not provable."); } if (!Array.isArray(payload.tree)) { - throw new Error("Protected-main recursive Git tree response is invalid."); + throw new Error("Recursive Git tree response is invalid."); } - const paths = []; + const entries = new Map(); for (const entry of payload.tree) { if ( - entry?.type === "blob" - && typeof entry.path === "string" - && entry.path.startsWith(REPOSITORY_WORKFLOW_PREFIX) + entry?.type !== "blob" + || typeof entry.path !== "string" + || !entry.path.startsWith(REPOSITORY_WORKFLOW_PREFIX) ) { - paths.push(entry.path); + continue; } + const identity = `${entry.type}\u0000${String(entry.mode ?? "")}\u0000${String(entry.sha ?? "")}`; + const prior = entries.get(entry.path); + if (prior !== undefined && prior !== identity) { + throw new Error(`Recursive Git tree contains conflicting workflow entries for ${entry.path}.`); + } + entries.set(entry.path, identity); } - return [...new Set(paths)].sort(); + return entries; +} + +/** + * Extract exact repository workflow blobs from one complete recursive Git tree. + * A truncated tree cannot prove absence and therefore fails closed. + * @param {unknown} payload GitHub recursive tree response. + * @returns {string[]} Exact tracked workflow paths sorted for deterministic evidence. + */ +export function repositoryWorkflowPathsFromTree(payload) { + return [...repositoryWorkflowEntryMap(payload).keys()].sort(); +} + +function changedWorkflowPathsBetweenTrees(basePayload, headPayload) { + const baseEntries = repositoryWorkflowEntryMap(basePayload); + const headEntries = repositoryWorkflowEntryMap(headPayload); + const paths = new Set([...baseEntries.keys(), ...headEntries.keys()]); + return [...paths] + .filter((path) => baseEntries.get(path) !== headEntries.get(path)) + .sort(); } function createGhJsonReader(delegatedGithubToken) { @@ -151,37 +171,95 @@ async function listArrayPages(ghJson, endpointForPage, label) { throw new Error(`${label} pagination exceeded ${MAX_PAGES} pages without a terminal short page.`); } -async function activePullRequestWorkflowPaths(repository, ghJson) { - const pulls = await listArrayPages( +function openPullRequestSnapshot(pulls) { + const identities = []; + const seenNumbers = new Set(); + for (const pull of pulls) { + if (!Number.isSafeInteger(pull?.number) || pull.number <= 0) { + throw new Error("Open pull-request inventory contains an invalid pull number."); + } + if (!LOWERCASE_SHA_40.test(pull?.head?.sha ?? "")) { + throw new Error("Open pull-request inventory contains an invalid head SHA."); + } + if (!LOWERCASE_SHA_40.test(pull?.base?.sha ?? "")) { + throw new Error("Open pull-request inventory contains an invalid base SHA."); + } + if (seenNumbers.has(pull.number)) { + throw new Error("Open pull-request inventory contains a duplicate pull number."); + } + seenNumbers.add(pull.number); + identities.push(`${pull.number}:${pull.head.sha}:${pull.base.sha}`); + } + return identities.sort(); +} + +async function listOpenPullRequests(repository, ghJson) { + return listArrayPages( ghJson, (page) => `repos/${repository}/pulls?state=open&per_page=${PER_PAGE}&page=${page}`, "Open pull requests", ); +} + +async function activePullRequestWorkflowPaths(repository, ghJson) { + const pulls = await listOpenPullRequests(repository, ghJson); + const initialSnapshot = openPullRequestSnapshot(pulls); const workflowPaths = new Set(); for (const pull of pulls) { - if (!Number.isSafeInteger(pull?.number) || pull.number <= 0) { - throw new Error("Open pull-request inventory contains an invalid pull number."); - } const files = await listArrayPages( ghJson, (page) => `repos/${repository}/pulls/${pull.number}/files?per_page=${PER_PAGE}&page=${page}`, `Pull request #${pull.number} files`, ); - for (const file of files) { - if ( - typeof file?.filename === "string" - && file.filename.startsWith(REPOSITORY_WORKFLOW_PREFIX) - ) { - workflowPaths.add(file.filename); - } + const detail = await ghJson(`repos/${repository}/pulls/${pull.number}`); + if ( + detail?.number !== pull.number + || detail?.head?.sha !== pull.head.sha + || detail?.base?.sha !== pull.base.sha + ) { + throw new Error(`Pull request #${pull.number} identity changed during file inventory.`); } + if (!Number.isSafeInteger(detail?.changed_files) || detail.changed_files < 0) { + throw new Error(`Pull request #${pull.number} advertised an invalid changed-file count.`); + } + if (files.length !== detail.changed_files) { + throw new Error( + `Pull request #${pull.number} file inventory retained ${files.length} of ${detail.changed_files} advertised changed files.`, + ); + } + + const comparison = await ghJson( + `repos/${repository}/compare/${pull.base.sha}...${pull.head.sha}`, + ); + const mergeBaseSha = comparison?.merge_base_commit?.sha; + if (!LOWERCASE_SHA_40.test(mergeBaseSha ?? "")) { + throw new Error(`Pull request #${pull.number} comparison is missing a valid merge-base SHA.`); + } + const exactMergeBaseTree = await ghJson( + `repos/${repository}/git/trees/${mergeBaseSha}?recursive=1`, + ); + const exactHeadTree = await ghJson( + `repos/${repository}/git/trees/${pull.head.sha}?recursive=1`, + ); + for (const workflowPath of changedWorkflowPathsBetweenTrees(exactMergeBaseTree, exactHeadTree)) { + workflowPaths.add(workflowPath); + } + } + + const finalSnapshot = openPullRequestSnapshot( + await listOpenPullRequests(repository, ghJson), + ); + if (JSON.stringify(finalSnapshot) !== JSON.stringify(initialSnapshot)) { + throw new Error("Open pull-request inventory changed during workflow-path collection."); } + return [...workflowPaths].sort(); } /** * Collect the live Actions registry against independently re-resolved protected - * main and the workflow paths owned by open pull requests. This function is + * main and one stable open-PR head/base snapshot. Active-PR workflow ownership + * is derived from each immutable merge-base→head tree delta. This function is * read-only; it produces orphan findings but never disables workflow identities. * @param {object} input Collector dependencies. * @returns {Promise} Exact-main-bound workflow-registry audit evidence. diff --git a/scripts/workflow-registry-live-disable.mjs b/scripts/workflow-registry-live-disable.mjs index 6448ae885..1cb36dbe5 100644 --- a/scripts/workflow-registry-live-disable.mjs +++ b/scripts/workflow-registry-live-disable.mjs @@ -219,9 +219,10 @@ export async function collectLiveWorkflowRecords(input) { /** * Execute one and only one requested active-orphan workflow disablement. - * The operator collects a full exact-main audit, immediately refreshes raw registry - * identities, builds process-local mutation authority, revalidates main plus workflow - * state around the mutation, and then performs a second full audit before returning a receipt. + * The operator refreshes the raw registry first, then collects the full exact-main + * audit so active-PR ownership is the freshest broad state before mutation authority + * is built. The executor then revalidates main plus workflow state around the + * mutation and performs a second full audit before returning a receipt. * * @param {object} input exact repository, workflow id, audit/live collectors, and transport * @returns {Promise} bounded postcondition receipt @@ -247,9 +248,9 @@ export async function runWorkflowRegistryDisablement(input) { throw new Error("workflow disablement operator is missing authorized transport"); } + const liveWorkflows = await input.collectLiveWorkflows(); const audit = await input.collectAudit(); const exactMain = audit?.default_branch_sha; - const liveWorkflows = await input.collectLiveWorkflows(); const plan = buildWorkflowDisablementPlan({ audit, expectedRepository: repository, diff --git a/test/workflow-registry-active-pr-path-ambiguity.test.ts b/test/workflow-registry-active-pr-path-ambiguity.test.ts new file mode 100644 index 000000000..dbb0f1eaf --- /dev/null +++ b/test/workflow-registry-active-pr-path-ambiguity.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { classifyWorkflowRegistry } from "../scripts/workflow-registry-audit.mjs"; + +const repository = "ContextualWisdomLab/noema"; +const defaultBranchSha = "1fbe857a5cf52b5af31e2db5e4676876289e3e23"; +const observedAt = "2026-08-18T00:00:00.000Z"; + +function classify(registryPath: string, activePullRequestPath: string) { + return classifyWorkflowRegistry({ + repository, + defaultBranchSha, + observedAt, + workflows: [ + { + id: 900, + name: "Bounded repair", + path: registryPath, + state: "active", + }, + ], + trackedWorkflowPaths: [], + activePullRequestWorkflowPaths: [activePullRequestPath], + pagination: { + totalCount: 1, + receipts: [{ page: 1, itemCount: 1, hasNext: false }], + }, + }); +} + +describe("workflow registry active-PR path ambiguity", () => { + it("fails closed instead of orphaning a registry path that differs only by case from an active PR path", () => { + const result = classify( + ".github/workflows/bounded-current-repair.yml", + ".github/workflows/Bounded-Current-Repair.yml", + ); + + expect(result.status).toBe("FAIL"); + expect(result.workflows[0]).toMatchObject({ + workflow_id: 900, + classification: "unresolved_registry_record", + }); + expect(result.failures).toContainEqual( + expect.objectContaining({ + code: "active_pr_workflow_path_case_mismatch", + workflow_id: 900, + }), + ); + expect(result.failures).not.toContainEqual( + expect.objectContaining({ code: "active_orphan_workflow" }), + ); + }); + + it("fails closed instead of orphaning a registry path that differs only by Unicode normalization from an active PR path", () => { + const result = classify( + ".github/workflows/résumé-repair.yml", + ".github/workflows/résumé-repair.yml", + ); + + expect(result.status).toBe("FAIL"); + expect(result.workflows[0]).toMatchObject({ + workflow_id: 900, + classification: "unresolved_registry_record", + }); + expect(result.failures).toContainEqual( + expect.objectContaining({ + code: "active_pr_workflow_path_normalization_mismatch", + workflow_id: 900, + }), + ); + expect(result.failures).not.toContainEqual( + expect.objectContaining({ code: "active_orphan_workflow" }), + ); + }); + + it("still identifies an active orphan when open-PR workflow paths are unrelated", () => { + const result = classify( + ".github/workflows/orphaned-repair.yml", + ".github/workflows/unrelated-current-repair.yml", + ); + + expect(result.status).toBe("FAIL"); + expect(result.workflows[0]).toMatchObject({ + workflow_id: 900, + classification: "active_orphan", + }); + expect(result.failures).toContainEqual( + expect.objectContaining({ + code: "active_orphan_workflow", + workflow_id: 900, + }), + ); + }); +}); diff --git a/test/workflow-registry-live-audit-pr-snapshot.test.ts b/test/workflow-registry-live-audit-pr-snapshot.test.ts new file mode 100644 index 000000000..4d6b41f43 --- /dev/null +++ b/test/workflow-registry-live-audit-pr-snapshot.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it } from "vitest"; +import { collectLiveWorkflowRegistryAudit } from "../scripts/workflow-registry-live-audit.mjs"; + +const repository = "ContextualWisdomLab/noema"; +const mainSha = "071d116fff8a856809a3553d57506e6e9703b8b4"; +const firstPullHead = "b".repeat(40); +const movedPullHead = "c".repeat(40); +const firstPullBase = "d".repeat(40); +const movedPullBase = "e".repeat(40); + +function commonResponse(endpoint: string) { + if (endpoint === `repos/${repository}/branches/main`) { + return { commit: { sha: mainSha } }; + } + if (endpoint === `repos/${repository}/git/trees/${mainSha}?recursive=1`) { + return { truncated: false, tree: [] }; + } + if (endpoint === `repos/${repository}/compare/${firstPullBase}...${firstPullHead}`) { + return { merge_base_commit: { sha: firstPullBase } }; + } + if ( + endpoint === `repos/${repository}/git/trees/${firstPullBase}?recursive=1` + || endpoint === `repos/${repository}/git/trees/${firstPullHead}?recursive=1` + ) { + return { truncated: false, tree: [] }; + } + if (endpoint === `repos/${repository}/actions/workflows?per_page=100&page=1`) { + return { + total_count: 1, + workflows: [{ + id: 11, + path: ".github/workflows/bounded-repair.yml", + state: "active", + }], + }; + } + if (endpoint === `repos/${repository}/pulls/99`) { + return { + number: 99, + head: { sha: firstPullHead }, + base: { sha: firstPullBase }, + changed_files: 1, + }; + } + if (endpoint === `repos/${repository}/pulls/99/files?per_page=100&page=1`) { + return [{ filename: ".github/workflows/bounded-repair.yml" }]; + } + return undefined; +} + +function expectMovingSnapshotFailure(result: Awaited>) { + expect(result.status).toBe("FAIL"); + expect(result.failures).toContainEqual( + expect.objectContaining({ code: "workflow_registry_collection_failed" }), + ); + expect(String(result.failures[0]?.detail ?? "")) + .toContain("Open pull-request inventory changed during workflow-path collection"); +} + +describe("live workflow-registry open-PR snapshot", () => { + it("fails closed when an open PR head moves while workflow ownership is collected", async () => { + let pullReads = 0; + const result = await collectLiveWorkflowRegistryAudit({ + repository, + defaultBranch: "main", + now: () => "2026-08-18T00:00:00.000Z", + ghJson: async (endpoint: string) => { + const common = commonResponse(endpoint); + if (common !== undefined) return common; + if (endpoint === `repos/${repository}/pulls?state=open&per_page=100&page=1`) { + pullReads += 1; + return [{ + number: 99, + head: { sha: pullReads === 1 ? firstPullHead : movedPullHead }, + base: { sha: firstPullBase }, + }]; + } + throw new Error(`unexpected endpoint ${endpoint}`); + }, + }); + + expect(pullReads).toBe(2); + expectMovingSnapshotFailure(result); + }); + + it("fails closed when an open PR base changes while workflow ownership is collected", async () => { + let pullReads = 0; + const result = await collectLiveWorkflowRegistryAudit({ + repository, + defaultBranch: "main", + now: () => "2026-08-18T00:00:00.000Z", + ghJson: async (endpoint: string) => { + const common = commonResponse(endpoint); + if (common !== undefined) return common; + if (endpoint === `repos/${repository}/pulls?state=open&per_page=100&page=1`) { + pullReads += 1; + return [{ + number: 99, + head: { sha: firstPullHead }, + base: { sha: pullReads === 1 ? firstPullBase : movedPullBase }, + }]; + } + throw new Error(`unexpected endpoint ${endpoint}`); + }, + }); + + expect(pullReads).toBe(2); + expectMovingSnapshotFailure(result); + }); + + it("fails closed when GitHub's PR-file listing retains fewer files than the PR advertises", async () => { + const result = await collectLiveWorkflowRegistryAudit({ + repository, + defaultBranch: "main", + now: () => "2026-08-18T00:00:00.000Z", + ghJson: async (endpoint: string) => { + if (endpoint === `repos/${repository}/pulls/99`) { + return { + number: 99, + head: { sha: firstPullHead }, + base: { sha: firstPullBase }, + changed_files: 2, + }; + } + const common = commonResponse(endpoint); + if (common !== undefined) return common; + if (endpoint === `repos/${repository}/pulls?state=open&per_page=100&page=1`) { + return [{ + number: 99, + head: { sha: firstPullHead }, + base: { sha: firstPullBase }, + }]; + } + throw new Error(`unexpected endpoint ${endpoint}`); + }, + }); + + expect(result.status).toBe("FAIL"); + expect(result.failures).toContainEqual( + expect.objectContaining({ code: "workflow_registry_collection_failed" }), + ); + expect(String(result.failures[0]?.detail ?? "")) + .toContain("Pull request #99 file inventory retained 1 of 2 advertised changed files"); + }); + + it("binds active-PR workflow ownership to the exact immutable head tree despite an ABA-shaped file listing", async () => { + const result = await collectLiveWorkflowRegistryAudit({ + repository, + defaultBranch: "main", + now: () => "2026-08-18T00:00:00.000Z", + ghJson: async (endpoint: string) => { + if (endpoint === `repos/${repository}/git/trees/${firstPullBase}?recursive=1`) { + return { truncated: false, tree: [] }; + } + if (endpoint === `repos/${repository}/git/trees/${firstPullHead}?recursive=1`) { + return { + truncated: false, + tree: [{ + path: ".github/workflows/bounded-repair.yml", + type: "blob", + mode: "100644", + sha: "1".repeat(40), + }], + }; + } + if (endpoint === `repos/${repository}/pulls/99/files?per_page=100&page=1`) { + return [{ filename: "README.md" }]; + } + const common = commonResponse(endpoint); + if (common !== undefined) return common; + if (endpoint === `repos/${repository}/pulls?state=open&per_page=100&page=1`) { + return [{ + number: 99, + head: { sha: firstPullHead }, + base: { sha: firstPullBase }, + }]; + } + throw new Error(`unexpected endpoint ${endpoint}`); + }, + }); + + expect(result.workflows[0]).toMatchObject({ + workflow_id: 11, + classification: "active_pr_owned", + }); + expect(result.failures).not.toContainEqual( + expect.objectContaining({ code: "active_orphan_workflow" }), + ); + }); + + it("does not let an unchanged workflow inherited from a stale PR base suppress a real orphan", async () => { + const unchangedWorkflow = { + path: ".github/workflows/bounded-repair.yml", + type: "blob", + mode: "100644", + sha: "1".repeat(40), + }; + const result = await collectLiveWorkflowRegistryAudit({ + repository, + defaultBranch: "main", + now: () => "2026-08-18T00:00:00.000Z", + ghJson: async (endpoint: string) => { + if ( + endpoint === `repos/${repository}/git/trees/${firstPullBase}?recursive=1` + || endpoint === `repos/${repository}/git/trees/${firstPullHead}?recursive=1` + ) { + return { truncated: false, tree: [unchangedWorkflow] }; + } + if (endpoint === `repos/${repository}/pulls/99/files?per_page=100&page=1`) { + return [{ filename: "README.md" }]; + } + const common = commonResponse(endpoint); + if (common !== undefined) return common; + if (endpoint === `repos/${repository}/pulls?state=open&per_page=100&page=1`) { + return [{ + number: 99, + head: { sha: firstPullHead }, + base: { sha: firstPullBase }, + }]; + } + throw new Error(`unexpected endpoint ${endpoint}`); + }, + }); + + expect(result.workflows[0]).toMatchObject({ + workflow_id: 11, + classification: "active_orphan", + }); + expect(result.failures).toContainEqual( + expect.objectContaining({ code: "active_orphan_workflow" }), + ); + }); + + it("does not treat a workflow removed only on the current base as active-PR-owned when the PR never changed it from the merge base", async () => { + const mergeBaseSha = "f".repeat(40); + const inheritedWorkflow = { + path: ".github/workflows/bounded-repair.yml", + type: "blob", + mode: "100644", + sha: "1".repeat(40), + }; + const result = await collectLiveWorkflowRegistryAudit({ + repository, + defaultBranch: "main", + now: () => "2026-08-18T00:00:00.000Z", + ghJson: async (endpoint: string) => { + if (endpoint === `repos/${repository}/compare/${firstPullBase}...${firstPullHead}`) { + return { merge_base_commit: { sha: mergeBaseSha } }; + } + if (endpoint === `repos/${repository}/git/trees/${firstPullBase}?recursive=1`) { + return { truncated: false, tree: [] }; + } + if ( + endpoint === `repos/${repository}/git/trees/${mergeBaseSha}?recursive=1` + || endpoint === `repos/${repository}/git/trees/${firstPullHead}?recursive=1` + ) { + return { truncated: false, tree: [inheritedWorkflow] }; + } + if (endpoint === `repos/${repository}/pulls/99/files?per_page=100&page=1`) { + return [{ filename: "README.md" }]; + } + const common = commonResponse(endpoint); + if (common !== undefined) return common; + if (endpoint === `repos/${repository}/pulls?state=open&per_page=100&page=1`) { + return [{ + number: 99, + head: { sha: firstPullHead }, + base: { sha: firstPullBase }, + }]; + } + throw new Error(`unexpected endpoint ${endpoint}`); + }, + }); + + expect(result.workflows[0]).toMatchObject({ + workflow_id: 11, + classification: "active_orphan", + }); + expect(result.failures).toContainEqual( + expect.objectContaining({ code: "active_orphan_workflow" }), + ); + }); +}); diff --git a/test/workflow-registry-live-audit.test.ts b/test/workflow-registry-live-audit.test.ts index ba18afddd..b0aacb698 100644 --- a/test/workflow-registry-live-audit.test.ts +++ b/test/workflow-registry-live-audit.test.ts @@ -39,6 +39,7 @@ describe("live workflow-registry collector", () => { it("collects registry pages, active-PR workflow ownership, and exact protected-main identity", async () => { const calls: string[] = []; let branchReads = 0; + const pullHeadSha = "b".repeat(40); const ghJson = vi.fn(async (endpoint: string) => { calls.push(endpoint); if (endpoint === "repos/ContextualWisdomLab/noema/branches/main") { @@ -54,6 +55,15 @@ describe("live workflow-registry collector", () => { ], }; } + if (endpoint === `repos/ContextualWisdomLab/noema/compare/${mainSha}...${pullHeadSha}`) { + return { merge_base_commit: { sha: mainSha } }; + } + if (endpoint === `repos/ContextualWisdomLab/noema/git/trees/${pullHeadSha}?recursive=1`) { + return { + truncated: false, + tree: [{ path: ".github/workflows/bounded-repair.yml", type: "blob" }], + }; + } if (endpoint === "repos/ContextualWisdomLab/noema/actions/workflows?per_page=100&page=1") { return { total_count: 2, @@ -64,7 +74,19 @@ describe("live workflow-registry collector", () => { }; } if (endpoint === "repos/ContextualWisdomLab/noema/pulls?state=open&per_page=100&page=1") { - return [{ number: 99 }]; + return [{ + number: 99, + head: { sha: pullHeadSha }, + base: { sha: mainSha }, + }]; + } + if (endpoint === "repos/ContextualWisdomLab/noema/pulls/99") { + return { + number: 99, + head: { sha: pullHeadSha }, + base: { sha: mainSha }, + changed_files: 1, + }; } if (endpoint === "repos/ContextualWisdomLab/noema/pulls/99/files?per_page=100&page=1") { return [{ filename: ".github/workflows/bounded-repair.yml" }]; @@ -91,6 +113,7 @@ describe("live workflow-registry collector", () => { classification: "active_pr_owned", })); expect(calls).toContain(`repos/ContextualWisdomLab/noema/git/trees/${mainSha}?recursive=1`); + expect(calls).toContain(`repos/ContextualWisdomLab/noema/git/trees/${pullHeadSha}?recursive=1`); }); it("fails closed when the protected branch moves during collection", async () => { diff --git a/test/workflow-registry-live-disable-pr-race.test.ts b/test/workflow-registry-live-disable-pr-race.test.ts new file mode 100644 index 000000000..b649c75be --- /dev/null +++ b/test/workflow-registry-live-disable-pr-race.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; +import { runWorkflowRegistryDisablement } from "../scripts/workflow-registry-live-disable.mjs"; + +const repository = "ContextualWisdomLab/noema"; +const mainSha = "a".repeat(40); +const workflowPath = ".github/workflows/obsolete-repair.yml"; + +function activeOrphanAudit() { + return { + schema_version: 1, + repository_full_name: repository, + default_branch_sha: mainSha, + observed_at: "2026-08-18T00:00:00.000Z", + pagination_receipts: [{ page: 1, itemCount: 1, hasNext: false }], + status: "FAIL", + failures: [{ + code: "active_orphan_workflow", + workflow_id: 101, + detail: "Active workflow is absent from protected main.", + }], + workflows: [{ + workflow_id: 101, + workflow_path: workflowPath, + workflow_state: "active", + classification: "active_orphan", + }], + }; +} + +function activePullRequestOwnedAudit() { + return { + schema_version: 1, + repository_full_name: repository, + default_branch_sha: mainSha, + observed_at: "2026-08-18T00:00:01.000Z", + pagination_receipts: [{ page: 1, itemCount: 1, hasNext: false }], + status: "PASS", + failures: [], + workflows: [{ + workflow_id: 101, + workflow_path: workflowPath, + workflow_state: "active", + classification: "active_pr_owned", + }], + }; +} + +describe("workflow-registry disablement active-PR race", () => { + it("collects the live registry before the authorizing audit so a newly opened PR blocks disablement", async () => { + let activePullRequestAdopted = false; + const collectLiveWorkflows = vi.fn(async () => { + activePullRequestAdopted = true; + return [{ id: 101, path: workflowPath, state: "active" }]; + }); + const collectAudit = vi.fn(async () => ( + activePullRequestAdopted ? activePullRequestOwnedAudit() : activeOrphanAudit() + )); + const disableWorkflow = vi.fn(); + + await expect(runWorkflowRegistryDisablement({ + repository, + workflowId: 101, + collectAudit, + collectLiveWorkflows, + transport: { + revalidateDefaultBranch: vi.fn().mockResolvedValue({ sha: mainSha }), + revalidateWorkflow: vi.fn().mockResolvedValue({ + id: 101, + path: workflowPath, + state: "active", + }), + disableWorkflow, + }, + })).rejects.toThrow("fresh workflow disablement plan is non-authorizing"); + + expect(collectLiveWorkflows).toHaveBeenCalledTimes(1); + expect(collectAudit).toHaveBeenCalledTimes(1); + expect(disableWorkflow).not.toHaveBeenCalled(); + }); +});