diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c29424..92def7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,28 @@ jobs: - name: Production dependency audit run: npm audit --omit=dev --audit-level=high + pnpm-oci: + name: pnpm-oci + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Set up Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .node-version + cache: npm + - name: Install without lifecycle scripts + run: npm ci --ignore-scripts + - name: Build Mill for the OCI canary + run: npm run build + - name: Run the pinned pnpm workspace OCI canary + run: node scripts/qualify-pnpm-oci.mjs + dependency-review: name: dependency-review if: github.event_name == 'pull_request' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 58eb823..5b8685c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -337,6 +337,11 @@ jobs: gh api "repos/$GITHUB_REPOSITORY/actions/runs/$CANDIDATE_RUN_ID" > "$RUNNER_TEMP/candidate-run.json" gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/actions/runs/$CANDIDATE_RUN_ID/jobs?per_page=100" > "$RUNNER_TEMP/candidate-jobs.json" node scripts/verify-independent-release.mjs "$RUNNER_TEMP/qualified" "$RUNNER_TEMP/trusted/trusted-verifier.json" "$RUNNER_TEMP/candidate-run.json" "$RUNNER_TEMP/candidate-jobs.json" "$RUNNER_TEMP/identity.json" + - name: Bind candidate and publish workflow identities + env: + CANDIDATE_RUN_ID: ${{ inputs.candidate_run_id }} + run: | + node -e 'const fs=require("node:fs");const candidate=JSON.parse(fs.readFileSync(process.env.RUNNER_TEMP+"/candidate-run.json","utf8"));const current={id:String(process.env.GITHUB_RUN_ID),url:`${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`,headCommit:process.env.GITHUB_SHA};const bound={candidate:{id:String(candidate.id),url:candidate.html_url,headCommit:candidate.head_sha},publish:current};if(!/^[1-9][0-9]*$/.test(bound.candidate.id)||!/^[1-9][0-9]*$/.test(bound.publish.id)||typeof bound.candidate.url!=="string"||!/^[a-f0-9]{40}$/.test(bound.candidate.headCommit)||!/^[a-f0-9]{40}$/.test(bound.publish.headCommit))throw new Error("invalid workflow run identity");fs.writeFileSync(process.env.RUNNER_TEMP+"/workflow-runs.json",`${JSON.stringify(bound)}\n`,{flag:"wx",mode:0o600})' - name: Verify preserved public-alpha qualification run: | qualification_input=.mill-release-qualification.json @@ -353,9 +358,10 @@ jobs: node scripts/assemble-release-evidence.mjs \ "$RUNNER_TEMP/qualified/artifact-metadata.json" \ "$RUNNER_TEMP/qualified/qualification.json" \ - "$RUNNER_TEMP/qualified/sbom.cdx.json" \ - "$RUNNER_TEMP/identity.json" \ - "$RUNNER_TEMP/qualified/release-evidence-prepublication.json" + "$RUNNER_TEMP/qualified/sbom.cdx.json" \ + "$RUNNER_TEMP/identity.json" \ + "$RUNNER_TEMP/qualified/release-evidence-prepublication.json" \ + - - "$RUNNER_TEMP/workflow-runs.json" - name: Prepare the pinned verifier before immutable publication id: prepare-release-verifier run: | @@ -399,8 +405,15 @@ jobs: --notes-file "$notes_file" \ "$artifact" "$checksum" \ "$RUNNER_TEMP/qualified/sbom.cdx.json" \ - "$RUNNER_TEMP/qualified/release-evidence-prepublication.json" - - name: Read back GitHub Release and finalize evidence + "$RUNNER_TEMP/qualified/release-evidence-prepublication.json" \ + "$RUNNER_TEMP/qualified/qualification.json" \ + "$RUNNER_TEMP/qualified/artifact-metadata.json" \ + "$RUNNER_TEMP/qualified/audit.json" \ + "$RUNNER_TEMP/qualified/identity.json" \ + "$RUNNER_TEMP/qualified/release-canary.json" \ + "$RUNNER_TEMP/trusted/trusted-verifier.json" \ + "$RUNNER_TEMP/trusted/trusted-canary.json" + - name: Read back draft GitHub Release evidence env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.tag }} @@ -408,7 +421,7 @@ jobs: artifact=$(find "$RUNNER_TEMP/qualified" -maxdepth 1 -type f -name '*.tgz') filename=$(basename "$artifact") mkdir -p "$RUNNER_TEMP/github-download" - gh release view "$RELEASE_TAG" --json url,tagName,assets > "$RUNNER_TEMP/github-release.json" + gh release view "$RELEASE_TAG" --json url,tagName,isDraft,isPrerelease,publishedAt,databaseId,assets > "$RUNNER_TEMP/github-release.json" node -e 'const fs=require("node:fs");const file=process.argv[1];const release=JSON.parse(fs.readFileSync(file,"utf8"));release.url=`${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/releases/tag/${encodeURIComponent(process.env.RELEASE_TAG)}`;fs.writeFileSync(file,`${JSON.stringify(release)}\n`,{flag:"w",mode:0o600})' "$RUNNER_TEMP/github-release.json" gh release download "$RELEASE_TAG" --pattern "$filename" --dir "$RUNNER_TEMP/github-download" node scripts/capture-release-readback.mjs \ @@ -423,8 +436,43 @@ jobs: "$RUNNER_TEMP/qualified/qualification.json" \ "$RUNNER_TEMP/qualified/sbom.cdx.json" \ "$RUNNER_TEMP/identity.json" \ - "$RUNNER_TEMP/release-evidence-final.json" \ + "$RUNNER_TEMP/release-evidence-draft.json" \ "$RUNNER_TEMP/registry-readback.json" \ - "$RUNNER_TEMP/github-readback.json" - gh release upload "$RELEASE_TAG" "$RUNNER_TEMP/release-evidence-final.json" + "$RUNNER_TEMP/github-readback.json" \ + "$RUNNER_TEMP/workflow-runs.json" + gh release upload "$RELEASE_TAG" "$RUNNER_TEMP/release-evidence-draft.json" + - name: Publish GitHub Release after draft evidence readback + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + run: | gh release edit "$RELEASE_TAG" --draft=false + - name: Read back published GitHub Release and attach final evidence + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + artifact=$(find "$RUNNER_TEMP/qualified" -maxdepth 1 -type f -name '*.tgz') + filename=$(basename "$artifact") + rm -rf "$RUNNER_TEMP/github-download" + mkdir -p "$RUNNER_TEMP/github-download" + gh release view "$RELEASE_TAG" --json url,tagName,isDraft,isPrerelease,publishedAt,databaseId,assets > "$RUNNER_TEMP/github-release-published.json" + node -e 'const fs=require("node:fs");const file=process.argv[1];const release=JSON.parse(fs.readFileSync(file,"utf8"));release.url=`${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/releases/tag/${encodeURIComponent(process.env.RELEASE_TAG)}`;fs.writeFileSync(file,`${JSON.stringify(release)}\n`,{flag:"w",mode:0o600})' "$RUNNER_TEMP/github-release-published.json" + gh release download "$RELEASE_TAG" --pattern "$filename" --dir "$RUNNER_TEMP/github-download" + node scripts/capture-release-readback.mjs \ + "$RUNNER_TEMP/qualified/artifact-metadata.json" \ + "$RUNNER_TEMP/registry-dist.json" \ + "$RUNNER_TEMP/github-release-published.json" \ + "$RUNNER_TEMP/github-download/$filename" \ + "$RUNNER_TEMP/registry-readback-published.json" \ + "$RUNNER_TEMP/github-readback-published.json" + node scripts/assemble-release-evidence.mjs \ + "$RUNNER_TEMP/qualified/artifact-metadata.json" \ + "$RUNNER_TEMP/qualified/qualification.json" \ + "$RUNNER_TEMP/qualified/sbom.cdx.json" \ + "$RUNNER_TEMP/identity.json" \ + "$RUNNER_TEMP/release-evidence-final.json" \ + "$RUNNER_TEMP/registry-readback-published.json" \ + "$RUNNER_TEMP/github-readback-published.json" \ + "$RUNNER_TEMP/workflow-runs.json" + gh release upload "$RELEASE_TAG" "$RUNNER_TEMP/release-evidence-final.json" diff --git a/AGENTS.md b/AGENTS.md index a1f20fa..48fdfec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,20 +134,23 @@ two-step plan/apply wrapper, never as implicit push authority. alter command controls, grant tools, credentials, delivery, merge or release authority, or update itself through the run it informed. - `adopt-native` is experimental Node package-manager adoption preserving - existing source and native commands. The source-only pnpm path accepts one - declared shallow workspace shape, exact manager/lock inputs, and no lifecycle - build exceptions. It is not the qualified web-recipe adoption path and does - not establish arbitrary-stack or published pnpm support. + existing source and native commands. One real OCI canary exercised a declared + shallow pnpm workspace with exact manager/lock inputs and no lifecycle build + exceptions. It is not the qualified web-recipe adoption path and does not + establish arbitrary-stack, native-dependency, or published pnpm support. - Documentation work uses `docs/writing.md` and, when selected, the digest-pinned `writing-quality` playbook. The guide can improve prose and review; it cannot change acceptance, command controls, delivery, merge, or release authority. - Report measured, partial and unavailable usage truthfully. Routine output must not expose private emails, commit trailers, raw worker context or logs. -- `stats` and `report` are read-only, redacted local aggregates. A report's - self-hosting rate is meaningful only when the repository has explicitly set - `reporting.selfHosted: true`; it is not a productivity or customer-value - measure. +- `stats` and `report` are read-only, redacted local aggregates. `report` uses + an explicitly declared development-evidence ledger for eligible-change and + route counts; it does not infer a productivity or customer-value measure from + lifecycle state. +- Retained verifier artifacts are opt-in command outputs. The contract fixes + relative paths and size limits; routine `artifacts` output exposes only their + candidate-bound descriptor, never bytes or state-store paths. - `continuation` is a read-only, versioned state projection. It may name one attended safe next action but must never perform that action, expose a worktree/prompt/delivery receipt, or route an uncertain worker or external @@ -257,9 +260,12 @@ millctl --json support-bundle --run retains evidence and is not successful apply. Neither reconciliation nor abandonment grants new authority or performs the original effect. - Restore validates the database and quarantines newer unreferenced worktrees. - Purge is allowed only after all runs are reviewed or terminal. Run purge from - a surviving original checkout, never a worktree scheduled for deletion. - Preserve an external state backup and candidate branches first. + Diagnostic commands open only an existing current schema and never create, + lock, or upgrade it. A mutating open takes the writer lease, validates foreign + keys, and preserves the pre-upgrade database before a forward migration. Purge + is allowed only after all runs are reviewed or terminal. Run purge from a + surviving original checkout, never a worktree scheduled for deletion. Preserve + an external state backup and candidate branches first. - Nested unresolved effects override enclosing run status: no repair, new delivery, terminal cancellation, purge or restore may supersede their journal. Confirmed merge freezes the candidate until exact post-merge finalization. @@ -339,188 +345,9 @@ Stop and report the exact blocker when: - release identity or provenance cannot be reconstructed from exact evidence; - the same subsystem produces recurring P0/P1 review findings. -## Approved one-time maintainer bootstrap - -David Ahmann approved MB-001 on 2026-09-03T23:10:55.000Z in the attended -conversation. The approved amendment digest is -`sha256:9b44a0a76a56cacee51eef8664c66287e8a950796e65a8886f62cb7e71797b2c`. -Approval expires 2026-09-04T23:10:55.000Z; the current execution deadline is -2026-09-04T01:10:55.000Z. One repair generation is permitted. - -The following exact approved proposal text defines the exception. Its original -proposed-status wording records the approval artifact; the approval above -activates only its stated scope. Canonical task and impact authority are -`product/tasks/MAINTAINER_VERIFIER_BOOTSTRAP.yaml` and -`product/impacts/MAINTAINER_VERIFIER_BOOTSTRAP.yaml`. - -### MB-001: one-time maintainer bootstrap amendment - -Status: proposed; requires David Ahmann's explicit approval of these exact -bytes. This is not active authority and does not amend AGENTS.md by being -written here. - -## Exact scope - -Source base: ceaf76a0e4b8237d2dcb0d016ed84e3c9ba5cfb8. Task: -mill-maintainer-verifier-bootstrap. Owner and approval authority: David Ahmann. -One writer in the existing disposable codex/brownfield-authority worktree. - -The user approved provisioning the dedicated maintainer verifier as the -prerequisite to brownfield implementation. Provisioning exposed a bootstrap -cycle: the native command controls must change before the first self-hosted -baseline can pass. The current AGENTS.md requires that passing baseline before -any such implementation. This amendment resolves only that cycle. - -## Bound approval bundle - -Approval must name this amendment and both exact proposal files: - -- task.yaml file digest: - sha256:22a3f98a13041cf86d8c48fcc963ae817fbdabcd9a07896de1ce270641d09c22 -- impact.json file digest: - sha256:049cf351d32d6e5c4f4aee13eb316d186993bcc9a948a17aeada703b617af8fd - -The task remains proposed and impact approval remains null until that owner -decision. Before implementation, the attended authority-preparation phase may -copy the task to its canonical path with status approved, copy the impact to its -canonical path with the actual owner/UTC approval record and its recomputed -canonical proposal digest, and insert this exact scoped exception in AGENTS.md. -Those are the only approval-materialization changes permitted by this bundle; -any scope, acceptance, constraint, image or budget change needs new approval. -Record and freeze the resulting authority-file digests before the writer starts. - -AGENTS.md and product/task/impact files are authority-preparation paths, not -builder-writable output. After implementation, only the attended maintainer may -record evidence and mechanical closure. Any candidate-byte change invalidates -earlier exact-candidate review and audit. The builder cannot write or approve -its own authority, close its task, or substitute new tests for prior oracles. - -## Proposed exception - -For this bootstrap task only, replace AGENTS.md's self-hosted baseline/run and -lifecycle-owned commit prerequisite with WORKFLOW.md's native maintainer path: -freeze the approved task and impact; preserve the exact prior test assertions; -implement in the disposable worktree; run the full native npm gate; create the -exact candidate commit; then obtain fresh read-only local Codex review and -audit. - -This bootstrap uses a frozen maintainer task brief rather than claiming a Mill -runtime run or a runtime baseline digest. The version-2 runtime execution -requirement resumes unchanged for the subsequent brownfield task. - -Before the bootstrap can be called complete, run the full native gate again in -the pre-provisioned digest-pinned OCI verifier with no network, read-only source -and dependencies, bounded resources, and declared scratch. Any missing or -failing required check blocks completion. Provisioned image availability alone -is not qualification. Preserve the failed pre-bootstrap OCI baseline as red -evidence; do not relabel it as a passing baseline or invent an approval digest. - -The frozen ceaf76a test assertions and coverage thresholds remain independent -preservation evidence. New tests and changed command controls are future-use or -supplementary evidence, not independent certification of themselves. A separate -reviewer must inspect the complete control change, source immutability, offline -installation, cleanup, scratch bounds, and unchanged acceptance criteria. - -Allowed paths for this exception, separated by role as above: - -- AGENTS.md: insert this scoped exception and record its closure only. -- mill.yaml: add the exact maintainer-only commands, image and build trust. -- package.json: native test config-loader options only; no version, dependency, - lifecycle-hook, acceptance or coverage-threshold changes. -- scripts/clean.mjs: clear declared generated outputs without deleting mounted - output roots; preserve failures and reject unsafe output-path indirection. -- vitest.config.ts: relocate transient caches/reports into declared scratch; - preserve all tests, exclusions, assertions, timeouts and coverage thresholds. -- .gitignore and .prettierignore: exclude only declared generated scratch. -- scripts/maintainer-verifier/: explicit image/cache preparation and preflight, - with no implicit network in verification and no forge credentials. -- test/maintainer-verifier.test.ts: supplementary bootstrap regression cases. -- docs/development.md and docs/canaries/maintainer-verifier.md: truthful - procedure, exact evidence, limits, ownership and recovery. -- product/tasks/MAINTAINER_VERIFIER_BOOTSTRAP.yaml and - product/impacts/MAINTAINER_VERIFIER_BOOTSTRAP.yaml: this task's scope, impact, - owner decision and closure. - -Do not change src/runtime/verifier.ts, existing test assertions, product -behavior, recipe compatibility, dependency versions, release workflow, branch -protection, or external-effect policy under this exception. If the stated path -set cannot satisfy the unchanged full gate, stop and return the concrete -failure. - -## Exit and expiry - -Approval expires 24 hours after its first recorded owner-approval receipt, even -if work pauses or validation remains blocked. The maximum execution budget is -two hours with one repair generation; neither resumption nor a new review grants -more time or attempts. Scope/base drift, exhausted budgets or expiry stops new -implementation and effects and requires fresh owner approval of the then-current -exact bundle. Never refresh the original approval timestamp. Safe read-only -diagnosis and truthful recording of already observed results remain permitted. - -After independent review and a passing native/OCI gate, freeze command controls -and the maintainer verifier identity as a new exact base. Close this exception -before any brownfield builder run. The brownfield task must use a version-2 -packet, approved product/scenario/impact closure, genuine baseline qualification -and its exact human-approved digest. The exception cannot qualify a changed -image, command set, candidate, or unrelated task. - -Exact PR-plan approval, attended unchanged-candidate delivery, draft-only PRs, -human readiness and merge, authoritative resulting-main checks, and separately -authorized tag/npm/release effects remain required. This amendment does not -authorize direct main writes, force pushes, merge, publication, or a support -claim. - -### MB-001-A1: approved executable-fixture scratch repair - -On 2026-09-03T23:54:25.000Z David Ahmann approved continuing and shipping after -the reported MB-001 blocker, including the explicitly requested narrow -verifier-policy expansion. This addendum authorizes that expansion only; the -original approval receipt, expiry and execution deadline above are unchanged. -Repair starts from exact local candidate -`879522e1de68500a9970ddc66558772bc504ed05`. - -The command contract may add an explicit, optional executable-fixture-scratch -grant for OCI test/package commands. With that grant only, the verifier may add -one fixed `/mill-fixtures` tmpfs outside `/workspace`, bounded to 256 MiB, -`exec,nosuid,nodev`, and owned by the same disposable container lifecycle. No -arbitrary mount path, extra host bind, privilege, network, source write or -dependency write is authorized. Existing commands without this grant keep their -exact default containment. All other scratch remains noexec. The native -maintainer runner must place temporary fixtures outside the repository and keep -the offline cache copy in its existing declared scratch. - -Additional builder paths are `src/runtime/verifier.ts`, -`src/contracts/schemas.ts`, `schemas/mill-config.schema.json`, -`architecture/ARCHITECTURE.md`, and `CHANGELOG.md`, solely for this additive -contract, enforcement, generated schema and matching documentation. Regression -tests may be added to `test/maintainer-verifier.test.ts`; all pre-existing test -file bytes, assertions, exclusions, timeouts and thresholds remain frozen. The -active task and impact may be updated in the attended authority-preparation -phase to record this owner decision and are frozen again before implementation. - -The independent baseline remains the preserved ceaf76a test suite. The repair -must prove default denial, explicit bounded opt-in, unchanged source/dependency -immutability and network denial, cleanup, the full host and real OCI native -gate, fresh exact-candidate read-only review and audit. Old failed evidence is -retained. The prior review's known noexec finding is the design input to this -one repair, not a waived finding. Unrelated product behavior, recipe support, -dependencies, release policy and human readiness/merge boundaries are unchanged. - -### MB-001 closure - -The attended maintainer closed MB-001 and MB-001-A1 on 2026-09-04T00:17:38.000Z -after full native host/OCI qualification, independent read-only review with no -actionable findings, and the native nine-category audit of implementation commit -`2c90f3d7a6c5ae9041b997de4dcfd6fe8551741e`, tree -`a4c0589d4a4e294e2dcac71719c476e83747ae13`. Exact results and retained failures -are in `docs/canaries/maintainer-verifier.md`. - -The exception is historical authority only and may not be reused for further -implementation. Its containing closure commit must receive fresh full native -host/OCI checks, exact read-only review and audit before promotion; any failure -blocks. Once those pass, freeze that final commit and the unchanged verifier -image/command controls for the next task. Normal version-2 admission, -independent acceptance evidence and exact baseline approval apply to brownfield -work. Exact PR-plan approval, human readiness/merge and separately authorized -release effects remain unchanged. Original approval timestamps and budgets are -retained. +## Historical maintainer bootstrap + +MB-001 and MB-001-A1 expired and closed on 2026-09-04. They grant no current +authority. Their scope, approval receipts, limits, and closure are preserved in +[the historical bootstrap record](docs/history/maintainer-verifier-bootstrap-authority.md). +Use the current product/task/impact bundle and this contract for new work. diff --git a/CHANGELOG.md b/CHANGELOG.md index 81c5edb..bb64490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,33 @@ All notable changes follow Keep a Changelog and Semantic Versioning. ## [Unreleased] +## [0.7.0] - 2026-09-16 + +### Added + +- Add a read-only `init propose` cross-check that reports coherent draft inputs + without compiling mismatched or unapproved authority into a task. +- Add explicit, bounded verifier artifact retention and a redacted development + evidence ledger for measured route and effort facts. +- Add a reproducible pnpm workspace OCI canary and two disposable historical + maintenance replays with frozen application checks and explicit limits. + +### Fixed + +- Keep unclassified required GitHub feedback visible, require an explicit + `APPROVED` review state, and permit safe cancellation from ordinary + `awaiting_human` state while preserving delivery receipts. +- Make diagnostic state opens non-mutating and harden forward migration, backup, + restore, foreign-key, and historical-state recovery behavior. +- Remove raw worker stderr from routine public error envelopes. + +### Changed + +- Retain draft and published GitHub Release evidence in order, with source, + workflow, package, qualification, registry, and observed-release bindings. +- Rewrite first-path, reporting, development, and operator guidance around the + supported attended flow and its limits. + ## [0.6.0] - 2026-09-15 ### Added diff --git a/README.md b/README.md index 0ea82bf..df36659 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,17 @@ Mill makes those boundaries explicit: Mill is a small delivery control plane around the coding agent you already use. It makes scope, evidence, and external actions inspectable. +## Capability status + +| Capability | Status | Evidence and limit | +| -------------------------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| Core Node/npm delivery path | Qualified for the release-specific support tuple | See the release evidence attached to the selected version. | +| Proposal assessment | Shipped and exercised | `init propose` is read-only; approval remains outside Mill. | +| Cancellation, diagnostics, and state upgrade | Shipped and tested | Diagnostics do not create or upgrade state. A mutating command takes the writer lease and preserves a pre-upgrade backup. | +| Retained verifier reports | Shipped and exercised | Paths and limits are task-controlled. Mill lists descriptors, not report bytes. | +| Shallow pnpm workspace | Exercised | One pinned [OCI fixture](docs/development.md#pnpm-workspace-oci-canary) only. It is not general pnpm or native-package support. | +| Synthetic provider migration replay | Exercised | [Private disposable fixtures](docs/canaries/mrev-maintenance-replays.md), not live provider behavior, customer acceptance, or demand evidence. | + ## What it can do For its one qualified shape, Mill can: @@ -79,9 +90,8 @@ For its one qualified shape, Mill can: 13. report the built-in builder's trusted-host boundary and reject an unqualified request for isolated execution rather than silently claiming containment; and -14. report redacted aggregate lifecycle counts with `millctl stats`, plus - verification, measured usage, elapsed time, and an optional repository-local - self-hosting measure with `millctl report`. +14. report redacted aggregate lifecycle counts with `millctl stats`, plus a + repository-owned development-evidence ledger with `millctl report`. Mill does not autonomously research the web or turn prose into approved product intent. The operator supplies the structured drafts that Mill assesses and @@ -113,11 +123,12 @@ These capabilities remain outside the qualified public-alpha support claim until separately qualified. They do not grant a builder authority to change acceptance criteria, deliver, merge, or release. -The source also contains a constrained pnpm workspace preparation path. It binds -the pnpm version, lockfile, workspace declaration, and direct workspace package -manifests before an offline verifier uses the prepared dependencies. It rejects -install hooks and native-build allowlists. It is source-only and unqualified; -the public alpha does not support pnpm workspaces. +The source also contains a constrained pnpm workspace path. A real local OCI +canary exercised Node 24, pnpm 10.23.0, a shallow workspace, a service, CLI, +SQLite scratch state, an offline verifier, a retained report, and cleanup under +failure, timeout, and cancellation. It rejects install hooks, native-build +allowlists, and arbitrary layouts. That evidence is limited to the pinned +fixture; the public alpha does not support general pnpm workspaces. ## Supported shape @@ -231,11 +242,10 @@ millctl --json report `start` checks authority before dependency or model spend. `status` explains the selected run; `stats` gives lifecycle counters; `report` adds redacted outcome -and measured-usage aggregates. Then review the candidate and make the separately -proposed draft delivery effect: +and measured-usage aggregates. When `start` reports a reviewed candidate, plan +and make the separately proposed draft delivery effect: ```sh -millctl --json review --task product/tasks/TASK.yaml --run millctl --json pr plan --task product/tasks/TASK.yaml --run millctl --json pr open --task product/tasks/TASK.yaml --run \ --approve sha256: --attended diff --git a/docs/canaries/mrev-maintenance-replays.md b/docs/canaries/mrev-maintenance-replays.md new file mode 100644 index 0000000..88325af --- /dev/null +++ b/docs/canaries/mrev-maintenance-replays.md @@ -0,0 +1,58 @@ +# MREV synthetic maintenance replays + +Status: active evidence record. This document records private, disposable +fixtures used to test Mill's maintenance flow. It does not establish provider +fidelity, customer acceptance, customer demand, or a productivity claim. + +## Replay A + +Replay A used a synthetic owner-lookup retirement in private repository +`davidahmann/mill-mrev-replay-owners-a`. The frozen task bound three +customer-like configurations, a preservation case, a synthetic provider notice, +and offline OCI commands. An initial reviewed candidate could not plan delivery +because the fixture had `trustCeiling: build`; that failed preflight was +preserved. The attended configuration was corrected, the prior reviewed run was +cancelled, and a fresh baseline/run was created. + +The fresh run is `6046816e-2d1b-4cda-aca0-bb1cce71cf3f`, based on +`9c72779fe9b027cbacd20b6a40f812bf2ced3d34`, with candidate +`3380cb2e7db767b4ef9a923722449c42734b77d2`. Its preservation and three +configuration commands passed in the pinned offline OCI verifier. An independent +review completed and Mill opened draft +[PR #1](https://github.com/davidahmann/mill-mrev-replay-owners-a/pull/1). The PR +is intentionally unmerged. Tests, review, and draft delivery leave owner +acceptance `not_recorded`. + +## Replay B + +Replay B uses a separate private fixture repository, +`davidahmann/mill-mrev-replay-owners-b`, with the same synthetic contract and a +separately selected digest-pinned `provider-api-adaptation` playbook. The task +still owns its own acceptance and configuration matrix. The run is +`6952d171-e674-4a00-900c-d1d4c38ced5c`, based on +`1014434725c77e9c9d541a2aaa6533dbb58f5d0a`, with candidate +`c5425cf99e4d91f26f149029bb3e43944663448c`. + +That initial candidate passed verification but the reviewer correctly blocked +it: the fixture's policy authorized only build authority while its committed +configuration enabled attended draft delivery. The run was cancelled. The +maintainer then committed an explicit policy allowance before a new baseline. +Fresh run `db6e8c60-815f-45d1-b173-91110094c631` started from +`24b552d44e1128b921541ba42652a7e21891a5ea`. Its first builder attempt produced +no candidate; its bounded resume produced candidate +`70c9c1a4ba74e97d6fea93b37b606cfe6b0709f4`. It passed all four offline checks, a +refreshed review, and the required stale-scope refresh before Mill opened draft +[PR #1](https://github.com/davidahmann/mill-mrev-replay-owners-b/pull/1). The PR +is intentionally unmerged and owner acceptance remains `not_recorded`. + +The comparison is intentionally weak: the fixtures have the same source shape, +the maintainer had already seen the first replay, and there are only two trials. +Unknown human minutes remain null in the development-evidence ledger. The record +can show workflow behavior and measured provider usage; it cannot show that +playbook reuse reduced total maintenance work. + +The repository's deterministic integration-adaptation replay suite also injects +an incorrect migration candidate and proves that the frozen checks reject it. +The two live MREV replays instead preserved an incompatible authorization, an +empty candidate attempt, and stale review scope. They did not inject a source +mutant into either disposable draft PR. diff --git a/docs/development.md b/docs/development.md index f9abb49..f46f092 100644 --- a/docs/development.md +++ b/docs/development.md @@ -36,12 +36,39 @@ scratch limits. It is not a new supported downstream stack. Cleanup retains generated output roots so they can be mounted scratch directories; Vitest's native config loader and cache/report locations avoid writing into dependencies. -The source-only generic pnpm workspace path is more restrictive. It requires a -root version pin, lockfile version 9, declared shallow workspace directories, -and direct workspace manifests. It rejects `.npmrc`, pnpm hook files, lifecycle -scripts, and `onlyBuiltDependencies`. It has deterministic fake-OCI tests. This -host had no Docker daemon during the implementation, so an actual OCI run still -needs qualification before any support claim. +The constrained pnpm workspace path has one exercised OCI canary. It pins Node +24, pnpm 10.23.0, lockfile version 9, a shallow `packages/*` workspace, direct +workspace manifests, and a verifier image whose Corepack cache already contains +that pnpm version. The canary runs a service and CLI, a local client, SQLite +scratch state, an offline read-only verifier, and a retained scenario report. It +also proves cleanup after an expected failure, deadline expiry, and +cancellation. It rejects `.npmrc`, pnpm hook files, lifecycle scripts, +native-build allowlists, and arbitrary workspace layouts. This is evidence for +that pinned fixture only; it does not support arbitrary pnpm repositories, +native dependencies, or customer workloads. + +## Retained verifier artifacts + +A command may opt in to retain a small set of verifier-generated regular files. +Declare the paths and limits in `retainedArtifacts`; the verifier collects them +before it removes the container, validates their type, path, count, and bytes, +then binds each digest to the candidate, command, and verifier image. The +candidate cannot choose new paths while it runs. + +```yaml +retainedArtifacts: + paths: [reports/scenario.json] + required: true + maxFiles: 1 + maxFileBytes: 4096 + maxTotalBytes: 4096 +``` + +Use `millctl --json artifacts --run ` to inspect descriptors. It returns +the declared relative path, digest, and byte count, never file bytes or the +private state-store path. A missing required report fails verification. Artifact +descriptors establish provenance; their application-specific interpretation +remains with the repository that produced them. The optional command field `executableFixtureScratch: true` is permitted only for OCI `test` and `package` commands. It provides fixed `/mill-fixtures` diff --git a/docs/history/maintainer-verifier-bootstrap-authority.md b/docs/history/maintainer-verifier-bootstrap-authority.md new file mode 100644 index 0000000..f484f52 --- /dev/null +++ b/docs/history/maintainer-verifier-bootstrap-authority.md @@ -0,0 +1,193 @@ +# Historical maintainer verifier bootstrap authority + +Status: closed and expired. This record is preserved for lineage only; it grants +no current authority. + +The following text was moved from `AGENTS.md` on 2026-09-16 without changing its +historical wording. + +## Approved one-time maintainer bootstrap + +David Ahmann approved MB-001 on 2026-09-03T23:10:55.000Z in the attended +conversation. The approved amendment digest is +`sha256:9b44a0a76a56cacee51eef8664c66287e8a950796e65a8886f62cb7e71797b2c`. +Approval expires 2026-09-04T23:10:55.000Z; the current execution deadline is +2026-09-04T01:10:55.000Z. One repair generation is permitted. + +The following exact approved proposal text defines the exception. Its original +proposed-status wording records the approval artifact; the approval above +activates only its stated scope. Canonical task and impact authority are +`product/tasks/MAINTAINER_VERIFIER_BOOTSTRAP.yaml` and +`product/impacts/MAINTAINER_VERIFIER_BOOTSTRAP.yaml`. + +### MB-001: one-time maintainer bootstrap amendment + +Status: proposed; requires David Ahmann's explicit approval of these exact +bytes. This is not active authority and does not amend AGENTS.md by being +written here. + +## Exact scope + +Source base: ceaf76a0e4b8237d2dcb0d016ed84e3c9ba5cfb8. Task: +mill-maintainer-verifier-bootstrap. Owner and approval authority: David Ahmann. +One writer in the existing disposable codex/brownfield-authority worktree. + +The user approved provisioning the dedicated maintainer verifier as the +prerequisite to brownfield implementation. Provisioning exposed a bootstrap +cycle: the native command controls must change before the first self-hosted +baseline can pass. The current AGENTS.md requires that passing baseline before +any such implementation. This amendment resolves only that cycle. + +## Bound approval bundle + +Approval must name this amendment and both exact proposal files: + +- task.yaml file digest: + sha256:22a3f98a13041cf86d8c48fcc963ae817fbdabcd9a07896de1ce270641d09c22 +- impact.json file digest: + sha256:049cf351d32d6e5c4f4aee13eb316d186993bcc9a948a17aeada703b617af8fd + +The task remains proposed and impact approval remains null until that owner +decision. Before implementation, the attended authority-preparation phase may +copy the task to its canonical path with status approved, copy the impact to its +canonical path with the actual owner/UTC approval record and its recomputed +canonical proposal digest, and insert this exact scoped exception in AGENTS.md. +Those are the only approval-materialization changes permitted by this bundle; +any scope, acceptance, constraint, image or budget change needs new approval. +Record and freeze the resulting authority-file digests before the writer starts. + +AGENTS.md and product/task/impact files are authority-preparation paths, not +builder-writable output. After implementation, only the attended maintainer may +record evidence and mechanical closure. Any candidate-byte change invalidates +earlier exact-candidate review and audit. The builder cannot write or approve +its own authority, close its task, or substitute new tests for prior oracles. + +## Proposed exception + +For this bootstrap task only, replace AGENTS.md's self-hosted baseline/run and +lifecycle-owned commit prerequisite with WORKFLOW.md's native maintainer path: +freeze the approved task and impact; preserve the exact prior test assertions; +implement in the disposable worktree; run the full native npm gate; create the +exact candidate commit; then obtain fresh read-only local Codex review and +audit. + +This bootstrap uses a frozen maintainer task brief rather than claiming a Mill +runtime run or a runtime baseline digest. The version-2 runtime execution +requirement resumes unchanged for the subsequent brownfield task. + +Before the bootstrap can be called complete, run the full native gate again in +the pre-provisioned digest-pinned OCI verifier with no network, read-only source +and dependencies, bounded resources, and declared scratch. Any missing or +failing required check blocks completion. Provisioned image availability alone +is not qualification. Preserve the failed pre-bootstrap OCI baseline as red +evidence; do not relabel it as a passing baseline or invent an approval digest. + +The frozen ceaf76a test assertions and coverage thresholds remain independent +preservation evidence. New tests and changed command controls are future-use or +supplementary evidence, not independent certification of themselves. A separate +reviewer must inspect the complete control change, source immutability, offline +installation, cleanup, scratch bounds, and unchanged acceptance criteria. + +Allowed paths for this exception, separated by role as above: + +- AGENTS.md: insert this scoped exception and record its closure only. +- mill.yaml: add the exact maintainer-only commands, image and build trust. +- package.json: native test config-loader options only; no version, dependency, + lifecycle-hook, acceptance or coverage-threshold changes. +- scripts/clean.mjs: clear declared generated outputs without deleting mounted + output roots; preserve failures and reject unsafe output-path indirection. +- vitest.config.ts: relocate transient caches/reports into declared scratch; + preserve all tests, exclusions, assertions, timeouts and coverage thresholds. +- .gitignore and .prettierignore: exclude only declared generated scratch. +- scripts/maintainer-verifier/: explicit image/cache preparation and preflight, + with no implicit network in verification and no forge credentials. +- test/maintainer-verifier.test.ts: supplementary bootstrap regression cases. +- docs/development.md and docs/canaries/maintainer-verifier.md: truthful + procedure, exact evidence, limits, ownership and recovery. +- product/tasks/MAINTAINER_VERIFIER_BOOTSTRAP.yaml and + product/impacts/MAINTAINER_VERIFIER_BOOTSTRAP.yaml: this task's scope, impact, + owner decision and closure. + +Do not change src/runtime/verifier.ts, existing test assertions, product +behavior, recipe compatibility, dependency versions, release workflow, branch +protection, or external-effect policy under this exception. If the stated path +set cannot satisfy the unchanged full gate, stop and return the concrete +failure. + +## Exit and expiry + +Approval expires 24 hours after its first recorded owner-approval receipt, even +if work pauses or validation remains blocked. The maximum execution budget is +two hours with one repair generation; neither resumption nor a new review grants +more time or attempts. Scope/base drift, exhausted budgets or expiry stops new +implementation and effects and requires fresh owner approval of the then-current +exact bundle. Never refresh the original approval timestamp. Safe read-only +diagnosis and truthful recording of already observed results remain permitted. + +After independent review and a passing native/OCI gate, freeze command controls +and the maintainer verifier identity as a new exact base. Close this exception +before any brownfield builder run. The brownfield task must use a version-2 +packet, approved product/scenario/impact closure, genuine baseline qualification +and its exact human-approved digest. The exception cannot qualify a changed +image, command set, candidate, or unrelated task. + +Exact PR-plan approval, attended unchanged-candidate delivery, draft-only PRs, +human readiness and merge, authoritative resulting-main checks, and separately +authorized tag/npm/release effects remain required. This amendment does not +authorize direct main writes, force pushes, merge, publication, or a support +claim. + +### MB-001-A1: approved executable-fixture scratch repair + +On 2026-09-03T23:54:25.000Z David Ahmann approved continuing and shipping after +the reported MB-001 blocker, including the explicitly requested narrow +verifier-policy expansion. This addendum authorizes that expansion only; the +original approval receipt, expiry and execution deadline above are unchanged. +Repair starts from exact local candidate +`879522e1de68500a9970ddc66558772bc504ed05`. + +The command contract may add an explicit, optional executable-fixture-scratch +grant for OCI test/package commands. With that grant only, the verifier may add +one fixed `/mill-fixtures` tmpfs outside `/workspace`, bounded to 256 MiB, +`exec,nosuid,nodev`, and owned by the same disposable container lifecycle. No +arbitrary mount path, extra host bind, privilege, network, source write or +dependency write is authorized. Existing commands without this grant keep their +exact default containment. All other scratch remains noexec. The native +maintainer runner must place temporary fixtures outside the repository and keep +the offline cache copy in its existing declared scratch. + +Additional builder paths are `src/runtime/verifier.ts`, +`src/contracts/schemas.ts`, `schemas/mill-config.schema.json`, +`architecture/ARCHITECTURE.md`, and `CHANGELOG.md`, solely for this additive +contract, enforcement, generated schema and matching documentation. Regression +tests may be added to `test/maintainer-verifier.test.ts`; all pre-existing test +file bytes, assertions, exclusions, timeouts and thresholds remain frozen. The +active task and impact may be updated in the attended authority-preparation +phase to record this owner decision and are frozen again before implementation. + +The independent baseline remains the preserved ceaf76a test suite. The repair +must prove default denial, explicit bounded opt-in, unchanged source/dependency +immutability and network denial, cleanup, the full host and real OCI native +gate, fresh exact-candidate read-only review and audit. Old failed evidence is +retained. The prior review's known noexec finding is the design input to this +one repair, not a waived finding. Unrelated product behavior, recipe support, +dependencies, release policy and human readiness/merge boundaries are unchanged. + +### MB-001 closure + +The attended maintainer closed MB-001 and MB-001-A1 on 2026-09-04T00:17:38.000Z +after full native host/OCI qualification, independent read-only review with no +actionable findings, and the native nine-category audit of implementation commit +`2c90f3d7a6c5ae9041b997de4dcfd6fe8551741e`, tree +`a4c0589d4a4e294e2dcac71719c476e83747ae13`. Exact results and retained failures +are in `docs/canaries/maintainer-verifier.md`. + +The exception is historical authority only and may not be reused for further +implementation. Its containing closure commit must receive fresh full native +host/OCI checks, exact read-only review and audit before promotion; any failure +blocks. Once those pass, freeze that final commit and the unchanged verifier +image/command controls for the next task. Normal version-2 admission, +independent acceptance evidence and exact baseline approval apply to brownfield +work. Exact PR-plan approval, human readiness/merge and separately authorized +release effects remain unchanged. Original approval timestamps and budgets are +retained. diff --git a/docs/release.md b/docs/release.md index 74eab12..e0aa9bd 100644 --- a/docs/release.md +++ b/docs/release.md @@ -170,6 +170,29 @@ this historical artifact-name prefix. Inspect that artifact and workflow result. A missing or skipped required result is a failure, not an exception. +### Evidence retained with the release + +Actions artifacts are short-lived operational inputs. The GitHub Release is the +durable evidence location. Before publication, the workflow uploads a +prepublication evidence file and the selected tarball, qualification, SBOM, and +identity. It then records a draft-release observation in +`release-evidence-draft.json`, publishes the release, reads it back again, and +attaches `release-evidence-final.json`. The two files bind the same artifact, +support tuple, candidate and publish workflow identities. They differ only in +the observed GitHub Release state and timestamp. + +Use `scripts/reconstruct-release-evidence.mjs` with a directory of retained +release assets and the draft/final evidence names to validate the chain without +Actions artifacts. It rejects missing, swapped, or mismatched identities. A +later readback of an already public release cannot recreate an observation that +was not retained while the release was a draft. + +`v0.6.1` has an additive +[`release-evidence-supplement-2026-09-16.json`](https://github.com/davidahmann/mill/releases/download/v0.6.1/release-evidence-supplement-2026-09-16.json) +because its candidate artifact was nearing expiry. The supplement records +published-release and registry observations. It does not claim a historical +draft observation. New releases use the ordered draft/final assets above. + ### 4. Publish the preserved artifact Publication requires separate authorization, the successful candidate workflow diff --git a/docs/releases/v0.7.0.md b/docs/releases/v0.7.0.md new file mode 100644 index 0000000..e3b7cc6 --- /dev/null +++ b/docs/releases/v0.7.0.md @@ -0,0 +1,36 @@ +# Mill v0.7.0 + +`0.7.0` is a public-alpha release candidate that makes Mill's attended workflow +easier to assess and safer to recover. It keeps the existing authority model: +the repository owns acceptance, a verifier checks the candidate, a reviewer +inspects the exact candidate, and a human controls delivery and merge. + +## Candidate scope + +The release retains draft and published GitHub Release observations as separate +assets. Each binds the preserved package, qualification, source tag, candidate +and publish workflow runs, registry readback, and observed release state. The +release evidence can be reconstructed from permanent release assets after the +temporary Actions artifact expires. + +It also makes incomplete proposal assessment useful without compiling an +unapproved task, prevents unclassified required review feedback from appearing +clean, preserves a draft-delivery receipt on safe cancellation, and avoids raw +worker stderr in routine output. State diagnostics no longer initialize or +upgrade local state; forward upgrades retain a backup and validate foreign-key +integrity. + +One digest-pinned pnpm workspace OCI canary exercises a shallow service and CLI, +SQLite scratch state, offline read-only verification, retained reports, and +cleanup after failure, timeout, and cancellation. Two private, synthetic +historical maintenance replays exercise frozen application checks and a reused +playbook. They are not customer evidence, a general pnpm guarantee, or proof of +productivity. + +## Prepublication record + +This file is part of the immutable source candidate. It records intended scope +before tag creation and publication. The protected release workflow will attach +the draft and final release-evidence assets after it verifies the preserved +artifact, provenance, registry package, `latest` channel, and GitHub assets. +Those immutable assets are the provider closure for this release. diff --git a/docs/report.md b/docs/report.md index 77074af..d8bde95 100644 --- a/docs/report.md +++ b/docs/report.md @@ -1,25 +1,40 @@ -# Local outcome reports +# Development evidence report -`millctl --json report` returns a redacted operating summary for the current -repository. It reads local Mill state only. It does not run repository code, -contact a provider, create a run, or change a run. - -The report groups runs by lifecycle status and validation result. It includes -total builder attempts, repair generations, elapsed wall time, and provider -usage only when completed worker events recorded measured values. Missing usage -remains `unavailable`; the command does not estimate tokens or cost. +`millctl --json report` reads two local records. It reports durable run state +and, when `mill.yaml` declares `reporting.ledgerPath`, a maintainer-entered set +of development changes. It does not run repository code, contact a provider, +create a run, or change state. ```sh millctl --json report ``` -Set `reporting.selfHosted: true` in a repository's `mill.yaml` only when that -repository wants to count its own managed runs. The resulting completion rate is -closed runs divided by managed runs. It is a local process measure, not a claim -about engineering output, customer value, or other repositories. With the flag -absent, the self-hosting fields remain declared false and the rate is `null`. +The run section groups lifecycle and validation facts. Its elapsed time is run +wall time. It is not a measure of human effort or productivity. Provider usage +appears only when the provider recorded it; unavailable usage remains `null` or +`unavailable`. + +The `developmentEvidence` section uses the ledger as its denominator. Each +record states whether a change was eligible, whether it used Mill or a manual +route, why a manual route was used, its outcome, known human minutes, elapsed +time, repair count, and provider measurements when available. Eligible manual +and unsuccessful work stay in the count. A total for effort, elapsed time, or +usage is `null` unless every eligible record supplied that measurement. Mill +does not infer missing time, tokens, currency, customer value, or a productivity +rate. + +For example: + +```yaml +# mill.yaml +reporting: + ledgerPath: quality/development-evidence-ledger.yaml +``` -The output excludes task IDs, file paths, authority digests, prompts, command -output, review text, delivery receipts, credentials, and event payloads. Use a -specific [timeline](run-timeline.md) or [outcome](run-outcome.md) when an +Keep the ledger in the repository so another maintainer can inspect its scope +and exclusions. It is an operating record, not a customer report. The command +does not expose task IDs, file paths, authority digests, prompts, command +output, review text, delivery receipts, credentials, or event payloads. Use a +specific [timeline](run-timeline.md), [outcome](run-outcome.md), or +[`artifacts` listing](development.md#retained-verifier-artifacts) when an aggregate shows a problem. diff --git a/docs/repository-settings.md b/docs/repository-settings.md index 7889854..dba5c99 100644 --- a/docs/repository-settings.md +++ b/docs/repository-settings.md @@ -77,7 +77,9 @@ The publication boundary is configured as follows: - require passkey or 2FA on the npm maintainer account and store recovery codes offline; - keep GitHub Actions artifact retention long enough for the seven-day - candidate-to-publish window; + candidate-to-publish window. Copy the selected tarball, qualification, SBOM, + identity, and ordered draft/final release evidence to the GitHub Release + before the workflow artifact expires; - keep release/tag mutation limited to the maintainer and never store an npm token in GitHub, the repository, or a task packet. The one package-identity bootstrap used the maintainer's interactive 2FA session and stored no token; diff --git a/docs/stats.md b/docs/stats.md index 81b2554..ce9d88c 100644 --- a/docs/stats.md +++ b/docs/stats.md @@ -1,28 +1,29 @@ # Local delivery statistics -`millctl --json stats` returns a redacted aggregate for the current repository's -Mill state. It is for a maintainer deciding where delivery is stalling or -whether a repair budget needs investigation. - -Opening an older supported state records the required local schema migration -before the aggregate is read. The command does not create a run, change run -evidence, or perform a repository or remote effect. - -The response includes the state schema version and named applied migrations, the -total number of runs grouped by lifecycle status, total builder attempts, and -completed repair waves. It does not include task IDs, paths, prompts, -credentials, command output, review data, delivery receipts, or event payloads. +`millctl --json stats` reads the current repository's local Mill state. It is +for finding a stalled lifecycle stage or deciding where a repair budget needs +attention. ```sh millctl --json stats ``` +The command opens state read-only. With no state it reports an empty aggregate. +With an older supported state it stops with an upgrade-required error instead of +recording a migration while answering a diagnostic request. Use an attended +mutating command to perform the guarded upgrade and retain its backup. + +The response includes the state schema version, applied migrations, runs by +lifecycle status, builder attempts, and completed repair waves. It excludes task +IDs, paths, prompts, credentials, command output, review data, delivery +receipts, and event payloads. + Use the result as an operating signal, not a productivity score. A higher repair -count can reflect a harder change, a weak acceptance case, or a runtime failure. -Inspect the affected run's [timeline](run-timeline.md) and -[outcome](run-outcome.md) before changing policy. The default task schema keeps -one repair wave. A task may opt into exactly two waves only for the named -fixture-only experiment, where each repaired candidate still goes through fresh -validation and review. It is not a general retry increase. +count may reflect a harder change, weak acceptance cases, or a runtime failure. +Inspect the affected [timeline](run-timeline.md) and [outcome](run-outcome.md) +before changing policy. The default task schema keeps one repair wave. A task +may opt into two waves only through its named fixture-only experiment; every +repaired candidate still receives fresh validation and review. -For a broader redacted operating view, use [reports](report.md). +For the declared change denominator and maintainer-entered effort measurements, +use [`report`](report.md). diff --git a/mill.yaml b/mill.yaml index 1be1329..3ac5561 100644 --- a/mill.yaml +++ b/mill.yaml @@ -2,7 +2,7 @@ schemaVersion: "1" repositoryId: 889e67bd-0768-4f73-9e18-286f2fb8b5f3 trustCeiling: propose reporting: - selfHosted: true + ledgerPath: quality/development-evidence-ledger.yaml sensitivePaths: [".env", ".npmrc", ".mill/**"] propose: forge: github diff --git a/package-lock.json b/package-lock.json index 9751939..c56e74f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@davidahmann/mill", - "version": "0.6.1", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@davidahmann/mill", - "version": "0.6.1", + "version": "0.7.0", "bundleDependencies": [ "typescript" ], diff --git a/package.json b/package.json index eabbc06..b0ef71d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@davidahmann/mill", - "version": "0.6.1", + "version": "0.7.0", "description": "Local-first software factory for new and existing codebases. Turns approved product intent into tested, reviewed PRs with repo-native evidence and explicit human approval for delivery and merge.", "license": "Apache-2.0", "author": "David Ahmann", diff --git a/product/PLAN.md b/product/PLAN.md index 1b9c634..fdd81ff 100644 --- a/product/PLAN.md +++ b/product/PLAN.md @@ -54,6 +54,14 @@ channel state. Mill stores no bypass token. Future fresh releases publish directly to `latest` through the protected OIDC workflow, whose final release evidence is the canonical provider closure. +The +[architecture and development review follow-through](mrev-architecture-dev-review.md) +is the current attended increment. It tightens review and recovery controls, +adds draft coherence checks, bounded verifier artifacts, measured development +evidence, a narrow pnpm OCI canary, and two synthetic historical maintenance +replays. These records exercise the local workflow. They do not establish +customer demand, recurring value, or general stack support. + 1. **Foundation:** repository constitution, exact-version CLI, compact schemas, static source/repository inspection, native CI, and security/release design. 2. **Local delivery:** durable state, disposable worktree, bounded Codex build, diff --git a/product/development-readiness.md b/product/development-readiness.md index 60450d0..3bd19ca 100644 --- a/product/development-readiness.md +++ b/product/development-readiness.md @@ -40,9 +40,10 @@ API, a customer configuration, or a new product's acceptance criteria. declared shallow pnpm workspace shape. The adapter binds the exact pnpm version, lockfile, workspace file, and direct workspace manifests. It rejects lifecycle scripts, native-build allowlists, hook files, and registry config. -- DR-05 Documentation calls the pnpm path source-only and unqualified. It does - not turn deterministic fake-OCI tests into a Docker canary or a public-alpha - support claim. +- DR-05 originally recorded the pnpm path as source-only and unqualified. The + later MREV OCI canary exercises one pinned shallow workspace. It does not + claim broad workspace support, native dependency support, or a public-alpha + expansion. - DR-06 Native checks, generated-schema checks, the packed-package check, a clean audit, independent review, and required pull-request checks pass before any later merge decision. No release occurs. diff --git a/product/guided-operations.md b/product/guided-operations.md index e1f8d7f..30b490f 100644 --- a/product/guided-operations.md +++ b/product/guided-operations.md @@ -16,9 +16,9 @@ candidate release preparation. name only `MILL_GITHUB_TOKEN`; the token bytes remain outside configuration, state, prompts, logs, and support output. 3. Add `millctl report` alongside `stats`. It must report redacted lifecycle, - verification, elapsed-time, and recorded-usage aggregates. A self-hosting - rate is enabled only by repository-local configuration and counts managed - runs, not customer or engineering value. + verification, elapsed-time, and recorded-usage aggregates. The declared + development-evidence ledger distinguishes eligible changes, route, and + incomplete measurements; it is not a productivity claim. 4. Keep one repair generation as the default. Add one fixture-only task shape with exactly two repair generations and prove validation and review run after every repaired candidate. diff --git a/product/impacts/MREV_ARCH_DEV_REVIEW.yaml b/product/impacts/MREV_ARCH_DEV_REVIEW.yaml new file mode 100644 index 0000000..44a4844 --- /dev/null +++ b/product/impacts/MREV_ARCH_DEV_REVIEW.yaml @@ -0,0 +1,39 @@ +schemaVersion: "2" +id: mill-mrev-architecture-development-review +status: approved +approved_by: davidahmann +approval_source: >- + Attended Codex conversation on 2026-09-16: execute + TEMP_MILL_ARCH_DEV_REVIEW_2026-09-15.md end to end, cross-check every item, + test and fix, ship to green, and publish the new latest GitHub and npm + release. +source_base: 5b5803e353c67bad8a547fe4bd7d81d3db634734 +authority: product/mrev-architecture-dev-review.md +affected_invariants: + - INV-HUMAN-AUTHORITY + - INV-EXACT-EVIDENCE + - INV-WORKER-LEAST-AUTHORITY + - INV-DOWNSTREAM-INDEPENDENT +material_changes: + - Preserve durable release qualification evidence and published readback. + - Fail closed on unclassified required review feedback and unsafe recovery. + - Make draft assessment, first-run guidance, pnpm qualification and retained + verifier artifacts explicit and testable within stated boundaries. + - Record eligible-change and maintenance-replay evidence without claiming + customer demand, productivity, or generalized stack support. +affected_surfaces: + - runtime state, delivery, review, verifier, worker, reporting and CLI output + - planning contracts, schemas, fixtures, package and release workflow + - public and operator documentation, maintained playbooks and qualification + records +verification: + - focused positive, negative, recovery, privacy and schema regressions + - native check, packed package, real required OCI canaries and exact audit + - full-diff independent review, required PR/resulting-main checks and provider + readback + - two local historical maintenance replays with separately frozen acceptance +exceptions: + - The attended maintainer may update command controls, tests, docs, workflows, + product authority and local qualification fixtures together under this task. + - Two private disposable replay repositories may receive draft PRs only when + their identities and effects are recorded in durable evidence. diff --git a/product/impacts/RELEASE_V0_7_0.yaml b/product/impacts/RELEASE_V0_7_0.yaml new file mode 100644 index 0000000..0cd7b38 --- /dev/null +++ b/product/impacts/RELEASE_V0_7_0.yaml @@ -0,0 +1,33 @@ +schemaVersion: "2" +id: mill-release-v0.7.0 +status: approved +approved_by: davidahmann +approval_source: >- + Attended Codex conversation on 2026-09-16 authorizing end-to-end execution, + green delivery, new GitHub Release, npm publication to latest, and provider + readback for the approved MREV increment. +source_base: 5b5803e353c67bad8a547fe4bd7d81d3db634734 +authority: product/release-v0.7.0.md +affected_invariants: + - INV-HUMAN-AUTHORITY + - INV-EXACT-EVIDENCE + - INV-DOWNSTREAM-INDEPENDENT +material_changes: + - Distributes the reviewed MREV source increment as v0.7.0. + - Publishes one preserved qualified artifact through protected OIDC to npm + latest and creates a normal GitHub Release. + - Retains ordered draft and final release evidence with provider readback. +affected_surfaces: + - package and CLI version identity + - release workflow, permanent evidence, public release record, and npm + distribution +verification: + - native check, exact-candidate audit, complete review, and green source PR + and resulting-main checks + - exact annotated-tag identity, two independent candidate builds, independent + policy, and reconstructed permanent release evidence + - registry package, npm provenance/latest, and GitHub Release asset readback +exceptions: + - The attended maintainer may create and push the annotated v0.7.0 tag, + publish one preserved artifact through the protected environment, and create + the normal GitHub Release only as bound by this authority. diff --git a/product/mrev-architecture-dev-review.md b/product/mrev-architecture-dev-review.md new file mode 100644 index 0000000..85c2c38 --- /dev/null +++ b/product/mrev-architecture-dev-review.md @@ -0,0 +1,63 @@ +# Architecture and development review follow-through + +Status: approved for one attended maintainer increment on 2026-09-16. + +David Ahmann requested execution of the accepted local plan at +`.mill-scratch/plans/TEMP_MILL_ARCH_DEV_REVIEW_2026-09-15.md`, including its +cross-check, normal GitHub delivery, and one new latest npm and GitHub release. +This record turns that request into repository authority. Its source base is +`5b5803e353c67bad8a547fe4bd7d81d3db634734`. + +## Objective + +Make Mill easier to start and safer to operate without weakening its attended +authority model. Preserve v0.6.1 qualification records before their Actions +artifacts expire. Repair the review, cancellation, state recovery, and worker +output defects identified in the review. Demonstrate the supported pnpm path, +bounded verifier artifacts, measured maintenance replays, and current operator +documentation. Publish the resulting reviewed source as v0.7.0 only after the +candidate, protected publication, and provider readback all pass. + +## Scope + +The increment covers MREV-01 through MREV-13 in the accepted plan. It may add +generic local fixtures, historical replay material, a small eligible-change +ledger, and repository-owned playbooks. It must not alter Telryn, Setrya, +dbrain, customer material, or upstream OSS repositories. Historical replays must +keep their reference solution out of builder context and state their +contamination limits. + +The attended maintainer may create two private, disposable repositories under +the configured owner only for the two MREV-11 lifecycle replays. They may +contain synthetic copies of public historical fixtures, must receive only draft +pull requests, and must be deleted or archived after their immutable evidence is +preserved. This is a qualification action by the maintainer, not a Mill runtime +capability. + +## Boundaries + +No daemon, automatic merge, deployment, protection bypass, wildcard npm +environment rule, bypass-2FA token, republish, retag, credential persistence, +customer-data handling, arbitrary-stack support, generic native-package build +system, or unsupported pnpm support claim is authorized. A failed or uncertain +effect remains retained and reconciled. New tests supplement existing +independent checks; they do not authorize themselves. + +## Delivery + +The task uses the approved native-maintainer path. The attended maintainer may +implement, validate, commit, push, open and merge the normal reviewed PR. A +separate v0.7.0 release record must bind the exact source candidate before tag, +npm publication, and GitHub Release creation. The normal protected OIDC path +must publish directly to npm `latest`; final release evidence and provider +readback remain the release closure. + +## Completion + +Completion requires each MREV item to have its declared evidence or an explicit +owner-approved deferral. The requested scope contains no approved deferral. +Required evidence includes focused regression tests, the full native check, +actual required OCI lanes, clean exact-candidate audit, an independent review, +green PR and resulting-main checks, permanent release assets, and npm/GitHub +readback. The final reconciliation must distinguish verified facts from +remaining qualification limits. diff --git a/product/release-v0.7.0.md b/product/release-v0.7.0.md new file mode 100644 index 0000000..bca1009 --- /dev/null +++ b/product/release-v0.7.0.md @@ -0,0 +1,35 @@ +# v0.7.0 attended release authority + +## Authorization + +David Ahmann authorized this release in the attended Codex conversation on +2026-09-16: “execute the plan end to end ... ship till green incl new latest +GitHub and npm releases.” The authorization covers one fresh `v0.7.0` +public-alpha release from the reviewed source candidate: an annotated tag, +candidate and protected publish workflows, one preserved npm artifact, a normal +GitHub Release, npm `latest`, and provider readback. + +It does not authorize a wildcard, bypass-2FA token, weaker trusted publishing, +an npm dist-tag edit for an older version, a republish, retag, automatic merge, +or a broader support claim. + +## Scope and stop conditions + +The source candidate implements the accepted MREV architecture and development +review follow-through. It retains the public-alpha support boundary. The exact +annotated tag must bind the reviewed resulting-main tree. + +Stop before publication if the tag, source tree, preserved artifact, candidate +run, qualification, npm provenance, registry readback, GitHub asset, `latest` +pointer, or release state differs. A failed candidate or uncertain external +effect requires a new reviewed version. This authority never permits retagging +or republishing. + +## Required closure + +The protected workflow must retain `release-evidence-draft.json` while the +GitHub Release is a draft, then attach `release-evidence-final.json` only after +publication and provider readback. Both bind the tag commit and tree, candidate +and publish runs, tarball digest and integrity, qualification digest, npm +provenance and channels, GitHub Release identity, asset digests, and exact +support tuple. Close this authority only from that provider evidence. diff --git a/product/tasks/MREV_ARCH_DEV_REVIEW.yaml b/product/tasks/MREV_ARCH_DEV_REVIEW.yaml new file mode 100644 index 0000000..9bf90c7 --- /dev/null +++ b/product/tasks/MREV_ARCH_DEV_REVIEW.yaml @@ -0,0 +1,74 @@ +schemaVersion: "2" +task_id: mill-mrev-architecture-development-review +status: active +owner: davidahmann +base_commit: 5b5803e353c67bad8a547fe4bd7d81d3db634734 +execution_mode: approved_native_maintainer +authority: product/mrev-architecture-dev-review.md +impact_manifest: product/impacts/MREV_ARCH_DEV_REVIEW.yaml +objective: >- + Complete the accepted MREV-01 through MREV-13 architecture and development + review follow-through, then prepare one independently qualified v0.7.0 source + candidate for separately authorized release effects. +allowed_paths: + - src/** + - test/** + - schemas/** + - scripts/** + - recipes/** + - docs/** + - architecture/** + - quality/** + - product/** + - .github/** + - README.md + - AGENTS.md + - WORKFLOW.md + - CHANGELOG.md + - package.json + - package-lock.json + - mill.yaml +authority_preparation_paths: + - product/** + - quality/** +acceptance_items: + - MREV-01 durable release evidence reconstructs qualification and publication + - MREV-02 required GitHub feedback cannot be silently treated as clean + - MREV-03 cancellation preserves uncertain and confirmed external effects + - MREV-04 reads, upgrades, backups and restores preserve valid state + - MREV-05 public worker failures never expose raw stderr + - MREV-06 draft proposal assessment requires coherent matching inputs + - MREV-07 first-run instructions match the literal supported coordinator path + - MREV-08 one bounded pnpm workspace environment is actually qualified + - MREV-09 declared verifier artifacts are bounded and candidate-bound + - MREV-10 eligible-change reporting separates measured facts from inference + - MREV-11 two historical maintenance replays test frozen playbook reuse + - MREV-12 docs state shipped, exercised and qualified support truthfully + - MREV-13 exact candidate, review, CI and delivery evidence reconcile scope +validation_commands: + - npm run check + - node dist/cli.js --json --cwd . audit + - required digest-pinned OCI canaries with recorded no-network behavior +required_reviews: + - read-only review of the complete committed diff, including authority, tests, + schemas, release workflow, replay evidence and documentation +constraints: + - one attended maintainer writer and no reuse of closed authority + - preserve existing acceptance, support tuples, release bytes and failure + evidence + - no forge credential for builders or reviewers, no automatic merge or + deployment + - no secret disclosure, customer data, historical rewrite, retag or republish + - exact maintenance-replay acceptance remains separate from reusable playbook + content +stop_conditions: + - ambiguous authority, replay target, external effect identity or support + tuple + - unavailable required runtime, OCI lane, review evidence or release + provenance + - recurring P0 or P1 defect in the same subsystem after a design repair +closure: + status: pending + disposition: >- + Close only after every MREV item is reconciled against durable evidence and + the resulting v0.7.0 release is separately read back. diff --git a/product/tasks/RELEASE_V0_7_0.yaml b/product/tasks/RELEASE_V0_7_0.yaml new file mode 100644 index 0000000..2e39c5d --- /dev/null +++ b/product/tasks/RELEASE_V0_7_0.yaml @@ -0,0 +1,61 @@ +schemaVersion: "2" +task_id: mill-release-v0.7.0 +status: approved +owner: davidahmann +base_commit: 5b5803e353c67bad8a547fe4bd7d81d3db634734 +execution_mode: approved_native_maintainer +authority: product/release-v0.7.0.md +impact_manifest: product/impacts/RELEASE_V0_7_0.yaml +objective: >- + Ship the exact v0.7.0 public-alpha artifact, release documentation, and + permanent ordered release evidence through the protected OIDC workflow. +allowed_paths: + - package.json + - package-lock.json + - src/version.ts + - CHANGELOG.md + - README.md + - AGENTS.md + - docs/** + - product/** + - quality/** + - .github/** + - scripts/** + - schemas/** + - test/** +authority_preparation_paths: + - product/** + - quality/** + - docs/releases/** +acceptance_items: + - R070-01 package and CLI identify v0.7.0 + - R070-02 documentation states shipped, exercised, qualified, and limited + behavior accurately + - R070-03 release evidence reconstructs ordered draft and published provider + observations from permanent assets + - R070-04 native check, OCI canary, exact-candidate audit, full review, and + required source checks pass + - R070-05 the annotated tag, qualified artifact, npm latest, and normal GitHub + Release bind one v0.7.0 identity +validation_commands: + - npm run check + - node scripts/qualify-pnpm-oci.mjs + - node dist/cli.js --json --cwd . audit + - MILL_RELEASE_TAG=v0.7.0 node scripts/verify-release-tag.mjs +required_reviews: + - required PR checks and GitHub review feedback on the complete committed MREV + and release-preparation diff +constraints: + - single attended maintainer writer + - exact v0.7.0 npm-environment tag admission only; no wildcard or bypass + - preserve all historical tags, npm versions, releases, and evidence +stop_conditions: + - ambiguous tag, tree, artifact, candidate-run, provenance, support tuple, or + provider effect + - unavailable independent verification or recurring P0/P1 release finding +closure: + status: pending_provider_closure + provider_closure: >- + The protected workflow must retain ordered draft and final evidence after + provider readback. No post-release source update may alter the tagged + candidate or duplicate those provider facts. diff --git a/quality/development-evidence-ledger.yaml b/quality/development-evidence-ledger.yaml new file mode 100644 index 0000000..75aaab9 --- /dev/null +++ b/quality/development-evidence-ledger.yaml @@ -0,0 +1,54 @@ +schemaVersion: "1" +records: + - id: MREV-ARCH-DEV-REVIEW-2026-09-16 + change: Architecture and development review follow-through + eligibility: + status: eligible + route: + kind: manual + reason: >- + This owner-approved maintainer increment ran in the current Mill + checkout rather than through a separately compiled Mill task packet. + outcome: in_progress + effort: + preparationMinutes: null + reviewMinutes: null + repairMinutes: null + elapsedMinutes: null + repairs: 0 + - id: MREV-REPLAY-A-2026-09-16 + change: Synthetic provider-owner migration replay A + eligibility: + status: eligible + route: + kind: mill + outcome: not_accepted + effort: + preparationMinutes: null + reviewMinutes: null + repairMinutes: null + elapsedMinutes: null + repairs: 0 + providerUsage: + inputTokens: 322231 + outputTokens: 4152 + cost: null + currency: null + - id: MREV-REPLAY-B-2026-09-16 + change: Synthetic provider-owner migration replay B with selected playbook + eligibility: + status: eligible + route: + kind: mill + outcome: not_accepted + effort: + preparationMinutes: null + reviewMinutes: null + repairMinutes: null + elapsedMinutes: null + repairs: 0 + providerUsage: + inputTokens: 269177 + outputTokens: 3127 + cost: null + currency: null diff --git a/quality/support-tuples/darwin-arm64-node24-v0.7.0.json b/quality/support-tuples/darwin-arm64-node24-v0.7.0.json new file mode 100644 index 0000000..683ffbe --- /dev/null +++ b/quality/support-tuples/darwin-arm64-node24-v0.7.0.json @@ -0,0 +1,35 @@ +{ + "id": "darwin-arm64-node24-codex-v0.7.0", + "status": "qualified", + "testedAt": "2026-09-16T02:26:37.000Z", + "expiresAt": "2026-10-16T02:26:37.000Z", + "host": { + "os": "darwin", + "architecture": "arm64" + }, + "runtime": { + "node": "24.20.0", + "npm": "11.4.1" + }, + "container": { + "engine": "docker", + "version": "29.7.2", + "verifierImage": "mcr.microsoft.com/playwright:v1.62.1-noble@sha256:dcc5531e97840b9b5e794f2814476b21571c5124a3fca2267d73041f56e7580e" + }, + "worker": { + "adapter": "codex-cli", + "harnessVersion": "0.154.0-alpha.6.2", + "modelIdentity": "provider-mutable", + "authMode": "operator-session" + }, + "forge": { + "gitVersion": "2.50.0", + "ghVersion": "2.74.2", + "host": "github.com" + }, + "recipe": { + "id": "node-typescript-next-web", + "version": "1.0.0", + "digest": "sha256:908489bd1c3257bd421e09807cb024a39234598d11748120583308d016e8f286" + } +} diff --git a/schemas/delivery-record.schema.json b/schemas/delivery-record.schema.json index 61a59e9..3de97a2 100644 --- a/schemas/delivery-record.schema.json +++ b/schemas/delivery-record.schema.json @@ -97,7 +97,6 @@ } }, "postMergeRequiredChecks": { - "minItems": 1, "type": "array", "items": { "type": "string", "minLength": 1 } }, diff --git a/schemas/mill-config.schema.json b/schemas/mill-config.schema.json index 9387cb8..7694970 100644 --- a/schemas/mill-config.schema.json +++ b/schemas/mill-config.schema.json @@ -22,7 +22,12 @@ }, "reporting": { "type": "object", - "properties": { "selfHosted": { "default": false, "type": "boolean" } }, + "properties": { + "ledgerPath": { + "type": "string", + "pattern": "^(?!\\/)(?!.*(?:^|\\/)\\.\\.(?:\\/|$))[^*?[\\]\\\\]+$" + } + }, "additionalProperties": false }, "verifier": { @@ -296,7 +301,40 @@ "pattern": "^(?!\\.\\.?$)(?!.*[,/])[^*?[\\]\\\\]+$" } }, - "executableFixtureScratch": { "type": "boolean", "const": true } + "executableFixtureScratch": { "type": "boolean", "const": true }, + "retainedArtifacts": { + "type": "object", + "properties": { + "paths": { + "minItems": 1, + "maxItems": 32, + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "required": { "default": true, "type": "boolean" }, + "maxFiles": { + "default": 16, + "type": "integer", + "minimum": 1, + "maximum": 32 + }, + "maxFileBytes": { + "default": 1000000, + "type": "integer", + "minimum": 1, + "maximum": 10000000 + }, + "maxTotalBytes": { + "default": 5000000, + "type": "integer", + "minimum": 1, + "maximum": 50000000 + } + }, + "required": ["paths"], + "additionalProperties": false + } }, "required": ["argv", "cwd", "controlPaths", "capability"], "additionalProperties": false, diff --git a/schemas/release-evidence.schema.json b/schemas/release-evidence.schema.json index 2d6b786..9d8dd9f 100644 --- a/schemas/release-evidence.schema.json +++ b/schemas/release-evidence.schema.json @@ -108,7 +108,64 @@ "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "qualification": { + "type": "object", + "properties": { + "supportTuple": { + "type": "object", + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" }, + "status": { + "type": "string", + "enum": ["experimental", "qualified", "expired"] + }, + "testedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "digest": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" } + }, + "required": ["id", "status", "testedAt", "expiresAt", "digest"], + "additionalProperties": false + } + }, + "required": ["supportTuple"], + "additionalProperties": false + }, "sbomDigest": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "workflowRuns": { + "type": "object", + "properties": { + "candidate": { + "type": "object", + "properties": { + "id": { "type": "string", "pattern": "^[1-9][0-9]*$" }, + "url": { "type": "string", "format": "uri" }, + "headCommit": { "type": "string", "pattern": "^[a-f0-9]{40}$" } + }, + "required": ["id", "url", "headCommit"], + "additionalProperties": false + }, + "publish": { + "type": "object", + "properties": { + "id": { "type": "string", "pattern": "^[1-9][0-9]*$" }, + "url": { "type": "string", "format": "uri" }, + "headCommit": { "type": "string", "pattern": "^[a-f0-9]{40}$" } + }, + "required": ["id", "url", "headCommit"], + "additionalProperties": false + } + }, + "required": ["candidate", "publish"], + "additionalProperties": false + }, "registry": { "anyOf": [ { @@ -137,6 +194,23 @@ "artifactDigest": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" + }, + "state": { "type": "string", "enum": ["draft", "published"] }, + "releaseId": { "type": "string", "pattern": "^[1-9][0-9]*$" }, + "publishedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + { "type": "null" } + ] + }, + "observedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" } }, "required": ["url", "tag", "artifactDigest"], diff --git a/schemas/validation-evidence.schema.json b/schemas/validation-evidence.schema.json index bd0aab5..73f680e 100644 --- a/schemas/validation-evidence.schema.json +++ b/schemas/validation-evidence.schema.json @@ -163,6 +163,29 @@ "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "artifacts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "pattern": "^(?!\\/)(?!.*(?:^|\\/)\\.\\.(?:\\/|$))[^*?[\\]\\\\]+$" + }, + "sha256": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "bytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["path", "sha256", "bytes"], + "additionalProperties": false + } + }, "reason": { "type": "string", "enum": [ @@ -170,7 +193,8 @@ "CANCELLED", "DEADLINE_EXCEEDED", "OUTPUT_BUDGET_EXCEEDED", - "NONZERO_EXIT" + "NONZERO_EXIT", + "RETAINED_ARTIFACT_MISSING" ] } }, diff --git a/scripts/assemble-release-evidence.mjs b/scripts/assemble-release-evidence.mjs index 38fbfba..7fb7515 100644 --- a/scripts/assemble-release-evidence.mjs +++ b/scripts/assemble-release-evidence.mjs @@ -11,6 +11,7 @@ const [ outputPath, registryPath, githubPath, + workflowRunsPath, ] = process.argv.slice(2); if ( metadataPath === undefined || @@ -20,7 +21,7 @@ if ( outputPath === undefined ) { throw new Error( - "usage: assemble-release-evidence.mjs [registry.json] [github.json]", + "usage: assemble-release-evidence.mjs [registry.json] [github.json] [workflow-runs.json]", ); } const root = path.resolve(import.meta.dirname, ".."); @@ -35,9 +36,15 @@ const [metadata, qualification, sbomBytes, identity] = await Promise.all([ readJson(identityPath), ]); const registry = - registryPath === undefined ? null : await readJson(registryPath); + registryPath === undefined || registryPath === "-" + ? null + : await readJson(registryPath); const githubRelease = - githubPath === undefined ? null : await readJson(githubPath); + githubPath === undefined || githubPath === "-" + ? null + : await readJson(githubPath); +const workflowRuns = + workflowRunsPath === undefined ? undefined : await readJson(workflowRunsPath); if ( !Array.isArray(metadata.builders) || metadata.builders.length !== 2 || @@ -104,7 +111,17 @@ const evidence = mill.contractSchemas.releaseEvidence.parse({ builders: metadata.builders, selectedArtifact: metadata.selectedArtifact, qualificationDigest: mill.canonicalDigest(qualification), + qualification: { + supportTuple: { + id: qualification.supportTuple.id, + status: qualification.supportTuple.status, + testedAt: qualification.supportTuple.testedAt, + expiresAt: qualification.supportTuple.expiresAt, + digest: mill.canonicalDigest(qualification.supportTuple), + }, + }, sbomDigest: `sha256:${createHash("sha256").update(sbomBytes).digest("hex")}`, + ...(workflowRuns === undefined ? {} : { workflowRuns }), registry, githubRelease, generatedAt: new Date().toISOString(), @@ -135,6 +152,15 @@ if ( "GitHub Release readback does not prove tag and artifact identity", ); } +if ( + workflowRuns !== undefined && + (workflowRuns.candidate?.headCommit !== identity.tagCommit || + workflowRuns.publish?.headCommit !== identity.tagCommit) +) { + throw new Error( + "workflow run identities do not bind the exact tagged commit", + ); +} await writeFile(outputPath, `${JSON.stringify(evidence, undefined, 2)}\n`, { flag: "wx", mode: 0o644, diff --git a/scripts/capture-release-readback.mjs b/scripts/capture-release-readback.mjs index 2e3bf7d..f1d92a1 100644 --- a/scripts/capture-release-readback.mjs +++ b/scripts/capture-release-readback.mjs @@ -57,6 +57,7 @@ if ( ); } const expectedTag = `v${metadata.package.version}`; +const observedAt = new Date().toISOString(); let releaseUrl; try { releaseUrl = new URL(release.url); @@ -76,6 +77,14 @@ if ( `/releases/tag/${encodeURIComponent(expectedTag)}`, ) || !Array.isArray(release.assets) || + typeof release.isDraft !== "boolean" || + typeof release.isPrerelease !== "boolean" || + release.isPrerelease === true || + !Number.isSafeInteger(release.databaseId) || + release.databaseId <= 0 || + (release.isDraft === false && + (typeof release.publishedAt !== "string" || + Number.isNaN(Date.parse(release.publishedAt)))) || !release.assets.some( (asset) => asset.name === path.basename(downloadedArtifactPath), ) @@ -105,6 +114,10 @@ await Promise.all([ url: release.url, tag: expectedTag, artifactDigest: downloadedDigest, + state: release.isDraft ? "draft" : "published", + releaseId: String(release.databaseId), + publishedAt: release.isDraft ? null : release.publishedAt, + observedAt, }, undefined, 2, diff --git a/scripts/qualify-pnpm-oci.mjs b/scripts/qualify-pnpm-oci.mjs new file mode 100644 index 0000000..f2054aa --- /dev/null +++ b/scripts/qualify-pnpm-oci.mjs @@ -0,0 +1,451 @@ +import { execFile } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { clearTimeout, setTimeout } from "node:timers"; +import { promisify } from "node:util"; + +const execute = promisify(execFile); +const root = path.resolve(import.meta.dirname, ".."); +const baseImage = + "node:24-bookworm@sha256:f22d6a1f082c02f292e86929b5b0442ac2e5eaf438a5dea9b1566601c3e05940"; +const registryImage = + "registry@sha256:1be55279f18a2fe1a74edf2664cac61c1bea305b7b4642dab412e7affdcb3e33"; +const workspace = await mkdtemp(path.join(tmpdir(), "mill-pnpm-oci-")); +const state = await mkdtemp(path.join(tmpdir(), "mill-pnpm-oci-state-")); +const registryContainer = `mill-pnpm-oci-registry-${randomUUID()}`; +let registryStarted = false; + +async function availableLoopbackPort() { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + server.close(() => + reject(new Error("could not allocate a loopback port")), + ); + return; + } + server.close((error) => { + if (error !== undefined) { + reject(error); + return; + } + resolve(address.port); + }); + }); + }); +} + +async function startLoopbackRegistry() { + const port = await availableLoopbackPort(); + await execute("docker", ["pull", registryImage], { cwd: root }); + await execute( + "docker", + [ + "run", + "--detach", + "--rm", + "--network", + "host", + "--pull", + "never", + "--name", + registryContainer, + "--env", + `REGISTRY_HTTP_ADDR=127.0.0.1:${port}`, + registryImage, + ], + { cwd: root }, + ); + registryStarted = true; + const registry = `127.0.0.1:${port}`; + await new Promise((resolve) => setTimeout(resolve, 250)); + const running = ( + await execute( + "docker", + ["inspect", "--format", "{{.State.Running}}", registryContainer], + { cwd: root }, + ) + ).stdout.trim(); + if (running !== "true") { + throw new Error("pnpm OCI canary registry did not remain running"); + } + return registry; +} + +async function pushCanaryImage(imageTag) { + const deadline = Date.now() + 15_000; + let lastError; + while (Date.now() < deadline) { + try { + await execute("docker", ["push", imageTag], { cwd: root }); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + throw lastError; +} + +try { + const registry = await startLoopbackRegistry(); + const imageTag = `${registry}/mill-pnpm-oci-canary:node24-pnpm10-23`; + await execute("docker", ["pull", baseImage], { cwd: root }); + const [ + { millConfigSchema }, + { prepareDependencySnapshot }, + { verifyDeclaredCommands }, + ] = await Promise.all([ + import("../dist/contracts/schemas.js"), + import("../dist/runtime/dependencies.js"), + import("../dist/runtime/verifier.js"), + ]); + await mkdir(path.join(workspace, "packages", "service"), { + recursive: true, + }); + await mkdir(path.join(workspace, "packages", "cli"), { recursive: true }); + await writeFile( + path.join(workspace, "Dockerfile.verifier"), + [ + `FROM ${baseImage}`, + "ENV COREPACK_HOME=/opt/corepack", + 'RUN corepack enable pnpm && corepack install --global pnpm@10.23.0 && test "$(/usr/local/bin/pnpm --version)" = 10.23.0 && chmod -R a+rX /opt/corepack', + "USER node", + ].join("\n"), + ); + await Promise.all([ + writeFile( + path.join(workspace, "package.json"), + `${JSON.stringify( + { + name: "mill-pnpm-oci-canary", + private: true, + type: "module", + packageManager: "pnpm@10.23.0", + }, + null, + 2, + )}\n`, + ), + writeFile( + path.join(workspace, "pnpm-workspace.yaml"), + "packages:\n - packages/*\n", + ), + writeFile( + path.join(workspace, "pnpm-lock.yaml"), + [ + "lockfileVersion: '9.0'", + "settings:", + " autoInstallPeers: true", + "importers:", + " .: {}", + " packages/cli:", + " dependencies:", + " '@mill-pnpm/service':", + " specifier: workspace:*", + " version: link:../service", + " packages/service: {}", + "packages: {}", + "", + ].join("\n"), + ), + writeFile( + path.join(workspace, "packages", "service", "package.json"), + `${JSON.stringify( + { + name: "@mill-pnpm/service", + version: "1.0.0", + type: "module", + exports: "./index.mjs", + }, + null, + 2, + )}\n`, + ), + writeFile( + path.join(workspace, "packages", "service", "index.mjs"), + "export const ownerFor = (configuration) => configuration === 'custom' ? 'finance' : 'sales';\n", + ), + writeFile( + path.join(workspace, "packages", "cli", "package.json"), + `${JSON.stringify( + { + name: "@mill-pnpm/cli", + version: "1.0.0", + type: "module", + dependencies: { "@mill-pnpm/service": "workspace:*" }, + bin: { "mill-pnpm-cli": "./index.mjs" }, + }, + null, + 2, + )}\n`, + ), + writeFile( + path.join(workspace, "packages", "cli", "index.mjs"), + [ + "import assert from 'node:assert/strict';", + "import { mkdir, writeFile } from 'node:fs/promises';", + "import http from 'node:http';", + "import { DatabaseSync } from 'node:sqlite';", + "import { ownerFor } from '@mill-pnpm/service';", + "const owner = ownerFor(process.env.MILL_PNPM_CONFIGURATION ?? 'custom');", + "assert.equal(owner, 'finance');", + "const database = new DatabaseSync('scratch/check.sqlite');", + "database.exec('CREATE TABLE checks (owner TEXT NOT NULL)');", + "database.prepare('INSERT INTO checks(owner) VALUES (?)').run(owner);", + "database.close();", + "const server = http.createServer((_request, response) => response.end(owner));", + "await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));", + "const address = server.address();", + "if (address === null || typeof address === 'string') throw new Error('server did not bind');", + "const body = await new Promise((resolve, reject) => {", + " http.get({ host: '127.0.0.1', port: address.port }, (response) => {", + " let value = ''; response.setEncoding('utf8'); response.on('data', (chunk) => { value += chunk; });", + " response.on('end', () => resolve(value));", + " }).on('error', reject);", + "});", + "server.close();", + "assert.equal(body, owner);", + "const artifactRoot = process.env.MILL_ARTIFACTS_DIR;", + "if (artifactRoot === undefined) throw new Error('artifact output is unavailable');", + "await mkdir(`${artifactRoot}/reports`, { recursive: true });", + "await writeFile(`${artifactRoot}/reports/pnpm-oci.json`, JSON.stringify({ owner, body }) + '\\n');", + "await writeFile(`${artifactRoot}/reports/owner's.json`, 'ok');", + ].join("\n"), + ), + ]); + await execute( + "docker", + [ + "build", + "--pull=false", + "--file", + path.join(workspace, "Dockerfile.verifier"), + "--tag", + imageTag, + workspace, + ], + { cwd: root }, + ); + await pushCanaryImage(imageTag); + const inspected = JSON.parse( + ( + await execute("docker", ["image", "inspect", imageTag], { + cwd: root, + }) + ).stdout, + ); + const image = inspected[0]?.RepoDigests?.find( + (candidate) => + typeof candidate === "string" && + candidate.startsWith(`${registry}/mill-pnpm-oci-canary@sha256:`), + ); + if (typeof image !== "string") { + throw new Error( + "pnpm OCI canary image is not locally addressable by digest", + ); + } + const config = millConfigSchema.parse({ + schemaVersion: "1", + repositoryId: "8e4a811a-c1fa-4aa7-8d21-94d50b2bc770", + trustCeiling: "build", + sensitivePaths: [], + verifier: { + image, + network: "none", + dependencies: { + manager: "pnpm", + version: "10.23.0", + registry: "https://registry.npmjs.org", + targetPath: "node_modules", + lockPaths: ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml"], + workspacePaths: ["packages/*"], + }, + }, + commands: { + pnpm_oci: { + argv: ["/usr/local/bin/node", "packages/cli/index.mjs"], + cwd: ".", + controlPaths: [ + "package.json", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "packages/**", + ], + capability: "test", + required: true, + timeoutSeconds: 60, + execution: "oci", + writablePaths: ["scratch"], + retainedArtifacts: { + paths: ["reports/pnpm-oci.json", "reports/owner's.json"], + required: true, + maxFiles: 2, + maxFileBytes: 4096, + maxTotalBytes: 4096, + }, + }, + expected_failure: { + argv: ["/usr/local/bin/node", "-e", "process.exit(7)"], + cwd: ".", + controlPaths: ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml"], + capability: "test", + required: false, + timeoutSeconds: 30, + execution: "oci", + }, + expected_timeout: { + argv: ["/usr/local/bin/node", "-e", "setInterval(() => {}, 1_000)"], + cwd: ".", + controlPaths: ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml"], + capability: "test", + required: false, + timeoutSeconds: 1, + execution: "oci", + }, + expected_cancellation: { + argv: ["/usr/local/bin/node", "-e", "setInterval(() => {}, 1_000)"], + cwd: ".", + controlPaths: ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml"], + capability: "test", + required: false, + timeoutSeconds: 30, + execution: "oci", + }, + }, + }); + const prepared = await prepareDependencySnapshot({ + root: workspace, + stateDirectory: state, + config, + attended: true, + }); + const evidence = await verifyDeclaredCommands({ + root: workspace, + dependencyRoot: prepared.directory, + artifactDirectory: path.join(state, "artifacts"), + candidateCommit: "a".repeat(40), + config, + task: { commandIds: ["pnpm_oci"] }, + deadlineMs: Date.now() + 90_000, + maxOutputBytes: 1024 * 1024, + }); + if (!evidence.passed) { + throw new Error( + `pnpm OCI canary did not pass: ${JSON.stringify( + evidence.commands.map((command) => ({ + commandId: command.commandId, + status: command.status, + exitCode: command.exitCode, + reason: command.reason ?? null, + outputDigest: command.outputDigest, + })), + )}`, + ); + } + const artifact = evidence.commands[0]?.artifacts?.find( + (candidate) => candidate.path === "reports/pnpm-oci.json", + ); + if (artifact?.path !== "reports/pnpm-oci.json") { + throw new Error( + "pnpm OCI canary did not retain its declared scenario report", + ); + } + const report = await readFile( + path.join( + state, + "artifacts", + createHash("sha256").update("pnpm_oci").digest("hex"), + artifact.path, + ), + "utf8", + ); + if (JSON.parse(report).owner !== "finance") { + throw new Error("pnpm OCI canary retained an unexpected scenario report"); + } + const apostropheArtifact = evidence.commands[0]?.artifacts?.find( + (candidate) => candidate.path === "reports/owner's.json", + ); + if (apostropheArtifact?.bytes !== 2) { + throw new Error( + "pnpm OCI canary did not retain the apostrophe-path scenario report", + ); + } + const assertNoVerifierContainers = async () => { + const result = await execute( + "docker", + ["ps", "--all", "--quiet", "--filter", "label=dev.mill.owner=verifier"], + { cwd: root }, + ); + if (result.stdout.trim() !== "") { + throw new Error("pnpm OCI canary left a verifier container behind"); + } + }; + const negativeControl = async (commandId, expectedReason, extra = {}) => { + const result = await verifyDeclaredCommands({ + root: workspace, + dependencyRoot: prepared.directory, + artifactDirectory: path.join(state, "artifacts-negative", commandId), + candidateCommit: "a".repeat(40), + config, + task: { commandIds: [commandId] }, + deadlineMs: Date.now() + 10_000, + maxOutputBytes: 1024 * 1024, + ...extra, + }); + const command = result.commands[0]; + if (command?.status !== "failed" || command.reason !== expectedReason) { + throw new Error( + `pnpm OCI ${commandId} control did not produce ${expectedReason}: ${JSON.stringify(command)}`, + ); + } + await assertNoVerifierContainers(); + return { commandId, reason: command.reason }; + }; + const failure = await negativeControl("expected_failure", "NONZERO_EXIT"); + const timeout = await negativeControl( + "expected_timeout", + "DEADLINE_EXCEEDED", + ); + const controller = new globalThis.AbortController(); + const cancellationTimer = setTimeout(() => controller.abort(), 1_000); + let cancellation; + try { + cancellation = await negativeControl("expected_cancellation", "CANCELLED", { + signal: controller.signal, + }); + } finally { + clearTimeout(cancellationTimer); + } + process.stdout.write( + `${JSON.stringify( + { + schemaVersion: "1", + status: "passed", + image, + pnpm: "10.23.0", + artifact, + dependencySnapshotReused: prepared.reused, + negativeControls: [failure, timeout, cancellation], + }, + null, + 2, + )}\n`, + ); +} finally { + if (registryStarted) { + await execute("docker", ["rm", "--force", registryContainer], { + cwd: root, + }).catch(() => undefined); + } + await Promise.all([ + rm(workspace, { recursive: true, force: true }), + rm(state, { recursive: true, force: true }), + ]); +} diff --git a/scripts/reconstruct-release-evidence.mjs b/scripts/reconstruct-release-evidence.mjs new file mode 100644 index 0000000..eb1456c --- /dev/null +++ b/scripts/reconstruct-release-evidence.mjs @@ -0,0 +1,148 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const [assetsDirectory, draftName, finalName] = process.argv.slice(2); +if ( + assetsDirectory === undefined || + draftName === undefined || + finalName === undefined +) { + throw new Error( + "usage: reconstruct-release-evidence.mjs ", + ); +} +for (const name of [draftName, finalName]) { + if (path.basename(name) !== name) { + throw new Error("release evidence filenames must not contain a path"); + } +} +const root = path.resolve(import.meta.dirname, ".."); +const mill = await import( + pathToFileURL(path.join(root, "dist", "index.js")).href +); +const asset = (name) => path.join(path.resolve(assetsDirectory), name); +const readJson = async (name) => + JSON.parse(await readFile(asset(name), "utf8")); +const digest = (bytes) => + `sha256:${createHash("sha256").update(bytes).digest("hex")}`; +const same = (left, right) => + mill.canonicalDigest(left) === mill.canonicalDigest(right); + +const [metadata, qualification, identity, sbom, draft, final] = + await Promise.all([ + readJson("artifact-metadata.json"), + readJson("qualification.json"), + readJson("identity.json"), + readFile(asset("sbom.cdx.json")), + readJson(draftName), + readJson(finalName), + ]); +const parsedQualification = + mill.contractSchemas.publicAlphaQualification.parse(qualification); +const parsedDraft = mill.contractSchemas.releaseEvidence.parse(draft); +const parsedFinal = mill.contractSchemas.releaseEvidence.parse(final); +const artifactPath = asset(metadata.selectedArtifact?.filename); +const artifactBytes = await readFile(artifactPath); + +if ( + !Array.isArray(metadata.builders) || + metadata.builders.length !== 2 || + !same(parsedDraft.builders, metadata.builders) || + !same(parsedFinal.builders, metadata.builders) || + !same(parsedDraft.selectedArtifact, metadata.selectedArtifact) || + !same(parsedFinal.selectedArtifact, metadata.selectedArtifact) || + digest(artifactBytes) !== metadata.selectedArtifact?.sha256 +) { + throw new Error( + "release evidence does not bind the retained package artifact", + ); +} +const expectedPackage = { + name: metadata.package?.name, + version: metadata.package?.version, + tag: `v${metadata.package?.version}`, +}; +const expectedQualification = { + supportTuple: { + id: parsedQualification.supportTuple.id, + status: parsedQualification.supportTuple.status, + testedAt: parsedQualification.supportTuple.testedAt, + expiresAt: parsedQualification.supportTuple.expiresAt, + digest: mill.canonicalDigest(parsedQualification.supportTuple), + }, +}; +for (const evidence of [parsedDraft, parsedFinal]) { + if ( + !same(evidence.package, expectedPackage) || + evidence.qualificationDigest !== + mill.canonicalDigest(parsedQualification) || + !same(evidence.qualification, expectedQualification) || + evidence.sbomDigest !== digest(sbom) || + evidence.source.reviewedCandidateTree !== identity.reviewedCandidateTree || + evidence.source.resultingMainCommit !== identity.tagCommit || + evidence.source.resultingMainTree !== identity.mainTree || + evidence.source.tagCommit !== identity.tagCommit || + evidence.workflowRuns?.candidate.headCommit !== identity.tagCommit || + evidence.workflowRuns?.publish.headCommit !== identity.tagCommit + ) { + throw new Error( + "release evidence does not bind its qualification and source identity", + ); + } +} +if ( + parsedDraft.state !== "verified" || + parsedDraft.githubRelease?.state !== "draft" || + parsedDraft.githubRelease.publishedAt !== null || + parsedFinal.state !== "verified" || + parsedFinal.githubRelease?.state !== "published" || + parsedFinal.githubRelease.publishedAt === null || + !same(parsedDraft.workflowRuns, parsedFinal.workflowRuns) || + !same(parsedDraft.registry, parsedFinal.registry) +) { + throw new Error( + "release evidence does not preserve ordered draft and published observations", + ); +} +for (const evidence of [parsedDraft, parsedFinal]) { + const receipt = evidence.githubRelease; + if ( + receipt === null || + receipt.tag !== expectedPackage.tag || + receipt.artifactDigest !== metadata.selectedArtifact.sha256 || + new URL(receipt.url).pathname !== + `/davidahmann/mill/releases/tag/${expectedPackage.tag}` + ) { + throw new Error( + "release evidence does not bind the provider release receipt", + ); + } +} +if ( + parsedDraft.githubRelease.releaseId !== parsedFinal.githubRelease.releaseId +) { + throw new Error( + "release evidence does not preserve one GitHub Release identity", + ); +} +process.stdout.write( + `${JSON.stringify( + { + schemaVersion: "1", + package: expectedPackage, + artifact: { + filename: metadata.selectedArtifact.filename, + sha256: metadata.selectedArtifact.sha256, + }, + evidence: { + draft: { name: draftName, digest: mill.canonicalDigest(parsedDraft) }, + final: { name: finalName, digest: mill.canonicalDigest(parsedFinal) }, + }, + reconstructedAt: new Date().toISOString(), + }, + undefined, + 2, + )}\n`, +); diff --git a/scripts/release-workflow-policy.mjs b/scripts/release-workflow-policy.mjs index e14a8a7..bcb14ee 100644 --- a/scripts/release-workflow-policy.mjs +++ b/scripts/release-workflow-policy.mjs @@ -103,9 +103,21 @@ export function releasePublicationFailures(jobs) { const create = steps.find( (step) => step?.name === "Create draft GitHub Release with exact artifacts", ); - const finalize = steps.find( - (step) => step?.name === "Read back GitHub Release and finalize evidence", + const draftReadbackIndex = steps.findIndex( + (step) => step?.name === "Read back draft GitHub Release evidence", ); + const publishReleaseIndex = steps.findIndex( + (step) => + step?.name === "Publish GitHub Release after draft evidence readback", + ); + const finalReadbackIndex = steps.findIndex( + (step) => + step?.name === + "Read back published GitHub Release and attach final evidence", + ); + const draftReadback = steps[draftReadbackIndex]; + const publishRelease = steps[publishReleaseIndex]; + const finalReadback = steps[finalReadbackIndex]; const failures = []; if ( typeof publish?.run !== "string" || @@ -139,26 +151,51 @@ export function releasePublicationFailures(jobs) { "publish: GitHub release must be a plainly labelled normal public-alpha release", ); } - const finalEvidenceUpload = - 'gh release upload "$RELEASE_TAG" "$RUNNER_TEMP/release-evidence-final.json"'; + for (const requiredAsset of [ + '"$RUNNER_TEMP/qualified/qualification.json"', + '"$RUNNER_TEMP/qualified/artifact-metadata.json"', + '"$RUNNER_TEMP/qualified/audit.json"', + '"$RUNNER_TEMP/qualified/identity.json"', + '"$RUNNER_TEMP/qualified/release-canary.json"', + '"$RUNNER_TEMP/trusted/trusted-verifier.json"', + '"$RUNNER_TEMP/trusted/trusted-canary.json"', + ]) { + if ( + typeof create?.run !== "string" || + !create.run.includes(requiredAsset) + ) { + failures.push( + "publish: draft release must retain the qualified candidate and independent verifier records", + ); + break; + } + } + const draftEvidenceUpload = + 'gh release upload "$RELEASE_TAG" "$RUNNER_TEMP/release-evidence-draft.json"'; const finalRelease = 'gh release edit "$RELEASE_TAG" --draft=false'; if ( - typeof finalize?.run !== "string" || - !finalize.run.includes(finalRelease) || - finalize.run.includes("--prerelease") + typeof publishRelease?.run !== "string" || + !publishRelease.run.includes(finalRelease) || + publishRelease.run.includes("--prerelease") ) { failures.push( "publish: final GitHub release must remain a normal public-alpha release", ); } if ( - typeof finalize?.run !== "string" || - !finalize.run.includes(finalEvidenceUpload) || - finalize.run.indexOf(finalEvidenceUpload) > - finalize.run.indexOf(finalRelease) + typeof draftReadback?.run !== "string" || + !draftReadback.run.includes(draftEvidenceUpload) || + typeof publishRelease?.run !== "string" || + typeof finalReadback?.run !== "string" || + !finalReadback.run.includes( + 'gh release upload "$RELEASE_TAG" "$RUNNER_TEMP/release-evidence-final.json"', + ) || + draftReadbackIndex < 0 || + publishReleaseIndex <= draftReadbackIndex || + finalReadbackIndex <= publishReleaseIndex ) { failures.push( - "publish: final release evidence must be attached before publication", + "publish: draft and published release evidence must be retained in their observed order", ); } return failures; diff --git a/src/cli-program.ts b/src/cli-program.ts index 4ae6a87..4a216cb 100644 --- a/src/cli-program.ts +++ b/src/cli-program.ts @@ -5,7 +5,12 @@ import { parse as parseYaml } from "yaml"; import { auditRepository } from "./audit/repository.js"; import { findRepositoryRoot, enforceExactVersion } from "./config/lock.js"; -import { contractSchemas, type ContractKind } from "./contracts/schemas.js"; +import { + changeRequestSchema, + contractSchemas, + type ContractKind, +} from "./contracts/schemas.js"; +import { canonicalDigest, type JsonValue } from "./contracts/canonical.js"; import { doctor, doctorReady, type DoctorMode } from "./doctor.js"; import { asMillError, ExitCode, MillError } from "./errors.js"; import { inspectPrd } from "./intake/prd.js"; @@ -48,6 +53,7 @@ import { qualifyBaseline, resumeRun, reviewRun, + retainedVerifierArtifacts, runOutcome, runReport, runStats, @@ -278,7 +284,7 @@ export function createProgram(io: CliIo, jsonErrors = false): Command { const global = globals(program); const root = await findRepositoryRoot(global.cwd); await enforceExactVersion(root); - const [inspection, planning, proposal, impactInputs, tasks] = + const [inspection, planning, proposal, impactInputs, requestText] = await Promise.all([ inspectPrd(root, options.prd), loadPlanningSources({ @@ -293,7 +299,7 @@ export function createProgram(io: CliIo, jsonErrors = false): Command { scenarioPath: options.scenarios, impactPath: options.impact, }), - compileChangeTasks({ root, requestPath: options.request }), + safeReadText(root, options.request, 2 * 1024 * 1024), ]); const specification = assessSpecificationProposal({ proposal, @@ -307,7 +313,54 @@ export function createProgram(io: CliIo, jsonErrors = false): Command { product: impactInputs.product, scenarios: impactInputs.scenarios, }); - const blockers = [...specification.blockers, ...impact.blockers]; + const request = changeRequestSchema.parse(parseYaml(requestText)); + const coherenceBlockers: string[] = []; + const proposalProductDigest = canonicalDigest( + proposal.productContract as unknown as JsonValue, + ); + const selectedProductDigest = canonicalDigest( + impactInputs.product as unknown as JsonValue, + ); + const proposalScenarioDigest = canonicalDigest( + proposal.scenarioSet as unknown as JsonValue, + ); + const selectedScenarioDigest = canonicalDigest( + impactInputs.scenarios as unknown as JsonValue, + ); + if (proposalProductDigest !== selectedProductDigest) { + coherenceBlockers.push( + "proposal product contract differs from --product input", + ); + } + if (proposalScenarioDigest !== selectedScenarioDigest) { + coherenceBlockers.push( + "proposal scenario set differs from --scenarios input", + ); + } + if ( + request.source.path !== options.prd || + request.productPath !== options.product || + request.scenariosPath !== options.scenarios || + request.tasks.some((task) => task.impactPath !== options.impact) + ) { + coherenceBlockers.push( + "change request paths do not match the selected proposal bundle", + ); + } + const blockers = [ + ...specification.blockers, + ...impact.blockers, + ...coherenceBlockers, + ]; + const tasks = + blockers.length === 0 + ? await compileChangeTasks({ root, requestPath: options.request }) + : { + status: "not_compiled", + reason: + "Task compilation requires one approved, matching proposal bundle.", + files: [], + }; emit( io, global.json === true, @@ -1420,7 +1473,7 @@ export function createProgram(io: CliIo, jsonErrors = false): Command { program .command("report") .description( - "report redacted lifecycle outcomes, measured usage and declared self-hosting progress", + "report redacted lifecycle outcomes, measured usage and declared development evidence", ) .action(async () => { const global = globals(program); @@ -1437,6 +1490,27 @@ export function createProgram(io: CliIo, jsonErrors = false): Command { ); }); + program + .command("artifacts") + .description( + "list candidate-bound retained verifier artifacts without exposing their bytes", + ) + .requiredOption("--run ", "run identifier") + .action(async (options: { run: string }) => { + const global = globals(program); + const root = await findRepositoryRoot(global.cwd); + await enforceExactVersion(root); + emit( + io, + global.json === true, + commandResult({ + command: "artifacts", + ok: true, + data: await retainedVerifierArtifacts({ root, runId: options.run }), + }), + ); + }); + program .command("continuation") .description( diff --git a/src/contracts/schemas.ts b/src/contracts/schemas.ts index 88344f3..4720754 100644 --- a/src/contracts/schemas.ts +++ b/src/contracts/schemas.ts @@ -477,6 +477,23 @@ const pnpmDependencySchema = z.strictObject({ workspacePaths: z.array(shallowWorkspacePathSchema).min(1), }); +const retainedVerifierArtifactsSchema = z.strictObject({ + paths: uniqueNonemptyStringArraySchema + .max(32) + .refine( + (paths) => + paths.every( + (artifactPath) => + repositoryFilePathSchema.safeParse(artifactPath).success, + ), + "expected canonical relative artifact file paths", + ), + required: z.boolean().default(true), + maxFiles: z.number().int().min(1).max(32).default(16), + maxFileBytes: z.number().int().min(1).max(10_000_000).default(1_000_000), + maxTotalBytes: z.number().int().min(1).max(50_000_000).default(5_000_000), +}); + export const millConfigSchema = z .strictObject({ schemaVersion: z.literal("1"), @@ -484,7 +501,9 @@ export const millConfigSchema = z trustCeiling: z.enum(["inspect", "build", "propose"]), sensitivePaths: z.array(repositoryPathPatternSchema).default([]), reporting: z - .strictObject({ selfHosted: z.boolean().default(false) }) + .strictObject({ + ledgerPath: repositoryFilePathSchema.optional(), + }) .optional(), verifier: z .strictObject({ @@ -538,6 +557,7 @@ export const millConfigSchema = z execution: z.enum(["oci", "host"]).default("oci"), writablePaths: z.array(repositoryMountDirectorySchema).optional(), executableFixtureScratch: z.literal(true).optional(), + retainedArtifacts: retainedVerifierArtifactsSchema.optional(), }) .meta({ allOf: [ @@ -571,6 +591,27 @@ export const millConfigSchema = z "Executable fixture scratch requires an OCI test/package command", }); } + if ( + command.retainedArtifacts !== undefined && + command.execution !== "oci" + ) { + context.addIssue({ + code: "custom", + path: ["commands", commandId, "retainedArtifacts"], + message: "Retained verifier artifacts require an OCI command", + }); + } + if ( + command.retainedArtifacts !== undefined && + command.retainedArtifacts.maxTotalBytes < + command.retainedArtifacts.maxFileBytes + ) { + context.addIssue({ + code: "custom", + path: ["commands", commandId, "retainedArtifacts", "maxTotalBytes"], + message: "Retained artifact total limit must cover one allowed file", + }); + } } if (value.trustCeiling === "propose" && value.propose === undefined) { context.addIssue({ @@ -1196,6 +1237,15 @@ export const validationEvidenceSchema = z.strictObject({ exitCode: z.number().int().nullable(), durationMs: z.number().int().min(0), outputDigest: digestSchema, + artifacts: z + .array( + z.strictObject({ + path: repositoryFilePathSchema, + sha256: digestSchema, + bytes: z.number().int().min(0), + }), + ) + .optional(), reason: z .enum([ "HOST_EXECUTION_NOT_QUALIFIED", @@ -1203,6 +1253,7 @@ export const validationEvidenceSchema = z.strictObject({ "DEADLINE_EXCEEDED", "OUTPUT_BUDGET_EXCEEDED", "NONZERO_EXIT", + "RETAINED_ARTIFACT_MISSING", ]) .optional(), }), @@ -1265,93 +1316,132 @@ export const mergeApprovalPlanSchema = z.strictObject({ expiresAt: z.iso.datetime(), }); -export const deliveryRecordSchema = z.strictObject({ - schemaVersion: z.literal("1"), - runId: z.uuid(), - deliveryKey: digestSchema, - proposalDigest: digestSchema, - approvalExpiresAt: z.iso.datetime(), - state: z.enum([ - "planned", - "proposing", - "effect_unknown", - "awaiting_ci", - "awaiting_human", - "merged", - "post_merge_verified", - "closed", - "cancelled", - "blocked", - ]), - target: z.strictObject({ - forge: z.literal("github"), - host: z.literal("github.com"), - owner: z.string().min(1), - repository: z.string().min(1), - repositoryNodeId: z.string().min(1), - cloneUrl: z.url(), - remoteName: z.string().min(1), - baseBranch: z.string().min(1), - actorLogin: z.string().min(1), - actorId: z.number().int().positive(), - }), - branchName: z.string().min(1), - candidateCommit: z.string().regex(/^[a-f0-9]{40}$/u), - candidateTree: z.string().regex(/^[a-f0-9]{40}$/u), - requiredChecks: z.array(z.string().min(1)), - checkProducers: z.record(z.string().min(1), checkProducerSchema).optional(), - postMergeRequiredChecks: z.array(z.string().min(1)).min(1).optional(), - postMergePolicySource: z - .enum(["configured", "implicit_default", "legacy_migrated"]) - .optional(), - legacyPostMergePolicyConfigDigest: digestSchema.optional(), - reviewPolicy: githubReviewPolicySchema, - allowedMergerLogins: z.array(z.string().min(1)).min(1), - allowedMergeMethods: z - .array(z.enum(["merge", "linear_tree_preserving"])) - .min(1), - effects: z.array(remoteEffectSchema), - mergeApproval: z - .strictObject({ - plan: mergeApprovalPlanSchema, - digest: digestSchema, - state: z.enum([ - "planned", - "ready_started", - "ready_verified", - "merge_started", - "effect_unknown", - "merged", - ]), - approvalSource: z.literal("attended_operator").optional(), - }) - .optional(), - remoteHeadCommit: z - .string() - .regex(/^[a-f0-9]{40}$/u) - .nullable(), - pullRequest: z - .strictObject({ - number: z.number().int().positive(), - nodeId: z.string().min(1), - url: z.url(), - }) - .nullable(), - observation: z.record(z.string(), z.unknown()).nullable(), - merge: z - .strictObject({ - commit: z.string().regex(/^[a-f0-9]{40}$/u), - tree: z.string().regex(/^[a-f0-9]{40}$/u), - method: z.enum(["merge", "linear_tree_preserving"]), - mergedByLogin: z.string().min(1), - mergedAt: z.iso.datetime(), - defaultBranchHead: z.string().regex(/^[a-f0-9]{40}$/u), - }) - .nullable(), - lastErrorCode: z.string().min(1).nullable(), - createdAt: z.iso.datetime(), - updatedAt: z.iso.datetime(), -}); +export const deliveryRecordSchema = z + .strictObject({ + schemaVersion: z.literal("1"), + runId: z.uuid(), + deliveryKey: digestSchema, + proposalDigest: digestSchema, + approvalExpiresAt: z.iso.datetime(), + state: z.enum([ + "planned", + "proposing", + "effect_unknown", + "awaiting_ci", + "awaiting_human", + "merged", + "post_merge_verified", + "closed", + "cancelled", + "blocked", + ]), + target: z.strictObject({ + forge: z.literal("github"), + host: z.literal("github.com"), + owner: z.string().min(1), + repository: z.string().min(1), + repositoryNodeId: z.string().min(1), + cloneUrl: z.url(), + remoteName: z.string().min(1), + baseBranch: z.string().min(1), + actorLogin: z.string().min(1), + actorId: z.number().int().positive(), + }), + branchName: z.string().min(1), + candidateCommit: z.string().regex(/^[a-f0-9]{40}$/u), + candidateTree: z.string().regex(/^[a-f0-9]{40}$/u), + requiredChecks: z.array(z.string().min(1)), + checkProducers: z.record(z.string().min(1), checkProducerSchema).optional(), + postMergeRequiredChecks: z.array(z.string().min(1)).optional(), + postMergePolicySource: z + .enum(["configured", "implicit_default", "legacy_migrated"]) + .optional(), + legacyPostMergePolicyConfigDigest: digestSchema.optional(), + reviewPolicy: githubReviewPolicySchema, + allowedMergerLogins: z.array(z.string().min(1)).min(1), + allowedMergeMethods: z + .array(z.enum(["merge", "linear_tree_preserving"])) + .min(1), + effects: z.array(remoteEffectSchema), + mergeApproval: z + .strictObject({ + plan: mergeApprovalPlanSchema, + digest: digestSchema, + state: z.enum([ + "planned", + "ready_started", + "ready_verified", + "merge_started", + "effect_unknown", + "merged", + ]), + approvalSource: z.literal("attended_operator").optional(), + }) + .optional(), + remoteHeadCommit: z + .string() + .regex(/^[a-f0-9]{40}$/u) + .nullable(), + pullRequest: z + .strictObject({ + number: z.number().int().positive(), + nodeId: z.string().min(1), + url: z.url(), + }) + .nullable(), + observation: z.record(z.string(), z.unknown()).nullable(), + merge: z + .strictObject({ + commit: z.string().regex(/^[a-f0-9]{40}$/u), + tree: z.string().regex(/^[a-f0-9]{40}$/u), + method: z.enum(["merge", "linear_tree_preserving"]), + mergedByLogin: z.string().min(1), + mergedAt: z.iso.datetime(), + defaultBranchHead: z.string().regex(/^[a-f0-9]{40}$/u), + }) + .nullable(), + lastErrorCode: z.string().min(1).nullable(), + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), + }) + .superRefine((value, context) => { + if ( + value.postMergeRequiredChecks?.length === 0 && + value.requiredChecks.length > 0 + ) { + context.addIssue({ + code: "custom", + path: ["postMergeRequiredChecks"], + message: + "an empty post-merge check list is valid only when the pull-request check list is empty", + }); + } + if ( + value.postMergeRequiredChecks !== undefined && + !value.postMergeRequiredChecks.every((check) => + value.requiredChecks.includes(check), + ) + ) { + context.addIssue({ + code: "custom", + path: ["postMergeRequiredChecks"], + message: + "post-merge required checks must be a subset of pull-request required checks", + }); + } + if ( + (value.postMergePolicySource === "configured" || + value.postMergePolicySource === "implicit_default") && + value.postMergeRequiredChecks === undefined + ) { + context.addIssue({ + code: "custom", + path: ["postMergeRequiredChecks"], + message: + "new delivery records must bind their effective post-merge checks", + }); + } + }); export const millLockSchema = z.strictObject({ schemaVersion: z.literal("1"), @@ -1653,7 +1743,32 @@ export const releaseEvidenceSchema = z.strictObject({ builders: z.array(releaseArtifactSchema).length(2), selectedArtifact: releaseArtifactSchema, qualificationDigest: digestSchema, + qualification: z + .strictObject({ + supportTuple: z.strictObject({ + id: z.string().regex(/^[a-z0-9][a-z0-9._-]*$/u), + status: z.enum(["experimental", "qualified", "expired"]), + testedAt: z.iso.datetime(), + expiresAt: z.iso.datetime(), + digest: digestSchema, + }), + }) + .optional(), sbomDigest: digestSchema, + workflowRuns: z + .strictObject({ + candidate: z.strictObject({ + id: z.string().regex(/^[1-9][0-9]*$/u), + url: z.url(), + headCommit: z.string().regex(/^[a-f0-9]{40}$/u), + }), + publish: z.strictObject({ + id: z.string().regex(/^[1-9][0-9]*$/u), + url: z.url(), + headCommit: z.string().regex(/^[a-f0-9]{40}$/u), + }), + }) + .optional(), registry: z .strictObject({ tarball: z.url(), @@ -1666,6 +1781,13 @@ export const releaseEvidenceSchema = z.strictObject({ url: z.url(), tag: z.string().min(1), artifactDigest: digestSchema, + state: z.enum(["draft", "published"]).optional(), + releaseId: z + .string() + .regex(/^[1-9][0-9]*$/u) + .optional(), + publishedAt: z.iso.datetime().nullable().optional(), + observedAt: z.iso.datetime().optional(), }) .nullable(), generatedAt: z.iso.datetime(), diff --git a/src/runtime/codex.ts b/src/runtime/codex.ts index 3682816..ca0dd96 100644 --- a/src/runtime/codex.ts +++ b/src/runtime/codex.ts @@ -390,7 +390,6 @@ async function invoke( { exitCode: result.exitCode, durationMs: result.durationMs, - stderr: result.stderr.slice(0, 2_000), ...(safeProviderErrorCode === undefined ? {} : { providerErrorCode: safeProviderErrorCode }), diff --git a/src/runtime/delivery.ts b/src/runtime/delivery.ts index e546486..b177a70 100644 --- a/src/runtime/delivery.ts +++ b/src/runtime/delivery.ts @@ -730,7 +730,7 @@ export function reviewsPassed( review.actorLogin === login && review.commitId === candidateCommit, ) .at(-1); - return latest?.state === "APPROVED" || latest?.state === "COMMENTED"; + return latest?.state === "APPROVED"; }); } diff --git a/src/runtime/dependencies.ts b/src/runtime/dependencies.ts index 88d7ac4..8ad82dd 100644 --- a/src/runtime/dependencies.ts +++ b/src/runtime/dependencies.ts @@ -45,6 +45,7 @@ interface DependencyIdentity { interface DependencyMarker extends DependencyIdentity { treeDigest: string; + workspaceTreeDigests?: Record; } export interface DependencyPreparationResult { @@ -510,8 +511,12 @@ async function regularFileDigest(file: string): Promise { } } -async function dependencyTreeDigest(directory: string): Promise { +async function dependencyTreeDigest( + directory: string, + containmentRoot = directory, +): Promise { const canonicalRoot = await realpath(directory); + const canonicalContainmentRoot = await realpath(containmentRoot); const aggregate = createHash("sha256"); let entriesVisited = 0; const record = (value: readonly (string | number)[]): void => { @@ -568,8 +573,8 @@ async function dependencyTreeDigest(directory: string): Promise { ); if ( path.isAbsolute(target) || - !isWithin(canonicalRoot, resolvedTarget) || - !isWithin(canonicalRoot, await realpath(childAbsolute)) + !isWithin(canonicalContainmentRoot, resolvedTarget) || + !isWithin(canonicalContainmentRoot, await realpath(childAbsolute)) ) { throw new MillError( "DEPENDENCY_TREE_INVALID", @@ -602,6 +607,39 @@ async function dependencyTreeDigest(directory: string): Promise { return `sha256:${aggregate.digest("hex")}`; } +async function optionalDependencyTreeDigest( + directory: string, + containmentRoot = directory, +): Promise { + try { + await lstat(directory); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return "absent"; + } + throw error; + } + return await dependencyTreeDigest(directory, containmentRoot); +} + +async function pnpmWorkspaceTreeDigests( + directory: string, + dependencies: Extract, +): Promise> { + const digests: Record = {}; + for (const manifest of await pnpmWorkspaceManifestPaths( + directory, + dependencies, + )) { + const workspacePath = path.dirname(manifest); + digests[workspacePath] = await optionalDependencyTreeDigest( + path.join(directory, workspacePath, "node_modules"), + directory, + ); + } + return digests; +} + async function markerMatches( directory: string, expected: DependencyIdentity, @@ -649,11 +687,37 @@ async function markerMatches( targetPath: parsed.targetPath, locks: parsed.locks, }; - return ( - JSON.stringify(claimedIdentity) === JSON.stringify(expected) && - parsed.treeDigest === - (await dependencyTreeDigest(path.join(directory, "node_modules"))) + if ( + JSON.stringify(claimedIdentity) !== JSON.stringify(expected) || + parsed.treeDigest !== + (await dependencyTreeDigest( + path.join(directory, "node_modules"), + directory, + )) + ) + return false; + if (expected.manager !== "pnpm") return true; + const workspaceTreeDigests = record(parsed.workspaceTreeDigests); + if (workspaceTreeDigests === undefined) return false; + const workspaceManifests = await pnpmWorkspaceManifestPaths( + directory, + expected as unknown as Extract, ); + for (const workspacePath of workspaceManifests.map((manifest) => + path.dirname(manifest), + )) { + const expectedDigest = workspaceTreeDigests[workspacePath]; + if ( + typeof expectedDigest !== "string" || + expectedDigest !== + (await optionalDependencyTreeDigest( + path.join(directory, workspacePath, "node_modules"), + directory, + )) + ) + return false; + } + return true; } catch { return false; } @@ -1110,7 +1174,12 @@ async function prepareDependencySnapshotWithSignal(input: { "DEPENDENCY_PREPARATION_FAILED", "The exact dependency snapshot could not be prepared.", ExitCode.unavailable, - { exitCode: result.exitCode, stderr: result.stderr.slice(0, 2_000) }, + { + exitCode: result.exitCode, + stderrDigest: `sha256:${createHash("sha256") + .update(result.stderr, "utf8") + .digest("hex")}`, + }, ); } let modules = await lstat(path.join(temporary, "node_modules")).catch( @@ -1147,7 +1216,16 @@ async function prepareDependencySnapshotWithSignal(input: { ...identity.marker, treeDigest: await dependencyTreeDigest( path.join(temporary, "node_modules"), + temporary, ), + ...(dependencies.manager === "pnpm" + ? { + workspaceTreeDigests: await pnpmWorkspaceTreeDigests( + temporary, + dependencies, + ), + } + : {}), }; await writeFile( path.join(temporary, "marker.json"), diff --git a/src/runtime/development-evidence.ts b/src/runtime/development-evidence.ts new file mode 100644 index 0000000..a072772 --- /dev/null +++ b/src/runtime/development-evidence.ts @@ -0,0 +1,198 @@ +import { parse as parseYaml } from "yaml"; +import { z } from "zod"; + +import { ExitCode, MillError } from "../errors.js"; +import { safeReadText } from "../security/safe-path.js"; + +const minutes = z.number().nonnegative().nullable(); + +const developmentEvidenceLedgerSchema = z.strictObject({ + schemaVersion: z.literal("1"), + records: z + .array( + z.strictObject({ + id: z.string().min(1), + change: z.string().min(1), + eligibility: z.discriminatedUnion("status", [ + z.strictObject({ status: z.literal("eligible") }), + z.strictObject({ + status: z.literal("excluded"), + reason: z.string().min(1), + }), + ]), + route: z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("mill") }), + z.strictObject({ + kind: z.literal("manual"), + reason: z.string().min(1), + }), + ]), + outcome: z.enum([ + "in_progress", + "accepted", + "not_accepted", + "abandoned", + ]), + effort: z.strictObject({ + preparationMinutes: minutes, + reviewMinutes: minutes, + repairMinutes: minutes, + }), + elapsedMinutes: minutes, + repairs: z.number().int().nonnegative(), + providerUsage: z + .strictObject({ + inputTokens: z.number().int().nonnegative().nullable(), + outputTokens: z.number().int().nonnegative().nullable(), + cost: z.number().nonnegative().nullable(), + currency: z.string().min(1).nullable(), + }) + .optional(), + }), + ) + .refine( + (records) => + new Set(records.map((record) => record.id)).size === records.length, + { + message: "expected unique development-evidence record IDs", + }, + ), +}); + +export type DevelopmentEvidenceLedger = z.infer< + typeof developmentEvidenceLedgerSchema +>; + +export interface DevelopmentEvidenceSummary { + ledgerPath: string | null; + records: number; + eligibleChanges: number; + eligibleMillRoute: number; + eligibleManualRoute: number; + eligibleCompleted: number; + eligibleAccepted: number; + effort: { + preparationMinutes: number | null; + reviewMinutes: number | null; + repairMinutes: number | null; + }; + elapsedMinutes: number | null; + providerUsage: { + inputTokens: number | null; + outputTokens: number | null; + cost: number | null; + currency: string | null; + }; +} + +function completeSum(values: readonly (number | null)[]): number | null { + if (values.length === 0 || values.some((value) => value === null)) { + return null; + } + return values.reduce((total, value) => total + (value ?? 0), 0); +} + +/** + * Loads maintainer-entered change evidence. It intentionally does not infer a + * denominator, human time, or provider cost from durable run state. + */ +export async function developmentEvidenceSummary(input: { + root: string; + ledgerPath?: string; +}): Promise { + if (input.ledgerPath === undefined) { + return { + ledgerPath: null, + records: 0, + eligibleChanges: 0, + eligibleMillRoute: 0, + eligibleManualRoute: 0, + eligibleCompleted: 0, + eligibleAccepted: 0, + effort: { + preparationMinutes: null, + reviewMinutes: null, + repairMinutes: null, + }, + elapsedMinutes: null, + providerUsage: { + inputTokens: null, + outputTokens: null, + cost: null, + currency: null, + }, + }; + } + let parsed: DevelopmentEvidenceLedger; + try { + parsed = developmentEvidenceLedgerSchema.parse( + parseYaml( + await safeReadText(input.root, input.ledgerPath, 2 * 1024 * 1024), + ), + ); + } catch (error) { + if (error instanceof MillError) throw error; + throw new MillError( + "DEVELOPMENT_EVIDENCE_LEDGER_INVALID", + `The development-evidence ledger does not satisfy its contract: ${input.ledgerPath}.`, + ExitCode.data, + ); + } + const eligible = parsed.records.filter( + (record) => record.eligibility.status === "eligible", + ); + const values = ( + key: keyof (typeof parsed.records)[number]["effort"], + ): (number | null)[] => eligible.map((record) => record.effort[key]); + const elapsed = eligible.map((record) => record.elapsedMinutes); + const usage = eligible.map((record) => record.providerUsage); + const currencies = new Set( + usage + .map((value) => value?.currency) + .filter( + (value): value is string => value !== null && value !== undefined, + ), + ); + const currency = + usage.length > 0 && + usage.every( + (value) => value?.currency !== null && value?.currency !== undefined, + ) && + currencies.size === 1 + ? ([...currencies][0] ?? null) + : null; + return { + ledgerPath: input.ledgerPath, + records: parsed.records.length, + eligibleChanges: eligible.length, + eligibleMillRoute: eligible.filter((record) => record.route.kind === "mill") + .length, + eligibleManualRoute: eligible.filter( + (record) => record.route.kind === "manual", + ).length, + eligibleCompleted: eligible.filter( + (record) => record.outcome !== "in_progress", + ).length, + eligibleAccepted: eligible.filter((record) => record.outcome === "accepted") + .length, + effort: { + preparationMinutes: completeSum(values("preparationMinutes")), + reviewMinutes: completeSum(values("reviewMinutes")), + repairMinutes: completeSum(values("repairMinutes")), + }, + elapsedMinutes: completeSum(elapsed), + providerUsage: { + inputTokens: completeSum( + usage.map((value) => value?.inputTokens ?? null), + ), + outputTokens: completeSum( + usage.map((value) => value?.outputTokens ?? null), + ), + cost: + currency === null + ? null + : completeSum(usage.map((value) => value?.cost ?? null)), + currency, + }, + }; +} diff --git a/src/runtime/github.ts b/src/runtime/github.ts index 884e8d4..dfe30d8 100644 --- a/src/runtime/github.ts +++ b/src/runtime/github.ts @@ -821,7 +821,7 @@ class GhGitHubAdapter implements GitHubAdapter { if ( review.body.trim().length === 0 || review.commitId === null || - reviewPriority === "unclassified" + review.state === "APPROVED" ) { return []; } diff --git a/src/runtime/lifecycle.ts b/src/runtime/lifecycle.ts index dba72b9..43b1fd7 100644 --- a/src/runtime/lifecycle.ts +++ b/src/runtime/lifecycle.ts @@ -1,4 +1,5 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; +import { lstat, readFile } from "node:fs/promises"; import path from "node:path"; import { @@ -55,6 +56,7 @@ import { type PublicRunRecord, type RunRecord, } from "./state.js"; +import { CURRENT_STATE_SCHEMA_VERSION } from "./state-migrations.js"; import { createWorkerInvocation } from "./worker.js"; import { dependencySnapshotDirectory } from "./dependencies.js"; import { verifyDeclaredCommands, type ValidationEvidence } from "./verifier.js"; @@ -67,6 +69,7 @@ import { MILL_VERSION } from "../version.js"; import { validationRepairFindings } from "./repair.js"; import { summarizeUsage } from "./usage.js"; import { continuationPacket } from "./continuation.js"; +import { developmentEvidenceSummary } from "./development-evidence.js"; import { projectRunOutcome, type RunOutcome } from "./outcome.js"; import { projectRunTimeline, type RunTimeline } from "./timeline.js"; import { @@ -805,6 +808,12 @@ export async function qualifyBaseline(input: { const evidence = await verifyDeclaredCommands({ root: destination, ...(dependencyRoot === undefined ? {} : { dependencyRoot }), + artifactDirectory: path.join( + store.directory, + "baseline-artifacts", + qualified.baseCommit, + randomUUID(), + ), candidateCommit: qualified.baseCommit, config: inputs.config, task: @@ -883,6 +892,12 @@ export async function verifyRun(input: { const evidence = await verifyDeclaredCommands({ root: candidate.worktree, ...(dependencyRoot === undefined ? {} : { dependencyRoot }), + artifactDirectory: path.join( + store.directory, + "artifacts", + run.id, + candidate.commit, + ), candidateCommit: candidate.commit, config: inputs.config, task: inputs.task, @@ -1394,8 +1409,11 @@ export async function runStatus(input: { }> { const config = await loadMillConfig(input.root); const commonDirectory = await commonGitDirectory(input.root); - const store = await StateStore.open(config.repositoryId, commonDirectory); - let lease: Awaited> | undefined; + const store = await StateStore.openReadOnly( + config.repositoryId, + commonDirectory, + ); + if (store === undefined) return {}; try { const run = input.runId === undefined ? store.latestRun() : store.getRun(input.runId); @@ -1411,16 +1429,8 @@ export async function runStatus(input: { !isTerminalRun(run.status) && (run.status === "running" || active !== undefined) ) { - try { - lease = await acquireWriterLease(store); - controllerAbsent = true; - } catch (error) { - if (!( - error instanceof MillError && error.code === "WRITER_ALREADY_ACTIVE" - )) { - throw error; - } - } + controllerAbsent = + active !== undefined && processIdentityStatus(active) === "mismatch"; } let activeWorker = active !== undefined; if (controllerAbsent) { @@ -1434,6 +1444,10 @@ export async function runStatus(input: { } else if (run.status === "running") { interrupted = true; } + } else if (active !== undefined && !isTerminalRun(run.status)) { + // A read-only observer cannot establish which controller owns a live + // process. Require attended reconciliation before a later mutating call. + reconciliationRequired = true; } const publicRun = publicRunRecord(run); const usage = summarizeUsage(store.events(run.id)); @@ -1455,11 +1469,7 @@ export async function runStatus(input: { }), }; } finally { - try { - await lease?.release(); - } finally { - store.close(); - } + store.close(); } } @@ -1469,7 +1479,11 @@ export async function runTimeline(input: { }): Promise { const config = await loadMillConfig(input.root); const commonDirectory = await commonGitDirectory(input.root); - const store = await StateStore.open(config.repositoryId, commonDirectory); + const store = await StateStore.openReadOnly( + config.repositoryId, + commonDirectory, + ); + if (store === undefined) return undefined; try { const snapshot = store.runEventSnapshot(input.runId); if (snapshot.run === undefined) return undefined; @@ -1488,7 +1502,11 @@ export async function runOutcome(input: { }): Promise { const config = await loadMillConfig(input.root); const commonDirectory = await commonGitDirectory(input.root); - const store = await StateStore.open(config.repositoryId, commonDirectory); + const store = await StateStore.openReadOnly( + config.repositoryId, + commonDirectory, + ); + if (store === undefined) return undefined; try { const snapshot = store.runEventSnapshot(input.runId); if (snapshot.run === undefined) return undefined; @@ -1512,7 +1530,11 @@ export async function runInventory(input: { }): Promise { const config = await loadMillConfig(input.root); const commonDirectory = await commonGitDirectory(input.root); - const store = await StateStore.open(config.repositoryId, commonDirectory); + const store = await StateStore.openReadOnly( + config.repositoryId, + commonDirectory, + ); + if (store === undefined) return []; try { return store.runs().map(publicRunRecord); } finally { @@ -1523,7 +1545,42 @@ export async function runInventory(input: { export async function runStats(input: { root: string }): Promise { const config = await loadMillConfig(input.root); const commonDirectory = await commonGitDirectory(input.root); - const store = await StateStore.open(config.repositoryId, commonDirectory); + const store = await StateStore.openReadOnly( + config.repositoryId, + commonDirectory, + ); + if (store === undefined) { + return { + schemaVersion: CURRENT_STATE_SCHEMA_VERSION, + migrations: [], + runs: { + total: 0, + byStatus: Object.fromEntries( + [ + "approved", + "ready", + "running", + "committed", + "verified", + "reviewed", + "proposing", + "effect_unknown", + "awaiting_ci", + "awaiting_human", + "merged", + "post_merge_verified", + "closed", + "blocked", + "cancelled", + "failed", + "stale", + ].map((status) => [status, 0]), + ) as StateStats["runs"]["byStatus"], + builderAttempts: 0, + repairs: 0, + }, + }; + } try { return store.stats(); } finally { @@ -1532,7 +1589,7 @@ export async function runStats(input: { root: string }): Promise { } export interface RunReport { - schemaVersion: "1"; + schemaVersion: "2"; redacted: true; runs: { total: number; @@ -1543,12 +1600,7 @@ export interface RunReport { }; elapsed: { totalMilliseconds: number; averageMilliseconds: number | null }; usage: ReturnType; - selfHosting: { - declared: boolean; - eligibleRuns: number; - completedRuns: number; - completionRate: number | null; - }; + developmentEvidence: Awaited>; } function hasSuccessfulValidation(run: RunRecord): boolean | undefined { @@ -1564,7 +1616,32 @@ function hasSuccessfulValidation(run: RunRecord): boolean | undefined { export async function runReport(input: { root: string }): Promise { const config = await loadMillConfig(input.root); const commonDirectory = await commonGitDirectory(input.root); - const store = await StateStore.open(config.repositoryId, commonDirectory); + const store = await StateStore.openReadOnly( + config.repositoryId, + commonDirectory, + ); + const developmentEvidence = await developmentEvidenceSummary({ + root: input.root, + ...(config.reporting?.ledgerPath === undefined + ? {} + : { ledgerPath: config.reporting.ledgerPath }), + }); + if (store === undefined) { + return { + schemaVersion: "2", + redacted: true, + runs: { + total: 0, + byStatus: (await runStats(input)).runs.byStatus, + verification: { passed: 0, failed: 0, notRecorded: 0 }, + repairs: 0, + builderAttempts: 0, + }, + elapsed: { totalMilliseconds: 0, averageMilliseconds: null }, + usage: summarizeUsage([]), + developmentEvidence, + }; + } try { const stats = store.stats(); const runs = store.runs(); @@ -1575,13 +1652,8 @@ export async function runReport(input: { root: string }): Promise { total + (Number.isSafeInteger(duration) && duration >= 0 ? duration : 0) ); }, 0); - const declared = config.reporting?.selfHosted === true; - const eligibleRuns = declared ? runs.length : 0; - const completedRuns = declared - ? runs.filter((run) => run.status === "closed").length - : 0; return { - schemaVersion: "1", + schemaVersion: "2", redacted: true, runs: { total: stats.runs.total, @@ -1601,19 +1673,89 @@ export async function runReport(input: { root: string }): Promise { runs.length === 0 ? null : Math.round(elapsed / runs.length), }, usage: summarizeUsage(runs.flatMap((run) => store.events(run.id))), - selfHosting: { - declared, - eligibleRuns, - completedRuns, - completionRate: - eligibleRuns === 0 ? null : completedRuns / eligibleRuns, - }, + developmentEvidence, }; } finally { store.close(); } } +export interface RetainedArtifactReport { + candidateCommit: string; + artifacts: readonly { + commandId: string; + path: string; + sha256: string; + bytes: number; + available: boolean; + }[]; +} + +/** Lists candidate-bound verifier artifacts without exposing their host paths or bytes. */ +export async function retainedVerifierArtifacts(input: { + root: string; + runId: string; +}): Promise { + const config = await loadMillConfig(input.root); + const commonDirectory = await commonGitDirectory(input.root); + const store = await StateStore.openReadOnly( + config.repositoryId, + commonDirectory, + ); + if (store === undefined) return undefined; + try { + const run = store.getRun(input.runId); + if (run.validationJson === undefined) return undefined; + const validation = validationEvidenceSchema.parse( + JSON.parse(run.validationJson), + ); + const artifacts = await Promise.all( + validation.commands.flatMap((command) => + (command.artifacts ?? []).map(async (artifact) => { + const commandDirectory = createHash("sha256") + .update(command.commandId, "utf8") + .digest("hex"); + const file = path.join( + store.directory, + "artifacts", + run.id, + validation.candidateCommit, + commandDirectory, + artifact.path, + ); + try { + const information = await lstat(file); + if (!information.isFile() || information.isSymbolicLink()) { + return { + ...artifact, + commandId: command.commandId, + available: false, + }; + } + const bytes = await readFile(file); + const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`; + return { + ...artifact, + commandId: command.commandId, + available: + bytes.length === artifact.bytes && digest === artifact.sha256, + }; + } catch { + return { + ...artifact, + commandId: command.commandId, + available: false, + }; + } + }), + ), + ); + return { candidateCommit: validation.candidateCommit, artifacts }; + } finally { + store.close(); + } +} + export async function stateBackup(input: { root: string }): Promise { const config = await loadMillConfig(input.root); const commonDirectory = await commonGitDirectory(input.root); diff --git a/src/runtime/repository.ts b/src/runtime/repository.ts index 068869e..6a7fd92 100644 --- a/src/runtime/repository.ts +++ b/src/runtime/repository.ts @@ -156,7 +156,9 @@ async function git( exitCode: result.exitCode, timedOut: result.timedOut, outputExceeded: result.outputExceeded, - stderr: result.stderr.slice(0, 2_000), + stderrDigest: `sha256:${createHash("sha256") + .update(result.stderr, "utf8") + .digest("hex")}`, }, ); } diff --git a/src/runtime/state-migrations.ts b/src/runtime/state-migrations.ts index 76fa701..f5a9953 100644 --- a/src/runtime/state-migrations.ts +++ b/src/runtime/state-migrations.ts @@ -64,6 +64,16 @@ function createInitialTables(database: DatabaseSync): void { evidence_digest TEXT NOT NULL, created_at TEXT NOT NULL ) STRICT; + CREATE TRIGGER IF NOT EXISTS run_events_no_update + BEFORE UPDATE ON run_events BEGIN SELECT RAISE(ABORT, 'run events are append-only'); END; + CREATE TRIGGER IF NOT EXISTS run_events_no_delete + BEFORE DELETE ON run_events BEGIN SELECT RAISE(ABORT, 'run events are append-only'); END; + `); + createWorkerInvocationTables(database); +} + +function createWorkerInvocationTables(database: DatabaseSync): void { + database.exec(` CREATE TABLE IF NOT EXISTS worker_invocations ( id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES runs(id), @@ -79,10 +89,6 @@ function createInitialTables(database: DatabaseSync): void { type TEXT NOT NULL, data_json TEXT NOT NULL ) STRICT; - CREATE TRIGGER IF NOT EXISTS run_events_no_update - BEFORE UPDATE ON run_events BEGIN SELECT RAISE(ABORT, 'run events are append-only'); END; - CREATE TRIGGER IF NOT EXISTS run_events_no_delete - BEFORE DELETE ON run_events BEGIN SELECT RAISE(ABORT, 'run events are append-only'); END; CREATE TRIGGER IF NOT EXISTS worker_invocations_no_update BEFORE UPDATE ON worker_invocations BEGIN SELECT RAISE(ABORT, 'worker invocations are immutable'); END; CREATE TRIGGER IF NOT EXISTS worker_invocations_no_delete @@ -118,6 +124,7 @@ function addV2RunColumns(database: DatabaseSync): void { database.exec(`ALTER TABLE runs ADD COLUMN ${column}`); } } + createWorkerInvocationTables(database); } function expandRepairCount(database: DatabaseSync): void { @@ -195,7 +202,13 @@ const stateMigrations: readonly StateMigration[] = [ }, ]; -function stateVersion(database: DatabaseSync): number { +export function stateSchemaVersion(database: DatabaseSync): number { + const metadata = database + .prepare( + "SELECT 1 AS present FROM sqlite_schema WHERE type = 'table' AND name = 'metadata'", + ) + .get() as { present: number } | undefined; + if (metadata?.present !== 1) return 0; const row = database .prepare("SELECT value FROM metadata WHERE key = 'schema_version'") .get() as { value: string } | undefined; @@ -311,34 +324,33 @@ export function applyStateMigrations(database: DatabaseSync): void { applied_at TEXT NOT NULL ) STRICT; `); - const current = stateVersion(database); + const current = stateSchemaVersion(database); validateRecordedMigrations(database, current); if (current === CURRENT_STATE_SCHEMA_VERSION) { assertCurrentStateMigrations(database); } else { for (const migration of stateMigrations) { if (migration.version <= current) { - migration.apply(database); recordMigration(database, migration); continue; } applyMigration(database, migration); } } - database.exec("COMMIT"); - transactionStarted = false; - database.exec("PRAGMA foreign_keys = ON"); - foreignKeysDisabled = false; - const foreignKeyViolation = database + const foreignKeyViolations = database .prepare("PRAGMA foreign_key_check") - .get(); - if (foreignKeyViolation !== undefined) { + .all(); + if (foreignKeyViolations.length > 0) { throw new MillError( "STATE_MIGRATION_FOREIGN_KEY_FAILURE", "Operational state migration produced an invalid foreign-key reference.", ExitCode.data, ); } + database.exec("COMMIT"); + transactionStarted = false; + database.exec("PRAGMA foreign_keys = ON"); + foreignKeysDisabled = false; } catch (error) { if (transactionStarted) { try { diff --git a/src/runtime/state.ts b/src/runtime/state.ts index 8bf3294..e06e374 100644 --- a/src/runtime/state.ts +++ b/src/runtime/state.ts @@ -30,6 +30,7 @@ import { applyStateMigrations, assertCurrentStateMigrations, CURRENT_STATE_SCHEMA_VERSION, + stateSchemaVersion, stateMigrationHistory, type AppliedStateMigration, } from "./state-migrations.js"; @@ -293,7 +294,7 @@ const transitions: Readonly> = { "failed", "stale", ], - awaiting_human: ["merged", "blocked", "failed", "stale"], + awaiting_human: ["merged", "blocked", "cancelled", "failed", "stale"], merged: ["post_merge_verified", "blocked", "failed", "stale"], post_merge_verified: ["closed", "blocked", "failed", "stale"], closed: [], @@ -440,25 +441,133 @@ export class StateStore { await chmod(directory, 0o700); await chmod(path.join(directory, "worktrees"), 0o700); const databasePath = path.join(directory, "state.sqlite3"); + const information = await lstat(databasePath) + .then((entry) => entry) + .catch((error: unknown) => { + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) + return undefined; + throw error; + }); + if ( + information !== undefined && + (!information.isFile() || information.isSymbolicLink()) + ) { + throw new MillError( + "INVALID_STATE_FILE", + "Operational state must be a regular Mill-owned database file.", + ExitCode.configuration, + ); + } + const existed = information !== undefined; + let before = 0; + if (existed) { + const probe = new DatabaseSync(databasePath, { + readOnly: true, + allowExtension: false, + enableDoubleQuotedStringLiterals: false, + }); + try { + before = stateSchemaVersion(probe); + } finally { + probe.close(); + } + } + const migrationLease = + before < CURRENT_STATE_SCHEMA_VERSION + ? await acquireExclusiveLease({ + path: path.join(directory, "writer-lease.sqlite3"), + activeCode: "WRITER_ALREADY_ACTIVE", + activeMessage: + "Another Mill writer is active for this repository; state upgrade is deferred.", + unavailableCode: "WRITER_LEASE_UNAVAILABLE", + unavailableMessage: + "The repository state upgrade lease could not be acquired safely.", + }) + : undefined; + try { + const database = new DatabaseSync(databasePath, { + timeout: 5_000, + allowExtension: false, + enableDoubleQuotedStringLiterals: false, + }); + database.exec(` + PRAGMA journal_mode = WAL; + PRAGMA synchronous = FULL; + PRAGMA foreign_keys = ON; + PRAGMA trusted_schema = OFF; + `); + try { + if (existed && before > 0 && before < CURRENT_STATE_SCHEMA_VERSION) { + const preUpgrade = path.join( + directory, + `state-backup-preupgrade-v${before}-${new Date().toISOString().replaceAll(/[:.]/gu, "-")}.sqlite3`, + ); + await backup(database, preUpgrade); + await chmod(preUpgrade, 0o600); + } + applyStateMigrations(database); + assertCurrentStateMigrations(database); + } catch (error) { + database.close(); + throw error; + } + await chmod(databasePath, 0o600); + return new StateStore(directory, database); + } finally { + await migrationLease?.release(); + } + } + + /** Opens an existing current state without creating, upgrading, or locking it. */ + static async openReadOnly( + repositoryId: string, + commonDirectory: string, + ): Promise { + const directory = repositoryStateDirectory(repositoryId, commonDirectory); + const databasePath = path.join(directory, "state.sqlite3"); + let information; + try { + information = await lstat(databasePath); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") + return undefined; + throw error; + } + if (!information.isFile() || information.isSymbolicLink()) { + throw new MillError( + "INVALID_STATE_FILE", + "Operational state must be a regular Mill-owned database file.", + ExitCode.configuration, + ); + } const database = new DatabaseSync(databasePath, { + readOnly: true, timeout: 5_000, allowExtension: false, enableDoubleQuotedStringLiterals: false, }); - database.exec(` - PRAGMA journal_mode = WAL; - PRAGMA synchronous = FULL; - PRAGMA foreign_keys = ON; - PRAGMA trusted_schema = OFF; - `); try { - applyStateMigrations(database); + database.exec( + "PRAGMA foreign_keys = ON; PRAGMA trusted_schema = OFF; PRAGMA query_only = ON;", + ); + const schemaVersion = stateSchemaVersion(database); + if (schemaVersion < CURRENT_STATE_SCHEMA_VERSION) { + throw new MillError( + "STATE_UPGRADE_REQUIRED", + "Operational state is older than this Mill version; run an attended mutating command to upgrade it.", + ExitCode.configuration, + { schemaVersion, currentSchemaVersion: CURRENT_STATE_SCHEMA_VERSION }, + ); + } assertCurrentStateMigrations(database); } catch (error) { database.close(); throw error; } - await chmod(databasePath, 0o600); return new StateStore(directory, database); } @@ -1990,6 +2099,9 @@ export async function restoreStateBackup( candidate.exec("PRAGMA trusted_schema = OFF; PRAGMA foreign_keys = ON;"); const integrity = candidate.prepare("PRAGMA integrity_check").get() as { integrity_check?: string } | undefined; + const foreignKeyViolations = candidate + .prepare("PRAGMA foreign_key_check") + .all(); const version = candidate .prepare("SELECT value FROM metadata WHERE key = 'schema_version'") .get() as { value?: string } | undefined; @@ -2014,6 +2126,7 @@ export async function restoreStateBackup( .all() as unknown as { worktree_path: string }[]; if ( integrity?.integrity_check !== "ok" || + foreignKeyViolations.length > 0 || (version?.value !== "1" && version?.value !== "2" && version?.value !== "3" && diff --git a/src/runtime/verifier.ts b/src/runtime/verifier.ts index 0275193..03b1b97 100644 --- a/src/runtime/verifier.ts +++ b/src/runtime/verifier.ts @@ -44,6 +44,11 @@ export interface CommandEvidence { exitCode: number | null; durationMs: number; outputDigest: string; + artifacts?: readonly { + path: string; + sha256: string; + bytes: number; + }[]; reason?: string; } @@ -75,6 +80,301 @@ function stoppedCommands( })); } +function artifactDirectoryName(commandId: string): string { + return createHash("sha256").update(commandId, "utf8").digest("hex"); +} + +function artifactTransportBudget( + retained: NonNullable, +): number { + const encodedPayload = Math.ceil(retained.maxTotalBytes / 3) * 4; + const framing = retained.paths.reduce( + (total, artifactPath) => + total + Buffer.byteLength(artifactPath, "utf8") + 64, + 256, + ); + return encodedPayload + framing; +} + +function artifactTmpfsBytes( + retained: NonNullable, +): number { + const pageBytes = 4096; + return retained.maxTotalBytes + (retained.maxFiles + 1) * pageBytes; +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\"'\"'")}'`; +} + +function retainedArtifactProtocolScript(input: { + paths: readonly string[]; + maxFileBytes: number; +}): string { + const paths = input.paths.map(shellQuote).join(" "); + return [ + "set +e", + '"$@"', + "mill_status=$?", + 'printf "\\n%s:begin\\n" "$MILL_ARTIFACT_PROTOCOL"', + `for mill_artifact_path in ${paths}; do`, + ' mill_artifact_full="/mill-artifacts/$mill_artifact_path"', + ' mill_artifact_parent=$(dirname "$mill_artifact_full")', + " mill_artifact_invalid=0", + ' while [ "$mill_artifact_parent" != "/mill-artifacts" ]; do', + ' if [ -L "$mill_artifact_parent" ]; then mill_artifact_invalid=1; break; fi', + ' mill_artifact_parent=$(dirname "$mill_artifact_parent")', + " done", + ' if [ "$mill_artifact_invalid" = 1 ] || [ -L "$mill_artifact_full" ]; then', + ' printf "invalid\\n"', + ' elif [ ! -f "$mill_artifact_full" ]; then', + ' printf "missing\\n"', + " else", + ' mill_artifact_bytes=$(wc -c < "$mill_artifact_full" | tr -d " ")', + ' case "$mill_artifact_bytes" in', + " ''|*[!0-9]*) printf \"invalid\\n\" ;;", + ` *) if [ "$mill_artifact_bytes" -gt ${input.maxFileBytes} ]; then printf "too_large\\n"; else printf "regular:%s\\n" "$mill_artifact_bytes"; base64 -w 0 "$mill_artifact_full"; printf "\\n"; fi ;;`, + " esac", + " fi", + "done", + 'printf "%s:end:%s\\n" "$MILL_ARTIFACT_PROTOCOL" "$mill_status"', + "exit 0", + ].join("\n"); +} + +async function decodeRetainedArtifactProtocol(input: { + stdout: string; + marker: string; + commandId: string; + command: NonNullable; + outputRoot: string; +}): Promise<{ stdout: string; exitCode: number }> { + const retained = input.command.retainedArtifacts; + if (retained === undefined) { + throw new Error( + "artifact protocol requires retained-artifact configuration", + ); + } + const begin = `\n${input.marker}:begin\n`; + const beginAt = input.stdout.lastIndexOf(begin); + if (beginAt < 0) { + throw new MillError( + "VERIFIER_ARTIFACT_COLLECTION_FAILED", + "The verifier did not return its bounded artifact protocol.", + ExitCode.temporary, + { commandId: input.commandId }, + ); + } + let offset = beginAt + begin.length; + const protocolFailure = (): MillError => + new MillError( + "VERIFIER_ARTIFACT_COLLECTION_FAILED", + "The verifier returned a malformed bounded artifact protocol.", + ExitCode.temporary, + { commandId: input.commandId }, + ); + for (const artifactPath of retained.paths) { + const lineEnd = input.stdout.indexOf("\n", offset); + if (lineEnd < 0) throw protocolFailure(); + const status = input.stdout.slice(offset, lineEnd); + offset = lineEnd + 1; + if (status === "missing") continue; + if (status === "invalid") { + throw new MillError( + "VERIFIER_ARTIFACT_TYPE_INVALID", + "A retained verifier artifact cannot traverse a symbolic link or use a non-regular file.", + ExitCode.data, + { commandId: input.commandId, path: artifactPath }, + ); + } + if (status === "too_large") { + throw new MillError( + "VERIFIER_ARTIFACT_FILE_LIMIT_EXCEEDED", + "A retained verifier artifact exceeded its approved file-size limit.", + ExitCode.data, + { commandId: input.commandId, path: artifactPath }, + ); + } + const matched = /^regular:([0-9]+)$/u.exec(status); + if (matched?.[1] === undefined) throw protocolFailure(); + const bytes = Number(matched[1]); + if (!Number.isSafeInteger(bytes) || bytes > retained.maxFileBytes) { + throw protocolFailure(); + } + const encodedBytes = Math.ceil(bytes / 3) * 4; + const encoded = input.stdout.slice(offset, offset + encodedBytes); + if ( + encoded.length !== encodedBytes || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test( + encoded, + ) || + input.stdout.at(offset + encodedBytes) !== "\n" + ) { + throw protocolFailure(); + } + offset += encodedBytes + 1; + const contents = Buffer.from(encoded, "base64"); + if (contents.length !== bytes) throw protocolFailure(); + const destination = path.resolve(input.outputRoot, artifactPath); + if (!isWithin(input.outputRoot, destination)) { + throw new MillError( + "VERIFIER_ARTIFACT_PATH_INVALID", + "A verifier artifact path escaped its dedicated output directory.", + ExitCode.configuration, + { commandId: input.commandId, path: artifactPath }, + ); + } + await mkdir(path.dirname(destination), { recursive: true, mode: 0o700 }); + await writeFile(destination, contents, { flag: "wx", mode: 0o600 }); + } + const end = `${input.marker}:end:`; + if (!input.stdout.startsWith(end, offset)) throw protocolFailure(); + const exitCode = Number(input.stdout.slice(offset + end.length).trim()); + if (!Number.isSafeInteger(exitCode) || exitCode < 0 || exitCode > 255) { + throw protocolFailure(); + } + return { stdout: input.stdout.slice(0, beginAt), exitCode }; +} + +async function collectRetainedArtifacts(input: { + commandId: string; + command: NonNullable; + outputRoot: string; + destinationRoot: string | undefined; +}): Promise<{ + artifacts: { path: string; sha256: string; bytes: number }[]; + missingRequired: boolean; +}> { + const retained = input.command.retainedArtifacts; + if (retained === undefined) return { artifacts: [], missingRequired: false }; + if (input.destinationRoot === undefined) { + throw new MillError( + "VERIFIER_ARTIFACT_STORE_REQUIRED", + "Retained verifier artifacts require a lifecycle-owned storage directory.", + ExitCode.configuration, + { commandId: input.commandId }, + ); + } + if (retained.paths.length > retained.maxFiles) { + throw new MillError( + "VERIFIER_ARTIFACT_COUNT_EXCEEDED", + "The declared verifier artifact paths exceed the approved file limit.", + ExitCode.configuration, + { commandId: input.commandId }, + ); + } + const destination = path.join( + input.destinationRoot, + artifactDirectoryName(input.commandId), + ); + await mkdir(destination, { recursive: true, mode: 0o700 }); + await chmod(destination, 0o700); + const artifacts: { path: string; sha256: string; bytes: number }[] = []; + let totalBytes = 0; + let missingRequired = false; + for (const artifactPath of retained.paths) { + const source = path.resolve(input.outputRoot, artifactPath); + if (!isWithin(input.outputRoot, source)) { + throw new MillError( + "VERIFIER_ARTIFACT_PATH_INVALID", + "A verifier artifact path escaped its dedicated output directory.", + ExitCode.configuration, + { commandId: input.commandId, path: artifactPath }, + ); + } + let ancestor = input.outputRoot; + for (const segment of artifactPath.split("/")) { + ancestor = path.join(ancestor, segment); + let ancestorInfo; + try { + ancestorInfo = await lstat(ancestor); + } catch (error) { + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) + break; + throw error; + } + if (ancestorInfo.isSymbolicLink()) { + throw new MillError( + "VERIFIER_ARTIFACT_TYPE_INVALID", + "A retained verifier artifact cannot traverse a symbolic link.", + ExitCode.data, + { commandId: input.commandId, path: artifactPath }, + ); + } + } + let before; + try { + before = await lstat(source); + } catch (error) { + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) { + missingRequired ||= retained.required; + continue; + } + throw error; + } + if (!before.isFile() || before.isSymbolicLink()) { + throw new MillError( + "VERIFIER_ARTIFACT_TYPE_INVALID", + "A retained verifier artifact must be a regular non-symlink file.", + ExitCode.data, + { commandId: input.commandId, path: artifactPath }, + ); + } + if (before.size > retained.maxFileBytes) { + throw new MillError( + "VERIFIER_ARTIFACT_FILE_LIMIT_EXCEEDED", + "A retained verifier artifact exceeded its approved file-size limit.", + ExitCode.data, + { commandId: input.commandId, path: artifactPath }, + ); + } + if (totalBytes + before.size > retained.maxTotalBytes) { + throw new MillError( + "VERIFIER_ARTIFACT_TOTAL_LIMIT_EXCEEDED", + "Retained verifier artifacts exceeded their approved aggregate limit.", + ExitCode.data, + { commandId: input.commandId }, + ); + } + const bytes = await readFile(source); + const after = await lstat(source); + if ( + !after.isFile() || + after.isSymbolicLink() || + after.dev !== before.dev || + after.ino !== before.ino || + after.size !== before.size || + after.mtimeMs !== before.mtimeMs || + after.ctimeMs !== before.ctimeMs + ) { + throw new MillError( + "VERIFIER_ARTIFACT_CHANGED_DURING_COLLECTION", + "A verifier artifact changed while Mill was collecting it.", + ExitCode.data, + { commandId: input.commandId, path: artifactPath }, + ); + } + const destinationPath = path.join(destination, artifactPath); + await mkdir(path.dirname(destinationPath), { + recursive: true, + mode: 0o700, + }); + await writeFile(destinationPath, bytes, { flag: "wx", mode: 0o600 }); + const sha256 = `sha256:${createHash("sha256").update(bytes).digest("hex")}`; + artifacts.push({ path: artifactPath, sha256, bytes: bytes.length }); + totalBytes += bytes.length; + } + return { artifacts, missingRequired }; +} + function validationEvidence(input: { candidateCommit: string; verifierImage: string; @@ -225,7 +525,7 @@ async function removeVerifierContainer( { containerName, exitCode: result.exitCode, - stderr: result.stderr.slice(0, 2_000), + stderrDigest: digestOutput("", result.stderr), }, ); } @@ -305,8 +605,10 @@ async function removeWorkspaceSkeleton(skeleton: string): Promise { async function workspaceMountPlan( root: string, declaredMountPaths: readonly string[], + workspacePaths: readonly string[] = [], ): Promise<{ mounts: string[]; + workspaceDirectories: readonly string[]; dispose(): Promise; }> { const mountPaths = [...new Set(declaredMountPaths)].sort(); @@ -361,10 +663,143 @@ async function workspaceMountPlan( "--mount", `type=bind,source=${skeleton},target=/workspace,readonly`, ]; + const workspaceDirectories: string[] = []; + const workspaceParents = new Set( + workspacePaths.map((workspacePath) => workspacePath.slice(0, -2)), + ); + const addWorkspaceParent = async (parent: string): Promise => { + const sourceParent = path.join(root, parent); + const parentInfo = await lstat(sourceParent); + if (!parentInfo.isDirectory() || parentInfo.isSymbolicLink()) { + throw new MillError( + "VERIFIER_WORKSPACE_ENTRY_UNSUPPORTED", + "A declared pnpm workspace parent must be a regular directory.", + ExitCode.configuration, + { path: parent }, + ); + } + const targetParent = path.join(skeleton, parent); + await mkdir(targetParent, { mode: 0o700 }); + const childHandle = await opendir(sourceParent); + const children = []; + for await (const child of childHandle) children.push(child); + if (children.length > 256) { + throw new MillError( + "VERIFIER_WORKSPACE_ENTRY_LIMIT_EXCEEDED", + "A declared pnpm workspace parent has too many direct entries.", + ExitCode.configuration, + { path: parent }, + ); + } + for (const child of children.sort((left, right) => + left.name.localeCompare(right.name), + )) { + const relative = `${parent}/${child.name}`; + if (child.name.includes(",")) { + throw new MillError( + "VERIFIER_WORKSPACE_ENTRY_UNSUPPORTED", + "A pnpm workspace entry contains a comma and cannot be bound safely.", + ExitCode.configuration, + { path: relative }, + ); + } + const sourceWorkspace = path.join(sourceParent, child.name); + const workspaceInfo = await lstat(sourceWorkspace); + if (!workspaceInfo.isDirectory() || workspaceInfo.isSymbolicLink()) { + throw new MillError( + "VERIFIER_WORKSPACE_ENTRY_UNSUPPORTED", + "A declared pnpm workspace entry must be a regular directory.", + ExitCode.configuration, + { path: relative }, + ); + } + try { + await lstat(path.join(sourceWorkspace, "node_modules")); + throw new MillError( + "VERIFIER_WORKSPACE_NODE_MODULES_OCCUPIED", + "A candidate pnpm workspace must not contain node_modules before verification.", + ExitCode.configuration, + { path: relative }, + ); + } catch (error) { + if ( + error instanceof MillError || + !( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) + ) { + throw error; + } + } + const targetWorkspace = path.join(targetParent, child.name); + await mkdir(targetWorkspace, { mode: 0o700 }); + await mkdir(path.join(targetWorkspace, "node_modules"), { + mode: 0o700, + }); + const workspaceHandle = await opendir(sourceWorkspace); + const workspaceEntries = []; + for await (const entry of workspaceHandle) workspaceEntries.push(entry); + if (workspaceEntries.length > 256) { + throw new MillError( + "VERIFIER_WORKSPACE_ENTRY_LIMIT_EXCEEDED", + "A pnpm workspace has too many direct entries.", + ExitCode.configuration, + { path: relative }, + ); + } + for (const entry of workspaceEntries.sort((left, right) => + left.name.localeCompare(right.name), + )) { + const entryPath = `${relative}/${entry.name}`; + if (entry.name === "node_modules" || entry.name.includes(",")) { + throw new MillError( + "VERIFIER_WORKSPACE_ENTRY_UNSUPPORTED", + "A pnpm workspace has an unsupported verifier mount entry.", + ExitCode.configuration, + { path: entryPath }, + ); + } + const sourceEntry = path.join(sourceWorkspace, entry.name); + const entryInfo = await lstat(sourceEntry); + if (entryInfo.isSymbolicLink()) { + throw new MillError( + "VERIFIER_WORKSPACE_ENTRY_UNSUPPORTED", + "A pnpm workspace symbolic link cannot cross the verifier mount boundary.", + ExitCode.configuration, + { path: entryPath }, + ); + } + const targetEntry = path.join(targetWorkspace, entry.name); + if (entryInfo.isDirectory()) { + await mkdir(targetEntry, { mode: 0o700 }); + } else if (entryInfo.isFile()) { + await writeFile(targetEntry, "", { flag: "wx", mode: 0o600 }); + } else { + throw new MillError( + "VERIFIER_WORKSPACE_ENTRY_UNSUPPORTED", + "A pnpm workspace entry has an unsupported filesystem type.", + ExitCode.configuration, + { path: entryPath }, + ); + } + mounts.push( + "--mount", + `type=bind,source=${path.join(source.source, parent, child.name, entry.name)},target=/workspace/${entryPath},readonly`, + ); + } + workspaceDirectories.push(relative); + } + }; for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name), )) { if (mountPaths.includes(entry.name)) continue; + if (workspaceParents.has(entry.name)) { + await addWorkspaceParent(entry.name); + continue; + } if (entry.name.includes(",")) { throw new MillError( "VERIFIER_WORKSPACE_ENTRY_UNSUPPORTED", @@ -402,6 +837,7 @@ async function workspaceMountPlan( } return { mounts, + workspaceDirectories, async dispose(): Promise { try { await removeWorkspaceSkeleton(skeleton); @@ -423,6 +859,7 @@ async function workspaceMountPlan( export async function verifyDeclaredCommands(input: { root: string; dependencyRoot?: string; + artifactDirectory?: string; candidateCommit: string; config: MillConfig; task: TaskPacket; @@ -487,12 +924,20 @@ export async function verifyDeclaredCommands(input: { }, ); const evidence: CommandEvidence[] = []; + if (input.artifactDirectory !== undefined) { + await mkdir(input.artifactDirectory, { recursive: true, mode: 0o700 }); + await chmod(input.artifactDirectory, 0o700); + } const uid = process.getuid?.() ?? 1000; const gid = process.getgid?.() ?? 1000; const canonicalRoot = await realpath(input.root); const dependencyMounts: string[] = []; let dependencyMount: Awaited> | undefined; + let dependencyRoot: string | undefined; + const workspaceDependencyMounts: Awaited< + ReturnType + >[] = []; if (input.config.verifier.dependencies !== undefined) { if (input.dependencyRoot === undefined) { throw new MillError( @@ -502,6 +947,7 @@ export async function verifyDeclaredCommands(input: { ); } const canonicalDependencyRoot = await realpath(input.dependencyRoot); + dependencyRoot = canonicalDependencyRoot; const lockPaths = await dependencyLockPaths({ root: canonicalRoot, config: input.config, @@ -561,17 +1007,62 @@ export async function verifyDeclaredCommands(input: { } let workspace: Awaited> | undefined; try { - workspace = await workspaceMountPlan(canonicalRoot, [ - ...(input.config.verifier.dependencies === undefined - ? [] - : [input.config.verifier.dependencies.targetPath]), - ...input.task.commandIds.flatMap( - (commandId) => - input.config.commands[commandId]?.writablePaths?.map( - (configuredPath) => configuredPath.replace(/\/\*\*$/u, ""), - ) ?? [], - ), - ]); + workspace = await workspaceMountPlan( + canonicalRoot, + [ + ...(input.config.verifier.dependencies === undefined + ? [] + : [input.config.verifier.dependencies.targetPath]), + ...input.task.commandIds.flatMap( + (commandId) => + input.config.commands[commandId]?.writablePaths?.map( + (configuredPath) => configuredPath.replace(/\/\*\*$/u, ""), + ) ?? [], + ), + ], + input.config.verifier.dependencies?.manager === "pnpm" + ? input.config.verifier.dependencies.workspacePaths + : [], + ); + if ( + input.config.verifier.dependencies?.manager === "pnpm" && + dependencyRoot !== undefined + ) { + for (const workspaceDirectory of workspace.workspaceDirectories) { + const candidate = path.join( + dependencyRoot, + workspaceDirectory, + "node_modules", + ); + let information; + try { + information = await lstat(candidate); + } catch (error) { + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) { + continue; + } + throw error; + } + if (!information.isDirectory() || information.isSymbolicLink()) { + throw new MillError( + "VERIFIER_DEPENDENCIES_UNAVAILABLE", + "A pnpm workspace dependency directory is not a qualified regular directory.", + ExitCode.unavailable, + { path: `${workspaceDirectory}/node_modules` }, + ); + } + const mount = await verifierMountSource(candidate); + workspaceDependencyMounts.push(mount); + dependencyMounts.push( + "--mount", + `type=bind,source=${mount.source},target=/workspace/${workspaceDirectory}/node_modules,readonly`, + ); + } + } for (let index = 0; index < input.task.commandIds.length; index += 1) { const commandId = input.task.commandIds[index]; if (commandId === undefined) continue; @@ -665,7 +1156,17 @@ export async function verifyDeclaredCommands(input: { `type=tmpfs,target=/workspace/${writablePath},tmpfs-size=268435456,tmpfs-mode=1777`, ); } + const retainedArtifactsConfig = command.retainedArtifacts; + const artifactOutput = + retainedArtifactsConfig === undefined + ? undefined + : await mkdtemp(path.join(tmpdir(), "mill-verifier-artifacts-")); + if (artifactOutput !== undefined) await chmod(artifactOutput, 0o700); + const artifactProtocol = + retainedArtifactsConfig === undefined ? undefined : randomUUID(); let result: ProcessResult; + let retainedArtifacts: + Awaited> | undefined; try { result = await runProcess({ executable: docker, @@ -704,6 +1205,12 @@ export async function verifyDeclaredCommands(input: { ...workspace.mounts, ...dependencyMounts, ...writableMounts, + ...(retainedArtifactsConfig === undefined + ? [] + : [ + "--tmpfs", + `/mill-artifacts:rw,size=${artifactTmpfsBytes(retainedArtifactsConfig)},mode=1777`, + ]), "--workdir", containerCwd, "--user", @@ -716,10 +1223,31 @@ export async function verifyDeclaredCommands(input: { "NEXT_TELEMETRY_DISABLED=1", "--env", "PLAYWRIGHT_BROWSERS_PATH=/ms-playwright", + ...(retainedArtifactsConfig === undefined + ? [] + : [ + "--env", + "MILL_ARTIFACTS_DIR=/mill-artifacts", + "--env", + `MILL_ARTIFACT_PROTOCOL=${artifactProtocol}`, + ]), "--entrypoint", - commandExecutable, + retainedArtifactsConfig === undefined + ? commandExecutable + : "/bin/sh", input.config.verifier.image, - ...command.argv.slice(1), + ...(retainedArtifactsConfig === undefined + ? command.argv.slice(1) + : [ + "-ec", + retainedArtifactProtocolScript({ + paths: retainedArtifactsConfig.paths, + maxFileBytes: retainedArtifactsConfig.maxFileBytes, + }), + "mill-artifact-protocol", + commandExecutable, + ...command.argv.slice(1), + ]), ], cwd: input.root, env: { @@ -729,7 +1257,11 @@ export async function verifyDeclaredCommands(input: { LC_ALL: "C", }, deadlineMs: commandDeadline, - maxOutputBytes: input.maxOutputBytes, + maxOutputBytes: + input.maxOutputBytes + + (retainedArtifactsConfig === undefined + ? 0 + : artifactTransportBudget(retainedArtifactsConfig)), ...(input.signal === undefined ? {} : { signal: input.signal }), ...(input.onSpawn === undefined ? {} : { onSpawn: input.onSpawn }), ...(input.onExit === undefined ? {} : { onExit: input.onExit }), @@ -737,14 +1269,52 @@ export async function verifyDeclaredCommands(input: { ? {} : { cancellationRequested: input.cancellationRequested }), }); + if (artifactOutput !== undefined) { + if ( + !result.timedOut && + !result.cancelled && + !result.outputExceeded && + artifactProtocol !== undefined + ) { + const decoded = await decodeRetainedArtifactProtocol({ + stdout: result.stdout, + marker: artifactProtocol, + commandId, + command, + outputRoot: artifactOutput, + }); + result = { + ...result, + stdout: decoded.stdout, + exitCode: decoded.exitCode, + outputExceeded: + Buffer.byteLength(decoded.stdout, "utf8") + + Buffer.byteLength(result.stderr, "utf8") > + input.maxOutputBytes, + }; + } + retainedArtifacts = await collectRetainedArtifacts({ + commandId, + command, + outputRoot: artifactOutput, + destinationRoot: input.artifactDirectory, + }); + } } finally { - await removeVerifierContainer(docker, input.root, containerName); + try { + await removeVerifierContainer(docker, input.root, containerName); + } finally { + if (artifactOutput !== undefined) { + await rm(artifactOutput, { recursive: true, force: true }); + } + } } const passed = result.exitCode === 0 && !result.timedOut && !result.outputExceeded && - !result.cancelled; + !result.cancelled && + retainedArtifacts?.missingRequired !== true; evidence.push({ commandId, required: command.required, @@ -752,6 +1322,9 @@ export async function verifyDeclaredCommands(input: { exitCode: result.exitCode, durationMs: result.durationMs, outputDigest: digestOutput(result.stdout, result.stderr), + ...(retainedArtifacts === undefined + ? {} + : { artifacts: retainedArtifacts.artifacts }), ...(passed ? {} : { @@ -761,7 +1334,9 @@ export async function verifyDeclaredCommands(input: { ? "DEADLINE_EXCEEDED" : result.outputExceeded ? "OUTPUT_BUDGET_EXCEEDED" - : "NONZERO_EXIT", + : retainedArtifacts?.missingRequired === true + ? "RETAINED_ARTIFACT_MISSING" + : "NONZERO_EXIT", }), }); } @@ -781,7 +1356,13 @@ export async function verifyDeclaredCommands(input: { try { await workspace?.dispose(); } finally { - await dependencyMount?.dispose(); + try { + await Promise.all( + workspaceDependencyMounts.map((mount) => mount.dispose()), + ); + } finally { + await dependencyMount?.dispose(); + } } } } diff --git a/src/version.ts b/src/version.ts index 760f0d4..bf10226 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,3 +1,3 @@ export const MILL_PACKAGE = "@davidahmann/mill"; -export const MILL_VERSION = "0.6.1"; +export const MILL_VERSION = "0.7.0"; export const RESULT_SCHEMA_VERSION = "1"; diff --git a/test/fixtures/state-v0.5.sql b/test/fixtures/state-v0.5.sql new file mode 100644 index 0000000..1e1fc2e --- /dev/null +++ b/test/fixtures/state-v0.5.sql @@ -0,0 +1,81 @@ +-- Exact v0.5.0 state table shape from src/runtime/state-migrations.ts. +-- The fixture omits only runtime-generated timestamps and row values. +CREATE TABLE runs ( + id TEXT PRIMARY KEY, + repository_id TEXT NOT NULL, + task_id TEXT NOT NULL, + task_digest TEXT NOT NULL, + config_digest TEXT NOT NULL, + status TEXT NOT NULL, + base_commit TEXT NOT NULL, + worktree_path TEXT, + context_digest TEXT, + context_json TEXT, + control_json TEXT, + candidate_commit TEXT, + candidate_tree TEXT, + deadline_at TEXT NOT NULL, + active_process_id TEXT, + active_pid INTEGER, + active_process_group INTEGER, + active_process_identity TEXT, + cancel_requested INTEGER NOT NULL DEFAULT 0 CHECK(cancel_requested IN (0, 1)), + repair_count INTEGER NOT NULL DEFAULT 0 CHECK(repair_count BETWEEN 0 AND 1), + attempt_count INTEGER NOT NULL DEFAULT 0 CHECK(attempt_count BETWEEN 0 AND 2), + block_code TEXT, + validation_json TEXT, + review_json TEXT, + delivery_json TEXT, + remote_feedback_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; +CREATE TABLE run_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL REFERENCES runs(id), + occurred_at TEXT NOT NULL, + type TEXT NOT NULL, + data_json TEXT NOT NULL +) STRICT; +CREATE TABLE baseline_qualifications ( + approval_digest TEXT PRIMARY KEY, + repository_id TEXT NOT NULL, + task_digest TEXT NOT NULL, + config_digest TEXT NOT NULL, + base_commit TEXT NOT NULL, + evidence_digest TEXT NOT NULL, + created_at TEXT NOT NULL +) STRICT; +CREATE TABLE worker_invocations ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL REFERENCES runs(id), + phase TEXT NOT NULL, + envelope_digest TEXT NOT NULL, + envelope_json TEXT NOT NULL, + created_at TEXT NOT NULL +) STRICT; +CREATE TABLE worker_invocation_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + invocation_id TEXT NOT NULL REFERENCES worker_invocations(id), + occurred_at TEXT NOT NULL, + type TEXT NOT NULL, + data_json TEXT NOT NULL +) STRICT; +CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT; +CREATE TABLE schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL +) STRICT; +CREATE TRIGGER run_events_no_update + BEFORE UPDATE ON run_events BEGIN SELECT RAISE(ABORT, 'run events are append-only'); END; +CREATE TRIGGER run_events_no_delete + BEFORE DELETE ON run_events BEGIN SELECT RAISE(ABORT, 'run events are append-only'); END; +CREATE TRIGGER worker_invocations_no_update + BEFORE UPDATE ON worker_invocations BEGIN SELECT RAISE(ABORT, 'worker invocations are immutable'); END; +CREATE TRIGGER worker_invocations_no_delete + BEFORE DELETE ON worker_invocations BEGIN SELECT RAISE(ABORT, 'worker invocations are immutable'); END; +CREATE TRIGGER worker_invocation_events_no_update + BEFORE UPDATE ON worker_invocation_events BEGIN SELECT RAISE(ABORT, 'worker invocation events are append-only'); END; +CREATE TRIGGER worker_invocation_events_no_delete + BEFORE DELETE ON worker_invocation_events BEGIN SELECT RAISE(ABORT, 'worker invocation events are append-only'); END; diff --git a/test/planning-tasks.test.ts b/test/planning-tasks.test.ts index 68e15dc..24d71b1 100644 --- a/test/planning-tasks.test.ts +++ b/test/planning-tasks.test.ts @@ -106,7 +106,7 @@ async function requestFixture(kind = "prd", allowedPaths = ["src/value.js"]) { describe("change-plan task compilation", () => { it("combines reviewed planning drafts without creating authority files", async () => { - const { fixture, input } = await requestFixture(); + const { fixture, input, request } = await requestFixture(); try { const product = parseYaml( await readFile( @@ -156,31 +156,44 @@ describe("change-plan task compilation", () => { await writeFile(path.join(fixture.root, "proposal.yaml"), yaml(proposal)); await git(fixture.root, ["add", "proposal.yaml"]); await git(fixture.root, ["commit", "-m", "test: add proposal draft"]); + const initProposalArgs = (impactPath: string, requestPath: string) => [ + "--json", + "--cwd", + fixture.root, + "init", + "propose", + "--prd", + "product/PRD.md", + "--sources", + "product/sources.yaml", + "--proposal", + "proposal.yaml", + "--product", + "product/contract.yaml", + "--scenarios", + "quality/scenarios.yaml", + "--impact", + impactPath, + "--request", + requestPath, + ]; + const captureInitProposal = () => { + const output: string[] = []; + const errors: string[] = []; + return { + stdout: output, + stderr: errors, + io: { + stdout: { write: (value: string) => void output.push(value) }, + stderr: { write: (value: string) => void errors.push(value) }, + }, + }; + }; const stdout: string[] = []; const stderr: string[] = []; expect( await runCli( - [ - "--json", - "--cwd", - fixture.root, - "init", - "propose", - "--prd", - "product/PRD.md", - "--sources", - "product/sources.yaml", - "--proposal", - "proposal.yaml", - "--product", - "product/contract.yaml", - "--scenarios", - "quality/scenarios.yaml", - "--impact", - "product/impact.yaml", - "--request", - input.requestPath, - ], + initProposalArgs("product/impact.yaml", input.requestPath), { stdout: { write: (value) => void stdout.push(value) }, stderr: { write: (value) => void stderr.push(value) }, @@ -204,6 +217,75 @@ describe("change-plan task compilation", () => { "utf8", ), ).rejects.toMatchObject({ code: "ENOENT" }); + + const unapprovedImpact = parseYaml( + await readFile( + path.join(fixture.root, "product", "impact.yaml"), + "utf8", + ), + ) as Record; + await writeFile( + path.join(fixture.root, "product", "impact-unapproved.yaml"), + yaml({ ...unapprovedImpact, approval: null }), + ); + const unapprovedRequest = { + ...request, + tasks: request.tasks.map((task) => ({ + ...task, + impactPath: "product/impact-unapproved.yaml", + })), + }; + await writeFile( + path.join(fixture.root, "change-unapproved.yaml"), + yaml(unapprovedRequest), + ); + const unapproved = captureInitProposal(); + expect( + await runCli( + initProposalArgs( + "product/impact-unapproved.yaml", + "change-unapproved.yaml", + ), + unapproved.io, + ), + ).toBe(0); + expect(JSON.parse(unapproved.stdout.join(""))).toMatchObject({ + command: "init.propose", + ok: true, + status: "blocked", + data: { + impact: { approved: false }, + tasks: { status: "not_compiled", files: [] }, + }, + }); + + const mismatchedRequest = { + ...request, + productPath: "product/not-the-selected-contract.yaml", + }; + await writeFile( + path.join(fixture.root, "change-mismatched.yaml"), + yaml(mismatchedRequest), + ); + const mismatched = captureInitProposal(); + expect( + await runCli( + initProposalArgs("product/impact.yaml", "change-mismatched.yaml"), + mismatched.io, + ), + ).toBe(0); + expect(JSON.parse(mismatched.stdout.join(""))).toMatchObject({ + command: "init.propose", + ok: true, + status: "blocked", + data: { tasks: { status: "not_compiled", files: [] } }, + reasons: [ + expect.objectContaining({ + message: + "change request paths do not match the selected proposal bundle", + }), + ], + }); } finally { await fixture.cleanup(); } diff --git a/test/pnpm-dependencies.test.ts b/test/pnpm-dependencies.test.ts index 500eaaf..6127f22 100644 --- a/test/pnpm-dependencies.test.ts +++ b/test/pnpm-dependencies.test.ts @@ -12,10 +12,12 @@ import { afterEach, describe, expect, it } from "vitest"; import { stringify as yaml } from "yaml"; import { millConfigSchema } from "../src/contracts/schemas.js"; +import type { TaskPacket } from "../src/runtime/inputs.js"; import { dependencySnapshotDirectory, prepareDependencySnapshot, } from "../src/runtime/dependencies.js"; +import { verifyDeclaredCommands } from "../src/runtime/verifier.js"; import { temporaryDirectory } from "./helpers.js"; const originalDocker = process.env.MILL_DOCKER_PATH; @@ -106,11 +108,11 @@ const args=process.argv.slice(2); appendFileSync(${JSON.stringify(log)},JSON.stringify(args)+"\\n"); if(args[0]==="image"||args[0]==="rm")process.exit(0); if(args[0]!=="run")process.exit(2); -const mount=args.find((value)=>value.startsWith("type=bind,")&&value.endsWith("target=/workspace")); +const mount=args.find((value)=>value.startsWith("type=bind,")&&value.includes("target=/workspace")); if(mount===undefined)process.exit(3); const prefix="type=bind,source="; -const suffix=",target=/workspace"; -mkdirSync(path.join(mount.slice(prefix.length,-suffix.length),"node_modules"),{recursive:true}); +const source=mount.slice(prefix.length,mount.indexOf(",target=/workspace")); +mkdirSync(path.join(source,"node_modules"),{recursive:true}); process.exit(0); `, { mode: 0o755 }, @@ -121,6 +123,54 @@ process.exit(0); } describe("generic pnpm dependency preparation", () => { + it("mounts each prepared shallow-workspace dependency directory into the read-only candidate", async () => { + const value = await pnpmFixture(); + try { + const prepared = await prepareDependencySnapshot({ + root: value.repository.path, + stateDirectory: value.state.path, + config: value.config, + attended: true, + }); + await mkdir( + path.join(prepared.directory, "packages", "example", "node_modules"), + { recursive: true }, + ); + const evidence = await verifyDeclaredCommands({ + root: value.repository.path, + dependencyRoot: prepared.directory, + candidateCommit: "a".repeat(40), + config: value.config, + task: { commandIds: ["test"] } as TaskPacket, + deadlineMs: Date.now() + 30_000, + maxOutputBytes: 1024, + }); + expect(evidence).toMatchObject({ + passed: true, + commands: [{ commandId: "test", status: "passed" }], + }); + const calls = (await readFile(value.log, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as string[]); + const verify = calls.find( + (call) => + call.includes("--network") && + call.includes("none") && + call.includes("/usr/local/bin/pnpm"), + ); + expect(verify?.join(" ")).toContain( + "target=/workspace/packages/example/node_modules,readonly", + ); + } finally { + await Promise.all([ + value.repository.cleanup(), + value.state.cleanup(), + value.tools.cleanup(), + ]); + } + }); + it("binds the root, workspace manifests, lockfile, pnpm version, and disabled lifecycle scripts", async () => { const value = await pnpmFixture(); try { @@ -139,6 +189,17 @@ describe("generic pnpm dependency preparation", () => { config: value.config, }), ).resolves.toBe(prepared.directory); + await mkdir( + path.join(prepared.directory, "packages", "example", "node_modules"), + { recursive: true }, + ); + await expect( + dependencySnapshotDirectory({ + root: value.repository.path, + stateDirectory: value.state.path, + config: value.config, + }), + ).rejects.toMatchObject({ code: "VERIFIER_DEPENDENCIES_UNAVAILABLE" }); const calls = (await readFile(value.log, "utf8")) .trim() .split("\n") @@ -160,6 +221,39 @@ describe("generic pnpm dependency preparation", () => { } }); + it("rejects a dangling link inside a newly present workspace dependency tree", async () => { + const value = await pnpmFixture(); + try { + const prepared = await prepareDependencySnapshot({ + root: value.repository.path, + stateDirectory: value.state.path, + config: value.config, + attended: true, + }); + const workspaceModules = path.join( + prepared.directory, + "packages", + "example", + "node_modules", + ); + await mkdir(workspaceModules, { recursive: true }); + await symlink("missing-target", path.join(workspaceModules, "dangling")); + await expect( + dependencySnapshotDirectory({ + root: value.repository.path, + stateDirectory: value.state.path, + config: value.config, + }), + ).rejects.toMatchObject({ code: "VERIFIER_DEPENDENCIES_UNAVAILABLE" }); + } finally { + await Promise.all([ + value.repository.cleanup(), + value.state.cleanup(), + value.tools.cleanup(), + ]); + } + }); + it("rejects pnpm native-build exceptions before registry access", async () => { const value = await pnpmFixture(); try { diff --git a/test/policy-scripts.test.ts b/test/policy-scripts.test.ts index 91036e9..c21a250 100644 --- a/test/policy-scripts.test.ts +++ b/test/policy-scripts.test.ts @@ -658,6 +658,10 @@ describe("repository policy scripts", () => { JSON.stringify({ url: "https://github.com/davidahmann/mill/releases/tag/v0.1.0", tagName: "v0.1.0", + isDraft: false, + isPrerelease: false, + publishedAt: "2026-09-16T00:00:00.000Z", + databaseId: 1, assets: [{ name: path.basename(downloaded) }], }), ), @@ -686,6 +690,8 @@ describe("repository policy scripts", () => { expect(JSON.parse(await readFile(githubOutput, "utf8"))).toMatchObject({ tag: "v0.1.0", artifactDigest, + state: "published", + releaseId: "1", }); await writeFile( @@ -693,6 +699,10 @@ describe("repository policy scripts", () => { JSON.stringify({ url: "https://github.com/davidahmann/mill/releases/tag/untagged-draft", tagName: "v0.1.0", + isDraft: true, + isPrerelease: false, + publishedAt: null, + databaseId: 1, assets: [{ name: path.basename(downloaded) }], }), ); diff --git a/test/reconstruct-release-evidence.test.ts b/test/reconstruct-release-evidence.test.ts new file mode 100644 index 0000000..c191952 --- /dev/null +++ b/test/reconstruct-release-evidence.test.ts @@ -0,0 +1,270 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +import { describe, expect, it } from "vitest"; + +import { canonicalDigest } from "../src/contracts/canonical.js"; +import { temporaryDirectory } from "./helpers.js"; + +const script = path.resolve("scripts/reconstruct-release-evidence.mjs"); +const hex = (character: string) => character.repeat(40); +const sha256 = (value: Buffer | string) => + `sha256:${createHash("sha256").update(value).digest("hex")}`; + +function run( + directory: string, + draft = "release-evidence-draft.json", + final = "release-evidence-final.json", +) { + return spawnSync(process.execPath, [script, directory, draft, final], { + cwd: directory, + encoding: "utf8", + timeout: 10_000, + }); +} + +describe("permanent release evidence reconstruction", () => { + it("reconstructs retained draft and published evidence and rejects missing, swapped, or mismatched assets", async () => { + const temporary = await temporaryDirectory("mill-release-reconstruct-"); + try { + const assets = path.join(temporary.path, "assets"); + await mkdir(assets); + const artifact = Buffer.from("qualified artifact\n"); + const artifactDigest = sha256(artifact); + const digest = sha256("fixture"); + const integrity = `sha512-${Buffer.alloc(64).toString("base64")}`; + const supportTuple = { + id: "darwin-arm64-node24-codex", + status: "qualified", + testedAt: "2026-09-16T00:00:00.000Z", + expiresAt: "2026-10-16T00:00:00.000Z", + host: { os: "darwin", architecture: "arm64" }, + runtime: { node: "24.20.0", npm: "11.19.0" }, + container: { + engine: "docker", + version: "29.7.2", + verifierImage: `image@${digest}`, + }, + worker: { + adapter: "codex-cli", + harnessVersion: "0.153.0-alpha.5", + modelIdentity: "provider-mutable", + authMode: "operator-session", + }, + forge: { + gitVersion: "2.50.1", + ghVersion: "2.74.2", + host: "github.com", + }, + recipe: { id: "node-typescript-next-web", version: "1.0.0", digest }, + } as const; + const qualification = { + schemaVersion: "1", + package: { + name: "@davidahmann/mill", + version: "0.7.0", + artifactDigest, + npmIntegrity: integrity, + }, + supportTuple, + sequence: { + steps: ["1", "2", "3", "4", "5"].map((id, index) => ({ + id: `step-${id}`, + dependsOn: index === 0 ? [] : [`step-${index}`], + baseCommit: hex(String(index + 1)), + candidateCommit: hex(String(index + 2)), + status: "accepted", + newBehavior: { requiredIds: [`A-${id}`], passedIds: [`A-${id}`] }, + preservation: { requiredIds: ["INV-1"], passedIds: ["INV-1"] }, + scenarioIds: [`SCN-${id}`], + usage: { + inputTokens: null, + outputTokens: null, + currencyCost: null, + source: "unavailable", + }, + })), + seededFault: { + baseCommit: hex("6"), + candidateCommit: hex("f"), + status: "failed", + rejected: true, + recovered: true, + enteredAcceptedSequence: false, + reason: "independent preservation check rejected the fixture", + }, + }, + canaries: { + packedInstall: "passed", + greenfield: "passed", + adoption: "passed", + downstreamWithoutMill: "passed", + recovery: "passed", + security: "passed", + }, + auditCandidate: { commit: hex("7"), tree: hex("8") }, + audits: [ + "product", + "code", + "ux", + "accessibility", + "security", + "dependencies", + "architecture", + "operations", + "release", + ].map((category) => ({ + category, + status: "passed", + reportDigest: digest, + })), + generatedAt: "2026-09-16T00:00:00.000Z", + } as const; + const identity = { + reviewedCandidateTree: hex("a"), + tagCommit: hex("b"), + mainTree: hex("a"), + }; + const builder = { + builder: "build-a", + filename: "davidahmann-mill-0.7.0.tgz", + sha256: artifactDigest, + npmIntegrity: integrity, + contentsDigest: digest, + }; + const metadata = { + package: { name: "@davidahmann/mill", version: "0.7.0" }, + builders: [builder, { ...builder, builder: "build-b" }], + selectedArtifact: builder, + }; + const evidence = (state: "draft" | "published") => ({ + schemaVersion: "1", + state: "verified", + package: { name: "@davidahmann/mill", version: "0.7.0", tag: "v0.7.0" }, + source: { + reviewedCandidateTree: identity.reviewedCandidateTree, + resultingMainCommit: identity.tagCommit, + resultingMainTree: identity.mainTree, + tagCommit: identity.tagCommit, + }, + builders: metadata.builders, + selectedArtifact: metadata.selectedArtifact, + qualificationDigest: canonicalDigest(qualification), + qualification: { + supportTuple: { + id: supportTuple.id, + status: supportTuple.status, + testedAt: supportTuple.testedAt, + expiresAt: supportTuple.expiresAt, + digest: canonicalDigest(supportTuple), + }, + }, + sbomDigest: sha256("sbom\n"), + workflowRuns: { + candidate: { + id: "11", + url: "https://github.com/davidahmann/mill/actions/runs/11", + headCommit: identity.tagCommit, + }, + publish: { + id: "12", + url: "https://github.com/davidahmann/mill/actions/runs/12", + headCommit: identity.tagCommit, + }, + }, + registry: { + tarball: + "https://registry.npmjs.org/@davidahmann/mill/-/mill-0.7.0.tgz", + integrity, + provenanceVerified: true, + }, + githubRelease: { + url: "https://github.com/davidahmann/mill/releases/tag/v0.7.0", + tag: "v0.7.0", + artifactDigest, + state, + releaseId: "13", + publishedAt: state === "draft" ? null : "2026-09-16T00:01:00.000Z", + observedAt: "2026-09-16T00:01:00.000Z", + }, + generatedAt: "2026-09-16T00:01:00.000Z", + }); + await Promise.all([ + writeFile( + path.join(assets, metadata.selectedArtifact.filename), + artifact, + ), + writeFile( + path.join(assets, "artifact-metadata.json"), + JSON.stringify(metadata), + ), + writeFile( + path.join(assets, "qualification.json"), + JSON.stringify(qualification), + ), + writeFile(path.join(assets, "identity.json"), JSON.stringify(identity)), + writeFile(path.join(assets, "sbom.cdx.json"), "sbom\n"), + writeFile( + path.join(assets, "release-evidence-draft.json"), + JSON.stringify(evidence("draft")), + ), + writeFile( + path.join(assets, "release-evidence-final.json"), + JSON.stringify(evidence("published")), + ), + ]); + + expect(run(assets).status).toBe(0); + + await rm(path.join(assets, "qualification.json")); + expect(run(assets).status).toBe(1); + await writeFile( + path.join(assets, "qualification.json"), + JSON.stringify(qualification), + ); + + const draft = await readFile( + path.join(assets, "release-evidence-draft.json"), + "utf8", + ); + const final = await readFile( + path.join(assets, "release-evidence-final.json"), + "utf8", + ); + await writeFile(path.join(assets, "release-evidence-draft.json"), final); + expect(run(assets).status).toBe(1); + await writeFile(path.join(assets, "release-evidence-draft.json"), draft); + + const parsedFinal = JSON.parse(final) as unknown as { + githubRelease: { releaseId: string }; + }; + const mismatchedRelease = { + ...parsedFinal, + githubRelease: { + ...parsedFinal.githubRelease, + releaseId: "unexpected-release", + }, + }; + await writeFile( + path.join(assets, "release-evidence-final.json"), + JSON.stringify(mismatchedRelease), + ); + expect(run(assets).status).toBe(1); + await writeFile(path.join(assets, "release-evidence-final.json"), final); + + const mismatched = { + ...qualification, + supportTuple: { ...supportTuple, status: "expired" }, + }; + await writeFile( + path.join(assets, "qualification.json"), + JSON.stringify(mismatched), + ); + expect(run(assets).status).toBe(1); + } finally { + await temporary.cleanup(); + } + }); +}); diff --git a/test/release-workflow.test.ts b/test/release-workflow.test.ts index fc889a3..54761b5 100644 --- a/test/release-workflow.test.ts +++ b/test/release-workflow.test.ts @@ -88,7 +88,7 @@ describe("release verifier preparation policy", () => { entry.name === (phase === "draft" ? "Create draft GitHub Release with exact artifacts" - : "Read back GitHub Release and finalize evidence"), + : "Publish GitHub Release after draft evidence readback"), ); if (!step?.run) throw new Error("missing release publication fixture"); step.run = `${step.run}\n# --prerelease`; @@ -103,7 +103,8 @@ describe("release verifier preparation policy", () => { const workflow = await fixture(); const finalize = workflow.jobs.publish?.steps.find( (entry) => - entry.name === "Read back GitHub Release and finalize evidence", + entry.name === + "Read back published GitHub Release and attach final evidence", ); if (!finalize?.run) throw new Error("missing release finalization fixture"); finalize.run = finalize.run.replace( @@ -111,7 +112,23 @@ describe("release verifier preparation policy", () => { "true", ); await expect(check(workflow)).rejects.toThrow( - "final release evidence must be attached before publication", + "draft and published release evidence must be retained", + ); + }); + + it("rejects a draft release that omits a qualification record", async () => { + const workflow = await fixture(); + const create = workflow.jobs.publish?.steps.find( + (entry) => + entry.name === "Create draft GitHub Release with exact artifacts", + ); + if (!create?.run) throw new Error("missing release draft fixture"); + create.run = create.run.replace( + '"$RUNNER_TEMP/qualified/qualification.json"', + "", + ); + await expect(check(workflow)).rejects.toThrow( + "draft release must retain the qualified candidate and independent verifier records", ); }); it.each(["alpha", "missing-readback"])( diff --git a/test/runtime-boundaries.test.ts b/test/runtime-boundaries.test.ts index 369a897..8ea767f 100644 --- a/test/runtime-boundaries.test.ts +++ b/test/runtime-boundaries.test.ts @@ -1,4 +1,5 @@ import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; import { chmod, mkdir, @@ -938,6 +939,114 @@ playbooks: } }); + it("retains only declared regular verifier artifacts bound to one command", async () => { + const fixture = await runtimeFixture(); + const tools = await temporaryDirectory("mill-verifier-artifacts-"); + const artifactDirectory = path.join(tools.path, "retained"); + let selectedArtifactDirectory = artifactDirectory; + const docker = path.join(tools.path, "docker"); + const inputs = await loadRuntimeInputs(fixture.root, fixture.taskPath); + const testCommand = inputs.config.commands.test; + if (testCommand === undefined) throw new Error("fixture command missing"); + const config = { + ...inputs.config, + commands: { + ...inputs.config.commands, + test: { + ...testCommand, + retainedArtifacts: { + paths: ["reports/check.json"], + required: true, + maxFiles: 1, + maxFileBytes: 1024, + maxTotalBytes: 1024, + }, + }, + }, + }; + const writeDocker = async ( + artifactStatus: "regular" | "missing" | "invalid", + contents = "", + commandExitCode = 0, + ) => { + await writeFile( + docker, + `#!${process.execPath}\nconst args=process.argv.slice(2);const status=${JSON.stringify(artifactStatus)};const contents=${JSON.stringify(contents)};const exitCode=${commandExitCode};if(args[0]==="image"||args[0]==="rm")process.exit(0);if(args[0]!=="run")process.exit(2);const marker=args.find((value)=>value.startsWith("MILL_ARTIFACT_PROTOCOL="))?.slice("MILL_ARTIFACT_PROTOCOL=".length);if(marker===undefined)process.exit(3);const record=status==="regular"?\`regular:\${Buffer.byteLength(contents)}\\n\${Buffer.from(contents).toString("base64")}\\n\`:\`\${status}\\n\`;process.stdout.write(\`\\n\${marker}:begin\\n\${record}\${marker}:end:\${exitCode}\\n\`);process.exit(0);`, + { mode: 0o755 }, + ); + await chmod(docker, 0o755); + process.env.MILL_DOCKER_PATH = docker; + }; + const call = () => + verifyDeclaredCommands({ + root: fixture.root, + artifactDirectory: selectedArtifactDirectory, + candidateCommit: "a".repeat(40), + config, + task: inputs.task, + deadlineMs: Date.now() + 30_000, + maxOutputBytes: 1024 * 1024, + }); + try { + await writeDocker("regular", '{"passed":true}\n'); + const evidence = await call(); + const digest = `sha256:${createHash("sha256") + .update('{"passed":true}\n') + .digest("hex")}`; + expect(evidence).toMatchObject({ + passed: true, + commands: [ + { + commandId: "test", + artifacts: [ + { path: "reports/check.json", sha256: digest, bytes: 16 }, + ], + }, + ], + }); + await expect( + readFile( + path.join( + artifactDirectory, + createHash("sha256").update("test").digest("hex"), + "reports/check.json", + ), + "utf8", + ), + ).resolves.toBe('{"passed":true}\n'); + + await writeDocker("regular", '{"passed":false}\n', 1); + selectedArtifactDirectory = path.join(tools.path, "failed"); + await expect(call()).resolves.toMatchObject({ + passed: false, + commands: [ + { + reason: "NONZERO_EXIT", + artifacts: [{ path: "reports/check.json", bytes: 17 }], + }, + ], + }); + + await writeDocker("missing"); + await expect(call()).resolves.toMatchObject({ + passed: false, + commands: [{ reason: "RETAINED_ARTIFACT_MISSING", artifacts: [] }], + }); + + await writeDocker("invalid"); + await expect(call()).rejects.toMatchObject({ + code: "VERIFIER_ARTIFACT_TYPE_INVALID", + }); + + await writeDocker("invalid"); + await expect(call()).rejects.toMatchObject({ + code: "VERIFIER_ARTIFACT_TYPE_INVALID", + }); + } finally { + await Promise.all([fixture.cleanup(), tools.cleanup()]); + } + }); + it("rejects verifier mounts that hide content or cannot be represented exactly", async () => { const fixture = await runtimeFixture(); process.env.MILL_DOCKER_PATH = fixture.dockerPath; @@ -1226,22 +1335,31 @@ if(args[0]==="run"){ const inputs = await loadRuntimeInputs(fixture.root, fixture.taskPath); await writeFile( docker, - `#!${process.execPath}\nconst args=process.argv.slice(2);if(args[0]==="image"||args[0]==="run")process.exit(0);if(args[0]==="rm"){console.error("daemon unavailable");process.exit(9)}process.exit(2);\n`, + `#!${process.execPath}\nconst args=process.argv.slice(2);if(args[0]==="image"||args[0]==="run")process.exit(0);if(args[0]==="rm"){console.error("MREV_PRIVATE_CLEANUP_MARKER");process.exit(9)}process.exit(2);\n`, { mode: 0o755 }, ); await chmod(docker, 0o755); process.env.MILL_DOCKER_PATH = docker; try { - await expect( - verifyDeclaredCommands({ - root: fixture.root, - candidateCommit: "a".repeat(40), - config: inputs.config, - task: inputs.task, - deadlineMs: Date.now() + 5_000, - maxOutputBytes: 1024, - }), - ).rejects.toMatchObject({ code: "VERIFIER_CONTAINER_CLEANUP_FAILED" }); + await verifyDeclaredCommands({ + root: fixture.root, + candidateCommit: "a".repeat(40), + config: inputs.config, + task: inputs.task, + deadlineMs: Date.now() + 5_000, + maxOutputBytes: 1024, + }) + .then(() => { + throw new Error("expected verifier cleanup failure"); + }) + .catch((error: unknown) => { + expect(error).toMatchObject({ + code: "VERIFIER_CONTAINER_CLEANUP_FAILED", + }); + expect(JSON.stringify(error)).not.toContain( + "MREV_PRIVATE_CLEANUP_MARKER", + ); + }); } finally { await Promise.all([fixture.cleanup(), tools.cleanup()]); } diff --git a/test/runtime-cli.test.ts b/test/runtime-cli.test.ts index a05939b..38f8ee6 100644 --- a/test/runtime-cli.test.ts +++ b/test/runtime-cli.test.ts @@ -125,12 +125,7 @@ describe("runtime CLI contracts", () => { ok: true, data: { schemaVersion: 4, - migrations: [ - { version: 1, name: "initial-durable-state" }, - { version: 2, name: "worker-and-delivery-recovery-columns" }, - { version: 3, name: "numbered-migration-ledger" }, - { version: 4, name: "fixture-only-second-repair-capacity" }, - ], + migrations: [], runs: { total: 0, builderAttempts: 0, repairs: 0 }, }, }, @@ -144,7 +139,7 @@ describe("runtime CLI contracts", () => { command: "report", ok: true, data: { - schemaVersion: "1", + schemaVersion: "2", redacted: true, runs: { total: 0, @@ -153,11 +148,14 @@ describe("runtime CLI contracts", () => { builderAttempts: 0, }, elapsed: { totalMilliseconds: 0, averageMilliseconds: null }, - selfHosting: { - declared: false, - eligibleRuns: 0, - completedRuns: 0, - completionRate: null, + developmentEvidence: { + ledgerPath: null, + records: 0, + eligibleChanges: 0, + eligibleMillRoute: 0, + eligibleManualRoute: 0, + eligibleCompleted: 0, + eligibleAccepted: 0, }, }, }, diff --git a/test/runtime-codex.test.ts b/test/runtime-codex.test.ts index 252903b..cec51ac 100644 --- a/test/runtime-codex.test.ts +++ b/test/runtime-codex.test.ts @@ -176,7 +176,19 @@ describe("Codex adapter boundaries", () => { deadlineMs: Date.now() + 5_000, maxOutputBytes: 1024, }), - ).rejects.toMatchObject({ code: "CODEX_EXECUTION_FAILED" }); + ).rejects.toMatchObject({ + code: "CODEX_EXECUTION_FAILED", + details: { exitCode: 7 }, + }); + await runCodexBuilder({ + root: fixture.root, + task: inputs.task, + manifest: frozen.manifest, + deadlineMs: Date.now() + 5_000, + maxOutputBytes: 1024, + }).catch((error: unknown) => { + expect(JSON.stringify(error)).not.toContain("provider unavailable"); + }); } finally { await Promise.all([fixture.cleanup(), tools.cleanup()]); } @@ -277,6 +289,15 @@ describe("Codex adapter boundaries", () => { maxOutputBytes: 128, ...(signal === undefined ? {} : { signal }), }); + const assertNoPrivateMarker = async ( + invocation: Promise, + code: string, + ) => { + await invocation.catch((error: unknown) => { + expect(error).toMatchObject({ code }); + expect(JSON.stringify(error)).not.toContain("MREV_PRIVATE_MARKER"); + }); + }; try { process.env.MILL_CODEX_PATH = path.join(tools.path, "missing-codex"); await expect(call(Date.now() + 5_000)).rejects.toMatchObject({ @@ -285,29 +306,41 @@ describe("Codex adapter boundaries", () => { process.env.MILL_CODEX_PATH = await executableScript( tools.path, - "setInterval(()=>{},1000);", + 'process.stderr.write("MREV_PRIVATE_MARKER");setInterval(()=>{},1000);', + ); + await assertNoPrivateMarker( + call(Date.now() + 100), + "CODEX_DEADLINE_EXCEEDED", ); - await expect(call(Date.now() + 100)).rejects.toMatchObject({ - code: "CODEX_DEADLINE_EXCEEDED", - }); process.env.MILL_CODEX_PATH = await executableScript( tools.path, - 'process.stdout.write("x".repeat(10000));setInterval(()=>{},1000);', + 'process.stderr.write("MREV_PRIVATE_MARKER");process.stdout.write("x".repeat(10000));setInterval(()=>{},1000);', + ); + await assertNoPrivateMarker( + call(Date.now() + 5_000), + "CODEX_OUTPUT_BUDGET_EXCEEDED", ); - await expect(call(Date.now() + 5_000)).rejects.toMatchObject({ - code: "CODEX_OUTPUT_BUDGET_EXCEEDED", - }); process.env.MILL_CODEX_PATH = await executableScript( tools.path, - "setInterval(()=>{},1000);", + 'process.stderr.write("MREV_PRIVATE_MARKER");setInterval(()=>{},1000);', ); const controller = new AbortController(); setTimeout(() => controller.abort(), 100).unref(); - await expect( + await assertNoPrivateMarker( call(Date.now() + 5_000, controller.signal), - ).rejects.toMatchObject({ code: "CODEX_CANCELLED" }); + "CODEX_CANCELLED", + ); + + process.env.MILL_CODEX_PATH = await executableScript( + tools.path, + 'process.stdout.write("MREV_PRIVATE_MARKER");console.log(JSON.stringify({type:"turn.completed"}));', + ); + await assertNoPrivateMarker( + call(Date.now() + 5_000), + "MALFORMED_WORKER_EVENT", + ); } finally { await Promise.all([fixture.cleanup(), tools.cleanup()]); } diff --git a/test/runtime-delivery.test.ts b/test/runtime-delivery.test.ts index c65270a..01cd6a0 100644 --- a/test/runtime-delivery.test.ts +++ b/test/runtime-delivery.test.ts @@ -18,11 +18,13 @@ import { import { MillError, ExitCode } from "../src/errors.js"; import { runCli } from "../src/cli-program.js"; import { + actionableFeedback, finalizeDraftPr, observeDraftPr, openDraftPr, planDraftPr, reconcileDraftPr, + reviewsPassed, } from "../src/runtime/delivery.js"; import type { GitHubAdapter, @@ -434,6 +436,7 @@ async function seedLegacyPostMergeDelivery( } const delivery = JSON.parse(run.deliveryJson) as Record; delete delivery.postMergeRequiredChecks; + delete delivery.postMergePolicySource; delete delivery.legacyPostMergePolicyConfigDigest; store.setDelivery( runId, @@ -1567,6 +1570,28 @@ describe("exact-candidate GitHub draft delivery", () => { } }); + it("binds an empty effective post-merge list only for a no-check local policy", async () => { + const { fixture, runId } = await reviewedFixture({ requiredChecks: [] }); + const adapter = new FakeGitHub( + (await git(fixture.root, ["rev-parse", "main"])).stdout.trim(), + ); + try { + const planned = await planDraftPr({ + root: fixture.root, + taskPath: fixture.taskPath, + runId, + adapter, + }); + expect(planned.delivery).toMatchObject({ + requiredChecks: [], + postMergeRequiredChecks: [], + postMergePolicySource: "implicit_default", + }); + } finally { + await fixture.cleanup(); + } + }); + it("binds a subset-safe post-merge policy for a legacy merged delivery", async () => { const { fixture, runId, candidateTree } = await reviewedFixture({ requiredChecks: ["validate", "dependency-review", "codeql"], @@ -1802,6 +1827,65 @@ describe("exact-candidate GitHub draft delivery", () => { } }); + it("cancels an ordinary human-decision checkpoint without erasing its draft receipt", async () => { + const { fixture, runId } = await reviewedFixture(); + const adapter = new FakeGitHub( + (await git(fixture.root, ["rev-parse", "main"])).stdout.trim(), + ); + try { + await planAndOpen({ fixture, runId, adapter }); + adapter.checks = [completedCheck("success")]; + const ready = await observeDraftPr({ + root: fixture.root, + taskPath: fixture.taskPath, + runId, + adapter, + }); + expect(ready.run.status).toBe("awaiting_human"); + const pullRequestUrl = adapter.pullRequest?.url; + expect(pullRequestUrl).toBeTypeOf("string"); + + await expect( + cancelRun({ root: fixture.root, runId }), + ).resolves.toMatchObject({ + status: "cancelled", + cancelRequested: true, + }); + await expect( + cancelRun({ root: fixture.root, runId }), + ).resolves.toMatchObject({ + status: "cancelled", + cancelRequested: true, + }); + const store = await StateStore.open( + (await loadRuntimeInputs(fixture.root, fixture.taskPath)).config + .repositoryId, + await commonGitDirectory(fixture.root), + ); + try { + expect(store.getRun(runId).deliveryJson).toContain(pullRequestUrl); + } finally { + store.close(); + } + + const qualification = await qualifyBaseline({ + root: fixture.root, + taskPath: fixture.taskPath, + }); + if (qualification.approvalDigest === null) + throw new Error("fresh baseline qualification missing"); + await expect( + startLocalRun({ + root: fixture.root, + taskPath: fixture.taskPath, + approvalDigest: qualification.approvalDigest, + }), + ).resolves.toMatchObject({ run: { status: "committed" } }); + } finally { + await fixture.cleanup(); + } + }); + it("honors durable cancellation before a subsequent remote effect", async () => { const { fixture, runId } = await reviewedFixture(); const adapter = new FakeGitHub( @@ -2454,7 +2538,7 @@ describe("exact-candidate GitHub draft delivery", () => { adapter.reviews.push({ id: "23", actorLogin: "codex-review", - state: "COMMENTED", + state: "APPROVED", commitId: reviewed.run.candidateCommit, body: "", url: "https://github.com/example/app/pull/41#pullrequestreview-23", @@ -2471,6 +2555,61 @@ describe("exact-candidate GitHub draft delivery", () => { } }, 10_000); + it("requires an explicit approval and retains unclassified top-level feedback", () => { + const candidateCommit = "a".repeat(40); + const observation = { + reviews: [ + { + id: "top-level", + actorLogin: "codex-review", + state: "COMMENTED", + commitId: candidateCommit, + body: "This can select the wrong owner.", + url: "https://github.com/example/app/pull/41#pullrequestreview-1", + }, + ] as GitHubReview[], + feedback: [ + { + id: "review-top-level", + actorLogin: "codex-review", + priority: "unclassified" as const, + body: "This can select the wrong owner.", + path: null, + line: null, + url: "https://github.com/example/app/pull/41#pullrequestreview-1", + commitId: candidateCommit, + }, + ], + } as Pick as GitHubObservation; + const policy = { + mode: "github_required" as const, + requiredReviewerLogins: ["codex-review"], + }; + + expect(reviewsPassed(observation, policy, candidateCommit)).toBe(false); + expect(actionableFeedback(observation, policy, candidateCommit)).toEqual( + observation.feedback, + ); + const firstReview = observation.reviews.at(0); + if (firstReview === undefined) throw new Error("missing fixture review"); + expect( + reviewsPassed( + { + ...observation, + reviews: [ + { + ...firstReview, + state: "APPROVED", + body: "", + }, + ], + }, + policy, + candidateCommit, + ), + ).toBe(true); + }); + it("fails closed on PR drift and post-merge evidence until every identity settles", async () => { const { fixture, runId, candidateCommit, candidateTree } = await reviewedFixture(); diff --git a/test/runtime-development-evidence.test.ts b/test/runtime-development-evidence.test.ts new file mode 100644 index 0000000..5292515 --- /dev/null +++ b/test/runtime-development-evidence.test.ts @@ -0,0 +1,112 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { developmentEvidenceSummary } from "../src/runtime/development-evidence.js"; +import { runtimeFixture } from "./runtime-fixture.js"; + +describe("development evidence ledger", () => { + it("uses the declared eligible-change denominator and leaves partial measurements unavailable", async () => { + const fixture = await runtimeFixture(); + try { + await mkdir(path.join(fixture.root, "quality"), { recursive: true }); + await writeFile( + path.join(fixture.root, "quality", "ledger.yaml"), + `schemaVersion: "1" +records: + - id: eligible-through-mill + change: A completed compatible change + eligibility: { status: eligible } + route: { kind: mill } + outcome: accepted + effort: { preparationMinutes: 2, reviewMinutes: 3, repairMinutes: 0 } + elapsedMinutes: 12 + repairs: 0 + providerUsage: { inputTokens: 10, outputTokens: 4, cost: 0.02, currency: USD } + - id: eligible-manual + change: A compatible change outside Mill + eligibility: { status: eligible } + route: { kind: manual, reason: OCI runtime unavailable } + outcome: not_accepted + effort: { preparationMinutes: null, reviewMinutes: 5, repairMinutes: null } + elapsedMinutes: null + repairs: 1 + - id: excluded-docs + change: A documentation-only correction + eligibility: { status: excluded, reason: No approved task boundary } + route: { kind: manual, reason: Excluded from the trial } + outcome: accepted + effort: { preparationMinutes: 1, reviewMinutes: 1, repairMinutes: 0 } + elapsedMinutes: 2 + repairs: 0 +`, + ); + await expect( + developmentEvidenceSummary({ + root: fixture.root, + ledgerPath: "quality/ledger.yaml", + }), + ).resolves.toEqual({ + ledgerPath: "quality/ledger.yaml", + records: 3, + eligibleChanges: 2, + eligibleMillRoute: 1, + eligibleManualRoute: 1, + eligibleCompleted: 2, + eligibleAccepted: 1, + effort: { + preparationMinutes: null, + reviewMinutes: 8, + repairMinutes: null, + }, + elapsedMinutes: null, + providerUsage: { + inputTokens: null, + outputTokens: null, + cost: null, + currency: null, + }, + }); + } finally { + await fixture.cleanup(); + } + }); + + it("rejects a ledger whose records have duplicate identities", async () => { + const fixture = await runtimeFixture(); + try { + await mkdir(path.join(fixture.root, "quality"), { recursive: true }); + await writeFile( + path.join(fixture.root, "quality", "ledger.yaml"), + `schemaVersion: "1" +records: + - id: duplicate + change: First + eligibility: { status: eligible } + route: { kind: mill } + outcome: accepted + effort: { preparationMinutes: 1, reviewMinutes: 1, repairMinutes: 0 } + elapsedMinutes: 2 + repairs: 0 + - id: duplicate + change: Second + eligibility: { status: eligible } + route: { kind: mill } + outcome: accepted + effort: { preparationMinutes: 1, reviewMinutes: 1, repairMinutes: 0 } + elapsedMinutes: 2 + repairs: 0 +`, + ); + await expect( + developmentEvidenceSummary({ + root: fixture.root, + ledgerPath: "quality/ledger.yaml", + }), + ).rejects.toMatchObject({ code: "DEVELOPMENT_EVIDENCE_LEDGER_INVALID" }); + } finally { + await fixture.cleanup(); + } + }); +}); diff --git a/test/runtime-fixture.ts b/test/runtime-fixture.ts index 1a871f4..ffd1472 100644 --- a/test/runtime-fixture.ts +++ b/test/runtime-fixture.ts @@ -379,6 +379,9 @@ const sourceMount=mounts.find((value)=>value.includes("target=/workspace/src,rea const source=/source=([^,]+)/u.exec(sourceMount)?.[1]; if(!source)process.exit(2); const value=await readFile(path.join(source,"value.js"),"utf8"); +const passed=/value = [1-9]/u.test(value)&&!(${options.nativeRepair === true}&&/value = 2/u.test(value)); +const marker=args.find((entry)=>entry.startsWith("MILL_ARTIFACT_PROTOCOL="))?.slice("MILL_ARTIFACT_PROTOCOL=".length); +if(marker!==undefined){const contents='{"baseline":true}\\n';process.stdout.write(\`\\n\${marker}:begin\\nregular:\${Buffer.byteLength(contents)}\\n\${Buffer.from(contents).toString("base64")}\\n\${marker}:end:\${passed?0:1}\\n\`)} process.exit(/value = [1-9]/u.test(value)&&!(${options.nativeRepair === true}&&/value = 2/u.test(value))?0:1); `, { mode: 0o755 }, diff --git a/test/runtime-github.test.ts b/test/runtime-github.test.ts index edb8251..d62e761 100644 --- a/test/runtime-github.test.ts +++ b/test/runtime-github.test.ts @@ -74,7 +74,7 @@ else if(endpoint.endsWith("/protection"))console.log(JSON.stringify({enforce_adm else if(endpoint==="graphql")console.log(JSON.stringify(mode.ready??{data:{markPullRequestReadyForReview:{pullRequest:{id:"PR_example",isDraft:false}}}})); else if(endpoint.endsWith("/pulls/41/merge"))console.log(JSON.stringify({merged:mode.merged??true})); else if(endpoint.includes("/status?"))console.log(JSON.stringify([{statuses:[{state:"pending",context:"legacy"}]}])) -else if(endpoint.includes("/reviews?"))console.log(JSON.stringify([[{id:11,user:{login:"codex-review"},state:"COMMENTED",commit_id:"${sha}",body:"[P1] top-level finding",html_url:"https://github.com/example/app/pull/41#pullrequestreview-11"}]])); +else if(endpoint.includes("/reviews?"))console.log(JSON.stringify([[{id:11,user:{login:"codex-review"},state:"COMMENTED",commit_id:"${sha}",body:"Top-level concern without a priority label",html_url:"https://github.com/example/app/pull/41#pullrequestreview-11",...mode.review}]])); else if(endpoint.includes("/comments?"))console.log(JSON.stringify([[{id:12,user:{login:"codex-review"},body:"[P2] clarify edge case",path:"src/index.ts",line:4,html_url:"https://github.com/example/app/pull/41#discussion_r12",commit_id:"${sha}"}]])); else process.exit(2); `, @@ -155,14 +155,29 @@ else process.exit(2); actorLogin: "codex-review", state: "COMMENTED", commitId: sha, - body: "[P1] top-level finding", + body: "Top-level concern without a priority label", }, ], feedback: [ - { priority: "P1", commitId: sha, path: null }, + { priority: "unclassified", commitId: sha, path: null }, { priority: "P2", commitId: sha, path: "src/index.ts" }, ], }); + await writeFile( + path.join(tools.path, "mode.json"), + JSON.stringify({ review: { state: "APPROVED", body: "LGTM" } }), + ); + await expect( + adapter.observe({ + config, + pullRequestNumber: 41, + deadlineMs: Date.now() + 10_000, + }), + ).resolves.toMatchObject({ + reviews: [{ state: "APPROVED", body: "LGTM" }], + feedback: [{ priority: "P2", path: "src/index.ts" }], + }); + await writeFile(path.join(tools.path, "mode.json"), "{}"); const calls = await readFile(path.join(tools.path, "calls.log"), "utf8"); const producerConfig: ProposeConfig = { ...config, diff --git a/test/runtime-lifecycle.test.ts b/test/runtime-lifecycle.test.ts index 06f7f3d..564a309 100644 --- a/test/runtime-lifecycle.test.ts +++ b/test/runtime-lifecycle.test.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import { chmod, readFile, writeFile } from "node:fs/promises"; +import { createHash, randomUUID } from "node:crypto"; +import { chmod, mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import { promisify } from "node:util"; @@ -11,6 +11,7 @@ import { cancelRun, qualifyBaseline, reviewRun, + retainedVerifierArtifacts, resumeRun, runStatus, startLocalRun, @@ -84,6 +85,162 @@ async function qualifiedApproval( } describe("local delivery lifecycle", () => { + it("keeps repeated baseline retained-artifact collection in distinct storage", async () => { + const fixture = await runtimeFixture(); + activate(fixture); + try { + const configPath = path.join(fixture.root, "mill.yaml"); + const config = await readFile(configPath, "utf8"); + await writeFile( + configPath, + config.replace( + " execution: oci\n", + ` execution: oci + retainedArtifacts: + paths: [reports/check.json] + required: true + maxFiles: 1 + maxFileBytes: 1024 + maxTotalBytes: 1024 +`, + ), + ); + await git(fixture.root, ["add", "mill.yaml"]); + await git(fixture.root, [ + "commit", + "--no-gpg-sign", + "-m", + "test: retain baseline reports", + ]); + const first = await qualifyBaseline({ + root: fixture.root, + taskPath: fixture.taskPath, + }); + const second = await qualifyBaseline({ + root: fixture.root, + taskPath: fixture.taskPath, + }); + expect(first.evidence.passed).toBe(true); + expect(second.evidence.passed).toBe(true); + const inputs = await loadRuntimeInputs(fixture.root, fixture.taskPath); + const store = await StateStore.open( + inputs.config.repositoryId, + await commonGitDirectory(fixture.root), + ); + try { + const directories = await readdir( + path.join( + store.directory, + "baseline-artifacts", + first.evidence.candidateCommit, + ), + ); + expect(directories).toHaveLength(2); + expect(new Set(directories).size).toBe(2); + } finally { + store.close(); + } + } finally { + await fixture.cleanup(); + } + }); + + it("lists only candidate-bound retained verifier artifacts and detects later tampering", async () => { + const fixture = await runtimeFixture(); + activate(fixture); + try { + const started = await startLocalRun({ + root: fixture.root, + taskPath: fixture.taskPath, + approvalDigest: await qualifiedApproval(fixture), + }); + const candidateCommit = started.run.candidateCommit; + if (candidateCommit === undefined) { + throw new Error("started fixture run has no candidate commit"); + } + const contents = '{"scenario":"passed"}\n'; + const digest = `sha256:${createHash("sha256").update(contents).digest("hex")}`; + const inputs = await loadRuntimeInputs(fixture.root, fixture.taskPath); + const store = await StateStore.open( + inputs.config.repositoryId, + await commonGitDirectory(fixture.root), + ); + const artifact = path.join( + store.directory, + "artifacts", + started.run.id, + candidateCommit, + createHash("sha256").update("test").digest("hex"), + "reports", + "scenario.json", + ); + try { + await mkdir(path.dirname(artifact), { recursive: true, mode: 0o700 }); + await writeFile(artifact, contents, { mode: 0o600 }); + store.completeValidation( + started.run.id, + JSON.stringify({ + schemaVersion: "1", + candidateCommit, + verifierImage: + inputs.config.verifier?.image ?? + "node@sha256:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e", + network: "none", + commands: [ + { + commandId: "test", + required: true, + status: "passed", + exitCode: 0, + durationMs: 1, + outputDigest: `sha256:${"a".repeat(64)}`, + artifacts: [ + { + path: "reports/scenario.json", + sha256: digest, + bytes: Buffer.byteLength(contents), + }, + ], + }, + ], + passed: true, + }), + true, + ); + } finally { + store.close(); + } + await expect( + retainedVerifierArtifacts({ + root: fixture.root, + runId: started.run.id, + }), + ).resolves.toEqual({ + candidateCommit: started.run.candidateCommit, + artifacts: [ + { + commandId: "test", + path: "reports/scenario.json", + sha256: digest, + bytes: Buffer.byteLength(contents), + available: true, + }, + ], + }); + await writeFile(artifact, "tampered\n", { mode: 0o600 }); + await expect( + retainedVerifierArtifacts({ + root: fixture.root, + runId: started.run.id, + }), + ).resolves.toMatchObject({ + artifacts: [{ path: "reports/scenario.json", available: false }], + }); + } finally { + await fixture.cleanup(); + } + }); + it("repairs a committed native failure once and retains its original evidence and deadline", async () => { const fixture = await runtimeFixture({ nativeRepair: true }); activate(fixture); diff --git a/test/runtime-outcome.test.ts b/test/runtime-outcome.test.ts index 0af7851..986d300 100644 --- a/test/runtime-outcome.test.ts +++ b/test/runtime-outcome.test.ts @@ -1192,7 +1192,7 @@ describe("run outcome projection", () => { postMergePolicyDelivery, ); expect(outcome(postMergePolicyMismatch).integrity.reasons).toContainEqual( - expect.objectContaining({ code: "OUTCOME_DELIVERY_RECEIPT_MISMATCH" }), + expect.objectContaining({ code: "OUTCOME_DELIVERY_INVALID" }), ); }); diff --git a/test/runtime-state.test.ts b/test/runtime-state.test.ts index eee4e63..af2f1a2 100644 --- a/test/runtime-state.test.ts +++ b/test/runtime-state.test.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import { access, mkdir, + readdir, readFile, stat, symlink, @@ -48,6 +49,88 @@ function startWorkerInvocation( } describe("operational state", () => { + it("keeps absent and older state diagnostic reads free of writes", async () => { + const temporary = await temporaryDirectory("mill-state-read-only-"); + process.env.MILL_STATE_HOME = temporary.path; + const repositoryId = "11111111-1111-4111-8111-111111111111"; + const directory = repositoryStateDirectory(repositoryId, temporary.path); + try { + await expect( + StateStore.openReadOnly(repositoryId, temporary.path), + ).resolves.toBeUndefined(); + await expect(access(directory)).rejects.toMatchObject({ code: "ENOENT" }); + + const writable = await StateStore.open(repositoryId, temporary.path); + const databasePath = writable.databasePath; + writable.close(); + const database = new DatabaseSync(databasePath); + try { + database + .prepare( + "UPDATE metadata SET value = '3' WHERE key = 'schema_version'", + ) + .run(); + } finally { + database.close(); + } + const before = await readFile(databasePath); + await expect( + StateStore.openReadOnly(repositoryId, temporary.path), + ).rejects.toMatchObject({ code: "STATE_UPGRADE_REQUIRED" }); + await expect(readFile(databasePath)).resolves.toEqual(before); + } finally { + await temporary.cleanup(); + } + }); + + it("backs up a supported state before applying its forward upgrade", async () => { + const temporary = await temporaryDirectory("mill-state-preupgrade-"); + process.env.MILL_STATE_HOME = temporary.path; + const repositoryId = "11111111-1111-4111-8111-111111111111"; + const store = await StateStore.open(repositoryId, temporary.path); + const databasePath = store.databasePath; + const directory = store.directory; + store.close(); + const database = new DatabaseSync(databasePath); + try { + database.exec("DELETE FROM schema_migrations WHERE version = 4"); + database + .prepare("UPDATE metadata SET value = '3' WHERE key = 'schema_version'") + .run(); + } finally { + database.close(); + } + const upgraded = await StateStore.open(repositoryId, temporary.path); + try { + const backups = (await readdir(directory)).filter((name) => + name.startsWith("state-backup-preupgrade-v3-"), + ); + expect(backups).toHaveLength(1); + const backupPath = path.join(directory, backups[0] ?? ""); + const backup = new DatabaseSync(backupPath, { readOnly: true }); + try { + expect( + backup + .prepare("SELECT value FROM metadata WHERE key = 'schema_version'") + .get(), + ).toEqual({ value: "3" }); + expect( + backup + .prepare( + "SELECT 1 AS present FROM schema_migrations WHERE version = 4", + ) + .get(), + ).toBeUndefined(); + } finally { + backup.close(); + } + expect(upgraded.stats().schemaVersion).toBe(4); + } finally { + upgraded.close(); + await temporary.cleanup(); + } + }); + it.each(["1", "2", "3"])( "migrates supported v%s state to the numbered current schema without losing runs", async (legacyVersion) => { @@ -98,6 +181,184 @@ describe("operational state", () => { }, ); + it("upgrades a genuine v1 state that predates worker invocation tables", async () => { + const temporary = await temporaryDirectory("mill-state-v1-history-"); + process.env.MILL_STATE_HOME = temporary.path; + const repositoryId = "11111111-1111-4111-8111-111111111111"; + const directory = repositoryStateDirectory(repositoryId, temporary.path); + const databasePath = path.join(directory, "state.sqlite3"); + await mkdir(path.join(directory, "worktrees"), { recursive: true }); + const legacy = new DatabaseSync(databasePath); + try { + legacy.exec(` + CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT; + CREATE TABLE runs ( + id TEXT PRIMARY KEY, repository_id TEXT NOT NULL, task_id TEXT NOT NULL, + task_digest TEXT NOT NULL, config_digest TEXT NOT NULL, status TEXT NOT NULL, + base_commit TEXT NOT NULL, worktree_path TEXT, context_digest TEXT, + context_json TEXT, control_json TEXT, candidate_commit TEXT, + candidate_tree TEXT, deadline_at TEXT NOT NULL, active_pid INTEGER, + cancel_requested INTEGER NOT NULL DEFAULT 0, repair_count INTEGER NOT NULL DEFAULT 0, + attempt_count INTEGER NOT NULL DEFAULT 0, block_code TEXT, validation_json TEXT, + review_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL + ) STRICT; + CREATE TABLE run_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL REFERENCES runs(id), + occurred_at TEXT NOT NULL, type TEXT NOT NULL, data_json TEXT NOT NULL + ) STRICT; + CREATE TABLE baseline_qualifications ( + approval_digest TEXT PRIMARY KEY, repository_id TEXT NOT NULL, + task_digest TEXT NOT NULL, config_digest TEXT NOT NULL, base_commit TEXT NOT NULL, + evidence_digest TEXT NOT NULL, created_at TEXT NOT NULL + ) STRICT; + `); + legacy + .prepare("INSERT INTO metadata(key, value) VALUES (?, ?)") + .run("schema_version", "1"); + legacy + .prepare( + `INSERT INTO runs( + id, repository_id, task_id, task_digest, config_digest, status, + base_commit, deadline_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + "v1-run", + repositoryId, + "v1-task", + `sha256:${"a".repeat(64)}`, + `sha256:${"b".repeat(64)}`, + "ready", + "c".repeat(40), + "2026-09-02T00:00:00.000Z", + "2026-09-01T00:00:00.000Z", + "2026-09-01T00:00:00.000Z", + ); + } finally { + legacy.close(); + } + const migrated = await StateStore.open(repositoryId, temporary.path); + try { + expect(migrated.getRun("v1-run")).toMatchObject({ taskId: "v1-task" }); + const invocationId = startWorkerInvocation(migrated, "v1-run", "build"); + expect(invocationId).toMatch(/^[0-9a-f-]{36}$/u); + expect(migrated.stats().schemaVersion).toBe(4); + } finally { + migrated.close(); + await temporary.cleanup(); + } + }); + + it("upgrades the populated v0.5.0 release schema without losing its evidence", async () => { + const temporary = await temporaryDirectory("mill-state-v050-release-"); + process.env.MILL_STATE_HOME = temporary.path; + const repositoryId = "11111111-1111-4111-8111-111111111111"; + const directory = repositoryStateDirectory(repositoryId, temporary.path); + const databasePath = path.join(directory, "state.sqlite3"); + const occurredAt = "2026-09-01T00:00:00.000Z"; + const runId = "v050-run"; + const invocationId = "v050-invocation"; + await mkdir(path.join(directory, "worktrees"), { recursive: true }); + const legacy = new DatabaseSync(databasePath); + try { + legacy.exec( + await readFile( + new URL("./fixtures/state-v0.5.sql", import.meta.url), + "utf8", + ), + ); + legacy.exec("PRAGMA foreign_keys = ON"); + legacy + .prepare("INSERT INTO metadata(key, value) VALUES (?, ?)") + .run("schema_version", "3"); + const legacyMigrations: readonly (readonly [number, string])[] = [ + [1, "initial-durable-state"], + [2, "worker-and-delivery-recovery-columns"], + [3, "numbered-migration-ledger"], + ]; + for (const [version, name] of legacyMigrations) { + legacy + .prepare( + "INSERT INTO schema_migrations(version, name, applied_at) VALUES (?, ?, ?)", + ) + .run(version, name, occurredAt); + } + legacy + .prepare( + `INSERT INTO runs( + id, repository_id, task_id, task_digest, config_digest, status, + base_commit, deadline_at, candidate_commit, candidate_tree, + delivery_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + runId, + repositoryId, + "v050-task", + `sha256:${"a".repeat(64)}`, + `sha256:${"b".repeat(64)}`, + "awaiting_human", + "c".repeat(40), + "2026-09-02T00:00:00.000Z", + "d".repeat(40), + "e".repeat(40), + '{"pr":42}', + occurredAt, + occurredAt, + ); + legacy + .prepare( + "INSERT INTO run_events(run_id, occurred_at, type, data_json) VALUES (?, ?, ?, ?)", + ) + .run(runId, occurredAt, "delivery.opened", '{"pr":42}'); + legacy + .prepare( + "INSERT INTO worker_invocations(id, run_id, phase, envelope_digest, envelope_json, created_at) VALUES (?, ?, ?, ?, ?, ?)", + ) + .run( + invocationId, + runId, + "review", + `sha256:${"f".repeat(64)}`, + "{}", + occurredAt, + ); + legacy + .prepare( + "INSERT INTO worker_invocation_events(invocation_id, occurred_at, type, data_json) VALUES (?, ?, ?, ?)", + ) + .run(invocationId, occurredAt, "process_exited", "{}"); + } finally { + legacy.close(); + } + + const migrated = await StateStore.open(repositoryId, temporary.path); + try { + expect(migrated.stats().schemaVersion).toBe(4); + expect(migrated.getRun(runId)).toMatchObject({ + taskId: "v050-task", + status: "awaiting_human", + deliveryJson: '{"pr":42}', + }); + expect(migrated.events(runId)).toMatchObject([ + { type: "delivery.opened", data: { pr: 42 } }, + ]); + const upgraded = new DatabaseSync(databasePath, { readOnly: true }); + try { + expect( + upgraded + .prepare("SELECT COUNT(*) AS count FROM worker_invocation_events") + .get(), + ).toEqual({ count: 1 }); + } finally { + upgraded.close(); + } + } finally { + migrated.close(); + await temporary.cleanup(); + } + }); + it("blocks a future state schema before using local records", async () => { const temporary = await temporaryDirectory("mill-state-schema-"); process.env.MILL_STATE_HOME = temporary.path; @@ -762,7 +1023,27 @@ describe("operational state", () => { expect(requested.cancelRequested).toBe(true); store.transition(cancelled.id, "cancelled", "cancelled"); expect(store.requestCancellation(cancelled.id).status).toBe("cancelled"); - expect(store.runs()).toHaveLength(6); + + const awaitingHuman = create(); + store.transition(awaitingHuman.id, "ready", "ready"); + store.transition(awaitingHuman.id, "running", "running"); + store.commitCandidate(awaitingHuman.id, "d".repeat(40), "e".repeat(40)); + store.completeValidation(awaitingHuman.id, '{"passed":true}', true); + store.completeReview( + awaitingHuman.id, + '{"findings":[]}', + 0, + false, + startWorkerInvocation(store, awaitingHuman.id, "review"), + ); + store.transition(awaitingHuman.id, "proposing", "delivery.planned"); + store.transition(awaitingHuman.id, "awaiting_ci", "delivery.opened"); + store.transition(awaitingHuman.id, "awaiting_human", "delivery.ready"); + store.requestCancellation(awaitingHuman.id); + expect( + store.transition(awaitingHuman.id, "cancelled", "run.cancelled"), + ).toMatchObject({ status: "cancelled", cancelRequested: true }); + expect(store.runs()).toHaveLength(7); } finally { store.close(); store.close(); diff --git a/test/schemas.test.ts b/test/schemas.test.ts index 2395832..6c47aa5 100644 --- a/test/schemas.test.ts +++ b/test/schemas.test.ts @@ -1075,7 +1075,7 @@ describe("compact schemas", () => { ); const scopedToken = { ...localReview, - reporting: { selfHosted: true }, + reporting: { ledgerPath: "quality/development-evidence-ledger.yaml" }, propose: { ...localReview.propose, deliveryCredential: {