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 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 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 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 diff --git a/scripts/actions-runner-assignment-audit.mjs b/scripts/actions-runner-assignment-audit.mjs index 47de0f476..2b06160e3 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,12 +291,31 @@ 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; } +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. * @@ -353,7 +372,7 @@ export async function runActionsRunnerAssignmentAudit(input) { fetch_run: adapters.fetch_run, fetch_job_pages: adapters.fetch_job_pages, }); - const decision = evaluateRunnerAssignmentEvidence(evidence); + const auditDecision = evaluateRunnerAssignmentEvidence(evidence); const report = { schema_version: 1, objective: "github_actions_runner_assignment", @@ -362,9 +381,9 @@ export async function runActionsRunnerAssignmentAudit(input) { selected_run_ids: runIds, observed_at: observedAt, queue_grace_milliseconds: queueGrace, - status: decision.status, - checks: decision.checks, - failures: decision.failures, + status: auditDecision.audit_status, + checks: auditDecision.assignment_checks.map(schemaVersionOneCheck), + failures: auditDecision.assignment_failures.map(schemaVersionOneFailure), authority: { runner_assignment_only: true, required_check_success: false, @@ -377,7 +396,7 @@ export async function runActionsRunnerAssignmentAudit(input) { await input.write_report(report); return { - exit_code: decision.status === "PASS" ? 0 : 1, + exit_code: auditDecision.audit_status === "PASS" ? 0 : 1, report, }; } 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 +} diff --git a/scripts/lib/actions-runner-assignment-source.mjs b/scripts/lib/actions-runner-assignment-source.mjs index d4c68ce14..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); } @@ -48,57 +48,61 @@ export function parseSelectedRunIds(value) { /** * Flatten paginated GitHub Actions job pages without trusting page shape. * - * @param {unknown} pages Slurped `gh api --paginate` page objects. - * @returns {object[]} A bounded list containing every job from every page. + * 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} 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 { - id: run.id, - name: run.name, - event: run.event, - head_sha: run.head_sha, - run_attempt: run.run_attempt, - status: run.status, - 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 { - id: job.id, - name: job.name, - run_attempt: job.run_attempt, - status: job.status, - 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, }; } @@ -107,73 +111,82 @@ 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. * - * @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 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) { + 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."); } - 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 collectionRequest.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 collectionRequest.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, + expected_head_sha: collectionRequest.expected_head_sha, + observed_at: collectionRequest.observed_at, + queue_grace_milliseconds: collectionRequest.queue_grace_milliseconds, + workflow_runs: workflowRuns, }; } 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 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); }); }); 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, }], }); }); 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 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..2ad1270d2 --- /dev/null +++ b/test/actions-runner-assignment-cli-semantic-report.test.ts @@ -0,0 +1,130 @@ +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", + }; +} + +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("keeps semantic evaluator names behind the stable schema-version-one check shape", async () => { + const writeReport = vi.fn(); + const result = await runActionsRunnerAssignmentAudit({ + env: auditEnvironment(), + 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: { + schema_version: 1, + status: "PASS", + failures: [], + checks: [{ + 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 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 diff --git a/test/actions-runner-assignment-evidence-integrity.test.ts b/test/actions-runner-assignment-evidence-integrity.test.ts index 4bb5cc060..f5d3566d3 100644 --- a/test/actions-runner-assignment-evidence-integrity.test.ts +++ b/test/actions-runner-assignment-evidence-integrity.test.ts @@ -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, 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"); + }); +}); 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); }); 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" }), ); }); }); diff --git a/test/actions-runner-assignment-token-capability.test.ts b/test/actions-runner-assignment-token-capability.test.ts index 73932169a..0f787fd2f 100644 --- a/test/actions-runner-assignment-token-capability.test.ts +++ b/test/actions-runner-assignment-token-capability.test.ts @@ -97,6 +97,7 @@ 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: 1, status: "PASS", expected_head_sha: expectedHead, }); 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();