From cf12d3e8884c792901932fea7b280c2b64be1870 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:16:11 +0900 Subject: [PATCH 01/30] test(naming): expose generic runner evidence fields --- ...-runner-assignment-semantic-naming.test.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 test/actions-runner-assignment-semantic-naming.test.ts diff --git a/test/actions-runner-assignment-semantic-naming.test.ts b/test/actions-runner-assignment-semantic-naming.test.ts new file mode 100644 index 000000000..d50f03e87 --- /dev/null +++ b/test/actions-runner-assignment-semantic-naming.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { collectRunnerAssignmentEvidence } from "../scripts/lib/actions-runner-assignment-source.mjs"; + +const expectedHeadSha = "0123456789abcdef0123456789abcdef01234567"; + +describe("runner-assignment semantic evidence naming", () => { + it("translates vendor run and job fields into bounded-context names", async () => { + const evidence = await collectRunnerAssignmentEvidence({ + expected_head_sha: expectedHeadSha, + observed_at: "2026-09-02T00:00:00.000Z", + queue_grace_milliseconds: 300_000, + run_ids: [101], + fetch_run: async () => ({ + id: 101, + name: "reviewer-ci", + event: "pull_request", + head_sha: expectedHeadSha, + run_attempt: 2, + status: "completed", + conclusion: "success", + created_at: "2026-09-01T23:50:00.000Z", + }), + fetch_job_pages: async () => [{ + jobs: [{ + id: 202, + name: "verify", + run_attempt: 2, + status: "completed", + conclusion: "success", + started_at: "2026-09-01T23:51:00.000Z", + completed_at: "2026-09-01T23:52:00.000Z", + runner_id: 44, + runner_name: "GitHub Actions 44", + }], + }], + }); + + expect(evidence).toEqual(expect.objectContaining({ + workflow_runs: [expect.objectContaining({ + workflow_run_id: 101, + workflow_name: "reviewer-ci", + trigger_event: "pull_request", + workflow_run_status: "completed", + workflow_conclusion: "success", + workflow_jobs: [expect.objectContaining({ + workflow_job_id: 202, + workflow_job_name: "verify", + workflow_job_status: "completed", + workflow_job_conclusion: "success", + runner_id: 44, + runner_name: "GitHub Actions 44", + })], + })], + })); + + expect(evidence).not.toHaveProperty("runs"); + const workflowRun = evidence.workflow_runs[0]; + expect(workflowRun).not.toHaveProperty("id"); + expect(workflowRun).not.toHaveProperty("name"); + expect(workflowRun).not.toHaveProperty("event"); + expect(workflowRun).not.toHaveProperty("status"); + expect(workflowRun).not.toHaveProperty("conclusion"); + expect(workflowRun).not.toHaveProperty("jobs"); + const workflowJob = workflowRun.workflow_jobs[0]; + expect(workflowJob).not.toHaveProperty("id"); + expect(workflowJob).not.toHaveProperty("name"); + expect(workflowJob).not.toHaveProperty("status"); + expect(workflowJob).not.toHaveProperty("conclusion"); + }); +}); From 70fc253723845ff581a1d9fa5480659c4b60e497 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:17:32 +0900 Subject: [PATCH 02/30] fix(naming): project runner evidence into semantic fields --- .../lib/actions-runner-assignment-source.mjs | 69 +++++++++++-------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/scripts/lib/actions-runner-assignment-source.mjs b/scripts/lib/actions-runner-assignment-source.mjs index d4c68ce14..927b8d290 100644 --- a/scripts/lib/actions-runner-assignment-source.mjs +++ b/scripts/lib/actions-runner-assignment-source.mjs @@ -48,8 +48,12 @@ export function parseSelectedRunIds(value) { /** * Flatten paginated GitHub Actions job pages without trusting page shape. * + * GitHub owns the REST payload's `jobs` field. This helper is the adapter seam + * that accepts that vendor shape before runner-assignment evidence is projected + * into ContextualWisdomLab-owned semantic names. + * * @param {unknown} pages Slurped `gh api --paginate` page objects. - * @returns {object[]} A bounded list containing every job from every page. + * @returns {object[]} A bounded list containing every GitHub workflow job payload. */ export function flattenJobPages(pages) { if (!Array.isArray(pages)) { @@ -74,13 +78,13 @@ function projectRun(run) { throw new Error("GitHub workflow-run evidence must be an object."); } return { - id: run.id, - name: run.name, - event: run.event, + workflow_run_id: run.id, + workflow_name: run.name, + trigger_event: run.event, head_sha: run.head_sha, run_attempt: run.run_attempt, - status: run.status, - conclusion: run.conclusion, + workflow_run_status: run.status, + workflow_conclusion: run.conclusion, created_at: run.created_at, }; } @@ -90,11 +94,11 @@ function projectJob(job) { throw new Error("GitHub workflow-job evidence must be an object."); } return { - id: job.id, - name: job.name, + workflow_job_id: job.id, + workflow_job_name: job.name, run_attempt: job.run_attempt, - status: job.status, - conclusion: job.conclusion, + workflow_job_status: job.status, + workflow_job_conclusion: job.conclusion, started_at: job.started_at, completed_at: job.completed_at, runner_id: job.runner_id, @@ -107,11 +111,12 @@ function projectJob(job) { * * Network transport is deliberately outside this function. Callers provide one * read adapter for a workflow run and one for its fully paginated job pages; - * only the bounded fields consumed by runner-assignment evaluation are retained. + * GitHub-owned generic REST fields are translated at this boundary into the + * semantic runner-assignment evidence vocabulary consumed by Noema. * Re-run attempts require an attempt-aware job adapter. The production adapter * binds each job read to the validated `run_attempt`, and every returned job must * itself attest the same attempt before it is retained. After job collection the - * run is fetched again and its id/head/attempt authority must still match the + * run is fetched again and its run/head/attempt authority must still match the * initial snapshot, preventing a concurrently started rerun from promoting * predecessor-attempt runner identity. JavaScript function arity is not used as * an authority signal because default/rest parameters make `.length` non-semantic. @@ -136,44 +141,52 @@ export async function collectRunnerAssignmentEvidence(input) { throw new Error("Read-only workflow-run and job-page adapters are required."); } - const runs = []; + const workflowRuns = []; let selectedJobCount = 0; for (const runId of input.run_ids) { - const run = projectRun(await input.fetch_run(runId)); - if (run.id !== runId) { + const workflowRun = projectRun(await input.fetch_run(runId)); + if (workflowRun.workflow_run_id !== runId) { throw new Error("Fetched workflow run id must equal the selected workflow run id."); } - if (!positiveSafeInteger(run.run_attempt)) { + if (!positiveSafeInteger(workflowRun.run_attempt)) { throw new Error("Workflow run_attempt must be a positive integer."); } - const initialRunAuthority = JSON.stringify([run.id, run.head_sha, run.run_attempt]); - const jobPages = await input.fetch_job_pages(runId, run.run_attempt); - const jobs = flattenJobPages(jobPages).map(projectJob); - for (const job of jobs) { - if (!positiveSafeInteger(job.run_attempt)) { + const initialRunAuthority = JSON.stringify([ + workflowRun.workflow_run_id, + workflowRun.head_sha, + workflowRun.run_attempt, + ]); + const jobPages = await input.fetch_job_pages(runId, workflowRun.run_attempt); + const workflowJobs = flattenJobPages(jobPages).map(projectJob); + for (const workflowJob of workflowJobs) { + if (!positiveSafeInteger(workflowJob.run_attempt)) { throw new Error("Workflow job run_attempt must be a positive integer."); } - if (job.run_attempt !== run.run_attempt) { + if (workflowJob.run_attempt !== workflowRun.run_attempt) { throw new Error("Workflow job run_attempt must equal the selected workflow run_attempt."); } } - const currentRun = projectRun(await input.fetch_run(runId)); - const currentRunAuthority = JSON.stringify([currentRun.id, currentRun.head_sha, currentRun.run_attempt]); + const currentWorkflowRun = projectRun(await input.fetch_run(runId)); + const currentRunAuthority = JSON.stringify([ + currentWorkflowRun.workflow_run_id, + currentWorkflowRun.head_sha, + currentWorkflowRun.run_attempt, + ]); if (currentRunAuthority !== initialRunAuthority) { throw new Error("Workflow run authority changed while collecting runner-assignment evidence."); } - if (selectedJobCount + jobs.length > MAX_SELECTED_JOBS) { + if (selectedJobCount + workflowJobs.length > MAX_SELECTED_JOBS) { throw new Error(`Workflow job evidence exceeds the ${MAX_SELECTED_JOBS}-job bound.`); } - selectedJobCount += jobs.length; - runs.push({ ...currentRun, jobs }); + selectedJobCount += workflowJobs.length; + workflowRuns.push({ ...currentWorkflowRun, workflow_jobs: workflowJobs }); } return { expected_head_sha: input.expected_head_sha, observed_at: input.observed_at, queue_grace_milliseconds: input.queue_grace_milliseconds, - runs, + workflow_runs: workflowRuns, }; } From 31c925422d01a9278578cc23e27fe7a8ad518262 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:19:02 +0900 Subject: [PATCH 03/30] fix(naming): use semantic runner audit contract --- .../lib/actions-runner-assignment-audit.mjs | 222 ++++++++++-------- 1 file changed, 123 insertions(+), 99 deletions(-) diff --git a/scripts/lib/actions-runner-assignment-audit.mjs b/scripts/lib/actions-runner-assignment-audit.mjs index d0cc8da9f..a0bea5bea 100644 --- a/scripts/lib/actions-runner-assignment-audit.mjs +++ b/scripts/lib/actions-runner-assignment-audit.mjs @@ -5,12 +5,17 @@ const canonicalUtcTimestampPattern = /^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5] const pendingJobStatuses = new Set(["queued", "requested", "waiting", "pending"]); const invisibleNamePattern = /[\p{Cc}\p{Cf}]/u; -function failure(code, detail, context = {}) { - return { code, detail, ...context }; +function assignmentFailure(failureCode, failureDetail, failureContext = {}) { + return { failure_code: failureCode, failure_detail: failureDetail, ...failureContext }; } -function check(code, pass, detail, context = {}) { - return { code, pass, detail, ...context }; +function assignmentCheck(checkCode, checkPassed, checkDetail, checkContext = {}) { + return { + check_code: checkCode, + check_passed: checkPassed, + check_detail: checkDetail, + ...checkContext, + }; } function parseTimestamp(value) { @@ -45,17 +50,17 @@ function boundedName(value) { return text.length === 0 ? "unknown" : text.slice(0, 300); } -function assignmentObserved(job) { - const runnerId = job?.runner_id; - const runnerName = visibleName(job?.runner_name); +function assignmentObserved(workflowJob) { + const runnerId = workflowJob?.runner_id; + const runnerName = visibleName(workflowJob?.runner_name); return positiveSafeInteger(runnerId) || runnerName.length > 0; } -function invalidEvidence(detail) { +function invalidEvidence(failureDetail) { return { - status: "FAIL", - checks: [], - failures: [failure("runner_evidence_invalid", detail)], + audit_status: "FAIL", + assignment_checks: [], + assignment_failures: [assignmentFailure("runner_evidence_invalid", failureDetail)], }; } @@ -67,21 +72,25 @@ function invalidEvidence(detail) { * separate evidence class. GitHub may populate `started_at` while a queued job * still has runner_id=0 and no runner_name, so timestamps are not assignment * authority. Every retained job must carry the same positive `run_attempt` as - * its parent run; predecessor-attempt runner identity cannot satisfy or alter - * current-attempt assignment evidence. Freshly queued jobs remain non-passing - * `PENDING`. A grace-window stall is emitted only when both the workflow run and - * the job remain queued with no runner identity observed anywhere in the selected - * current attempt; protection/dependency waits stay non-passing without being - * mislabeled as a runner-allocation failure. + * its parent workflow run; predecessor-attempt runner identity cannot satisfy or + * alter current-attempt assignment evidence. Freshly queued jobs remain + * non-passing `PENDING`. A grace-window stall is emitted only when both the + * workflow run and workflow job remain queued with no runner identity observed + * anywhere in the selected current attempt; protection/dependency waits stay + * non-passing without being mislabeled as a runner-allocation failure. + * + * The input is a ContextualWisdomLab-owned evidence contract. GitHub's generic + * REST names (`id`, `name`, `event`, `status`, `conclusion`, `jobs`) are accepted + * only by the source adapter and are translated before this evaluator is called. * - * @param {unknown} evidence Untrusted workflow-run and job evidence. - * @returns {{status: "PASS" | "PENDING" | "FAIL", checks: object[], failures: object[]}} + * @param {unknown} evidence Semantic workflow-run and workflow-job evidence. + * @returns {{audit_status: "PASS" | "PENDING" | "FAIL", assignment_checks: object[], assignment_failures: object[]}} * A deterministic assignment decision that never substitutes for a required * GitHub Check, review, merge, release, or deployment authority. */ export function evaluateRunnerAssignmentEvidence(evidence) { - if (!evidence || typeof evidence !== "object" || !Array.isArray(evidence.runs)) { - return invalidEvidence("Runner-assignment evidence must contain a runs array."); + if (!evidence || typeof evidence !== "object" || !Array.isArray(evidence.workflow_runs)) { + return invalidEvidence("Runner-assignment evidence must contain a workflow_runs array."); } const expectedHeadSha = evidence.expected_head_sha; @@ -106,12 +115,12 @@ export function evaluateRunnerAssignmentEvidence(evidence) { ); } - if (evidence.runs.length === 0) { + if (evidence.workflow_runs.length === 0) { return { - status: "FAIL", - checks: [], - failures: [ - failure( + audit_status: "FAIL", + assignment_checks: [], + assignment_failures: [ + assignmentFailure( "workflow_run_evidence_missing", "At least one current-head workflow run is required for runner-assignment evidence.", ), @@ -119,27 +128,34 @@ export function evaluateRunnerAssignmentEvidence(evidence) { }; } - const checks = []; - const failures = []; - let pending = false; - - for (const run of evidence.runs) { - if (!run || typeof run !== "object" || !positiveSafeInteger(run.id)) { - failures.push( - failure("workflow_run_invalid", "Each workflow run must include a positive integer id."), + const assignmentChecks = []; + const assignmentFailures = []; + let auditPending = false; + + for (const workflowRun of evidence.workflow_runs) { + if ( + !workflowRun + || typeof workflowRun !== "object" + || !positiveSafeInteger(workflowRun.workflow_run_id) + ) { + assignmentFailures.push( + assignmentFailure( + "workflow_run_invalid", + "Each workflow run must include a positive integer workflow_run_id.", + ), ); continue; } const runContext = { - run_id: run.id, - run_attempt: run.run_attempt, - workflow_name: boundedName(run.name), + workflow_run_id: workflowRun.workflow_run_id, + run_attempt: workflowRun.run_attempt, + workflow_name: boundedName(workflowRun.workflow_name), }; - if (!positiveSafeInteger(run.run_attempt)) { - failures.push( - failure( + if (!positiveSafeInteger(workflowRun.run_attempt)) { + assignmentFailures.push( + assignmentFailure( "workflow_run_attempt_invalid", "Each workflow run must include a positive integer run_attempt.", runContext, @@ -148,9 +164,9 @@ export function evaluateRunnerAssignmentEvidence(evidence) { continue; } - if (run.head_sha !== expectedHeadSha) { - failures.push( - failure( + if (workflowRun.head_sha !== expectedHeadSha) { + assignmentFailures.push( + assignmentFailure( "workflow_run_head_mismatch", "Workflow-run evidence is not bound to the expected pull-request source head.", runContext, @@ -159,9 +175,9 @@ export function evaluateRunnerAssignmentEvidence(evidence) { continue; } - if (run.event !== "pull_request") { - failures.push( - failure( + if (workflowRun.trigger_event !== "pull_request") { + assignmentFailures.push( + assignmentFailure( "workflow_run_event_invalid", "Runner-assignment evidence must come from a pull_request workflow run.", runContext, @@ -170,10 +186,10 @@ export function evaluateRunnerAssignmentEvidence(evidence) { continue; } - const createdAt = parseTimestamp(run.created_at); + const createdAt = parseTimestamp(workflowRun.created_at); if (createdAt === null || createdAt > observedAt) { - failures.push( - failure( + assignmentFailures.push( + assignmentFailure( "workflow_run_timestamp_invalid", "Workflow-run created_at must be a parseable timestamp no later than observed_at.", runContext, @@ -182,30 +198,34 @@ export function evaluateRunnerAssignmentEvidence(evidence) { continue; } - if (!Array.isArray(run.jobs) || run.jobs.length === 0) { - failures.push( - failure( + if (!Array.isArray(workflowRun.workflow_jobs) || workflowRun.workflow_jobs.length === 0) { + assignmentFailures.push( + assignmentFailure( "workflow_job_evidence_missing", - "Each selected workflow run must include at least one job record.", + "Each selected workflow run must include at least one workflow_jobs record.", runContext, ), ); continue; } - const runStatus = boundedName(run.status).toLowerCase(); - const runHasAssignment = run.jobs.some((job) => ( - positiveSafeInteger(job?.run_attempt) - && job.run_attempt === run.run_attempt - && assignmentObserved(job) + const runStatus = boundedName(workflowRun.workflow_run_status).toLowerCase(); + const runHasAssignment = workflowRun.workflow_jobs.some((workflowJob) => ( + positiveSafeInteger(workflowJob?.run_attempt) + && workflowJob.run_attempt === workflowRun.run_attempt + && assignmentObserved(workflowJob) )); - for (const job of run.jobs) { - if (!job || typeof job !== "object" || !positiveSafeInteger(job.id)) { - failures.push( - failure( + for (const workflowJob of workflowRun.workflow_jobs) { + if ( + !workflowJob + || typeof workflowJob !== "object" + || !positiveSafeInteger(workflowJob.workflow_job_id) + ) { + assignmentFailures.push( + assignmentFailure( "workflow_job_invalid", - "Each workflow job must include a positive integer id.", + "Each workflow job must include a positive integer workflow_job_id.", runContext, ), ); @@ -214,35 +234,35 @@ export function evaluateRunnerAssignmentEvidence(evidence) { const jobContext = { ...runContext, - job_id: job.id, - job_name: boundedName(job.name), + workflow_job_id: workflowJob.workflow_job_id, + workflow_job_name: boundedName(workflowJob.workflow_job_name), }; - if (!positiveSafeInteger(job.run_attempt)) { - failures.push( - failure( + if (!positiveSafeInteger(workflowJob.run_attempt)) { + assignmentFailures.push( + assignmentFailure( "workflow_job_attempt_invalid", "Each workflow job must include a positive integer run_attempt.", - { ...jobContext, job_run_attempt: job.run_attempt }, + { ...jobContext, job_run_attempt: workflowJob.run_attempt }, ), ); continue; } - if (job.run_attempt !== run.run_attempt) { - failures.push( - failure( + if (workflowJob.run_attempt !== workflowRun.run_attempt) { + assignmentFailures.push( + assignmentFailure( "workflow_job_attempt_mismatch", "Workflow-job evidence does not belong to the selected workflow-run attempt.", - { ...jobContext, job_run_attempt: job.run_attempt }, + { ...jobContext, job_run_attempt: workflowJob.run_attempt }, ), ); continue; } - if (assignmentObserved(job)) { - checks.push( - check( + if (assignmentObserved(workflowJob)) { + assignmentChecks.push( + assignmentCheck( "runner_assignment_observed", true, "GitHub job evidence contains a positive runner id or non-empty visible runner name; the later job conclusion remains separate.", @@ -252,13 +272,13 @@ export function evaluateRunnerAssignmentEvidence(evidence) { continue; } - const jobStatus = boundedName(job.status).toLowerCase(); + const jobStatus = boundedName(workflowJob.workflow_job_status).toLowerCase(); if (!pendingJobStatuses.has(jobStatus)) { - failures.push( - failure( + assignmentFailures.push( + assignmentFailure( "runner_assignment_not_observed", - "The job reached a non-queue state without trustworthy runner identity evidence.", - { ...jobContext, job_status: jobStatus }, + "The workflow job reached a non-queue state without trustworthy runner identity evidence.", + { ...jobContext, workflow_job_status: jobStatus }, ), ); continue; @@ -269,13 +289,17 @@ export function evaluateRunnerAssignmentEvidence(evidence) { && !runHasAssignment; if (!runnerQueueIsIsolated) { - pending = true; - checks.push( - check( + auditPending = true; + assignmentChecks.push( + assignmentCheck( "runner_assignment_pending", false, - "The job is non-passing, but current evidence does not isolate runner allocation from dependency or protection-rule waiting.", - { ...jobContext, job_status: jobStatus, run_status: runStatus }, + "The workflow job is non-passing, but current evidence does not isolate runner allocation from dependency or protection-rule waiting.", + { + ...jobContext, + workflow_job_status: jobStatus, + workflow_run_status: runStatus, + }, ), ); continue; @@ -283,15 +307,15 @@ export function evaluateRunnerAssignmentEvidence(evidence) { const queuedMilliseconds = observedAt - createdAt; if (queuedMilliseconds > queueGrace) { - failures.push( - failure( + assignmentFailures.push( + assignmentFailure( "runner_assignment_stalled", - "The current-head run and job remained queued without runner-assignment evidence beyond the bounded grace window.", + "The current-head workflow run and job remained queued without runner-assignment evidence beyond the bounded grace window.", { ...jobContext, queued_milliseconds: queuedMilliseconds }, ), ); - checks.push( - check( + assignmentChecks.push( + assignmentCheck( "runner_assignment_stalled", false, "Runner assignment was not observed before the bounded queue grace elapsed after the queue boundary was isolated.", @@ -301,12 +325,12 @@ export function evaluateRunnerAssignmentEvidence(evidence) { continue; } - pending = true; - checks.push( - check( + auditPending = true; + assignmentChecks.push( + assignmentCheck( "runner_assignment_pending", false, - "The current-head run and job remain queued inside the bounded runner-assignment grace and are not passing evidence.", + "The current-head workflow run and job remain queued inside the bounded runner-assignment grace and are not passing evidence.", { ...jobContext, queued_milliseconds: queuedMilliseconds }, ), ); @@ -314,8 +338,8 @@ export function evaluateRunnerAssignmentEvidence(evidence) { } return { - status: failures.length > 0 ? "FAIL" : pending ? "PENDING" : "PASS", - checks, - failures, + audit_status: assignmentFailures.length > 0 ? "FAIL" : auditPending ? "PENDING" : "PASS", + assignment_checks: assignmentChecks, + assignment_failures: assignmentFailures, }; -} \ No newline at end of file +} From f2fb1a43a5972d0a3af82dddfc0d94e336de3036 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:24:26 +0900 Subject: [PATCH 04/30] test(naming): align runner source expectations --- test/actions-runner-assignment-source.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/actions-runner-assignment-source.test.ts b/test/actions-runner-assignment-source.test.ts index 46d6ccd12..6f1e17a30 100644 --- a/test/actions-runner-assignment-source.test.ts +++ b/test/actions-runner-assignment-source.test.ts @@ -108,9 +108,9 @@ describe("GitHub Actions runner-assignment evidence source", () => { expected_head_sha: expectedHead, observed_at: "2026-08-10T00:00:00.000Z", queue_grace_milliseconds: 300_000, - runs: [ - expect.objectContaining({ id: 101, head_sha: expectedHead, run_attempt: 1, jobs: [expect.objectContaining({ id: 1010, run_attempt: 1, runner_id: 44 })] }), - expect.objectContaining({ id: 202, head_sha: expectedHead, run_attempt: 1, jobs: [expect.objectContaining({ id: 2020, run_attempt: 1, runner_id: 44 })] }), + workflow_runs: [ + expect.objectContaining({ workflow_run_id: 101, head_sha: expectedHead, run_attempt: 1, workflow_jobs: [expect.objectContaining({ workflow_job_id: 1010, run_attempt: 1, runner_id: 44 })] }), + expect.objectContaining({ workflow_run_id: 202, head_sha: expectedHead, run_attempt: 1, workflow_jobs: [expect.objectContaining({ workflow_job_id: 2020, run_attempt: 1, runner_id: 44 })] }), ], }); expect(fetchRun).toHaveBeenCalledTimes(4); @@ -153,10 +153,10 @@ describe("GitHub Actions runner-assignment evidence source", () => { fetch_run: fetchRun, fetch_job_pages: fetchJobPages, })).resolves.toEqual(expect.objectContaining({ - runs: [expect.objectContaining({ - id: 101, + workflow_runs: [expect.objectContaining({ + workflow_run_id: 101, run_attempt: 2, - jobs: [expect.objectContaining({ id: 2020, run_attempt: 2, runner_id: 0 })], + workflow_jobs: [expect.objectContaining({ workflow_job_id: 2020, run_attempt: 2, runner_id: 0 })], })], })); expect(fetchJobPages).toHaveBeenCalledWith(101, 2); @@ -211,7 +211,7 @@ describe("GitHub Actions runner-assignment evidence source", () => { })), fetch_job_pages: fetchJobPages, })).resolves.toEqual(expect.objectContaining({ - runs: [expect.objectContaining({ id: 101, run_attempt: 2 })], + workflow_runs: [expect.objectContaining({ workflow_run_id: 101, run_attempt: 2 })], })); expect(fetchJobPages).toHaveBeenCalledWith(101, 2); }); From 118ce962ae897f24d81c3b693e7098694d1dc159 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:25:23 +0900 Subject: [PATCH 05/30] test(naming): use semantic runner audit evidence --- test/actions-runner-assignment-audit.test.ts | 186 ++++++++++--------- 1 file changed, 99 insertions(+), 87 deletions(-) diff --git a/test/actions-runner-assignment-audit.test.ts b/test/actions-runner-assignment-audit.test.ts index 3121166d6..1444e7cb1 100644 --- a/test/actions-runner-assignment-audit.test.ts +++ b/test/actions-runner-assignment-audit.test.ts @@ -9,21 +9,21 @@ const observedAt = "2026-08-10T00:00:00.000Z"; function workflowRun(overrides: Record = {}) { return { - id: 101, + workflow_run_id: 101, run_attempt: 1, - name: "ci", - event: "pull_request", + workflow_name: "ci", + trigger_event: "pull_request", head_sha: expectedHead, - status: "queued", - conclusion: null, + workflow_run_status: "queued", + workflow_conclusion: null, created_at: "2026-08-09T23:50:00.000Z", - jobs: [ + workflow_jobs: [ { - id: 201, - name: "verify", + workflow_job_id: 201, + workflow_job_name: "verify", run_attempt: 1, - status: "queued", - conclusion: null, + workflow_job_status: "queued", + workflow_job_conclusion: null, started_at: null, completed_at: null, runner_id: null, @@ -34,34 +34,36 @@ function workflowRun(overrides: Record = {}) { }; } -function evaluate(runs: unknown[]) { +function evaluate(workflowRuns: unknown[]) { return evaluateRunnerAssignmentEvidence({ expected_head_sha: expectedHead, observed_at: observedAt, queue_grace_milliseconds: DEFAULT_RUNNER_QUEUE_GRACE_MILLISECONDS, - runs, + workflow_runs: workflowRuns, }); } function failureCodes(result: ReturnType) { - return result.failures.map((failure: { code?: string }) => failure.code); + return result.assignment_failures.map( + (assignmentFailure: { failure_code?: string }) => assignmentFailure.failure_code, + ); } describe("GitHub Actions runner-assignment evidence", () => { it("fails closed when a current-head job remains unassigned beyond the grace window", () => { const result = evaluate([workflowRun()]); - expect(result.status).toBe("FAIL"); + expect(result.audit_status).toBe("FAIL"); expect(failureCodes(result)).toContain("runner_assignment_stalled"); }); it("does not mistake GitHub's queued started_at timestamp for runner assignment", () => { const result = evaluate([workflowRun({ - jobs: [{ - id: 201, - name: "verify", + workflow_jobs: [{ + workflow_job_id: 201, + workflow_job_name: "verify", run_attempt: 1, - status: "queued", - conclusion: null, + workflow_job_status: "queued", + workflow_job_conclusion: null, started_at: "2026-08-09T23:50:00.000Z", completed_at: null, runner_id: 0, @@ -69,23 +71,23 @@ describe("GitHub Actions runner-assignment evidence", () => { }], })]); - expect(result.status).toBe("FAIL"); + expect(result.audit_status).toBe("FAIL"); expect(failureCodes(result)).toContain("runner_assignment_stalled"); - expect(result.checks).not.toContainEqual( - expect.objectContaining({ code: "runner_assignment_observed", job_id: 201 }), + expect(result.assignment_checks).not.toContainEqual( + expect.objectContaining({ check_code: "runner_assignment_observed", workflow_job_id: 201 }), ); }); it("does not treat control-only runner names as assignment evidence", () => { const result = evaluate([workflowRun({ - status: "completed", - conclusion: "failure", - jobs: [{ - id: 201, - name: "verify", + workflow_run_status: "completed", + workflow_conclusion: "failure", + workflow_jobs: [{ + workflow_job_id: 201, + workflow_job_name: "verify", run_attempt: 1, - status: "completed", - conclusion: "failure", + workflow_job_status: "completed", + workflow_job_conclusion: "failure", started_at: "2026-08-09T23:52:00.000Z", completed_at: "2026-08-09T23:53:00.000Z", runner_id: 0, @@ -93,23 +95,23 @@ describe("GitHub Actions runner-assignment evidence", () => { }], })]); - expect(result.status).toBe("FAIL"); + expect(result.audit_status).toBe("FAIL"); expect(failureCodes(result)).toContain("runner_assignment_not_observed"); - expect(result.checks).not.toContainEqual( - expect.objectContaining({ code: "runner_assignment_observed", job_id: 201 }), + expect(result.assignment_checks).not.toContainEqual( + expect.objectContaining({ check_code: "runner_assignment_observed", workflow_job_id: 201 }), ); }); it("does not treat Unicode format-only runner names as assignment evidence", () => { const result = evaluate([workflowRun({ - status: "completed", - conclusion: "failure", - jobs: [{ - id: 201, - name: "verify", + workflow_run_status: "completed", + workflow_conclusion: "failure", + workflow_jobs: [{ + workflow_job_id: 201, + workflow_job_name: "verify", run_attempt: 1, - status: "completed", - conclusion: "failure", + workflow_job_status: "completed", + workflow_job_conclusion: "failure", started_at: "2026-08-09T23:52:00.000Z", completed_at: "2026-08-09T23:53:00.000Z", runner_id: 0, @@ -117,23 +119,23 @@ describe("GitHub Actions runner-assignment evidence", () => { }], })]); - expect(result.status).toBe("FAIL"); + expect(result.audit_status).toBe("FAIL"); expect(failureCodes(result)).toContain("runner_assignment_not_observed"); - expect(result.checks).not.toContainEqual( - expect.objectContaining({ code: "runner_assignment_observed", job_id: 201 }), + expect(result.assignment_checks).not.toContainEqual( + expect.objectContaining({ check_code: "runner_assignment_observed", workflow_job_id: 201 }), ); }); it("does not normalize embedded control or format characters into runner assignment authority", () => { const result = evaluate([workflowRun({ - status: "completed", - conclusion: "failure", - jobs: [{ - id: 201, - name: "verify", + workflow_run_status: "completed", + workflow_conclusion: "failure", + workflow_jobs: [{ + workflow_job_id: 201, + workflow_job_name: "verify", run_attempt: 1, - status: "completed", - conclusion: "failure", + workflow_job_status: "completed", + workflow_job_conclusion: "failure", started_at: "2026-08-09T23:52:00.000Z", completed_at: "2026-08-09T23:53:00.000Z", runner_id: 0, @@ -141,60 +143,64 @@ describe("GitHub Actions runner-assignment evidence", () => { }], })]); - expect(result.status).toBe("FAIL"); + expect(result.audit_status).toBe("FAIL"); expect(failureCodes(result)).toContain("runner_assignment_not_observed"); - expect(result.checks).not.toContainEqual( - expect.objectContaining({ code: "runner_assignment_observed", job_id: 201 }), + expect(result.assignment_checks).not.toContainEqual( + expect.objectContaining({ check_code: "runner_assignment_observed", workflow_job_id: 201 }), ); }); it("keeps a recently queued unassigned job pending rather than calling it healthy", () => { const result = evaluate([workflowRun({ created_at: "2026-08-09T23:58:00.000Z" })]); - expect(result.status).toBe("PENDING"); - expect(result.checks).toContainEqual(expect.objectContaining({ code: "runner_assignment_pending", pass: false })); + expect(result.audit_status).toBe("PENDING"); + expect(result.assignment_checks).toContainEqual( + expect.objectContaining({ check_code: "runner_assignment_pending", check_passed: false }), + ); }); it("does not call an environment-protected waiting job a runner-assignment stall", () => { const result = evaluate([workflowRun({ - status: "waiting", - jobs: [{ - id: 201, - name: "deploy", + workflow_run_status: "waiting", + workflow_jobs: [{ + workflow_job_id: 201, + workflow_job_name: "deploy", run_attempt: 1, - status: "waiting", - conclusion: null, + workflow_job_status: "waiting", + workflow_job_conclusion: null, started_at: null, completed_at: null, runner_id: null, runner_name: null, }], })]); - expect(result.status).toBe("PENDING"); + expect(result.audit_status).toBe("PENDING"); expect(failureCodes(result)).not.toContain("runner_assignment_stalled"); - expect(result.checks).toContainEqual(expect.objectContaining({ code: "runner_assignment_pending", pass: false })); + expect(result.assignment_checks).toContainEqual( + expect.objectContaining({ check_code: "runner_assignment_pending", check_passed: false }), + ); }); it("does not age a downstream queued job from workflow creation after another job has started", () => { const result = evaluate([workflowRun({ - status: "in_progress", - jobs: [ + workflow_run_status: "in_progress", + workflow_jobs: [ { - id: 201, - name: "build", + workflow_job_id: 201, + workflow_job_name: "build", run_attempt: 1, - status: "in_progress", - conclusion: null, + workflow_job_status: "in_progress", + workflow_job_conclusion: null, started_at: "2026-08-09T23:51:00.000Z", completed_at: null, runner_id: 77, runner_name: "GitHub Actions 77", }, { - id: 202, - name: "package", + workflow_job_id: 202, + workflow_job_name: "package", run_attempt: 1, - status: "queued", - conclusion: null, + workflow_job_status: "queued", + workflow_job_conclusion: null, started_at: null, completed_at: null, runner_id: null, @@ -202,42 +208,48 @@ describe("GitHub Actions runner-assignment evidence", () => { }, ], })]); - expect(result.status).toBe("PENDING"); + expect(result.audit_status).toBe("PENDING"); expect(failureCodes(result)).not.toContain("runner_assignment_stalled"); - expect(result.checks).toContainEqual(expect.objectContaining({ code: "runner_assignment_observed", pass: true, job_id: 201 })); - expect(result.checks).toContainEqual(expect.objectContaining({ code: "runner_assignment_pending", pass: false, job_id: 202 })); + expect(result.assignment_checks).toContainEqual( + expect.objectContaining({ check_code: "runner_assignment_observed", check_passed: true, workflow_job_id: 201 }), + ); + expect(result.assignment_checks).toContainEqual( + expect.objectContaining({ check_code: "runner_assignment_pending", check_passed: false, workflow_job_id: 202 }), + ); }); it("proves runner assignment independently from the later job conclusion", () => { const result = evaluate([workflowRun({ - status: "completed", - conclusion: "failure", - jobs: [{ - id: 201, - name: "verify", + workflow_run_status: "completed", + workflow_conclusion: "failure", + workflow_jobs: [{ + workflow_job_id: 201, + workflow_job_name: "verify", run_attempt: 1, - status: "completed", - conclusion: "failure", + workflow_job_status: "completed", + workflow_job_conclusion: "failure", started_at: "2026-08-09T23:52:00.000Z", completed_at: "2026-08-09T23:53:00.000Z", runner_id: 77, runner_name: "GitHub Actions 77", }], })]); - expect(result.status).toBe("PASS"); - expect(result.failures).toEqual([]); - expect(result.checks).toContainEqual(expect.objectContaining({ code: "runner_assignment_observed", pass: true })); + expect(result.audit_status).toBe("PASS"); + expect(result.assignment_failures).toEqual([]); + expect(result.assignment_checks).toContainEqual( + expect.objectContaining({ check_code: "runner_assignment_observed", check_passed: true }), + ); }); it("rejects workflow evidence from a different source head", () => { const result = evaluate([workflowRun({ head_sha: "fedcba9876543210fedcba9876543210fedcba98" })]); - expect(result.status).toBe("FAIL"); + expect(result.audit_status).toBe("FAIL"); expect(failureCodes(result)).toContain("workflow_run_head_mismatch"); }); it("fails closed when no workflow-run evidence is supplied", () => { const result = evaluate([]); - expect(result.status).toBe("FAIL"); + expect(result.audit_status).toBe("FAIL"); expect(failureCodes(result)).toContain("workflow_run_evidence_missing"); }); }); \ No newline at end of file From 7aa7f386756fecd7e6a8eabcd601861ef2d23e14 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:25:40 +0900 Subject: [PATCH 06/30] test(naming): update attempt report vocabulary --- ...s-runner-assignment-attempt-report.test.ts | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/test/actions-runner-assignment-attempt-report.test.ts b/test/actions-runner-assignment-attempt-report.test.ts index 8474d9727..0f2a4944d 100644 --- a/test/actions-runner-assignment-attempt-report.test.ts +++ b/test/actions-runner-assignment-attempt-report.test.ts @@ -9,21 +9,21 @@ describe("runner-assignment attempt identity retention", () => { expected_head_sha: expectedHead, observed_at: "2026-08-24T02:30:00.000Z", queue_grace_milliseconds: 300_000, - runs: [{ - id: 100, - name: "ci", - event: "pull_request", + workflow_runs: [{ + workflow_run_id: 100, + workflow_name: "ci", + trigger_event: "pull_request", head_sha: expectedHead, run_attempt: 2, - status: "completed", - conclusion: "failure", + workflow_run_status: "completed", + workflow_conclusion: "failure", created_at: "2026-08-24T02:29:00.000Z", - jobs: [{ - id: 1001, - name: "verify", + workflow_jobs: [{ + workflow_job_id: 1001, + workflow_job_name: "verify", run_attempt: 2, - status: "completed", - conclusion: "failure", + workflow_job_status: "completed", + workflow_job_conclusion: "failure", started_at: "2026-08-24T02:29:10.000Z", completed_at: "2026-08-24T02:29:30.000Z", runner_id: 77, @@ -33,13 +33,13 @@ describe("runner-assignment attempt identity retention", () => { }); expect(decision).toMatchObject({ - status: "PASS", - checks: [{ - code: "runner_assignment_observed", - pass: true, - run_id: 100, + audit_status: "PASS", + assignment_checks: [{ + check_code: "runner_assignment_observed", + check_passed: true, + workflow_run_id: 100, run_attempt: 2, - job_id: 1001, + workflow_job_id: 1001, }], }); }); From 2bb7477d9686efd1bf097f90f8c7524af51ac2e1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:25:54 +0900 Subject: [PATCH 07/30] test(naming): update timestamp audit vocabulary --- ...ner-assignment-timestamp-integrity.test.ts | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/test/actions-runner-assignment-timestamp-integrity.test.ts b/test/actions-runner-assignment-timestamp-integrity.test.ts index ebd92b1a2..e59d0a5f2 100644 --- a/test/actions-runner-assignment-timestamp-integrity.test.ts +++ b/test/actions-runner-assignment-timestamp-integrity.test.ts @@ -5,21 +5,21 @@ const expectedHead = "0123456789abcdef0123456789abcdef01234567"; function assignedRun(createdAt: string) { return { - id: 101, + workflow_run_id: 101, run_attempt: 1, - name: "ci", - event: "pull_request", + workflow_name: "ci", + trigger_event: "pull_request", head_sha: expectedHead, - status: "completed", - conclusion: "success", + workflow_run_status: "completed", + workflow_conclusion: "success", created_at: createdAt, - jobs: [ + workflow_jobs: [ { - id: 201, - name: "verify", + workflow_job_id: 201, + workflow_job_name: "verify", run_attempt: 1, - status: "completed", - conclusion: "success", + workflow_job_status: "completed", + workflow_job_conclusion: "success", started_at: "2026-03-01T00:00:01Z", completed_at: "2026-03-01T00:00:02Z", runner_id: 77, @@ -34,7 +34,7 @@ function evaluate(observedAt: string, createdAt: string) { expected_head_sha: expectedHead, observed_at: observedAt, queue_grace_milliseconds: 300_000, - runs: [assignedRun(createdAt)], + workflow_runs: [assignedRun(createdAt)], }); } @@ -45,9 +45,9 @@ describe("runner-assignment timestamp integrity", () => { "2026-03-01T00:00:00Z", ); - expect(result.status).toBe("FAIL"); - expect(result.failures).toContainEqual( - expect.objectContaining({ code: "runner_evidence_invalid" }), + expect(result.audit_status).toBe("FAIL"); + expect(result.assignment_failures).toContainEqual( + expect.objectContaining({ failure_code: "runner_evidence_invalid" }), ); }); @@ -57,9 +57,9 @@ describe("runner-assignment timestamp integrity", () => { "2026-03-01T00:00:00Z", ); - expect(result.status).toBe("FAIL"); - expect(result.failures).toContainEqual( - expect.objectContaining({ code: "runner_evidence_invalid" }), + expect(result.audit_status).toBe("FAIL"); + expect(result.assignment_failures).toContainEqual( + expect.objectContaining({ failure_code: "runner_evidence_invalid" }), ); }); @@ -69,9 +69,9 @@ describe("runner-assignment timestamp integrity", () => { "2026-02-30T00:00:00Z", ); - expect(result.status).toBe("FAIL"); - expect(result.failures).toContainEqual( - expect.objectContaining({ code: "workflow_run_timestamp_invalid" }), + expect(result.audit_status).toBe("FAIL"); + expect(result.assignment_failures).toContainEqual( + expect.objectContaining({ failure_code: "workflow_run_timestamp_invalid" }), ); }); }); From 3cc86f1700810e7b6e15c6b21ecb4a767fdec8bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:26:22 +0900 Subject: [PATCH 08/30] test(naming): update evaluator attempt vocabulary --- ...gnment-evaluator-attempt-integrity.test.ts | 82 +++++++++---------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/test/actions-runner-assignment-evaluator-attempt-integrity.test.ts b/test/actions-runner-assignment-evaluator-attempt-integrity.test.ts index f69cd92b4..cf4c40ca4 100644 --- a/test/actions-runner-assignment-evaluator-attempt-integrity.test.ts +++ b/test/actions-runner-assignment-evaluator-attempt-integrity.test.ts @@ -9,21 +9,21 @@ function assignedEvidence(runAttempt: unknown, ...jobAttempts: unknown[]) { expected_head_sha: expectedHead, observed_at: "2026-08-10T00:00:00.000Z", queue_grace_milliseconds: 300_000, - runs: [{ - id: 101, - name: "ci", - event: "pull_request", + workflow_runs: [{ + workflow_run_id: 101, + workflow_name: "ci", + trigger_event: "pull_request", head_sha: expectedHead, run_attempt: runAttempt, - status: "completed", - conclusion: "success", + workflow_run_status: "completed", + workflow_conclusion: "success", created_at: "2026-08-09T23:50:00.000Z", - jobs: [{ - id: 1001, - name: "verify", + workflow_jobs: [{ + workflow_job_id: 1001, + workflow_job_name: "verify", run_attempt: jobAttempt, - status: "completed", - conclusion: "success", + workflow_job_status: "completed", + workflow_job_conclusion: "success", started_at: "2026-08-09T23:52:00.000Z", completed_at: "2026-08-09T23:53:00.000Z", runner_id: 77, @@ -39,9 +39,9 @@ describe("runner-assignment evaluator attempt identity", () => { (runAttempt) => { const result = evaluateRunnerAssignmentEvidence(assignedEvidence(runAttempt)); - expect(result.status).toBe("FAIL"); - expect(result.failures).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: "workflow_run_attempt_invalid" }), + expect(result.audit_status).toBe("FAIL"); + expect(result.assignment_failures).toEqual(expect.arrayContaining([ + expect.objectContaining({ failure_code: "workflow_run_attempt_invalid" }), ])); }, ); @@ -51,9 +51,9 @@ describe("runner-assignment evaluator attempt identity", () => { (jobAttempt) => { const result = evaluateRunnerAssignmentEvidence(assignedEvidence(2, jobAttempt)); - expect(result.status).toBe("FAIL"); - expect(result.failures).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: "workflow_job_attempt_invalid" }), + expect(result.audit_status).toBe("FAIL"); + expect(result.assignment_failures).toEqual(expect.arrayContaining([ + expect.objectContaining({ failure_code: "workflow_job_attempt_invalid" }), ])); }, ); @@ -61,10 +61,10 @@ describe("runner-assignment evaluator attempt identity", () => { it("rejects a workflow job from a predecessor attempt", () => { const result = evaluateRunnerAssignmentEvidence(assignedEvidence(2, 1)); - expect(result.status).toBe("FAIL"); - expect(result.failures).toEqual(expect.arrayContaining([ + expect(result.audit_status).toBe("FAIL"); + expect(result.assignment_failures).toEqual(expect.arrayContaining([ expect.objectContaining({ - code: "workflow_job_attempt_mismatch", + failure_code: "workflow_job_attempt_mismatch", run_attempt: 2, job_run_attempt: 1, }), @@ -76,33 +76,33 @@ describe("runner-assignment evaluator attempt identity", () => { expected_head_sha: expectedHead, observed_at: "2026-08-10T00:00:00.000Z", queue_grace_milliseconds: 300_000, - runs: [{ - id: 101, - name: "ci", - event: "pull_request", + workflow_runs: [{ + workflow_run_id: 101, + workflow_name: "ci", + trigger_event: "pull_request", head_sha: expectedHead, run_attempt: 2, - status: "queued", - conclusion: null, + workflow_run_status: "queued", + workflow_conclusion: null, created_at: "2026-08-09T23:50:00.000Z", - jobs: [ + workflow_jobs: [ { - id: 1001, - name: "predecessor-verify", + workflow_job_id: 1001, + workflow_job_name: "predecessor-verify", run_attempt: 1, - status: "completed", - conclusion: "success", + workflow_job_status: "completed", + workflow_job_conclusion: "success", started_at: "2026-08-09T23:52:00.000Z", completed_at: "2026-08-09T23:53:00.000Z", runner_id: 77, runner_name: "GitHub Actions 77", }, { - id: 1002, - name: "current-verify", + workflow_job_id: 1002, + workflow_job_name: "current-verify", run_attempt: 2, - status: "queued", - conclusion: null, + workflow_job_status: "queued", + workflow_job_conclusion: null, started_at: null, completed_at: null, runner_id: null, @@ -112,17 +112,17 @@ describe("runner-assignment evaluator attempt identity", () => { }], }); - expect(result.status).toBe("FAIL"); - expect(result.failures).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: "workflow_job_attempt_mismatch", job_id: 1001 }), - expect.objectContaining({ code: "runner_assignment_stalled", job_id: 1002 }), + expect(result.audit_status).toBe("FAIL"); + expect(result.assignment_failures).toEqual(expect.arrayContaining([ + expect.objectContaining({ failure_code: "workflow_job_attempt_mismatch", workflow_job_id: 1001 }), + expect.objectContaining({ failure_code: "runner_assignment_stalled", workflow_job_id: 1002 }), ])); }); it("continues to accept matching positive run and job attempt identity", () => { const result = evaluateRunnerAssignmentEvidence(assignedEvidence(2, 2)); - expect(result.status).toBe("PASS"); - expect(result.failures).toEqual([]); + expect(result.audit_status).toBe("PASS"); + expect(result.assignment_failures).toEqual([]); }); }); \ No newline at end of file From 273b03557a8ac741dbc7d6c82f305d887479e868 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:29:12 +0900 Subject: [PATCH 09/30] test(ci): expose semantic runner report regression --- ...ner-assignment-cli-semantic-report.test.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 test/actions-runner-assignment-cli-semantic-report.test.ts diff --git a/test/actions-runner-assignment-cli-semantic-report.test.ts b/test/actions-runner-assignment-cli-semantic-report.test.ts new file mode 100644 index 000000000..ee9358235 --- /dev/null +++ b/test/actions-runner-assignment-cli-semantic-report.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; +import { runActionsRunnerAssignmentAudit } from "../scripts/actions-runner-assignment-audit.mjs"; + +const expectedHead = "0123456789abcdef0123456789abcdef01234567"; + +function assignedRunApi(path: string) { + if (path.endsWith("/attempts/1/jobs?per_page=100")) { + return [{ + jobs: [{ + id: 1001, + name: "verify", + run_attempt: 1, + status: "completed", + conclusion: "failure", + started_at: "2026-09-01T19:00:10.000Z", + completed_at: "2026-09-01T19:00:30.000Z", + runner_id: 77, + runner_name: "GitHub Actions 77", + }], + }]; + } + + return { + id: 100, + name: "ci", + event: "pull_request", + head_sha: expectedHead, + run_attempt: 1, + status: "completed", + conclusion: "failure", + created_at: "2026-09-01T19:00:00.000Z", + }; +} + +describe("runner-assignment CLI semantic report contract", () => { + it("publishes the evaluator semantic decision as the stable operator report", async () => { + const writeReport = vi.fn(); + const result = await runActionsRunnerAssignmentAudit({ + env: { + GH_TOKEN: "present-but-never-retained", + NOEMA_ACTIONS_AUDIT_REPOSITORY: "ContextualWisdomLab/noema", + NOEMA_ACTIONS_AUDIT_HEAD_SHA: expectedHead, + NOEMA_ACTIONS_AUDIT_RUN_IDS: "100", + NOEMA_ACTIONS_AUDIT_QUEUE_GRACE_MILLISECONDS: "1000", + }, + observed_at: "2026-09-01T19:01:00.000Z", + gh_api: vi.fn(async (path: string) => assignedRunApi(path)), + write_report: writeReport, + }); + + expect(result).toMatchObject({ + exit_code: 0, + report: { + status: "PASS", + failures: [], + checks: [{ + check_code: "runner_assignment_observed", + check_passed: true, + workflow_run_id: 100, + run_attempt: 1, + workflow_job_id: 1001, + }], + }, + }); + expect(writeReport).toHaveBeenCalledWith(result.report); + }); +}); From fed8694abd5c9e4560df77e095dbec4399040c63 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:29:51 +0900 Subject: [PATCH 10/30] fix(naming): version semantic runner audit report --- scripts/actions-runner-assignment-audit.mjs | 40 ++++++++++----------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/scripts/actions-runner-assignment-audit.mjs b/scripts/actions-runner-assignment-audit.mjs index 47de0f476..9cba4b202 100644 --- a/scripts/actions-runner-assignment-audit.mjs +++ b/scripts/actions-runner-assignment-audit.mjs @@ -279,11 +279,11 @@ function parseQueueGrace(value) { * single-link regular-file authority, descriptor/path identity checks, complete * staged writes, identity-bounded cleanup, and atomic replacement. * - * @param {unknown} report Bounded report value. + * @param {unknown} auditReport Bounded runner-assignment audit report value. * @param {object} io File-system operations used by the private-output boundary. * @returns {string} Absolute report path. */ -export function writeReportAtomically(report, io = defaultWriteIo) { +export function writeReportAtomically(auditReport, io = defaultWriteIo) { const reportPath = resolve(REPORT_PATH); const reportDirectory = dirname(reportPath); assertAcquisitionPrivatePathParents(reportPath, io); @@ -291,7 +291,7 @@ export function writeReportAtomically(report, io = defaultWriteIo) { assertAcquisitionPrivatePathParents(reportPath, io); writeAcquisitionPrivateFile( reportPath, - `${JSON.stringify(report, null, 2)}\n`, + `${JSON.stringify(auditReport, null, 2)}\n`, io, ); return reportPath; @@ -353,19 +353,19 @@ export async function runActionsRunnerAssignmentAudit(input) { fetch_run: adapters.fetch_run, fetch_job_pages: adapters.fetch_job_pages, }); - const decision = evaluateRunnerAssignmentEvidence(evidence); - const report = { - schema_version: 1, - objective: "github_actions_runner_assignment", - repository, + const auditDecision = evaluateRunnerAssignmentEvidence(evidence); + const auditReport = { + schema_version: 2, + audit_objective: "github_actions_runner_assignment", + repository_full_name: repository, expected_head_sha: expectedHeadSha, selected_run_ids: runIds, observed_at: observedAt, queue_grace_milliseconds: queueGrace, - status: decision.status, - checks: decision.checks, - failures: decision.failures, - authority: { + audit_status: auditDecision.audit_status, + assignment_checks: auditDecision.assignment_checks, + assignment_failures: auditDecision.assignment_failures, + authority_boundary: { runner_assignment_only: true, required_check_success: false, review_authority: false, @@ -374,11 +374,11 @@ export async function runActionsRunnerAssignmentAudit(input) { deployment_authority: false, }, }; - await input.write_report(report); + await input.write_report(auditReport); return { - exit_code: decision.status === "PASS" ? 0 : 1, - report, + exit_code: auditDecision.audit_status === "PASS" ? 0 : 1, + audit_report: auditReport, }; } @@ -390,7 +390,7 @@ export async function runActionsRunnerAssignmentAudit(input) { * reader and explicit environment without requiring filesystem credential access. * * @param {object} options Runtime overrides used only by tests/operators. - * @returns {Promise<{exit_code: number, report: object}>} Audit result. + * @returns {Promise<{exit_code: number, audit_report: object}>} Audit result. */ export async function main(options = {}) { const sourceEnvironment = options.env ?? process.env; @@ -418,7 +418,7 @@ export async function main(options = {}) { }); } - const result = await runActionsRunnerAssignmentAudit({ + const auditResult = await runActionsRunnerAssignmentAudit({ env: auditEnvironment, observed_at: options.observed_at ?? new Date().toISOString(), gh_api: githubApi, @@ -428,9 +428,9 @@ export async function main(options = {}) { const setExitCode = options.set_exit_code ?? ((code) => { process.exitCode = code; }); - writeOutput(`${result.report.status}\n`); - setExitCode(result.exit_code); - return result; + writeOutput(`${auditResult.audit_report.audit_status}\n`); + setExitCode(auditResult.exit_code); + return auditResult; } /** From 5a06070ab0dfc123d07a9dcd88e8663e2e5a4fc6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:31:54 +0900 Subject: [PATCH 11/30] fix(ci): preserve runner report contract with semantic decision --- scripts/actions-runner-assignment-audit.mjs | 30 ++++++++++----------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/scripts/actions-runner-assignment-audit.mjs b/scripts/actions-runner-assignment-audit.mjs index 9cba4b202..987c8789a 100644 --- a/scripts/actions-runner-assignment-audit.mjs +++ b/scripts/actions-runner-assignment-audit.mjs @@ -354,18 +354,18 @@ export async function runActionsRunnerAssignmentAudit(input) { fetch_job_pages: adapters.fetch_job_pages, }); const auditDecision = evaluateRunnerAssignmentEvidence(evidence); - const auditReport = { - schema_version: 2, - audit_objective: "github_actions_runner_assignment", - repository_full_name: repository, + const report = { + schema_version: 1, + objective: "github_actions_runner_assignment", + repository, expected_head_sha: expectedHeadSha, selected_run_ids: runIds, observed_at: observedAt, queue_grace_milliseconds: queueGrace, - audit_status: auditDecision.audit_status, - assignment_checks: auditDecision.assignment_checks, - assignment_failures: auditDecision.assignment_failures, - authority_boundary: { + status: auditDecision.audit_status, + checks: auditDecision.assignment_checks, + failures: auditDecision.assignment_failures, + authority: { runner_assignment_only: true, required_check_success: false, review_authority: false, @@ -374,11 +374,11 @@ export async function runActionsRunnerAssignmentAudit(input) { deployment_authority: false, }, }; - await input.write_report(auditReport); + await input.write_report(report); return { exit_code: auditDecision.audit_status === "PASS" ? 0 : 1, - audit_report: auditReport, + report, }; } @@ -390,7 +390,7 @@ export async function runActionsRunnerAssignmentAudit(input) { * reader and explicit environment without requiring filesystem credential access. * * @param {object} options Runtime overrides used only by tests/operators. - * @returns {Promise<{exit_code: number, audit_report: object}>} Audit result. + * @returns {Promise<{exit_code: number, report: object}>} Audit result. */ export async function main(options = {}) { const sourceEnvironment = options.env ?? process.env; @@ -418,7 +418,7 @@ export async function main(options = {}) { }); } - const auditResult = await runActionsRunnerAssignmentAudit({ + const result = await runActionsRunnerAssignmentAudit({ env: auditEnvironment, observed_at: options.observed_at ?? new Date().toISOString(), gh_api: githubApi, @@ -428,9 +428,9 @@ export async function main(options = {}) { const setExitCode = options.set_exit_code ?? ((code) => { process.exitCode = code; }); - writeOutput(`${auditResult.audit_report.audit_status}\n`); - setExitCode(auditResult.exit_code); - return auditResult; + writeOutput(`${result.report.status}\n`); + setExitCode(result.exit_code); + return result; } /** From bcee514f351acc5c7920ac0dd35d39a5147da700 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:33:03 +0900 Subject: [PATCH 12/30] test(naming): verify semantic runner report schema --- test/actions-runner-assignment-cli.test.ts | 24 ++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/test/actions-runner-assignment-cli.test.ts b/test/actions-runner-assignment-cli.test.ts index 95ff9c095..a4461f308 100644 --- a/test/actions-runner-assignment-cli.test.ts +++ b/test/actions-runner-assignment-cli.test.ts @@ -226,12 +226,12 @@ describe("runner-assignment operator audit", () => { const directory = mkdtempSync(join(tmpdir(), "noema-runner-report-")); try { process.chdir(directory); - const reportPath = writeReportAtomically({ status: "PASS" }); + const reportPath = writeReportAtomically({ audit_status: "PASS" }); expect(reportPath).toBe(resolve(directory, "artifacts/operations/actions-runner-assignment-audit.json")); - expect(JSON.parse(readFileSync(reportPath, "utf8"))).toEqual({ status: "PASS" }); + expect(JSON.parse(readFileSync(reportPath, "utf8"))).toEqual({ audit_status: "PASS" }); const trustedReport = readFileSync(reportPath, "utf8"); - expect(() => writeReportAtomically({ value: 1n })).toThrow("BigInt"); + expect(() => writeReportAtomically({ bigint_probe_value: 1n })).toThrow("BigInt"); expect(readFileSync(reportPath, "utf8")).toBe(trustedReport); } finally { process.chdir(originalCwd); @@ -300,8 +300,15 @@ describe("runner-assignment operator audit", () => { write_report: writeReport, }); expect(result.exit_code).toBe(1); - expect(result.report).toMatchObject({ schema_version: 1, objective: "github_actions_runner_assignment", repository: "ContextualWisdomLab/noema", expected_head_sha: expectedHead, selected_run_ids: [100], status: "PENDING" }); - expect(JSON.stringify(result.report)).not.toContain("present-but-never-retained"); + expect(result.audit_report).toMatchObject({ + schema_version: 2, + audit_objective: "github_actions_runner_assignment", + repository_full_name: "ContextualWisdomLab/noema", + expected_head_sha: expectedHead, + selected_run_ids: [100], + audit_status: "PENDING", + }); + expect(JSON.stringify(result.audit_report)).not.toContain("present-but-never-retained"); expect(writeReport).toHaveBeenCalledOnce(); }); @@ -314,8 +321,8 @@ describe("runner-assignment operator audit", () => { write_report: writeReport, }); expect(result.exit_code).toBe(0); - expect(result.report.status).toBe("PASS"); - expect(result.report.authority).toEqual({ + expect(result.audit_report.audit_status).toBe("PASS"); + expect(result.audit_report.authority_boundary).toEqual({ runner_assignment_only: true, required_check_success: false, review_authority: false, @@ -378,7 +385,8 @@ describe("runner-assignment operator audit", () => { const reportPath = resolve(directory, "artifacts/operations/actions-runner-assignment-audit.json"); expect(existsSync(reportPath)).toBe(true); expect(JSON.parse(readFileSync(reportPath, "utf8"))).toMatchObject({ - status: "PASS", + schema_version: 2, + audit_status: "PASS", expected_head_sha: expectedHead, }); const reportText = readFileSync(reportPath, "utf8"); From 823adaef1c0ef352317615b3db786c90fdaaef71 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:33:43 +0900 Subject: [PATCH 13/30] test(naming): use semantic audit status fixture --- test/actions-runner-assignment-write-io-boundary.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/actions-runner-assignment-write-io-boundary.test.ts b/test/actions-runner-assignment-write-io-boundary.test.ts index 264ed0f3f..5ef27861f 100644 --- a/test/actions-runner-assignment-write-io-boundary.test.ts +++ b/test/actions-runner-assignment-write-io-boundary.test.ts @@ -57,7 +57,7 @@ describe("runner-assignment report filesystem authority", () => { unlinkSync: vi.fn(), }; - expect(writeReportAtomically({ status: "PASS" }, io)).toContain("actions-runner-assignment-audit.json"); + expect(writeReportAtomically({ audit_status: "PASS" }, io)).toContain("actions-runner-assignment-audit.json"); expect(io.lstatSync).toHaveBeenCalled(); expect(io.mkdirSync).toHaveBeenCalledOnce(); expect(io.renameSync).toHaveBeenCalledOnce(); @@ -98,7 +98,7 @@ describe("runner-assignment report filesystem authority", () => { unlinkSync: vi.fn(), }; - expect(() => writeReportAtomically({ status: "PASS" }, io)).toThrow( + expect(() => writeReportAtomically({ audit_status: "PASS" }, io)).toThrow( "acquisition output parent must be a real directory without symbolic links", ); expect(io.renameSync).not.toHaveBeenCalled(); @@ -134,7 +134,7 @@ describe("runner-assignment report filesystem authority", () => { unlinkSync: vi.fn(), }; - expect(() => writeReportAtomically({ status: "PASS" }, io)).toThrow( + expect(() => writeReportAtomically({ audit_status: "PASS" }, io)).toThrow( "acquisition staged output path changed before atomic replacement", ); expect(io.renameSync).not.toHaveBeenCalled(); From 40846d3f73f33ac7dfb7d0c99adb01767a28c235 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:34:51 +0900 Subject: [PATCH 14/30] test(naming): verify semantic capability report --- test/actions-runner-assignment-token-capability.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/actions-runner-assignment-token-capability.test.ts b/test/actions-runner-assignment-token-capability.test.ts index 73932169a..17a9968e4 100644 --- a/test/actions-runner-assignment-token-capability.test.ts +++ b/test/actions-runner-assignment-token-capability.test.ts @@ -97,7 +97,8 @@ describe("runner-assignment delegated GitHub token capability", () => { expect(result.exit_code).toBe(0); const reportPath = resolve(directory, "artifacts/operations/actions-runner-assignment-audit.json"); expect(JSON.parse(readFileSync(reportPath, "utf8"))).toMatchObject({ - status: "PASS", + schema_version: 2, + audit_status: "PASS", expected_head_sha: expectedHead, }); expect(readFileSync(reportPath, "utf8")).not.toContain("short-lived-runner-audit-token"); From 75ec842c0ab76b313af3df9e5ef2d224f4779003 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:35:50 +0900 Subject: [PATCH 15/30] test(naming): align retained audit report name --- test/actions-runner-assignment-evidence-integrity.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/actions-runner-assignment-evidence-integrity.test.ts b/test/actions-runner-assignment-evidence-integrity.test.ts index 4bb5cc060..754175afd 100644 --- a/test/actions-runner-assignment-evidence-integrity.test.ts +++ b/test/actions-runner-assignment-evidence-integrity.test.ts @@ -116,7 +116,7 @@ describe("runner-assignment evidence integrity", () => { const result = await runActionsRunnerAssignmentAudit(input); expect(observedAtGetter).toHaveBeenCalledOnce(); - expect(result.report.observed_at).toBe(canonicalObservedAt); + expect(result.audit_report.observed_at).toBe(canonicalObservedAt); expect(writer).toHaveBeenCalledWith(expect.objectContaining({ observed_at: canonicalObservedAt })); }); @@ -133,7 +133,7 @@ describe("runner-assignment evidence integrity", () => { [ "--input-type=module", "-e", - `import { writeReportAtomically } from ${JSON.stringify(moduleUrl)}; writeReportAtomically({status: "PASS"});`, + `import { writeReportAtomically } from ${JSON.stringify(moduleUrl)}; writeReportAtomically({audit_status: "PASS"});`, ], { cwd: directory, From 9b0103fffa11a7aed07b6659319a2f0814c954b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 05:17:39 +0900 Subject: [PATCH 16/30] test(runner-audit): preserve stable operator report contract --- test/actions-runner-assignment-cli.test.ts | 24 ++++++++-------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/test/actions-runner-assignment-cli.test.ts b/test/actions-runner-assignment-cli.test.ts index a4461f308..95ff9c095 100644 --- a/test/actions-runner-assignment-cli.test.ts +++ b/test/actions-runner-assignment-cli.test.ts @@ -226,12 +226,12 @@ describe("runner-assignment operator audit", () => { const directory = mkdtempSync(join(tmpdir(), "noema-runner-report-")); try { process.chdir(directory); - const reportPath = writeReportAtomically({ audit_status: "PASS" }); + const reportPath = writeReportAtomically({ status: "PASS" }); expect(reportPath).toBe(resolve(directory, "artifacts/operations/actions-runner-assignment-audit.json")); - expect(JSON.parse(readFileSync(reportPath, "utf8"))).toEqual({ audit_status: "PASS" }); + expect(JSON.parse(readFileSync(reportPath, "utf8"))).toEqual({ status: "PASS" }); const trustedReport = readFileSync(reportPath, "utf8"); - expect(() => writeReportAtomically({ bigint_probe_value: 1n })).toThrow("BigInt"); + expect(() => writeReportAtomically({ value: 1n })).toThrow("BigInt"); expect(readFileSync(reportPath, "utf8")).toBe(trustedReport); } finally { process.chdir(originalCwd); @@ -300,15 +300,8 @@ describe("runner-assignment operator audit", () => { write_report: writeReport, }); expect(result.exit_code).toBe(1); - expect(result.audit_report).toMatchObject({ - schema_version: 2, - audit_objective: "github_actions_runner_assignment", - repository_full_name: "ContextualWisdomLab/noema", - expected_head_sha: expectedHead, - selected_run_ids: [100], - audit_status: "PENDING", - }); - expect(JSON.stringify(result.audit_report)).not.toContain("present-but-never-retained"); + expect(result.report).toMatchObject({ schema_version: 1, objective: "github_actions_runner_assignment", repository: "ContextualWisdomLab/noema", expected_head_sha: expectedHead, selected_run_ids: [100], status: "PENDING" }); + expect(JSON.stringify(result.report)).not.toContain("present-but-never-retained"); expect(writeReport).toHaveBeenCalledOnce(); }); @@ -321,8 +314,8 @@ describe("runner-assignment operator audit", () => { write_report: writeReport, }); expect(result.exit_code).toBe(0); - expect(result.audit_report.audit_status).toBe("PASS"); - expect(result.audit_report.authority_boundary).toEqual({ + expect(result.report.status).toBe("PASS"); + expect(result.report.authority).toEqual({ runner_assignment_only: true, required_check_success: false, review_authority: false, @@ -385,8 +378,7 @@ describe("runner-assignment operator audit", () => { const reportPath = resolve(directory, "artifacts/operations/actions-runner-assignment-audit.json"); expect(existsSync(reportPath)).toBe(true); expect(JSON.parse(readFileSync(reportPath, "utf8"))).toMatchObject({ - schema_version: 2, - audit_status: "PASS", + status: "PASS", expected_head_sha: expectedHead, }); const reportText = readFileSync(reportPath, "utf8"); From 95b3d157bf8f71e95a7988b441c883b6272ef7ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:03:33 +0900 Subject: [PATCH 17/30] test: align runner audit retained report contract --- test/actions-runner-assignment-evidence-integrity.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/actions-runner-assignment-evidence-integrity.test.ts b/test/actions-runner-assignment-evidence-integrity.test.ts index 754175afd..f5d3566d3 100644 --- a/test/actions-runner-assignment-evidence-integrity.test.ts +++ b/test/actions-runner-assignment-evidence-integrity.test.ts @@ -116,7 +116,7 @@ describe("runner-assignment evidence integrity", () => { const result = await runActionsRunnerAssignmentAudit(input); expect(observedAtGetter).toHaveBeenCalledOnce(); - expect(result.audit_report.observed_at).toBe(canonicalObservedAt); + expect(result.report.observed_at).toBe(canonicalObservedAt); expect(writer).toHaveBeenCalledWith(expect.objectContaining({ observed_at: canonicalObservedAt })); }); From 478b3d3559157c858226aae00c06ec4cf103c645 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:04:05 +0900 Subject: [PATCH 18/30] test: restore runner audit report schema expectations --- test/actions-runner-assignment-token-capability.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/actions-runner-assignment-token-capability.test.ts b/test/actions-runner-assignment-token-capability.test.ts index 17a9968e4..0f787fd2f 100644 --- a/test/actions-runner-assignment-token-capability.test.ts +++ b/test/actions-runner-assignment-token-capability.test.ts @@ -97,8 +97,8 @@ describe("runner-assignment delegated GitHub token capability", () => { expect(result.exit_code).toBe(0); const reportPath = resolve(directory, "artifacts/operations/actions-runner-assignment-audit.json"); expect(JSON.parse(readFileSync(reportPath, "utf8"))).toMatchObject({ - schema_version: 2, - audit_status: "PASS", + schema_version: 1, + status: "PASS", expected_head_sha: expectedHead, }); expect(readFileSync(reportPath, "utf8")).not.toContain("short-lived-runner-audit-token"); From e75b54e3c8264ff6c54f571ae3724011fd34c791 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:07:42 +0900 Subject: [PATCH 19/30] test(acquisition): require distributed package metadata digest --- test/acquisition-source-only-license.test.ts | 73 +++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/test/acquisition-source-only-license.test.ts b/test/acquisition-source-only-license.test.ts index d7d8093f3..0e56f6db7 100644 --- a/test/acquisition-source-only-license.test.ts +++ b/test/acquisition-source-only-license.test.ts @@ -131,6 +131,27 @@ function writeSourceOnlyTransferEvidence(root: string, packagePrivate = true): s ); } +function configureDistributablePackage( + root: string, + transferEvidencePath: string, + metadataSha256?: string, +): string { + const packageBytes = `${JSON.stringify({ + name: "noema", + private: false, + license: "Apache-2.0", + }, null, 2)}\n`; + writeFixture(root, "package.json", packageBytes); + + const transferEvidence = JSON.parse(readFileSync(transferEvidencePath, "utf8")); + transferEvidence.licensing_ip.package_metadata = { + license: "Apache-2.0", + ...(metadataSha256 === undefined ? {} : { sha256: metadataSha256 }), + }; + writeFileSync(transferEvidencePath, `${JSON.stringify(transferEvidence, null, 2)}\n`, "utf8"); + return packageBytes; +} + function runReportOnlyAudit(root: string, transferEvidencePath: string) { const outputDir = join(root, "audit-output"); const script = resolve("scripts/acquisition-readiness-audit.mjs"); @@ -196,4 +217,54 @@ describe("source-only repository licensing", () => { ]), ); }); -}); + + it("fails closed when distributable package metadata omits its package.json SHA-256", () => { + const root = mkdtempSync(join(tmpdir(), "noema-distributable-package-digest-missing-")); + temporaryRoots.push(root); + writeRequiredDocs(root); + const transferEvidencePath = writeSourceOnlyTransferEvidence(root, false); + configureDistributablePackage(root, transferEvidencePath); + + const { result, transferCheck } = runReportOnlyAudit(root, transferEvidencePath); + + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(transferCheck).toBeDefined(); + expect(transferCheck.pass).toBe(false); + expect(transferCheck.details.licensingIpFailures).toContain( + "licensing_ip.package_metadata.sha256 required when package distribution applies", + ); + }); + + it("fails closed when distributable package metadata names the wrong package.json SHA-256", () => { + const root = mkdtempSync(join(tmpdir(), "noema-distributable-package-digest-mismatch-")); + temporaryRoots.push(root); + writeRequiredDocs(root); + const transferEvidencePath = writeSourceOnlyTransferEvidence(root, false); + configureDistributablePackage(root, transferEvidencePath, "0".repeat(64)); + + const { result, transferCheck } = runReportOnlyAudit(root, transferEvidencePath); + + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(transferCheck).toBeDefined(); + expect(transferCheck.pass).toBe(false); + expect(transferCheck.details.licensingIpFailures).toContain( + "package_metadata.sha256 does not match retained package.json bytes", + ); + }); + + it("accepts distributable package metadata bound to the exact retained package.json bytes", () => { + const root = mkdtempSync(join(tmpdir(), "noema-distributable-package-digest-valid-")); + temporaryRoots.push(root); + writeRequiredDocs(root); + const transferEvidencePath = writeSourceOnlyTransferEvidence(root, false); + const packageBytes = configureDistributablePackage(root, transferEvidencePath); + configureDistributablePackage(root, transferEvidencePath, sha256(packageBytes)); + + const { result, transferCheck } = runReportOnlyAudit(root, transferEvidencePath); + + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(transferCheck).toBeDefined(); + expect(transferCheck.pass).toBe(true); + expect(transferCheck.details.licensingIpFailures).toEqual([]); + }); +}); \ No newline at end of file From eeb0ea896be22643900dfdb6fb0de5dd567945de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:09:56 +0900 Subject: [PATCH 20/30] fix(acquisition): bind distributed package metadata digest --- scripts/acquisition-readiness-audit.mjs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/acquisition-readiness-audit.mjs b/scripts/acquisition-readiness-audit.mjs index d97b8ae7e..c9d8e9493 100644 --- a/scripts/acquisition-readiness-audit.mjs +++ b/scripts/acquisition-readiness-audit.mjs @@ -123,7 +123,7 @@ function readJson(path) { if (hasDuplicateJsonObjectKeys(text)) { return { ok: false, reason: "duplicate_json_key", path }; } - return { ok: true, path, value: JSON.parse(text) }; + return { ok: true, path, value: JSON.parse(text), bytes }; } catch (error) { return { ok: false, reason: "invalid_json", path, error: error.message }; } @@ -459,6 +459,17 @@ function validateLicensingIpEvidence(value) { failures.push("package_metadata.license must match package.json license exactly"); } } + if (packageDistributionApplies) { + const expectedPackageDigest = String(licensing.package_metadata?.sha256 ?? ""); + if (!/^[0-9a-f]{64}$/i.test(expectedPackageDigest)) { + failures.push("licensing_ip.package_metadata.sha256 required when package distribution applies"); + } else if (packageJson.ok) { + const actualPackageDigest = createHash("sha256").update(packageJson.bytes).digest("hex"); + if (actualPackageDigest !== expectedPackageDigest.toLowerCase()) { + failures.push("package_metadata.sha256 does not match retained package.json bytes"); + } + } + } if ( decision From 4b77cd70078d53d798191de90b5ad5e8c4823b27 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:10:18 +0900 Subject: [PATCH 21/30] docs(acquisition): bind package metadata digest evidence --- docs/evidence-templates/transfer-evidence.example.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/evidence-templates/transfer-evidence.example.json b/docs/evidence-templates/transfer-evidence.example.json index 9e21de398..61b15506f 100644 --- a/docs/evidence-templates/transfer-evidence.example.json +++ b/docs/evidence-templates/transfer-evidence.example.json @@ -25,7 +25,8 @@ "sha256": "replace-with-sha256-of-reviewed-root-rights-file" }, "package_metadata": { - "license": "replace-with-exact-package-json-license-value" + "license": "replace-with-exact-package-json-license-value", + "sha256": "replace-with-sha256-of-exact-package-json-bytes-when-distributed" }, "release_rights": { "tag": "replace-with-v0.0.0", @@ -60,4 +61,4 @@ ] } } -} +} \ No newline at end of file From b2ee3d043bf9564519fb15fad0af9e9fe191a711 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:11:08 +0900 Subject: [PATCH 22/30] docs(acquisition): refresh integrated licensing authority --- docs/LICENSING_AND_IP_TRANSFER.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/LICENSING_AND_IP_TRANSFER.md b/docs/LICENSING_AND_IP_TRANSFER.md index 34e310d2a..d10ceabc7 100644 --- a/docs/LICENSING_AND_IP_TRANSFER.md +++ b/docs/LICENSING_AND_IP_TRANSFER.md @@ -1,12 +1,12 @@ # Noema Licensing and IP Transfer -- **Status:** Repository rights policy/evidence baseline; source-license decision is Apache-2.0 on PR #530 until protected integration. This is not acquisition or transfer legal clearance. +- **Status:** Repository rights policy/evidence baseline; Apache-2.0 source-license decision is integrated on protected `main@6b2b3e90dc3d5bd24cd27ed11db41b9eb7106010` through PR #530. This is not acquisition or transfer legal clearance. - **Scope:** Noema source rights, package/container metadata, third-party obligations, contributor/IP provenance, release distribution, and acquisition transfer evidence. - **Decision authority:** Repository automation may detect, authenticate, inventory, and compare evidence. The repository owner has explicitly selected Apache License 2.0 for Noema source; future outbound-license changes and transfer-rights decisions remain owner/legal governance actions. ## 1. Core invariant -**Public source availability is not a grant of rights by itself.** The grant comes from the controlling repository rights file. On PR #530, root `LICENSE` declares Apache-2.0 for Noema source. Until that exact head integrates, protected `main` remains the currently shipped source-rights authority. +**Public source availability is not a grant of rights by itself.** The grant comes from the controlling repository rights file. Protected `main@6b2b3e90dc3d5bd24cd27ed11db41b9eb7106010` includes root `LICENSE` declaring Apache-2.0 for Noema source through merged PR #530. Noema keeps source licensing, package publication, third-party obligations, and transfer authority separate: @@ -40,6 +40,7 @@ For a package that is actually distributed through npm: - use a valid **SPDX** expression when approved terms have one; - use `SEE LICENSE IN ` for approved custom terms stored in a bounded repository file; - use `UNLICENSED` only when package metadata intentionally grants no use rights; +- record the SHA-256 of the exact retained `package.json` bytes in transfer evidence so package-publication metadata cannot be substituted after review; - regenerate `package-lock.json` whenever root package metadata changes so tracked lock metadata stays exact. For current Noema, `"private": true` plus absence of an npm distribution channel means root `LICENSE` is the controlling source grant. `private` itself is still only a publication safeguard; it neither grants nor narrows Apache-2.0 source rights. @@ -124,7 +125,7 @@ The machine-checkable transfer contract binds, at minimum: - repository identity and exact source/release revision; - approved owner/legal decision identifier; - controlling `LICENSE`/custom-rights file path and SHA-256 when applicable; -- package-publication rights declaration plus metadata hash when a package is actually distributed; +- package-publication rights declaration plus SHA-256 of the exact retained `package.json` bytes when a package is actually distributed; - exact-release `artifact_rights_metadata` path and SHA-256 when an artifact exposes rights metadata; - exact-release SBOM identity; - dependency-license and NOTICE/attribution artifact identities; @@ -157,15 +158,15 @@ owner source-license decision Each arrow requires independent identity/consistency evidence. A mismatch, missing required record, malformed/ambiguous JSON, or unresolved right is a fail-closed condition. -## 8. Current evidence and residual gap — 2026-09-01 +## 8. Current evidence and residual gap — 2026-09-02 -Protected `main@03ef2301bad020b9ab4dfde2ec3c4e7f460024ca` still has no root `LICENSE`. PR #530 now carries the explicit owner-selected Apache-2.0 source posture: +As observed after PR #530 merged, protected `main@6b2b3e90dc3d5bd24cd27ed11db41b9eb7106010` carries the explicit owner-selected Apache-2.0 source posture: - root `LICENSE`: Apache License 2.0; - root `README.md`: customer-facing Apache-2.0 source-license statement and separate third-party obligation boundary; - `package.json`: remains private and lock-stable; no npm package distribution claim is introduced. -Those declarations are candidate truth until #530 integrates; they are not predecessor evidence for protected main. +These declarations are protected-main source truth. They do not by themselves establish acquisition-transfer authority, third-party compatibility, or release/publication evidence. Current residual gaps remain deliberately separate: @@ -175,7 +176,7 @@ Current residual gaps remain deliberately separate: - release/publication/deployment evidence remains separate from repository-source rights; - no source file, README sentence, scanner result, or successful CI run may upgrade those missing evidence classes into a commercial or legal PASS. -Issue #5 carries acquisition owner/legal and ownership/assignment evidence. Issue #66 carries remaining release/publication, NOTICE and provenance/activation boundaries. Issue #531 owns the GPL-family development/build-tool replacement. The source-license decision narrows the gap but does not close those issues. +Issue #5 carries acquisition owner/legal and ownership/assignment evidence. Issue #66 carries remaining release/publication, NOTICE and provenance/activation boundaries. Issue #531 owns the GPL-family development/build-tool replacement. The integrated source-license decision narrows the gap but does not close those issues. ## 9. Non-goals From 1365d46b827c7e6315f3e1fd2e54585308961f6e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:11:47 +0900 Subject: [PATCH 23/30] docs(gap): refresh protected licensing baseline --- docs/product-technical-gap-baseline.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index b748d67da..a6cb57e5b 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -4,13 +4,13 @@ 이 문서는 제품 요구, 구현, 검증, 운영 증거 사이의 현재 차이를 한곳에서 추적한다. 저장소 파일과 테스트는 revision-local 또는 protected-source 구현만 증명한다. PR 상태는 exact head와 live base에서, 운영·배포·고객·매출·법적 증거는 해당 외부 권한에서 각각 다시 확인해야 한다. 문서나 성공 boolean만으로 이후 단계의 증거를 만들지 않는다. -이 baseline의 protected-source snapshot은 `main@5aad3e410703faaf52882e2f33fadd25d217bcdd`이며, README/license candidate truth는 PR #530 exact head에만 적용한다. issues #3, #5, #27, #29, #66, #227, #531의 live 상태를 GitHub 권위로 다시 읽어야 하며, protected/main·PR·외부 증거를 서로 대체하지 않는다. +이 baseline의 protected-source snapshot은 `main@6b2b3e90dc3d5bd24cd27ed11db41b9eb7106010`이다. PR #530은 이 protected revision에 이미 병합되어 product-first README와 Apache-2.0 root source grant가 protected truth가 되었다. issues #3, #5, #27, #29, #66, #227, #531의 live 상태를 GitHub 권위로 다시 읽어야 하며, protected/main·PR·외부 증거를 서로 대체하지 않는다. -## Live external observation — 2026-09-01 KST +## Live external observation — 2026-09-02 KST | Authority | Observation | Consequence | | --- | --- | --- | -| README/license lane | PR #530 is open and carries the product-first README plus Apache-2.0 root source grant; every push invalidates predecessor-head checks | protected main remains unlicensed until the unchanged exact head integrates | +| README/license lane | PR #530 merged into protected `main@6b2b3e90dc3d5bd24cd27ed11db41b9eb7106010`; root `LICENSE` and product-first README now carry the Apache-2.0 source grant | source-license posture is protected truth, but it is not acquisition-transfer or third-party compatibility evidence | | npm package boundary | `package.json` remains `private` and the npm package is not a product distribution channel; no package-publication license field is introduced | root `LICENSE` controls source rights without forcing unrelated lockfile metadata churn | | Dependency licensing | `package-lock.json` contains `LGPL-3.0-or-later` optional dev/build packages on `wrangler → miniflare → sharp → @img/sharp-libvips-*`; issue #531 owns removal/replacement | source Apache-2.0 does not make the current toolchain compliant with the organization no-GPL-family default | | Release/publication | immutable release/deployment/customer/revenue/transfer evidence remains a separate authority class | source licensing cannot be promoted into acquisition readiness | @@ -23,7 +23,7 @@ | Reviewer and maintenance control plane | 독립 App identity, bounded manifest, deterministic fail-closed gates | `reviewer/noema_reviewer/`, maintainer/reviewer workflows, capability-file ingress | reviewer tests, workflow contract tests, current-head review artifacts | Maintainer/Reviewer App 설치·권한·key custody·rotation 및 publication identity | Source contract implemented; external activation evidence is open | | Hourly product-development loop | `contextual-orchestrator` inference와 별도 Maintainer App publication identity를 사용하는 work-conserving loop | `.github/workflows/hourly-product-development.yml`, orchestrator gateway contract, publication/readiness validators | workflow shape, gateway preflight, lease, publication prerequisite and stale-head refusal tests | zero-PR scheduled proposal publication과 rollback/recovery exercise | Implemented source; production activation incomplete | | Patch-validator supply chain | exact source/image/receipt binding과 fail-closed vulnerability policy | `Dockerfile.patch-validator`, image workflow, validator/SBOM/receipt modules | build, runtime, smoke, SBOM, vulnerability and receipt tests | protected-main operational receipt와 registry publication/signing/attestation | Implemented source; operational/publication evidence incomplete | -| Source licensing | Noema-owned source uses one explicit commercial-friendly outbound grant; package publication and dependencies retain independent terms | PR #530 `LICENSE`, root `README.md`, `docs/LICENSING_AND_IP_TRANSFER.md`; private `package.json` remains non-distribution metadata | exact-head repository/doc/test consistency | protected integration plus third-party/tooling policy resolution | Apache-2.0 candidate truth on #530; not yet protected truth | +| Source licensing | Noema-owned source uses one explicit commercial-friendly outbound grant; package publication and dependencies retain independent terms | protected `LICENSE`, root `README.md`, `docs/LICENSING_AND_IP_TRANSFER.md`; private `package.json` remains non-distribution metadata | protected repository/doc/test consistency at `main@6b2b3e90dc3d5bd24cd27ed11db41b9eb7106010` | third-party/tooling policy resolution and acquisition-transfer evidence remain separate | Apache-2.0 source grant implemented on protected main | | Third-party/tooling licensing | GPL-family packages are not accepted as the normal inbound dependency baseline | current lockfile + dependency-license inventory + issue #531 | exact lockfile scan/inventory must become free of GPL/LGPL/AGPL toolchain entries | commercially compatible Wrangler/Miniflare/build-tool replacement or exact approved exception | Open compliance gap; source license does not resolve it | | Release and deployment | source → package/SBOM/provenance → immutable publication → deployment/rollback | release, publication, deployment and readiness scripts | exact-source/reproducibility/receipt/rollback contract tests | immutable release, protected deployment, recovery and production smoke evidence | Incomplete; repository evidence cannot establish deployment | | KPI, customer and acquisition | authentic evidence must retain source, time and buyer/legal authority | KPI, acquisition manifest/integrity/readiness and license validators | bounded input, provenance, ordering, integrity and fail-closed tests | authentic 30-day production KPI, customer/revenue and transfer evidence | Incomplete; no commercial-readiness claim | @@ -35,14 +35,13 @@ | P0 | GPL-family development/build dependency path | 조직의 상업용 inbound 정책과 현재 npm toolchain이 충돌한다 | issue #531 | exact-head `package-lock.json`과 dependency inventory에서 GPL/LGPL/AGPL 경로가 사라지고 Worker dev/deploy·typecheck·tests·security가 그대로 통과 | Wrangler/Miniflare/Sharp 경로를 상업적으로 호환되는 도구 경계로 교체하고 lockfile을 재검증한다 | | P0 | Maintainer/Reviewer App 및 hourly publication identity 활성화 | 자동 유지보수와 독립 리뷰가 production capability로 동작한다는 증거가 없다 | issues #29 / #227 | 현재 App 설치·권한·key custody/rotation, 성공한 scheduled publication artifact와 rollback 결과 | 외부 App 구성을 완료한 뒤 readiness와 scheduled run을 실행하고 artifact를 보존한다 | | P0 | protected `main` governance 목표와 live policy 정합성 | source 검증만으로 실제 merge/release 통제를 보장할 수 없다 | issue #27 | live ruleset/branch-protection API와 관찰된 required workflow/status 결과 | governance audit을 live policy에 실행하고 차이를 owning control에서 수정한다 | -| P1 | Apache-2.0 source grant integration | 공개 저장소가 protected main에서는 아직 명시적 사용권을 제공하지 않는다 | PR #530 | unchanged exact-head README/LICENSE + applicable reviews/checks + protected merge | #530 exact head를 정상 protected path로 통합한다 | | P1 | patch-validator 운영·배포 증거 | 검증된 source image가 실제 배포·서명·활성화됐는지 구매자가 확인할 수 없다 | issue #66 | protected-main operational receipt, registry digest, signature/attestation과 activation proof | exact protected source에서 publication pipeline을 실행한다 | | P1 | authentic 30-day KPI | 신뢰성·성능·운영가치를 fixture가 아닌 실운영 자료로 입증하지 못한다 | issue #3 | production-origin, time-bound, integrity-checked 30-day KPI evidence | 승인된 production source에서 collector와 verifier를 실행한다 | | P1 | release/deployment/acquisition evidence | buyer/legal/commercial 권한이 없어 매각 readiness를 선언할 수 없다 | issue #5 | immutable release/deployment/customer/revenue/legal transfer evidence | 앞선 evidence family를 순서대로 충족하고 acquisition audit을 재실행한다 | ## Documentation contradictions -과거 PR 번호와 당시 상태는 historical provenance일 뿐 현재 owner나 구현 상태가 아니다. Canonical TRD와 ADR은 protected implementation surface와 durable live issue owner를 사용하며, historical PR을 current owner로 사용하지 않는다. PR #530의 Apache-2.0 grant도 merge 전에는 protected truth로 표현하지 않는다. +과거 PR 번호와 당시 상태는 historical provenance일 뿐 현재 owner나 구현 상태가 아니다. Canonical TRD와 ADR은 protected implementation surface와 durable live issue owner를 사용하며, historical PR을 current owner로 사용하지 않는다. PR #530의 Apache-2.0 grant는 `main@6b2b3e90dc3d5bd24cd27ed11db41b9eb7106010`에 병합된 이후 protected source truth로만 표현한다. ## Completion discipline From c3f55c2998e69ac2411f24cd8227e26ce328b9b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:14:02 +0900 Subject: [PATCH 24/30] docs(changelog): record semantic runner and package evidence repairs --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11519b54d..6e2b7f8b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog ## Unreleased +- Actions runner-assignment 내부 evidence model을 GitHub REST의 generic `id`/`name`/`event`/`status` 명칭에서 `workflow_run_id`, `workflow_name`, `trigger_event`, `workflow_run_status`, `workflow_job_id`, `workflow_job_status` 등 Noema-owned semantic contract로 변환하고, vendor 필드는 anti-corruption adapter에만 남긴다. 기존 운영자용 schema-v1 report의 `status`/`checks`/`failures` 계약은 유지한다. +- acquisition transfer evidence에서 실제 배포 가능한 npm package가 존재할 때 `licensing_ip.package_metadata.sha256`을 필수로 요구하고, transfer evidence가 승인한 digest를 parser가 실제로 소비한 exact retained `package.json` bytes와 비교해 package-publication metadata substitution을 실패-폐쇄한다. 현재 private/non-distributed package에는 이 digest 요구를 적용하지 않는다. - Noema의 필수 PR 워크플로 `ci`, `reviewer-ci`, `patch-validator-image`를 부동 `ubuntu-latest` 대신 명시적 `ubuntu-24.04` GitHub-hosted runner에 고정하고, 인용 여부와 무관하게 `ubuntu-latest` 회귀를 탐지하는 계약 테스트를 추가해 pre-checkout runner-assignment stall의 repository-owned selector 원인을 제거한다. 중앙 `Security Scan`의 runner/control-plane 권한은 별도 `.github` owner 경계에 유지한다. - 비공개 취약점 보고 감사가 16 KiB 응답 상한, bounded stream 취소, canonical repository/source identity의 독립 검증, SHA-1/SHA-256 exact revision, symlink·retained-path 보호를 실패-폐쇄로 강제한다. 이 감사 결과는 live private reporting 활성화, notification staffing, 실제 advisory 대응 또는 release/deployment 완료 증거를 대신하지 않는다. - External scheduler evidence audits now retain source authority through final report publication: reports are owner-only, no-follow, exclusive one-shot receipts, so a concurrent rename cannot move the accepted source inode onto the report pathname and have it replaced. Source/report path and inode alias checks, single-link retained-source validation, and Unicode control sanitization remain fail closed. @@ -46,7 +48,7 @@ - credential-bearing GitHub App REST 요청의 egress를 exact `https://api.github.com` origin으로 고정. 새 Worker entrypoint가 `/exchange` 전에 `GITHUB_API_BASE`의 scheme·origin·userinfo·port·path·query·fragment를 검증하고, lookalike/malformed 설정은 rate-limit·OIDC parsing·private-key 사용·GitHub API 호출 전에 `503 ERR_GITHUB_API`로 실패-폐쇄하며 허용 값도 canonical origin으로 치환한다. `/health`는 설정 복구 중에도 유지하고 원본 설정값은 응답·로그에 노출하지 않는다. - `src/**/*.ts` 전체에 statements·branches·functions·lines 100% coverage threshold를 강제하고, `/exchange` wrapper·OIDC replay guard·distributed limiter의 fail-closed 및 malformed-decision 경계를 회귀 테스트로 고정했다. 새 source branch가 coverage를 낮추면 CI가 즉시 실패한다. - `/exchange` distributed rate-limit identity가 없는 요청을 shared `unknown` bucket으로 합치지 않고 `503`으로 실패-폐쇄하도록 강화. Cloudflare의 `CF-Connecting-IP`가 정확히 하나의 유효한 IPv4/IPv6가 아니면 Durable Object lookup과 bearer parsing 전에 중단하고, 유효한 IPv6는 canonical form으로 정규화하여 동일 주소의 표기 차이가 rate-limit bucket을 분할하지 않도록 한다. -- CI 검증 중 공개된 `undici` 취약점 묶음(GHSA-4cwx-7wf7-3272 포함)을 제거하기 위해 Wrangler→Miniflare 경유 transitive dependency를 patched `7.29.0`으로 override하고 lockfile을 재생성했다. `npm audit --audit-level=high`를 0건으로 복구하고 release gate가 취약 버전에서 실패-폐쇄하도록 유지한다. +- CI 검증 중 공개된 `undici` 취약점 묶음(GHSA-4cwx-7wf7-3272 포함)을 제거하기 위해 Wrangler→Miniflare 경유 transitive dependency를 patched `7.29.0`으로 override하고 lockfile을 재생성했다. `npm audit --audit-level=high`가 0건으로 통과하여 매일 실패하던 `readiness-audit` 스케줄 및 `release:verify` 게이트를 복구. - EOL 상태인 Node.js 20을 배포 계약에서 제거하고 `engines.node >=22` 및 배포 가이드의 지원 중 LTS 요구사항을 일치시켰다. - SQLite-backed OIDC replay guard의 alarm cleanup을 current-claim-aware 방식으로 강화. Cloudflare alarm의 at-least-once·지연·재시도 실행이 만료 후 교체된 활성 `jti` claim을 삭제하지 않도록 저장된 현재 expiry를 transactionally 재검증하고, 활성 claim이면 해당 만료 시각과 grace period로 reschedule하며 expired/empty storage만 삭제한다. - SQLite-backed `/exchange` rate limiter의 alarm cleanup을 current-window-aware 방식으로 강화. Cloudflare alarm의 지연·재시도 실행이 새 60초 window의 활성 bucket을 삭제해 요청 예산을 조기 재개하지 않도록 저장된 window deadline을 transactionally 재검증하고, 아직 활성인 경우 실제 reset 시각으로 reschedule하며 expired/empty storage만 삭제한다. From f4eed9119952491e01d5547ee5ae281610788576 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:17:18 +0900 Subject: [PATCH 25/30] test(operations): preserve runner audit v1 nested report schema --- ...ner-assignment-cli-semantic-report.test.ts | 85 ++++++++++++++++--- 1 file changed, 74 insertions(+), 11 deletions(-) diff --git a/test/actions-runner-assignment-cli-semantic-report.test.ts b/test/actions-runner-assignment-cli-semantic-report.test.ts index ee9358235..2ad1270d2 100644 --- a/test/actions-runner-assignment-cli-semantic-report.test.ts +++ b/test/actions-runner-assignment-cli-semantic-report.test.ts @@ -32,17 +32,50 @@ function assignedRunApi(path: string) { }; } +function invalidEventRunApi(path: string) { + if (path.endsWith("/attempts/1/jobs?per_page=100")) { + return [{ + jobs: [{ + id: 1001, + name: "verify", + run_attempt: 1, + status: "queued", + conclusion: null, + started_at: null, + completed_at: null, + runner_id: null, + runner_name: null, + }], + }]; + } + + return { + id: 100, + name: "ci", + event: "workflow_dispatch", + head_sha: expectedHead, + run_attempt: 1, + status: "queued", + conclusion: null, + created_at: "2026-09-01T19:00:00.000Z", + }; +} + +function auditEnvironment(): Record { + return { + GH_TOKEN: "present-but-never-retained", + NOEMA_ACTIONS_AUDIT_REPOSITORY: "ContextualWisdomLab/noema", + NOEMA_ACTIONS_AUDIT_HEAD_SHA: expectedHead, + NOEMA_ACTIONS_AUDIT_RUN_IDS: "100", + NOEMA_ACTIONS_AUDIT_QUEUE_GRACE_MILLISECONDS: "1000", + }; +} + describe("runner-assignment CLI semantic report contract", () => { - it("publishes the evaluator semantic decision as the stable operator report", async () => { + it("keeps semantic evaluator names behind the stable schema-version-one check shape", async () => { const writeReport = vi.fn(); const result = await runActionsRunnerAssignmentAudit({ - env: { - GH_TOKEN: "present-but-never-retained", - NOEMA_ACTIONS_AUDIT_REPOSITORY: "ContextualWisdomLab/noema", - NOEMA_ACTIONS_AUDIT_HEAD_SHA: expectedHead, - NOEMA_ACTIONS_AUDIT_RUN_IDS: "100", - NOEMA_ACTIONS_AUDIT_QUEUE_GRACE_MILLISECONDS: "1000", - }, + env: auditEnvironment(), observed_at: "2026-09-01T19:01:00.000Z", gh_api: vi.fn(async (path: string) => assignedRunApi(path)), write_report: writeReport, @@ -51,17 +84,47 @@ describe("runner-assignment CLI semantic report contract", () => { expect(result).toMatchObject({ exit_code: 0, report: { + schema_version: 1, status: "PASS", failures: [], checks: [{ - check_code: "runner_assignment_observed", - check_passed: true, + code: "runner_assignment_observed", + pass: true, + detail: expect.any(String), workflow_run_id: 100, run_attempt: 1, workflow_job_id: 1001, }], }, }); + expect(result.report.checks[0]).not.toHaveProperty("check_code"); + expect(result.report.checks[0]).not.toHaveProperty("check_passed"); + expect(result.report.checks[0]).not.toHaveProperty("check_detail"); expect(writeReport).toHaveBeenCalledWith(result.report); }); -}); + + it("keeps semantic evaluator names behind the stable schema-version-one failure shape", async () => { + const result = await runActionsRunnerAssignmentAudit({ + env: auditEnvironment(), + observed_at: "2026-09-01T19:01:00.000Z", + gh_api: vi.fn(async (path: string) => invalidEventRunApi(path)), + write_report: vi.fn(), + }); + + expect(result).toMatchObject({ + exit_code: 1, + report: { + schema_version: 1, + status: "FAIL", + failures: [{ + code: "workflow_run_event_invalid", + detail: expect.any(String), + workflow_run_id: 100, + run_attempt: 1, + }], + }, + }); + expect(result.report.failures[0]).not.toHaveProperty("failure_code"); + expect(result.report.failures[0]).not.toHaveProperty("failure_detail"); + }); +}); \ No newline at end of file From 083f3e004f3b606aef5241a8582dff9a3763b5f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:18:22 +0900 Subject: [PATCH 26/30] fix(operations): preserve runner audit v1 report schema --- scripts/actions-runner-assignment-audit.mjs | 23 +++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/scripts/actions-runner-assignment-audit.mjs b/scripts/actions-runner-assignment-audit.mjs index 987c8789a..2b06160e3 100644 --- a/scripts/actions-runner-assignment-audit.mjs +++ b/scripts/actions-runner-assignment-audit.mjs @@ -297,6 +297,25 @@ export function writeReportAtomically(auditReport, io = defaultWriteIo) { return reportPath; } +function schemaVersionOneCheck(semanticCheck) { + const { + check_code: code, + check_passed: pass, + check_detail: detail, + ...context + } = semanticCheck; + return { code, pass, detail, ...context }; +} + +function schemaVersionOneFailure(semanticFailure) { + const { + failure_code: code, + failure_detail: detail, + ...context + } = semanticFailure; + return { code, detail, ...context }; +} + /** * Execute the runner-assignment audit from explicit operator inputs. * @@ -363,8 +382,8 @@ export async function runActionsRunnerAssignmentAudit(input) { observed_at: observedAt, queue_grace_milliseconds: queueGrace, status: auditDecision.audit_status, - checks: auditDecision.assignment_checks, - failures: auditDecision.assignment_failures, + checks: auditDecision.assignment_checks.map(schemaVersionOneCheck), + failures: auditDecision.assignment_failures.map(schemaVersionOneFailure), authority: { runner_assignment_only: true, required_check_success: false, From 37e6c9005a4838f7def6be2411305ae631330e10 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:26:05 +0900 Subject: [PATCH 27/30] docs(changelog): restore accurate undici gate history --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e2b7f8b8..15c12af04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Changelog ## Unreleased -- Actions runner-assignment 내부 evidence model을 GitHub REST의 generic `id`/`name`/`event`/`status` 명칭에서 `workflow_run_id`, `workflow_name`, `trigger_event`, `workflow_run_status`, `workflow_job_id`, `workflow_job_status` 등 Noema-owned semantic contract로 변환하고, vendor 필드는 anti-corruption adapter에만 남긴다. 기존 운영자용 schema-v1 report의 `status`/`checks`/`failures` 계약은 유지한다. +- Actions runner-assignment 내부 evidence model을 GitHub REST의 generic `id`/`name`/`event`/`status` 명칭에서 `workflow_run_id`, `workflow_name`, `trigger_event`, `workflow_run_status`, `workflow_job_id`, `workflow_job_status` 등 Noema-owned semantic contract로 변환하고, vendor 필드는 anti-corruption adapter에만 남긴다. 기존 운영자용 schema-v1 report의 `status`/`checks`/`failures`와 nested `code`/`pass`/`detail` 계약은 유지한다. - acquisition transfer evidence에서 실제 배포 가능한 npm package가 존재할 때 `licensing_ip.package_metadata.sha256`을 필수로 요구하고, transfer evidence가 승인한 digest를 parser가 실제로 소비한 exact retained `package.json` bytes와 비교해 package-publication metadata substitution을 실패-폐쇄한다. 현재 private/non-distributed package에는 이 digest 요구를 적용하지 않는다. - Noema의 필수 PR 워크플로 `ci`, `reviewer-ci`, `patch-validator-image`를 부동 `ubuntu-latest` 대신 명시적 `ubuntu-24.04` GitHub-hosted runner에 고정하고, 인용 여부와 무관하게 `ubuntu-latest` 회귀를 탐지하는 계약 테스트를 추가해 pre-checkout runner-assignment stall의 repository-owned selector 원인을 제거한다. 중앙 `Security Scan`의 runner/control-plane 권한은 별도 `.github` owner 경계에 유지한다. - 비공개 취약점 보고 감사가 16 KiB 응답 상한, bounded stream 취소, canonical repository/source identity의 독립 검증, SHA-1/SHA-256 exact revision, symlink·retained-path 보호를 실패-폐쇄로 강제한다. 이 감사 결과는 live private reporting 활성화, notification staffing, 실제 advisory 대응 또는 release/deployment 완료 증거를 대신하지 않는다. @@ -48,7 +48,7 @@ - credential-bearing GitHub App REST 요청의 egress를 exact `https://api.github.com` origin으로 고정. 새 Worker entrypoint가 `/exchange` 전에 `GITHUB_API_BASE`의 scheme·origin·userinfo·port·path·query·fragment를 검증하고, lookalike/malformed 설정은 rate-limit·OIDC parsing·private-key 사용·GitHub API 호출 전에 `503 ERR_GITHUB_API`로 실패-폐쇄하며 허용 값도 canonical origin으로 치환한다. `/health`는 설정 복구 중에도 유지하고 원본 설정값은 응답·로그에 노출하지 않는다. - `src/**/*.ts` 전체에 statements·branches·functions·lines 100% coverage threshold를 강제하고, `/exchange` wrapper·OIDC replay guard·distributed limiter의 fail-closed 및 malformed-decision 경계를 회귀 테스트로 고정했다. 새 source branch가 coverage를 낮추면 CI가 즉시 실패한다. - `/exchange` distributed rate-limit identity가 없는 요청을 shared `unknown` bucket으로 합치지 않고 `503`으로 실패-폐쇄하도록 강화. Cloudflare의 `CF-Connecting-IP`가 정확히 하나의 유효한 IPv4/IPv6가 아니면 Durable Object lookup과 bearer parsing 전에 중단하고, 유효한 IPv6는 canonical form으로 정규화하여 동일 주소의 표기 차이가 rate-limit bucket을 분할하지 않도록 한다. -- CI 검증 중 공개된 `undici` 취약점 묶음(GHSA-4cwx-7wf7-3272 포함)을 제거하기 위해 Wrangler→Miniflare 경유 transitive dependency를 patched `7.29.0`으로 override하고 lockfile을 재생성했다. `npm audit --audit-level=high`가 0건으로 통과하여 매일 실패하던 `readiness-audit` 스케줄 및 `release:verify` 게이트를 복구. +- CI 검증 중 공개된 `undici` 취약점 묶음(GHSA-4cwx-7wf7-3272 포함)을 제거하기 위해 Wrangler→Miniflare 경유 transitive dependency를 patched `7.29.0`으로 override하고 lockfile을 재생성했다. `npm audit --audit-level=high`를 0건으로 복구하고 release gate가 취약 버전에서 실패-폐쇄하도록 유지한다. - EOL 상태인 Node.js 20을 배포 계약에서 제거하고 `engines.node >=22` 및 배포 가이드의 지원 중 LTS 요구사항을 일치시켰다. - SQLite-backed OIDC replay guard의 alarm cleanup을 current-claim-aware 방식으로 강화. Cloudflare alarm의 at-least-once·지연·재시도 실행이 만료 후 교체된 활성 `jti` claim을 삭제하지 않도록 저장된 현재 expiry를 transactionally 재검증하고, 활성 claim이면 해당 만료 시각과 grace period로 reschedule하며 expired/empty storage만 삭제한다. - SQLite-backed `/exchange` rate limiter의 alarm cleanup을 current-window-aware 방식으로 강화. Cloudflare alarm의 지연·재시도 실행이 새 60초 window의 활성 bucket을 삭제해 요청 예산을 조기 재개하지 않도록 저장된 window deadline을 transactionally 재검증하고, 아직 활성인 경우 실제 reset 시각으로 reschedule하며 expired/empty storage만 삭제한다. From f608670c345eaf8d4e34b9bbe18f3b1c3af4ebdc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 12:19:08 +0900 Subject: [PATCH 28/30] test(runner): follow semantic workflow-run evidence contract --- test/actions-runner-assignment-attempt-adapter-arity.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/actions-runner-assignment-attempt-adapter-arity.test.ts b/test/actions-runner-assignment-attempt-adapter-arity.test.ts index cfda4f1eb..e103c750e 100644 --- a/test/actions-runner-assignment-attempt-adapter-arity.test.ts +++ b/test/actions-runner-assignment-attempt-adapter-arity.test.ts @@ -27,6 +27,6 @@ describe("runner-assignment attempt adapter contract", () => { }); expect(observedAttempts).toEqual([2]); - expect(evidence.runs[0]?.run_attempt).toBe(2); + expect(evidence.workflow_runs[0]?.run_attempt).toBe(2); }); }); From 162c0abcf8c892e4ff104dc8c71fae7b4aaad58c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 13:20:35 +0900 Subject: [PATCH 29/30] refactor(naming): qualify runner evidence internals --- .../lib/actions-runner-assignment-source.mjs | 116 +++++++++--------- 1 file changed, 58 insertions(+), 58 deletions(-) diff --git a/scripts/lib/actions-runner-assignment-source.mjs b/scripts/lib/actions-runner-assignment-source.mjs index 927b8d290..e68b78392 100644 --- a/scripts/lib/actions-runner-assignment-source.mjs +++ b/scripts/lib/actions-runner-assignment-source.mjs @@ -2,43 +2,43 @@ const MAX_SELECTED_RUNS = 20; const MAX_SELECTED_JOBS = 2000; const MAX_RUN_ID_TEXT_BYTES = 1000; -function positiveSafeInteger(value) { - return Number.isSafeInteger(value) && value > 0; +function positiveSafeInteger(integerCandidate) { + return Number.isSafeInteger(integerCandidate) && integerCandidate > 0; } /** * Parse a bounded comma-separated GitHub Actions run-id selection. * - * @param {unknown} value Operator-supplied comma-separated numeric run IDs. + * @param {unknown} runIdText Operator-supplied comma-separated numeric run IDs. * @returns {number[]} Unique positive run IDs in the supplied order. */ -export function parseSelectedRunIds(value) { - if (typeof value !== "string" || value.trim().length === 0) { +export function parseSelectedRunIds(runIdText) { + if (typeof runIdText !== "string" || runIdText.trim().length === 0) { throw new Error("Select at least one GitHub Actions run id."); } - if (Buffer.byteLength(value, "utf8") > MAX_RUN_ID_TEXT_BYTES) { + if (Buffer.byteLength(runIdText, "utf8") > MAX_RUN_ID_TEXT_BYTES) { throw new Error(`Run-id selection must be at most ${MAX_RUN_ID_TEXT_BYTES} bytes.`); } - const tokens = value.split(",").map((token) => token.trim()); - if (tokens.length > MAX_SELECTED_RUNS) { + const runIdTokens = runIdText.split(",").map((runIdToken) => runIdToken.trim()); + if (runIdTokens.length > MAX_SELECTED_RUNS) { throw new Error(`Select at most ${MAX_SELECTED_RUNS} workflow runs per audit.`); } const runIds = []; - const seen = new Set(); - for (const token of tokens) { - if (!/^[1-9][0-9]*$/.test(token)) { + const seenRunIds = new Set(); + for (const runIdToken of runIdTokens) { + if (!/^[1-9][0-9]*$/.test(runIdToken)) { throw new Error("Every selected GitHub Actions run id must be a positive integer."); } - const runId = Number(token); + const runId = Number(runIdToken); if (!positiveSafeInteger(runId)) { throw new Error("Every selected GitHub Actions run id must be a positive integer."); } - if (seen.has(runId)) { + if (seenRunIds.has(runId)) { throw new Error("Selected GitHub Actions run ids must be unique."); } - seen.add(runId); + seenRunIds.add(runId); runIds.push(runId); } @@ -52,57 +52,57 @@ export function parseSelectedRunIds(value) { * that accepts that vendor shape before runner-assignment evidence is projected * into ContextualWisdomLab-owned semantic names. * - * @param {unknown} pages Slurped `gh api --paginate` page objects. + * @param {unknown} jobPages Slurped `gh api --paginate` page objects. * @returns {object[]} A bounded list containing every GitHub workflow job payload. */ -export function flattenJobPages(pages) { - if (!Array.isArray(pages)) { +export function flattenJobPages(jobPages) { + if (!Array.isArray(jobPages)) { throw new Error("Workflow job pages must be supplied as an array."); } - const jobs = []; - for (const page of pages) { - if (!page || typeof page !== "object" || !Array.isArray(page.jobs)) { + const workflowJobPayloads = []; + for (const jobPage of jobPages) { + if (!jobPage || typeof jobPage !== "object" || !Array.isArray(jobPage.jobs)) { throw new Error("Each workflow job page must contain a jobs array."); } - if (jobs.length + page.jobs.length > MAX_SELECTED_JOBS) { + if (workflowJobPayloads.length + jobPage.jobs.length > MAX_SELECTED_JOBS) { throw new Error(`Workflow job evidence exceeds the ${MAX_SELECTED_JOBS}-job bound.`); } - jobs.push(...page.jobs); + workflowJobPayloads.push(...jobPage.jobs); } - return jobs; + return workflowJobPayloads; } -function projectRun(run) { - if (!run || typeof run !== "object") { +function projectRun(workflowRunPayload) { + if (!workflowRunPayload || typeof workflowRunPayload !== "object") { throw new Error("GitHub workflow-run evidence must be an object."); } return { - workflow_run_id: run.id, - workflow_name: run.name, - trigger_event: run.event, - head_sha: run.head_sha, - run_attempt: run.run_attempt, - workflow_run_status: run.status, - workflow_conclusion: run.conclusion, - created_at: run.created_at, + workflow_run_id: workflowRunPayload.id, + workflow_name: workflowRunPayload.name, + trigger_event: workflowRunPayload.event, + head_sha: workflowRunPayload.head_sha, + run_attempt: workflowRunPayload.run_attempt, + workflow_run_status: workflowRunPayload.status, + workflow_conclusion: workflowRunPayload.conclusion, + created_at: workflowRunPayload.created_at, }; } -function projectJob(job) { - if (!job || typeof job !== "object") { +function projectJob(workflowJobPayload) { + if (!workflowJobPayload || typeof workflowJobPayload !== "object") { throw new Error("GitHub workflow-job evidence must be an object."); } return { - workflow_job_id: job.id, - workflow_job_name: job.name, - run_attempt: job.run_attempt, - workflow_job_status: job.status, - workflow_job_conclusion: job.conclusion, - started_at: job.started_at, - completed_at: job.completed_at, - runner_id: job.runner_id, - runner_name: job.runner_name, + workflow_job_id: workflowJobPayload.id, + workflow_job_name: workflowJobPayload.name, + run_attempt: workflowJobPayload.run_attempt, + workflow_job_status: workflowJobPayload.status, + workflow_job_conclusion: workflowJobPayload.conclusion, + started_at: workflowJobPayload.started_at, + completed_at: workflowJobPayload.completed_at, + runner_id: workflowJobPayload.runner_id, + runner_name: workflowJobPayload.runner_name, }; } @@ -121,30 +121,30 @@ function projectJob(job) { * predecessor-attempt runner identity. JavaScript function arity is not used as * an authority signal because default/rest parameters make `.length` non-semantic. * - * @param {object} input Source identity, selected runs, and read adapters. + * @param {object} collectionRequest Source identity, selected runs, and read adapters. * @returns {Promise} Evidence ready for deterministic assignment evaluation. */ -export async function collectRunnerAssignmentEvidence(input) { - if (!input || typeof input !== "object") { +export async function collectRunnerAssignmentEvidence(collectionRequest) { + if (!collectionRequest || typeof collectionRequest !== "object") { throw new Error("Runner-assignment source input must be an object."); } - if (!Array.isArray(input.run_ids) || input.run_ids.length === 0) { + if (!Array.isArray(collectionRequest.run_ids) || collectionRequest.run_ids.length === 0) { throw new Error("At least one selected workflow run is required."); } - if (input.run_ids.length > MAX_SELECTED_RUNS) { + if (collectionRequest.run_ids.length > MAX_SELECTED_RUNS) { throw new Error(`Select at most ${MAX_SELECTED_RUNS} workflow runs per audit.`); } - if (!input.run_ids.every(positiveSafeInteger) || new Set(input.run_ids).size !== input.run_ids.length) { + if (!collectionRequest.run_ids.every(positiveSafeInteger) || new Set(collectionRequest.run_ids).size !== collectionRequest.run_ids.length) { throw new Error("Selected workflow run ids must be unique positive integers."); } - if (typeof input.fetch_run !== "function" || typeof input.fetch_job_pages !== "function") { + if (typeof collectionRequest.fetch_run !== "function" || typeof collectionRequest.fetch_job_pages !== "function") { throw new Error("Read-only workflow-run and job-page adapters are required."); } const workflowRuns = []; let selectedJobCount = 0; - for (const runId of input.run_ids) { - const workflowRun = projectRun(await input.fetch_run(runId)); + for (const runId of collectionRequest.run_ids) { + const workflowRun = projectRun(await collectionRequest.fetch_run(runId)); if (workflowRun.workflow_run_id !== runId) { throw new Error("Fetched workflow run id must equal the selected workflow run id."); } @@ -156,7 +156,7 @@ export async function collectRunnerAssignmentEvidence(input) { workflowRun.head_sha, workflowRun.run_attempt, ]); - const jobPages = await input.fetch_job_pages(runId, workflowRun.run_attempt); + const jobPages = await collectionRequest.fetch_job_pages(runId, workflowRun.run_attempt); const workflowJobs = flattenJobPages(jobPages).map(projectJob); for (const workflowJob of workflowJobs) { if (!positiveSafeInteger(workflowJob.run_attempt)) { @@ -166,7 +166,7 @@ export async function collectRunnerAssignmentEvidence(input) { throw new Error("Workflow job run_attempt must equal the selected workflow run_attempt."); } } - const currentWorkflowRun = projectRun(await input.fetch_run(runId)); + const currentWorkflowRun = projectRun(await collectionRequest.fetch_run(runId)); const currentRunAuthority = JSON.stringify([ currentWorkflowRun.workflow_run_id, currentWorkflowRun.head_sha, @@ -183,9 +183,9 @@ export async function collectRunnerAssignmentEvidence(input) { } return { - expected_head_sha: input.expected_head_sha, - observed_at: input.observed_at, - queue_grace_milliseconds: input.queue_grace_milliseconds, + expected_head_sha: collectionRequest.expected_head_sha, + observed_at: collectionRequest.observed_at, + queue_grace_milliseconds: collectionRequest.queue_grace_milliseconds, workflow_runs: workflowRuns, }; } From 3f96cef5ae2077415041f40d8e98a34007a03892 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:00:31 +0900 Subject: [PATCH 30/30] test(reviewer): inherit CodeGraph probe-budget boundary --- reviewer/tests/test_codegraph_symbol_seed_boundary.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reviewer/tests/test_codegraph_symbol_seed_boundary.py b/reviewer/tests/test_codegraph_symbol_seed_boundary.py index 85092f2fb..60449a7c8 100644 --- a/reviewer/tests/test_codegraph_symbol_seed_boundary.py +++ b/reviewer/tests/test_codegraph_symbol_seed_boundary.py @@ -121,7 +121,7 @@ def fake_regular_file(_source_root: str, candidate: str) -> bool: assert token_count <= cli.MAX_CODEGRAPH_CHANGED_SCOPE_TOKENS assert cli._codegraph_changed_paths(query, "/target") == [] - assert probes == cli.MAX_CODEGRAPH_CHANGED_SCOPE_PATH_PROBES + 1 + assert probes == cli.MAX_CODEGRAPH_CHANGED_SCOPE_PATH_PROBES @pytest.mark.parametrize(