Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
735c37e
test(workflow-registry): fail closed on active-PR path ambiguity
seonghobae Aug 17, 2026
649332f
fix(workflow-registry): preserve active-PR path ambiguity
seonghobae Aug 17, 2026
74e9ea6
test(workflow-registry): preserve unrelated orphan classification
seonghobae Aug 17, 2026
e472e7f
test(workflow-registry): refuse active-PR adoption race
seonghobae Aug 17, 2026
4e98543
fix(workflow-registry): refresh PR ownership before mutation
seonghobae Aug 17, 2026
1ee9c95
test(workflow-registry): detect moving open-PR ownership snapshot
seonghobae Aug 17, 2026
8a0a7b8
fix(workflow-registry): bind open-PR ownership to stable heads
seonghobae Aug 17, 2026
f00e8b1
test(workflow-registry): bind live collector fixture to PR head
seonghobae Aug 17, 2026
7a753e1
test(workflow-registry): detect moving PR base snapshot
seonghobae Aug 17, 2026
d36a285
fix(workflow-registry): bind PR ownership to exact base SHA
seonghobae Aug 17, 2026
3eae180
test(workflow-registry): bind live fixture to exact PR base
seonghobae Aug 17, 2026
f377fe1
test(workflow-registry): detect truncated PR-file inventory
seonghobae Aug 17, 2026
9aa2017
test(workflow-registry): align live-audit fixture with PR detail check
seonghobae Aug 17, 2026
2d1f8f8
fix(workflow-registry): verify complete PR file inventory
seonghobae Aug 17, 2026
9e9950e
test(workflow-registry): bind active PR ownership to immutable head tree
seonghobae Aug 17, 2026
c4d35ba
test(workflow-registry): align collector fixture with exact PR head tree
seonghobae Aug 17, 2026
c30c3eb
fix(workflow-registry): bind active PR ownership to exact head trees
seonghobae Aug 17, 2026
ea92040
test(workflow-registry): reject stale inherited PR workflow ownership
seonghobae Aug 17, 2026
abd643a
test(workflow-registry): align snapshot fixtures with base-tree proof
seonghobae Aug 17, 2026
c9ceaed
fix(workflow-registry): scope PR ownership to immutable tree delta
seonghobae Aug 17, 2026
fcb007c
test(workflow-registry): reject current-base-only ownership
seonghobae Aug 18, 2026
c30142e
fix(workflow-registry): bind ownership to merge base
seonghobae Aug 18, 2026
796cc53
test(workflow-registry): provide immutable merge bases
seonghobae Aug 18, 2026
6946dec
test(workflow-registry): model merge-base comparison
seonghobae Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions scripts/workflow-registry-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down
134 changes: 106 additions & 28 deletions scripts/workflow-registry-live-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<object>} Exact-main-bound workflow-registry audit evidence.
Expand Down
9 changes: 5 additions & 4 deletions scripts/workflow-registry-live-disable.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<object>} bounded postcondition receipt
Expand All @@ -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,
Expand Down
93 changes: 93 additions & 0 deletions test/workflow-registry-active-pr-path-ambiguity.test.ts
Original file line number Diff line number Diff line change
@@ -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,
}),
);
});
});
Loading
Loading