From 82a76fe7f04b2e432b0f28fcdfa7be969d2aeee3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:56:46 +0000 Subject: [PATCH 01/20] Prepare the v1.4.0 release and stop stale completions projecting complete Bump the current release to 1.4.0 across source, materialized templates, current docs, install pins, and release hygiene, including the committed package manifest that had drifted to a stale beta version. Release readiness and release hygiene now fail when that manifest disagrees with pyproject.toml or src/code_mower/__init__.py. Add v1.4.0 release notes, changelog, and release-history entries covering the whole v1.3.1..v1.4.0 delta: optional Devin peer participation and the hosted work-order lifecycle, the bounded optional Graphify provider, and the Slack command and ingress foundation, described as a foundation for later worker delivery rather than a completed integration. DevinWorkOrders no longer projects a finished session while a persisted completion rejection stands: when the remote projection reports complete, the returned logical projection is running / result_not_ready / status. The authoritative rejection block, the durable remote record, RemoteSessions result precedence, and exact-round and PR/head verification are unchanged. Correct the Devin peer-support qualification record to separate the local round-0 build/Twine result from GitHub CI coverage, state the exact matrix scope, record the completed Board and cloud evidence from #911, and describe the PACKAGE_FILES materialization effect. Co-Authored-By: bot_apk --- CHANGELOG.md | 33 +++++ README.md | 23 ++-- code-mower-package-manifest.json | 2 +- docs/architecture.md | 2 +- docs/build-loop-in-30-minutes.md | 2 +- docs/cloud-benchmarking.md | 2 +- docs/code-structure-roadmap.md | 2 +- docs/context-delivery.md | 2 +- docs/context-provider-contract.md | 4 +- docs/current-state-and-roadmap.md | 21 +-- docs/devin-peer-support-qualification.md | 64 +++++++-- docs/early-adopter-invite-runbook.md | 4 +- docs/early-adopter-v05.md | 2 +- docs/first-run-transcript.md | 4 +- docs/first-user-demo-transcript.md | 6 +- docs/first-user-install-rehearsal.md | 26 ++-- docs/friendly-user-rollout-v05.md | 6 +- docs/github-setup.md | 2 +- docs/install.md | 20 +-- docs/jira-cloud-setup.md | 2 +- docs/mirror-removal-runbook.md | 2 +- docs/oss-v1-checklist.md | 8 +- docs/package-customization.md | 2 +- docs/provider-matrix.md | 2 +- docs/public-release-checklist.md | 4 +- docs/pypi-release.md | 18 +-- docs/quickstart.md | 10 +- docs/release-history.md | 1 + docs/sessions.md | 2 +- docs/troubleshooting.md | 4 +- docs/try-in-10-minutes.md | 8 +- docs/v140-release-notes.md | 103 +++++++++++++++ pyproject.toml | 2 +- src/code_mower/__init__.py | 2 +- src/code_mower/devin_work_orders.py | 20 ++- src/code_mower/package_manifest.py | 1 + src/code_mower/release_readiness.py | 36 +++++ .../workflows/builder-provenance.yml.j2 | 2 +- templates/workflows/builder-provenance.yml.j2 | 2 +- tests/test_devin_work_orders.py | 52 ++++++++ tests/test_release_hygiene.py | 123 +++++++++++------- 41 files changed, 471 insertions(+), 162 deletions(-) create mode 100644 docs/v140-release-notes.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ccea7e2..8097dc3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ project used alpha/beta prerelease tags while the first-user setup path, provider posture, and optional cloud sharing loop were hardening; v1.0 and later entries are regular releases. +## v1.4.0 + +Devin can act as an optional peer participant with a durable remote work-order +lifecycle, Graphify is a bounded optional local repository-graph provider, and +the Slack command and ingress foundation is in place. Claude Code + Codex remain +the first-run default and every new integration stays opt-in. + +### Added + +- One optional Devin setup and readiness path for the local CLI and the hosted + v3 API, a provider-neutral remote session lifecycle, trusted hosted work + orders, normalized local and hosted review evidence, and authorized context + delivery to approved Devin roles. +- Graphify as an optional bounded local provider behind the packet contract, + with an exact pin, revision-bound private graph state, no network access, and + no default dependency. +- The Slack command and identity contract plus authenticated, durable, bounded + ingress as a foundation for later worker delivery. +- A release-hygiene assertion and `release-readiness` check that fail when the + committed `code-mower-package-manifest.json` version disagrees with + `pyproject.toml` and `src/code_mower/__init__.py`. + +### Fixed + +- A rejected work-order completion no longer projects a finished session. When a + persisted rejection exists and the remote projection reports `complete`, the + returned session projection is `running` / `result_not_ready` / `status`, + while the authoritative rejection block, the durable remote record, and + exact-round and pull-request verification are unchanged. +- Devin results are recovered across restarts and the merge boundary, running + sessions holding a current-round result are recognized, GitHub bot login + suffixes are normalized, and collection recovers after a stale completion. + ## v1.3.1 Code Mower can carry optional organizational context through the ordinary diff --git a/README.md b/README.md index cf60b29a..1e9c6b97 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,13 @@ The current release is supervised-pilot, bring-your-own-agent-loop software. It is not a drop-in unattended merge gate. Humans still own credentials, repository policy, reviewer promotion, and exceptional decisions. -The current package-index release baseline is `v1.3.1`, with pinned package -install spec `code-mower==1.3.1`. Release evidence is recorded on the GitHub +The current package-index release baseline is `v1.4.0`, with pinned package +install spec `code-mower==1.4.0`. Release evidence is recorded on the GitHub release and in the first-user install rehearsal. Documentation on `main` follows the source on `main`. When using the published package, start with the -[`v1.3.1` guide](https://github.com/codemower-ai/code-mower/blob/v1.3.1/docs/try-in-10-minutes.md). +[`v1.4.0` guide](https://github.com/codemower-ai/code-mower/blob/v1.4.0/docs/try-in-10-minutes.md). ## What Code Mower Adds @@ -43,7 +43,7 @@ one stable `pipx` installation: ```bash python3.12 --version export CODE_MOWER_PYTHON="$(command -v python3.12)" -pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.3.1 +pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.4.0 command -v code-mower code-mower --version ``` @@ -107,7 +107,7 @@ paths available for each participant and records explicit handoffs where Code Mower has no automatic transport. Codex, Claude Code, and Cursor are qualified for the shared session, telemetry, -lease, and Jira-authority contract in v1.3.1. Devin, Grok Bot, Antigravity, +lease, and Jira-authority contract in v1.4.0. Devin, Grok Bot, Antigravity, Muse, and custom hosts are recognized for briefs and provenance, while their execution remains an explicit handoff or provider-specific transport. See [Participants And Sessions](docs/sessions.md) and the @@ -215,20 +215,22 @@ and the [Cloud Data Contract](docs/cloud-data-contract.md). ## Current Capabilities And Limits -| Area | v1.3.1 posture | +| Area | v1.4.0 posture | | --- | --- | | Default builders and reviewers | Claude Code + Codex | | Session hosts | Codex, Claude Code, and Cursor qualified; other identities recognized but require explicit handoff/provider transport | | Devin | Maintained local builder and hosted release-qualification transport; local review remains informational and Devin is not yet a qualified peer orchestrator | -| Organizational context | Optional Coworker delivery to approved Claude/Codex roles | +| Organizational context | Optional Coworker delivery to approved Claude/Codex/Devin roles | | Work trackers | GitHub Issues by default; Jira Cloud is optional and guarded | | Forge and merge gate | GitHub | | Cloud | Optional metadata/report upload; no upload by default | -| Graphify and Slack | Tracked future integrations; not included in v1.3.1 | +| Graphify | Optional bounded local repository-graph provider behind the packet contract; no default dependency and no network access for the provider | +| Slack | Command and authenticated bounded ingress foundation only; no Slack worker delivery, results, or orchestration authority | GitLab, Bitbucket, broad unattended rollout, uncalibrated merge gates, Devin -peer-orchestrator/reviewer parity, Graphify, and Slack task ingress are not -shipped in v1.3.1. The current priorities and boundaries are recorded in +peer-orchestrator/reviewer parity, a required Graphify dependency, and Slack +worker delivery are not shipped in v1.4.0. The current priorities and +boundaries are recorded in [Current State And Roadmap](docs/current-state-and-roadmap.md). ## Documentation @@ -282,6 +284,7 @@ shipped in v1.3.1. The current priorities and boundaries are recorded in - [Cloud Data Contract](docs/cloud-data-contract.md) - [Release Qualification](docs/release-qualification.md) - [Public Release Checklist](docs/public-release-checklist.md) +- [v1.4.0 Release Notes](docs/v140-release-notes.md) - [Release History And Archived Plans](docs/release-history.md) - [Changelog](CHANGELOG.md) - [Contributing](CONTRIBUTING.md) diff --git a/code-mower-package-manifest.json b/code-mower-package-manifest.json index e8d8e7f7..5d2b3f7b 100644 --- a/code-mower-package-manifest.json +++ b/code-mower-package-manifest.json @@ -1550,6 +1550,6 @@ "module": "code_mower", "name": "code-mower", "source_layout": "src/code_mower", - "version": "0.5.0b53" + "version": "1.4.0" } } diff --git a/docs/architecture.md b/docs/architecture.md index faafeb7c..0d7a5cff 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -111,7 +111,7 @@ can have one writer. These are different controls: the session lease prevents two orchestrators from coordinating the same working copy, while the branch rule prevents a reviewer or second builder from changing the owner's branch. -Codex, Claude Code, and Cursor are qualified session hosts in v1.3.1. Other host +Codex, Claude Code, and Cursor are qualified session hosts in v1.4.0. Other host identities can receive the same brief and telemetry shape, but Code Mower does not claim execution parity until the relevant transport and recovery behavior are qualified. See [Participants And Sessions](sessions.md). diff --git a/docs/build-loop-in-30-minutes.md b/docs/build-loop-in-30-minutes.md index 81985cd9..d6db9a82 100644 --- a/docs/build-loop-in-30-minutes.md +++ b/docs/build-loop-in-30-minutes.md @@ -60,7 +60,7 @@ audit evidence, skip to section 2. ```bash python3.12 --version export CODE_MOWER_PYTHON="$(command -v python3.12)" -pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.3.1 +pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.4.0 gh auth status >/dev/null 2>&1 && echo "gh auth ok" || { echo "gh auth NOT ready"; false; } code-mower init --easy code-mower init --easy --apply --output-dir .code-mower.generated diff --git a/docs/cloud-benchmarking.md b/docs/cloud-benchmarking.md index e9dd3c2e..89d87e77 100644 --- a/docs/cloud-benchmarking.md +++ b/docs/cloud-benchmarking.md @@ -5,7 +5,7 @@ reports, and the local Board without a hosted account. CodeMower.com is an optional destination for longitudinal team reporting and future aggregate benchmarks. -## Current v1.3.1 Surface +## Current v1.4.0 Surface The current client can: diff --git a/docs/code-structure-roadmap.md b/docs/code-structure-roadmap.md index 624fabcd..fe900763 100644 --- a/docs/code-structure-roadmap.md +++ b/docs/code-structure-roadmap.md @@ -22,7 +22,7 @@ of the domain behavior: - package and migration helpers own generated files, setup drift, rehearsals, and mirror-removal support. -Some root modules are still large. Approximate sizes at v1.3.1 are useful as +Some root modules are still large. Approximate sizes at v1.4.0 are useful as orientation, not as an API promise: | Module | Lines | Main responsibility | diff --git a/docs/context-delivery.md b/docs/context-delivery.md index 666ddb5b..bf3dfe28 100644 --- a/docs/context-delivery.md +++ b/docs/context-delivery.md @@ -267,4 +267,4 @@ organization search. The synthetic local-repository graph fixture uses the common packet validation and evidence renderer for all six roles without OAuth identity fields. This preserves an extension point for later Graphify evaluation; it does not install -or qualify Graphify in v1.3.1. +or qualify Graphify in v1.4.0. diff --git a/docs/context-provider-contract.md b/docs/context-provider-contract.md index d6252fe1..6ed93322 100644 --- a/docs/context-provider-contract.md +++ b/docs/context-provider-contract.md @@ -134,7 +134,7 @@ rules; a synthetic fixture or structurally valid JSON is not authorization. ## Implemented commands -The qualified connection and delivery path is available in v1.3.1. See the +The qualified connection and delivery path is available in v1.4.0. See the [setup guide](context-setup.md) for installation and explicit account selection: ```text @@ -203,7 +203,7 @@ is **adopted as an optional, bounded local provider** behind this contract; see the [evaluation record](graphify-evaluation.md) for the decision, the pinned package record, and the conditions an implementing change must meet. Nothing is installed or required yet. Synthetic graph fixtures prove only the extension -point; they do not establish Graphify compatibility or make it a v1.3.1 +point; they do not establish Graphify compatibility or make it a v1.4.0 dependency. The lifecycle around such a provider — exact pin, immutable tracked-file diff --git a/docs/current-state-and-roadmap.md b/docs/current-state-and-roadmap.md index 0d632421..3d5428a4 100644 --- a/docs/current-state-and-roadmap.md +++ b/docs/current-state-and-roadmap.md @@ -22,11 +22,11 @@ dry-run-first. ## Current Public Release -The current package-index release baseline is `v1.3.1`, with pinned package -install spec `code-mower==1.3.1`. Release evidence is recorded on the GitHub +The current package-index release baseline is `v1.4.0`, with pinned package +install spec `code-mower==1.4.0`. Release evidence is recorded on the GitHub release and in the first-user install rehearsal. -Version 1.3.1 requires Python 3.12 or newer. It provides: +Version 1.4.0 requires Python 3.12 or newer. It provides: - pipx, uv tool, and contributor installation paths; - safe setup previews and selectable participants; @@ -39,7 +39,7 @@ Version 1.3.1 requires Python 3.12 or newer. It provides: - release qualification and resumable provider campaigns; and - optional Coworker organizational context through a protected local store. -The v1.3.1 guided context workflow derives repository, work item, selected +The v1.4.0 guided context workflow derives repository, work item, selected connection, policy, packet, builder, pull-request head, input revision, reviewer, and feedback recipient from the session. Fetch, delivery, attachment, review, and feedback retain explicit authorization, expiry, and refresh checks. @@ -57,10 +57,10 @@ and feedback retain explicit authorization, expiry, and refresh checks. | Hosted builders | Explicit provider-specific dispatch and provenance; no implicit trust or merge authority | | Merge-eligible reviewers | Codex and Claude after repository setup and calibration | | Informational reviewers | Devin CLI and other optional providers until their evidence supports promotion | -| Organizational context | Optional Coworker packets for approved Claude/Codex orchestrator, builder, and reviewer roles | -| Repository context graph | Provider-neutral packet extension and offline scope/freshness checks exist; Graphify is adopted as an optional bounded provider but not yet installed or shipped | +| Organizational context | Optional Coworker packets for approved Claude/Codex/Devin orchestrator, builder, and reviewer roles | +| Repository context graph | Provider-neutral packet extension, offline scope/freshness checks, and a revision-bound local graph lifecycle; Graphify is an optional bounded provider with no default dependency | | Work tracking | GitHub Issues by default; Jira Cloud optional, bounded, and dry-run-first for writes | -| Team interaction | CLI, GitHub, local Board, and optional CodeMower.com metadata views; no Slack ingress yet | +| Team interaction | CLI, GitHub, local Board, and optional CodeMower.com metadata views; Slack has an authenticated bounded ingress foundation with no worker delivery | Provider selection, execution transport, and review authority are separate. For example, selecting Devin does not promote the Devin reviewer, and selecting @@ -116,9 +116,10 @@ future hosted-service work. - `session start` prepares state and instructions; it is not a universal multi-provider process launcher. - Devin has stronger builder support than reviewer or orchestrator support. -- Private Coworker delivery is limited to explicitly approved Claude and Codex - roles in v1.3.1. -- Graphify and Slack are not included in v1.3.1. +- Private Coworker delivery is limited to explicitly approved Claude, Codex, and + Devin roles in v1.4.0. +- Graphify remains an optional bounded provider with no default dependency, and + Slack is an ingress foundation only: v1.4.0 delivers no Slack worker results. - Provider cost fields remain unknown when the provider does not return them. - A successful release campaign proves installation and operational transport, not builder quality or reviewer promotion readiness. diff --git a/docs/devin-peer-support-qualification.md b/docs/devin-peer-support-qualification.md index 3845ea5d..52f888e9 100644 --- a/docs/devin-peer-support-qualification.md +++ b/docs/devin-peer-support-qualification.md @@ -225,22 +225,58 @@ Setup posture on the qualification head: the generated configuration valid, and wrote 36 generated files without requiring Devin setup. -Package qualification: fresh wheel installs on Python 3.12, 3.13, and 3.14; -sdist and wheel Twine and package-content checks; base installation without -optional dependencies; easy-mode and fresh-clone setup; first-user package -rehearsal; release readiness; privacy, workflow, and package guards. These run -in the GitHub package matrix and `code-mower/gate` on the exact PR head, and -their result is recorded in the PR's status checks rather than copied here. - -## Board and cloud evidence (pending, trusted orchestrator) +Package qualification separates local round-0 results from GitHub CI. + +Locally, on the round-0 builder checkout, `python -m build` produced an sdist +and a wheel and `python -m twine check dist/*` passed on both. That is a local +result on one interpreter, not CI coverage. + +GitHub CI on the exact PR head covers, in the package matrix and +`code-mower/gate`: editable `.[coworker]` installs with Ruff, the privacy scan, +the full unit suite, compilation, and the package-workflow guard on Python 3.12, +3.13, and 3.14; and, on Python 3.12 only, release readiness, generated-workflow +actionlint, the regular (non-editable) base install without optional +dependencies, the easy-mode smoke, the fresh-clone rehearsal, and the +package-install first-user rehearsal. PR CI does not run Twine; `twine check` +runs in the release workflow. No fresh-wheel install or Twine check is claimed +on every Python matrix version. The CI results are recorded in the PR's status +checks rather than copied here. + +Adding this document to `PACKAGE_FILES` is not inert: it changes generated +standalone package materialization, so the manifest lists it and the generated +package writes it. Documentation stays outside the built wheel, so the wheel +contents are unchanged. + +## Board and cloud evidence (completed, trusted orchestrator) Exact-head Board inspection, cloud dry-run inspection, and the metadata-only -upload are trusted-orchestrator steps. They were pending when this document -was written and were not performed by the builder. When performed, Board and -cloud records for this qualification may contain only allowlisted provider, -transport, state, reason, timing, round, PR/head, ACU/cost, and validation -metadata; the public task-class descriptions above are documentation, not -cloud metadata. The cloud dry run is inspected before any upload. +upload are trusted-orchestrator steps; the builder did not perform them. They +are recorded as completed on +[#911](https://github.com/codemower-ai/code-mower/issues/911): the exact-`main` +Board privacy and gate inspection passed, and CodeMower.com stored one +zero-report `board_snapshot` event with no report text. Board and cloud records +for this qualification contain only allowlisted provider, transport, state, +reason, timing, round, PR/head, ACU/cost, and validation metadata; the public +task-class descriptions above are documentation, not cloud metadata. The cloud +dry run was inspected before the upload. + +## v1.4.0 release hardening + +One pre-release correction was added while preparing the v1.4.0 release +([#912](https://github.com/codemower-ai/code-mower/issues/912)). During #958 +recovery, a new work-order message advanced the expected round while the +provider was actively running and its API still returned the prior round's +structured output. Code Mower correctly rejected that stale completion and +cleared verified PR evidence, but the same response still projected +`session.state=complete`, which can make an orchestrator stop polling an active +fix round. `DevinWorkOrders` now returns a copied logical session projection of +`state: running`, `reason: result_not_ready`, `next_action: status` whenever a +persisted completion rejection exists and the remote projection reports +`complete`. The authoritative rejection block, the durable remote record, +shared `RemoteSessions` result precedence, and exact-round, issue, repository, +branch, author, PR, head-SHA, and base-branch verification are unchanged, and a +later valid exact-round collection clears the rejection and returns verified PR +evidence. ## Limitations diff --git a/docs/early-adopter-invite-runbook.md b/docs/early-adopter-invite-runbook.md index e611c986..e0ccdd17 100644 --- a/docs/early-adopter-invite-runbook.md +++ b/docs/early-adopter-invite-runbook.md @@ -38,7 +38,7 @@ It is an OSS local-first tool for setting up AI peer-programmer/reviewer lanes on your real codebase, with optional privacy-first cloud reporting. Start here: -https://github.com/codemower-ai/code-mower/blob/v1.3.1/docs/try-in-10-minutes.md +https://github.com/codemower-ai/code-mower/blob/v1.4.0/docs/try-in-10-minutes.md Cloud sharing is optional. The default bundle excludes source code, raw diffs, model transcripts, raw stdout/stderr, auth output, and secrets. @@ -53,7 +53,7 @@ Before inviting a user: ```bash python3.12 --version export CODE_MOWER_PYTHON="$(command -v python3.12)" - pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.3.1 + pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.4.0 code-mower --version ``` diff --git a/docs/early-adopter-v05.md b/docs/early-adopter-v05.md index 7a490b42..58ef7229 100644 --- a/docs/early-adopter-v05.md +++ b/docs/early-adopter-v05.md @@ -1,7 +1,7 @@ # Code Mower Early Adopter Guide This document records the historical v0.5 early-adopter product plan. The -current public install path is the v1.3.1 supervised-pilot release; use +current public install path is the v1.4.0 supervised-pilot release; use [Install And Bootstrap](install.md), [Try Code Mower In 10 Minutes](try-in-10-minutes.md), and [Quickstart](quickstart.md) for live adoption steps. diff --git a/docs/first-run-transcript.md b/docs/first-run-transcript.md index 3996d1c1..84962a7b 100644 --- a/docs/first-run-transcript.md +++ b/docs/first-run-transcript.md @@ -9,7 +9,7 @@ same provider warnings. ```bash python3.12 --version export CODE_MOWER_PYTHON="$(command -v python3.12)" -pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.3.1 +pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.4.0 code-mower --version ``` @@ -17,7 +17,7 @@ Expected shape: ```text Python 3.12.x -code-mower 1.3.1 +code-mower 1.4.0 ``` ## Generate Local Setup diff --git a/docs/first-user-demo-transcript.md b/docs/first-user-demo-transcript.md index 6b8161d7..8dbddd66 100644 --- a/docs/first-user-demo-transcript.md +++ b/docs/first-user-demo-transcript.md @@ -14,9 +14,9 @@ release. ```bash python3.12 -m venv "$WORK_DIR/venv" "$WORK_DIR/venv/bin/python" -m pip install --upgrade pip -"$WORK_DIR/venv/bin/python" -m pip install code-mower==1.3.1 +"$WORK_DIR/venv/bin/python" -m pip install code-mower==1.4.0 "$WORK_DIR/venv/bin/code-mower" migration package-install-rehearsal \ - --package-spec code-mower==1.3.1 \ + --package-spec code-mower==1.4.0 \ --allow-package-index \ --python "$(command -v python3.12)" \ --json @@ -29,7 +29,7 @@ python3.12 -m venv "$WORK_DIR/venv" "mode": "package-install-rehearsal", "status": "pass", "steps": 27, - "package_spec": "code-mower==1.3.1", + "package_spec": "code-mower==1.4.0", "toy_repo": "$WORK_DIR/toy-repo", "doctor_status": "warn", "generated_artifacts": { diff --git a/docs/first-user-install-rehearsal.md b/docs/first-user-install-rehearsal.md index 20597c53..14e227bc 100644 --- a/docs/first-user-install-rehearsal.md +++ b/docs/first-user-install-rehearsal.md @@ -48,7 +48,7 @@ Use the current public tag or release candidate: ```bash code-mower migration package-install-rehearsal \ - --package-spec code-mower==1.3.1 \ + --package-spec code-mower==1.4.0 \ --allow-package-index \ --python "$(command -v python3.12)" \ --json @@ -72,7 +72,7 @@ For a fixed output directory: ```bash code-mower migration package-install-rehearsal \ - --package-spec code-mower==1.3.1 \ + --package-spec code-mower==1.4.0 \ --allow-package-index \ --python "$(command -v python3.12)" \ --work-dir /tmp/code-mower-first-user-rehearsal \ @@ -101,7 +101,7 @@ For a GitHub tag fallback, pass the tag URL explicitly: ```bash code-mower migration package-install-rehearsal \ - --package-spec "git+https://github.com/codemower-ai/code-mower.git@v1.3.1" \ + --package-spec "git+https://github.com/codemower-ai/code-mower.git@v1.4.0" \ --python "$(command -v python3.12)" \ --json ``` @@ -113,14 +113,14 @@ deciding the package index or the release is broken. For pipx: ```bash export CODE_MOWER_PYTHON="$(command -v python3.12)" -PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" code-mower==1.3.1 +PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" code-mower==1.4.0 code-mower --version ``` For uv: ```bash -uv tool install --python 3.12 --reinstall --refresh-package code-mower code-mower==1.3.1 +uv tool install --python 3.12 --reinstall --refresh-package code-mower code-mower==1.4.0 code-mower --version ``` @@ -148,7 +148,7 @@ repository after the package install succeeds: ```bash code-mower migration package-install-rehearsal \ - --package-spec code-mower==1.3.1 \ + --package-spec code-mower==1.4.0 \ --allow-package-index \ --repo-path /path/to/external-repo \ --python "$(command -v python3.12)" \ @@ -240,7 +240,7 @@ When a product repository already has Code Mower wrapper files, the same ```bash code-mower migration package-install-rehearsal \ - --package-spec code-mower==1.3.1 \ + --package-spec code-mower==1.4.0 \ --allow-package-index \ --repo-path /path/to/product-repo \ --python "$(command -v python3.12)" \ @@ -303,7 +303,7 @@ If this fails, fix the first-user path before cutting or promoting a release. Publish and rehearse the package-index artifacts in this order. After the release tag exists at the release commit, dispatch both package-index publication runs with -`--ref v1.3.1`; never substitute mutable `main`, because the TestPyPI +`--ref v1.4.0`; never substitute mutable `main`, because the TestPyPI and production PyPI builds must check out identical source. First, run `release.yml` for TestPyPI only: @@ -311,7 +311,7 @@ First, run `release.yml` for TestPyPI only: ```bash gh workflow run release.yml \ --repo codemower-ai/code-mower \ - --ref v1.3.1 \ + --ref v1.4.0 \ -f publish_testpypi=true \ -f publish_pypi=false ``` @@ -321,7 +321,7 @@ candidate from TestPyPI: ```bash code-mower migration package-install-rehearsal \ - --package-spec code-mower==1.3.1 \ + --package-spec code-mower==1.4.0 \ --allow-package-index \ --upgrade-pip \ --pip-index-url https://test.pypi.org/simple/ \ @@ -336,7 +336,7 @@ Then run `release.yml` for production PyPI only: ```bash gh workflow run release.yml \ --repo codemower-ai/code-mower \ - --ref v1.3.1 \ + --ref v1.4.0 \ -f publish_testpypi=false \ -f publish_pypi=true ``` @@ -346,7 +346,7 @@ production package from PyPI: ```bash code-mower migration package-install-rehearsal \ - --package-spec code-mower==1.3.1 \ + --package-spec code-mower==1.4.0 \ --allow-package-index \ --upgrade-pip \ --python "$(command -v python3.12)" \ @@ -364,7 +364,7 @@ against a private external TypeScript product repository: ```bash code-mower migration package-install-rehearsal \ - --package-spec code-mower==1.3.1 \ + --package-spec code-mower==1.4.0 \ --allow-package-index \ --repo-path "$REPO_PATH" \ --work-dir "$WORK_DIR" \ diff --git a/docs/friendly-user-rollout-v05.md b/docs/friendly-user-rollout-v05.md index 5b83d4dd..690578d8 100644 --- a/docs/friendly-user-rollout-v05.md +++ b/docs/friendly-user-rollout-v05.md @@ -4,7 +4,7 @@ This is the operating plan for the first 5-10 friendly users before Code Mower widens to 20-50 early adopters. The filename is historical from the v0.5 planning pass. The live baseline in -this document is the current v1.3.1 supervised-pilot release. +this document is the current v1.4.0 supervised-pilot release. Code Mower is supervised-pilot, bring-your-own-agent-loop software for teams willing to calibrate reviewers. It is not a drop-in autonomous merge gate. @@ -28,10 +28,10 @@ out in the invite: ```bash python3.12 --version export CODE_MOWER_PYTHON="$(command -v python3.12)" -pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.3.1 +pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.4.0 ``` -The current package-index release baseline is `v1.3.1`, with pinned package install spec `code-mower==1.3.1`. Release evidence is recorded on the GitHub release and in the first-user install rehearsal. +The current package-index release baseline is `v1.4.0`, with pinned package install spec `code-mower==1.4.0`. Release evidence is recorded on the GitHub release and in the first-user install rehearsal. ## Invite Criteria diff --git a/docs/github-setup.md b/docs/github-setup.md index c4efa3db..0504777e 100644 --- a/docs/github-setup.md +++ b/docs/github-setup.md @@ -794,7 +794,7 @@ or bootstrap job should fail on warnings. ## Non-GitHub Systems -Code Mower 1.3.1 is GitHub-first. +Code Mower 1.4.0 is GitHub-first. GitLab is the best next source-control target because merge requests, discussions, labels, approval rules, pipelines, and API concepts map closely to diff --git a/docs/install.md b/docs/install.md index 058db667..5f2aef38 100644 --- a/docs/install.md +++ b/docs/install.md @@ -63,7 +63,7 @@ Install with pipx and an explicit Python 3.12+ interpreter: ```bash python3.12 --version export CODE_MOWER_PYTHON="$(command -v python3.12)" -pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.3.1 +pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.4.0 code-mower --version ``` @@ -84,7 +84,7 @@ To replace an existing pipx install with an exact release, use `--force` so the old venv cannot keep serving the previous package: ```bash -PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" code-mower==1.3.1 +PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" code-mower==1.4.0 code-mower --version ``` @@ -97,7 +97,7 @@ export PIPX_HOME="$CODE_MOWER_AGENT_TOOLS/pipx" export PIPX_BIN_DIR="$CODE_MOWER_AGENT_TOOLS/bin" export PIPX_LOG_DIR="$CODE_MOWER_AGENT_TOOLS/logs" mkdir -p "$PIPX_HOME" "$PIPX_BIN_DIR" "$PIPX_LOG_DIR" -PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" code-mower==1.3.1 +PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" code-mower==1.4.0 "$PIPX_BIN_DIR/code-mower" --version ``` @@ -108,7 +108,7 @@ interactive shell profile: ```bash uv python install 3.12 -uv tool install --python 3.12 code-mower==1.3.1 +uv tool install --python 3.12 code-mower==1.4.0 code-mower --version ``` @@ -118,7 +118,7 @@ installed command directly from the uv tool bin directory for that session. To replace an existing uv tool install with an exact release: ```bash -uv tool install --python 3.12 --reinstall --refresh-package code-mower code-mower==1.3.1 +uv tool install --python 3.12 --reinstall --refresh-package code-mower code-mower==1.4.0 code-mower --version ``` @@ -133,7 +133,7 @@ With pipx: ```bash PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" \ - 'code-mower[coworker]==1.3.1' + 'code-mower[coworker]==1.4.0' code-mower context --help ``` @@ -141,7 +141,7 @@ With uv: ```bash uv tool install --python 3.12 --reinstall --refresh-package code-mower \ - 'code-mower[coworker]==1.3.1' + 'code-mower[coworker]==1.4.0' code-mower context --help ``` @@ -169,7 +169,7 @@ command -v code-mower code-mower --version pipx uninstall code-mower uv python install 3.12 -uv tool install --python 3.12 --reinstall --refresh-package code-mower code-mower==1.3.1 +uv tool install --python 3.12 --reinstall --refresh-package code-mower code-mower==1.4.0 hash -r command -v code-mower code-mower --version @@ -189,7 +189,7 @@ For pipx: ```bash python3.12 --version export CODE_MOWER_PYTHON="$(command -v python3.12)" -PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" code-mower==1.3.1 +PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" code-mower==1.4.0 code-mower --version ``` @@ -197,7 +197,7 @@ For uv: ```bash uv python install 3.12 -uv tool install --python 3.12 --reinstall --refresh-package code-mower code-mower==1.3.1 +uv tool install --python 3.12 --reinstall --refresh-package code-mower code-mower==1.4.0 code-mower --version ``` diff --git a/docs/jira-cloud-setup.md b/docs/jira-cloud-setup.md index b7f20732..693aadf7 100644 --- a/docs/jira-cloud-setup.md +++ b/docs/jira-cloud-setup.md @@ -214,7 +214,7 @@ GitHub state only. Controller dry-run never dispatches, merges, or writes Jira. This shared session-brief contract, the Jira REST commands, and the double write guard described elsewhere in this guide are available in -`code-mower==1.3.1`. +`code-mower==1.4.0`. `code-mower session start` adds a `tracker` section to the operating brief whenever `tracker.kind` is `jira_cloud`. Codex, Claude, and every other diff --git a/docs/mirror-removal-runbook.md b/docs/mirror-removal-runbook.md index a6a823d5..9f981f2a 100644 --- a/docs/mirror-removal-runbook.md +++ b/docs/mirror-removal-runbook.md @@ -32,7 +32,7 @@ Run: ```bash code-mower migration package-install-rehearsal \ - --package-spec code-mower==1.3.1 \ + --package-spec code-mower==1.4.0 \ --allow-package-index \ --repo-path /path/to/product-repo \ --json diff --git a/docs/oss-v1-checklist.md b/docs/oss-v1-checklist.md index 95800c40..146ab97d 100644 --- a/docs/oss-v1-checklist.md +++ b/docs/oss-v1-checklist.md @@ -46,7 +46,7 @@ history opens the repository. They should be able to confirm: ## Current v1.0 Baseline -The current public-release baseline is `v1.3.1` of the standalone +The current public-release baseline is `v1.4.0` of the standalone package. Before widening the release, record: - non-editable package-install rehearsal in a clean venv; @@ -55,7 +55,7 @@ package. Before widening the release, record: runs: TestPyPI first with `publish_testpypi=true`, `publish_pypi=false`, then production PyPI with `publish_testpypi=false`, `publish_pypi=true`; - package-install rehearsals against the exact TestPyPI and PyPI artifacts as - `code-mower==1.3.1` after those workflow runs finish, with workflow run + `code-mower==1.4.0` after those workflow runs finish, with workflow run links recorded as release evidence; - public-tag/source install validation as a fallback path; - production dogfood uploads from Code Mower OSS, CodeMower.com, and two @@ -187,7 +187,7 @@ It has not yet proved: ```bash CODE_MOWER_PYTHON="$(command -v python3.12)" -pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.3.1 +pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.4.0 code-mower init --easy code-mower init --easy --apply --output-dir .code-mower.generated code-mower doctor --preflight @@ -197,7 +197,7 @@ code-mower --help-all code-mower next-steps --profile recommended code-mower migration wrapper-rehearsal --repo-path /path/to/product-repo --json code-mower migration package-install-rehearsal \ - --package-spec code-mower==1.3.1 \ + --package-spec code-mower==1.4.0 \ --allow-package-index \ --repo-path /path/to/repo \ --json diff --git a/docs/package-customization.md b/docs/package-customization.md index 2125f25e..dae006ee 100644 --- a/docs/package-customization.md +++ b/docs/package-customization.md @@ -285,7 +285,7 @@ run: ```bash code-mower migration package-install-rehearsal \ - --package-spec code-mower==1.3.1 \ + --package-spec code-mower==1.4.0 \ --allow-package-index \ --repo-path /path/to/product-repo \ --json diff --git a/docs/provider-matrix.md b/docs/provider-matrix.md index ea0894f3..a904c016 100644 --- a/docs/provider-matrix.md +++ b/docs/provider-matrix.md @@ -29,7 +29,7 @@ agent hosting a session is the default orchestrator. | Devin, Grok Bot, Antigravity, Muse, custom hosts | Recognized host identities for briefs and telemetry; execution remains an explicit handoff or provider-specific transport until separately qualified | No implicit Jira authority; use the guarded Code Mower tracker commands for writes | The lease, shared Jira brief, and explicit Cursor qualification are available -in `code-mower==1.3.1`; see [Participants And Sessions](sessions.md) for the +in `code-mower==1.4.0`; see [Participants And Sessions](sessions.md) for the operating contract. ## Provider Classes diff --git a/docs/public-release-checklist.md b/docs/public-release-checklist.md index 6d0c4789..ac176c75 100644 --- a/docs/public-release-checklist.md +++ b/docs/public-release-checklist.md @@ -11,11 +11,11 @@ not know the original reference repos. - Apache-2.0 `LICENSE` and `NOTICE` are present. - The package has public releases and reports its version with `code-mower --version`. -- The current package-index release entrypoint is `code-mower==1.3.1`, with +- The current package-index release entrypoint is `code-mower==1.4.0`, with `code-mower doctor --adoption --repo OWNER/REPO` as the human-facing first-run setup diagnostic and `code-mower lanes status --repo OWNER/REPO` as the operator snapshot. The corresponding GitHub tag is - `v1.3.1`; `doctor --preflight` and `doctor --v05` remain + `v1.4.0`; `doctor --preflight` and `doctor --v05` remain compatibility presets for scripts. - The current supervised-pilot release includes Python 3.12+ install hardening, hosted-builder doctor postures, non-expiring token diagnostics, native diff --git a/docs/pypi-release.md b/docs/pypi-release.md index 51d40e76..4f991e3d 100644 --- a/docs/pypi-release.md +++ b/docs/pypi-release.md @@ -6,7 +6,7 @@ TestPyPI or production PyPI through trusted publishing. ```bash CODE_MOWER_PYTHON="$(command -v python3.12)" -pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.3.1 +pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.4.0 ``` ## Current Status @@ -88,7 +88,7 @@ should be the `/releases/latest` result, and exact-version installs should resolve from PyPI. ```bash -gh release view v1.3.1 \ +gh release view v1.4.0 \ --repo codemower-ai/code-mower \ --json tagName,isPrerelease gh api repos/codemower-ai/code-mower/releases/latest \ @@ -139,7 +139,7 @@ For pipx: ```bash python3.12 --version export CODE_MOWER_PYTHON="$(command -v python3.12)" -PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" code-mower==1.3.1 +PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" code-mower==1.4.0 code-mower --version ``` @@ -147,7 +147,7 @@ For uv: ```bash uv python install 3.12 -uv tool install --python 3.12 --reinstall --refresh-package code-mower code-mower==1.3.1 +uv tool install --python 3.12 --reinstall --refresh-package code-mower code-mower==1.4.0 code-mower --version ``` @@ -176,7 +176,7 @@ For production PyPI verification: ```bash python3.12 -m venv /tmp/code-mower-pypi-smoke /tmp/code-mower-pypi-smoke/bin/python -m pip install --upgrade pip -/tmp/code-mower-pypi-smoke/bin/python -m pip install code-mower==1.3.1 +/tmp/code-mower-pypi-smoke/bin/python -m pip install code-mower==1.4.0 /tmp/code-mower-pypi-smoke/bin/code-mower --version ``` @@ -184,7 +184,7 @@ Then run the release-gate first-user rehearsal against the same package: ```bash code-mower migration package-install-rehearsal \ - --package-spec code-mower==1.3.1 \ + --package-spec code-mower==1.4.0 \ --allow-package-index \ --upgrade-pip \ --python "$(command -v python3.12)" \ @@ -206,8 +206,8 @@ on production PyPI -- see ```bash code-mower release qualify \ - --release-tag v1.3.1 \ - --package-spec code-mower==1.3.1 \ + --release-tag v1.4.0 \ + --package-spec code-mower==1.4.0 \ --output result.json \ --package-source testpypi \ --execute @@ -250,7 +250,7 @@ an agent, and the release rehearsal all install the same artifact: ```bash CODE_MOWER_PYTHON="$(command -v python3.12)" -pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.3.1 +pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.4.0 ``` An unpinned `pipx install code-mower` may be mentioned as a convenience only diff --git a/docs/quickstart.md b/docs/quickstart.md index 709b8b6a..cf0a2fcc 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -23,7 +23,7 @@ is the first-class isolated path: ```bash uv python install 3.12 -uv tool install --python 3.12 code-mower==1.3.1 +uv tool install --python 3.12 code-mower==1.4.0 code-mower --version ``` @@ -32,11 +32,11 @@ For a laptop or workstation that already uses pipx: ```bash python3.12 --version export CODE_MOWER_PYTHON="$(command -v python3.12)" -pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.3.1 +pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.4.0 code-mower --version ``` -`1.3.1` is the supervised-pilot release. If you want a future prerelease instead +`1.4.0` is the supervised-pilot release. If you want a future prerelease instead of this exact verified release, use: ```bash @@ -224,7 +224,7 @@ do not put them in repository configuration. ```bash PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" \ - 'code-mower[coworker]==1.3.1' + 'code-mower[coworker]==1.4.0' code-mower init --easy --context-connection example-context --dry-run code-mower init --easy --context-connection example-context --apply code-mower context connect coworker --connection example-context @@ -475,7 +475,7 @@ export bundle, upload dry run, and CodeMower.com dogfood dry run. ```bash code-mower migration package-install-rehearsal \ - --package-spec code-mower==1.3.1 \ + --package-spec code-mower==1.4.0 \ --allow-package-index \ --python "$(command -v python3.12)" \ --json diff --git a/docs/release-history.md b/docs/release-history.md index acdfaa03..8541cdb5 100644 --- a/docs/release-history.md +++ b/docs/release-history.md @@ -11,6 +11,7 @@ guidance; use [Install And Bootstrap](install.md) instead. ## Current Release Line +- [v1.4.0 release notes](v140-release-notes.md) - [v1.3.1 release notes](v131-release-notes.md) - [v1.3.1 guided context qualification](v131-guided-context-qualification.md) - [v1.3.0 release notes](v130-release-notes.md) diff --git a/docs/sessions.md b/docs/sessions.md index 5a4ec023..cfd4f797 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -6,7 +6,7 @@ your conversation is the default orchestrator for that session. The participant picker, host-led session brief, single-orchestrator lease, shared Jira tracker brief, controller host telemetry, and explicit Cursor -qualification documented below are available in `code-mower==1.3.1`. +qualification documented below are available in `code-mower==1.4.0`. Install from the matching tag when following release documentation, or use a contributor checkout when testing later source changes. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d2017eb6..9ff3b59b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -168,7 +168,7 @@ If pipx should own the command, reinstall the exact release with cache bypass: ```bash export CODE_MOWER_PYTHON="$(command -v python3.12)" -PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" code-mower==1.3.1 +PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" code-mower==1.4.0 hash -r code-mower --version ``` @@ -179,7 +179,7 @@ path: ```bash pipx uninstall code-mower -uv tool install --python 3.12 --reinstall --refresh-package code-mower code-mower==1.3.1 +uv tool install --python 3.12 --reinstall --refresh-package code-mower code-mower==1.4.0 hash -r command -v code-mower code-mower --version diff --git a/docs/try-in-10-minutes.md b/docs/try-in-10-minutes.md index 0243faf5..bfac4138 100644 --- a/docs/try-in-10-minutes.md +++ b/docs/try-in-10-minutes.md @@ -21,8 +21,8 @@ Use this install matrix: | Environment | Command shape | | --- | --- | -| Laptop/workstation | `pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.3.1` | -| Hosted agent, CI box, or minimal Linux VM | `uv tool install --python 3.12 code-mower==1.3.1` | +| Laptop/workstation | `pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.4.0` | +| Hosted agent, CI box, or minimal Linux VM | `uv tool install --python 3.12 code-mower==1.4.0` | | Code Mower contributor checkout | `scripts/dev-python -m venv .venv` then `.venv/bin/python -m pip install -e ".[test]"` | For a cold laptop install: @@ -30,7 +30,7 @@ For a cold laptop install: ```bash python3.12 --version export CODE_MOWER_PYTHON="$(command -v python3.12)" -pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.3.1 +pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.4.0 command -v code-mower code-mower --version ``` @@ -48,7 +48,7 @@ For a repository that already has generated Code Mower support, follow [Upgrade An Existing Repository](upgrade-existing-repo.md) before copying a new `.code-mower.generated` tree. -`1.3.1` is the supervised-pilot release. To follow a future prerelease line +`1.4.0` is the supervised-pilot release. To follow a future prerelease line instead of pinning this exact build: ```bash diff --git a/docs/v140-release-notes.md b/docs/v140-release-notes.md new file mode 100644 index 00000000..644a13bb --- /dev/null +++ b/docs/v140-release-notes.md @@ -0,0 +1,103 @@ +# Code Mower v1.4.0 Release Notes + +Code Mower v1.4.0 turns Devin into an optional peer participant with a durable +remote work-order lifecycle, adopts Graphify as a bounded optional local +repository-graph provider, and lands the Slack command and ingress foundation. +Claude Code + Codex remain the first-run default builders and reviewers, and +Devin, Coworker, Graphify, and Slack all stay opt-in. + +## Devin peer participant and lifecycle + +Devin can now be selected as an optional participant with one setup and +readiness path for both the local CLI and the hosted v3 API: + +```sh +code-mower doctor --easy --devin --json +``` + +The release adds a provider-neutral remote session lifecycle, a reusable Devin +v3 session client, trusted hosted work orders, and normalized local and hosted +review evidence. Work-order collection verifies the exact round, pull-request +number, linked issue, author, repository, branch, head SHA, and base branch +before it publishes evidence. Results survive process restarts and the merge +boundary, and a running session that already holds a current-round result is +recognized without weakening that verification. + +Devin's local review stays informational and Devin is not a qualified peer +orchestrator or merge-eligible reviewer in v1.4.0. Structured provider output, +prompts, transcripts, diffs, and credentials stay in protected local state and +are never returned through the work-order surface. + +### Release hardening: stale completions never look complete + +A provider can resume work while its API still returns the previous round's +structured output. Code Mower already rejected that stale completion, cleared +verified pull-request evidence, and required a fresh collection, but the same +response could still project `session.state=complete`, which can make an +orchestrator stop polling an active fix round. + +When a persisted completion rejection exists and the remote projection reports +`complete`, the returned logical session projection is now `state: running`, +`reason: result_not_ready`, `next_action: status`. The authoritative rejection +block is unchanged (`state: rejected`, a bounded reason such as +`stale_completion`, and `next_action: collect_after_provider_update`), the +durable remote record and shared result precedence are untouched, and a later +valid exact-round collection clears the rejection and returns verified +pull-request evidence. + +## Bounded optional Graphify provider + +Graphify is adopted as an optional, bounded local provider behind the existing +provider-neutral packet contract. The local repository graph is pinned to an +exact artifact digest, bound to a full commit and tree revision, kept in private +state outside every checkout, denied network access, and fails closed on stale, +partial, or unknown revisions. There is no default dependency, background +service, subscription, or mandatory indexing step, and synthetic fixtures prove +the extension point rather than Graphify compatibility. + +## Slack command and ingress foundation + +Slack has an authenticated, bounded ingress seam with a documented command and +identity contract: request signature verification, tenant and actor resolution, +durable idempotent receipt, and allowlisted redacted responses. Slack is a +foundation for later worker delivery, not a completed Slack integration: it is +not a builder, reviewer, participant, scheduler, or orchestrator, it carries no +repository or merge authority, and it delivers no worker results in v1.4.0. + +## Release hygiene + +The committed `code-mower-package-manifest.json` is a current package surface. +Its `package.version` now tracks the release, and both the release-hygiene suite +and `code-mower migration release-readiness` fail when it disagrees with +`pyproject.toml` or `src/code_mower/__init__.py`. + +## Install or upgrade + +```bash +CODE_MOWER_PYTHON="$(command -v python3.12)" +PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" \ + 'code-mower[coworker]==1.4.0' +code-mower --version +``` + +Expected output: `code-mower 1.4.0`. The base package remains usable without the +Coworker extra, a context connection, Devin, or Slack. + +The privacy boundary is unchanged. Cloud upload does not collect organization +context, identities, queries, source text, citations, findings, credentials, +packet fingerprints, provider prose, or Slack message content. + +## Post-merge release steps + +The orchestrator owns every step after this pull request merges: binding the +annotated `v1.4.0` tag to the exact release merge commit, the no-publish +`release.yml` rehearsal at `--ref v1.4.0`, TestPyPI publication and rehearsal, +production PyPI publication and rehearsal, SHA-256 comparison of the verified +workflow wheel and sdist against PyPI before they are attached to the GitHub +v1.4.0 Release, local installation and Devin readiness verification, the +published-package qualification campaign, Board restart and verification from an +exact v1.4.0 checkout, and the allowlisted CodeMower.com metadata upload. The +expected evidence is each immutable workflow run ID with its exact head, the +rehearsal JSON, the digest comparison, the campaign result per provider, the +Board inventory showing version `1.4.0`, and accepted cloud event identifiers +and counts without report prose. diff --git a/pyproject.toml b/pyproject.toml index 6e3624d5..9475eb57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "code-mower" -version = "1.3.1" +version = "1.4.0" description = "Multi-reviewer AI code audit orchestration" requires-python = ">=3.12" readme = "README.md" diff --git a/src/code_mower/__init__.py b/src/code_mower/__init__.py index 79dfa2a6..7917f448 100644 --- a/src/code_mower/__init__.py +++ b/src/code_mower/__init__.py @@ -1,3 +1,3 @@ """Code Mower package.""" -__version__ = "1.3.1" +__version__ = "1.4.0" diff --git a/src/code_mower/devin_work_orders.py b/src/code_mower/devin_work_orders.py index 2679db70..7fefe1bc 100644 --- a/src/code_mower/devin_work_orders.py +++ b/src/code_mower/devin_work_orders.py @@ -19,7 +19,7 @@ from .context_store import ContextStore from .devin_sessions import REPO, DevinClient from .provider_capabilities import resolve_transport -from .remote_session import DevinProvider, RemoteError, RemoteSessions +from .remote_session import DevinProvider, RemoteError, RemoteSessions, public_projection from .work_orders import WORK_ORDER_SCHEMA COMPLETION_SCHEMA = "code_mower.builderCompletion.v1" @@ -367,6 +367,22 @@ def _verify(self, order, claim, round_number): "pr_number": claim["pr_number"], "head_sha": claim["head_sha"], "author_id": order.author_id} + @staticmethod + def _session(result, record): + """Project a rejected completion as unfinished work, never as a finished session. + + The durable remote record and its result precedence are unchanged: a + current-round structured result may still arrive while the raw provider + reports running. Only the returned logical projection is copied, so a + persisted completion rejection cannot present a ``complete`` session + that stops an orchestrator from polling an active round. The + authoritative rejection block stays the exact-round verdict. + """ + if record.get("completion_rejection") is None or result.get("state") != "complete": + return result + return public_projection({**result, "state": "running", + "reason": "result_not_ready", "next_action": "status"}) + def run(self, command: str, order: WorkOrder, *, apply: bool = False, request: str = "", prose: str = "", reviewed_head: str = "", acknowledge_delivered: bool = False, @@ -523,7 +539,7 @@ def _run(self, command, order, *, apply, request, prose, reviewed_head, # Evidence is returned only on freshly verified collect, never status/dispatch. response = {"schema": EVIDENCE_SCHEMA, "builder": self.transport.product, "transport": "fake" if self.remote.provider.name == "fake" else self.transport.transport, - "session": result, "round": record["round"], + "session": self._session(result, record), "round": record["round"], "acu_limit": order.acu_limit, "observed_acu": record["observed_acu"], "verified_pr": record["evidence"] if command == "collect" else None, "context": {"policy": order.context_policy, diff --git a/src/code_mower/package_manifest.py b/src/code_mower/package_manifest.py index ff5490fb..14fc74e3 100644 --- a/src/code_mower/package_manifest.py +++ b/src/code_mower/package_manifest.py @@ -590,6 +590,7 @@ "docs/v131-guided-context-qualification.md", "doc", ), + ("docs/v140-release-notes.md", "docs/v140-release-notes.md", "doc"), ("docs/context-packet-schema.md", "docs/context-packet-schema.md", "doc"), ("docs/context-delivery.md", "docs/context-delivery.md", "doc"), ("tools/CODE_MOWER_CLOUD_BENCHMARKING.md", "docs/cloud-benchmarking.md", "doc"), diff --git a/src/code_mower/release_readiness.py b/src/code_mower/release_readiness.py index 5ed51ab3..4dce7ae4 100644 --- a/src/code_mower/release_readiness.py +++ b/src/code_mower/release_readiness.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import re import tempfile from pathlib import Path @@ -125,6 +126,17 @@ def _materialized_package_versions(repo_path: Path) -> dict[str, Any]: } +def _committed_manifest_version(repo_path: Path) -> str: + text = _read_text_if_exists(repo_path / "code-mower-package-manifest.json") + try: + manifest = json.loads(text) if text.strip() else {} + except json.JSONDecodeError: + return "" + package = manifest.get("package") if isinstance(manifest, dict) else None + version = package.get("version") if isinstance(package, dict) else None + return version if isinstance(version, str) else "" + + def _release_tag_for_version(version: str) -> str: return code_mower_versioning.release_tag_for_version(version) @@ -215,6 +227,7 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: } init_version = _python_package_version(repo_path) pyproject_version = _pyproject_version(repo_path) + manifest_version = _committed_manifest_version(repo_path) version = init_version or pyproject_version materialized_versions = _materialized_package_versions(repo_path) release_tag = _release_tag_for_version(version) if version else "" @@ -316,6 +329,29 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: "error": materialized_versions["error"], }, ), + _release_check( + check_id="committed-package-manifest-version", + title="Committed package manifest version agrees with source", + status=( + "pass" + if ( + manifest_version + and manifest_version == init_version + and manifest_version == pyproject_version + ) + else "fail" + ), + evidence=( + f"code-mower-package-manifest.json={manifest_version or 'missing'}, " + f"src/code_mower/__init__.py={init_version or 'missing'}, " + f"pyproject.toml={pyproject_version or 'missing'}" + ), + detail={ + "manifest_version": manifest_version, + "init_version": init_version, + "pyproject_version": pyproject_version, + }, + ), _release_check( check_id="release-workflow-present", title="Release workflow exists", diff --git a/src/code_mower/templates/workflows/builder-provenance.yml.j2 b/src/code_mower/templates/workflows/builder-provenance.yml.j2 index 813c2609..fb380bad 100644 --- a/src/code_mower/templates/workflows/builder-provenance.yml.j2 +++ b/src/code_mower/templates/workflows/builder-provenance.yml.j2 @@ -8,7 +8,7 @@ permissions: contents: read env: - CODE_MOWER_PACKAGE_SPEC: code-mower==1.3.1 + CODE_MOWER_PACKAGE_SPEC: code-mower==1.4.0 jobs: auto-record: diff --git a/templates/workflows/builder-provenance.yml.j2 b/templates/workflows/builder-provenance.yml.j2 index 813c2609..fb380bad 100644 --- a/templates/workflows/builder-provenance.yml.j2 +++ b/templates/workflows/builder-provenance.yml.j2 @@ -8,7 +8,7 @@ permissions: contents: read env: - CODE_MOWER_PACKAGE_SPEC: code-mower==1.3.1 + CODE_MOWER_PACKAGE_SPEC: code-mower==1.4.0 jobs: auto-record: diff --git a/tests/test_devin_work_orders.py b/tests/test_devin_work_orders.py index 6f0f45a4..840cee8d 100644 --- a/tests/test_devin_work_orders.py +++ b/tests/test_devin_work_orders.py @@ -346,6 +346,58 @@ def test_malformed_and_pr_binding_rejections_allow_safe_recollection(self): self.assertEqual(result["verified_pr"]["head_sha"], HEAD) self.assertNotIn("completion", result) + def assert_rejected_projection(self, response, reason): + self.assertEqual(response["session"]["state"], "running") + self.assertEqual(response["session"]["reason"], "result_not_ready") + self.assertEqual(response["session"]["next_action"], "status") + self.assertEqual(response["completion"], { + "state": "rejected", "reason": reason, + "next_action": "collect_after_provider_update", + }) + with self.remote.store.locked(_key(self.key)) as locked: + self.assertEqual(locked.read()["state"], "complete") + + def test_rejected_completion_is_projected_as_running_work(self): + self.run_order("dispatch") + self.run_order("clarify", request="clarification", prose=CANARY) + self.complete(round=0) + with self.assertRaisesRegex(RemoteError, "^stale_completion"): + self.run_order("collect") + self.assert_rejected_projection(self.run_order("status"), "stale_completion") + with self.assertRaisesRegex(RemoteError, "^stale_completion"): + self.run_order("collect") + self.assert_rejected_projection(self.run_order("status"), "stale_completion") + + replacement_head = "b" * 40 + self.github.pr = replace(self.github.pr, head_sha=replacement_head) + self.complete(round=1, head_sha=replacement_head) + verified = self.run_order("collect") + self.assertEqual(verified["verified_pr"]["head_sha"], replacement_head) + self.assertNotIn("completion", verified) + self.assertEqual(verified["session"]["state"], "complete") + self.assertEqual(self.run_order("status")["session"]["state"], "complete") + + def test_malformed_and_pr_binding_rejections_are_projected_as_running_work(self): + self.run_order("dispatch") + self.provider.set_state(self.binding(), "complete", result={"raw": CANARY}) + with self.assertRaisesRegex(RemoteError, "^invalid_completion$"): + self.run_order("collect") + self.assert_rejected_projection(self.run_order("status"), "invalid_completion") + self.assertNotIn(CANARY, json.dumps(self.run_order("status"))) + + self.complete() + original = self.github.pr + self.github.pr = replace(original, head_sha="b" * 40) + with self.assertRaisesRegex(RemoteError, "pull_request_binding"): + self.run_order("collect") + self.assert_rejected_projection(self.run_order("status"), "invalid_completion") + + self.github.pr = original + verified = self.run_order("collect") + self.assertEqual(verified["verified_pr"]["head_sha"], HEAD) + self.assertEqual(verified["session"]["state"], "complete") + self.assertNotIn("completion", verified) + def test_transient_github_failures_preserve_the_collected_result(self): self.run_order("dispatch") self.complete() diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py index c55240f6..42b32a4d 100644 --- a/tests/test_release_hygiene.py +++ b/tests/test_release_hygiene.py @@ -65,7 +65,7 @@ class ReleaseHygieneTests(unittest.TestCase): def test_version_is_current_supervised_pilot_release(self) -> None: - self.assertEqual(__version__, "1.3.1") + self.assertEqual(__version__, "1.4.0") def test_dogfood_repo_has_real_root_config(self) -> None: config_path = ROOT / "code-mower.yml" @@ -216,7 +216,7 @@ def test_install_and_upgrade_docs_cover_agent_paths(self) -> None: self.assertIn("Cold Install Vs Upgrade", install) self.assertIn("Switching Between pipx And uv", install) self.assertIn("uv tool install --python 3.12 --reinstall --refresh-package", install) - self.assertIn("code-mower==1.3.1", troubleshooting) + self.assertIn("code-mower==1.4.0", troubleshooting) self.assertNotIn("code-mower==0.8.0b1", troubleshooting) self.assertIn("pipx uninstall code-mower", install) for env_name in ("PIPX_HOME", "PIPX_BIN_DIR", "PIPX_LOG_DIR"): @@ -1101,7 +1101,7 @@ def test_direct_cli_execution_points_to_package_or_dev_wrapper(self) -> None: ) self.assertNotEqual(completed.returncode, 0) - self.assertIn("pipx install code-mower==1.3.1", completed.stderr) + self.assertIn("pipx install code-mower==1.4.0", completed.stderr) self.assertIn("scripts/dev-python -m venv .venv", completed.stderr) self.assertIn(".venv/bin/code-mower", completed.stderr) self.assertNotIn("PYTHONPATH=src", completed.stderr) @@ -3261,10 +3261,10 @@ def test_setup_drift_reports_standalone_pin_states(self) -> None: cases = ( ( - 'CODE_MOWER_STANDALONE_REF="v1.3.1"\n', + 'CODE_MOWER_STANDALONE_REF="v1.4.0"\n', "pass", "matches_running_package", - "v1.3.1", + "v1.4.0", ), ( 'CODE_MOWER_STANDALONE_REF="v0.9.1-beta.1"\n', @@ -3295,12 +3295,12 @@ def test_setup_drift_reports_standalone_pin_states(self) -> None: payload = migration._standalone_pin_drift_summary( repo, - package_version="1.3.1", + package_version="1.4.0", ) self.assertEqual(payload["status"], status) self.assertEqual(payload["reason"], reason) - self.assertEqual(payload["expected_ref"], "v1.3.1") + self.assertEqual(payload["expected_ref"], "v1.4.0") if current_ref is None: self.assertNotIn("current_ref", payload) else: @@ -3321,7 +3321,7 @@ def test_setup_drift_warns_on_non_utf8_standalone_pin(self) -> None: payload = migration._standalone_pin_drift_summary( repo, - package_version="1.3.1", + package_version="1.4.0", ) self.assertEqual(payload["status"], "warn") @@ -3384,7 +3384,7 @@ def test_setup_drift_text_prints_absent_standalone_pin_line(self) -> None: "standalone_pin": { "status": "skip", "reason": "pin_file_absent", - "expected_ref": "v1.3.1", + "expected_ref": "v1.4.0", }, "builder_hint": { "status": "skip", @@ -3396,7 +3396,7 @@ def test_setup_drift_text_prints_absent_standalone_pin_line(self) -> None: rendered = migration.render_setup_drift_text(payload) - self.assertIn("Standalone pin: SKIP pin_file_absent expected=v1.3.1", rendered) + self.assertIn("Standalone pin: SKIP pin_file_absent expected=v1.4.0", rendered) def test_setup_drift_reports_builder_hint_when_builders_omitted(self) -> None: from code_mower import migration @@ -5265,6 +5265,15 @@ def test_package_manifest_has_no_local_output_path(self) -> None: ) self.assertEqual(manifest["output_dir"], "") + def test_package_manifest_version_matches_source_version(self) -> None: + manifest = json.loads( + (ROOT / "code-mower-package-manifest.json").read_text(encoding="utf-8") + ) + pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + + self.assertEqual(manifest["package"]["version"], __version__) + self.assertIn(f'version = "{__version__}"', pyproject) + def test_package_manifest_excludes_generated_cache_artifacts(self) -> None: forbidden_fragments = ( "__pycache__", @@ -5585,11 +5594,11 @@ def test_package_materializer_can_run_from_extracted_checkout(self) -> None: (output_dir / "src/code_mower/cloud_client/dogfood.py").is_file() ) self.assertIn( - 'version = "1.3.1"', + 'version = "1.4.0"', (output_dir / "pyproject.toml").read_text(encoding="utf-8"), ) self.assertIn( - '__version__ = "1.3.1"', + '__version__ = "1.4.0"', (output_dir / "src/code_mower/__init__.py").read_text( encoding="utf-8" ), @@ -7594,7 +7603,7 @@ def _run_mocked_pip_install( "pip", "install", "--no-cache-dir", - "code-mower==1.3.1", + "code-mower==1.4.0", ] def fake_run_step(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]: @@ -7661,7 +7670,7 @@ def test_package_install_rehearsal_classifies_package_index_specs(self) -> None: self.assertTrue(code_mower_migration._package_spec_uses_package_index("code-mower")) self.assertTrue( code_mower_migration._package_spec_uses_package_index( - "code-mower==1.3.1" + "code-mower==1.4.0" ) ) self.assertFalse(code_mower_migration._package_spec_uses_package_index(".")) @@ -7670,7 +7679,7 @@ def test_package_install_rehearsal_classifies_package_index_specs(self) -> None: ) self.assertFalse( code_mower_migration._package_spec_uses_package_index( - "git+https://github.com/codemower-ai/code-mower.git@v1.3.1" + "git+https://github.com/codemower-ai/code-mower.git@v1.4.0" ) ) @@ -7680,7 +7689,7 @@ def test_package_install_rehearsal_requires_package_index_opt_in(self) -> None: with self.assertRaisesRegex(ValueError, "--allow-package-index"): code_mower_migration.run_package_install_rehearsal( - package_spec="code-mower==1.3.1", + package_spec="code-mower==1.4.0", work_dir=work_dir, ) @@ -7708,7 +7717,7 @@ def test_package_install_rehearsal_cli_passes_network_opt_ins(self) -> None: [ "package-install-rehearsal", "--package-spec", - "code-mower==1.3.1", + "code-mower==1.4.0", "--allow-package-index", "--upgrade-pip", "--pip-no-cache", @@ -7764,16 +7773,19 @@ def test_release_readiness_reports_package_index_promotion_gate(self) -> None: payload = release_readiness.render_release_readiness(ROOT) self.assertEqual(payload["status"], "pass") - self.assertEqual(payload["version"], "1.3.1") - self.assertEqual(payload["release_tag"], "v1.3.1") - self.assertEqual(payload["alpha_tag"], "v1.3.1") - self.assertEqual(payload["package_index_spec"], "code-mower==1.3.1") + self.assertEqual(payload["version"], "1.4.0") + self.assertEqual(payload["release_tag"], "v1.4.0") + self.assertEqual(payload["alpha_tag"], "v1.4.0") + self.assertEqual(payload["package_index_spec"], "code-mower==1.4.0") check_ids = {check["id"]: check for check in payload["checks"]} self.assertEqual(check_ids["package-version-consistency"]["status"], "pass") self.assertEqual( check_ids["materialized-package-version-consistency"]["status"], "pass", ) + manifest_check = check_ids["committed-package-manifest-version"] + self.assertEqual(manifest_check["status"], "pass") + self.assertEqual(manifest_check["detail"]["manifest_version"], "1.4.0") self.assertEqual(check_ids["testpypi-gate"]["status"], "pass") self.assertEqual(check_ids["pypi-gate"]["status"], "pass") self.assertEqual(check_ids["trusted-publishing-runbook"]["status"], "pass") @@ -7783,9 +7795,9 @@ def test_release_readiness_reports_package_index_promotion_gate(self) -> None: self.assertEqual(check_ids["public-support-redaction-guidance"]["status"], "pass") commands = {action["id"]: action["command"] for action in payload["next_actions"]} urls = {action["id"]: action.get("url", "") for action in payload["next_actions"]} - self.assertIn("--ref v1.3.1", commands["dry-run-release-workflow"]) + self.assertIn("--ref v1.4.0", commands["dry-run-release-workflow"]) self.assertNotIn("--ref main", commands["dry-run-release-workflow"]) - self.assertIn("--ref v1.3.1", commands["publish-testpypi-candidate"]) + self.assertIn("--ref v1.4.0", commands["publish-testpypi-candidate"]) self.assertNotIn("--ref main", commands["publish-testpypi-candidate"]) self.assertIn("publish_testpypi=true", commands["publish-testpypi-candidate"]) self.assertIn("publish_pypi=false", commands["publish-testpypi-candidate"]) @@ -7822,9 +7834,24 @@ def test_release_readiness_fails_on_materialized_package_version_drift( check_ids = {check["id"]: check for check in payload["checks"]} check = check_ids["materialized-package-version-consistency"] self.assertEqual(check["status"], "fail") - self.assertEqual(check["detail"]["source_version"], "1.3.1") + self.assertEqual(check["detail"]["source_version"], "1.4.0") self.assertEqual(check["detail"]["generated_init_version"], "0.0.0") + def test_release_readiness_fails_on_committed_manifest_version_drift(self) -> None: + with mock.patch.object( + release_readiness, + "_committed_manifest_version", + return_value="0.5.0b53", + ): + payload = release_readiness.render_release_readiness(ROOT) + + check_ids = {check["id"]: check for check in payload["checks"]} + check = check_ids["committed-package-manifest-version"] + self.assertEqual(payload["status"], "fail") + self.assertEqual(check["status"], "fail") + self.assertEqual(check["detail"]["manifest_version"], "0.5.0b53") + self.assertEqual(check["detail"]["init_version"], "1.4.0") + def test_public_support_docs_are_packaged_and_privacy_forward(self) -> None: manifest = (ROOT / "MANIFEST.in").read_text(encoding="utf-8") readme = (ROOT / "README.md").read_text(encoding="utf-8") @@ -7926,20 +7953,20 @@ def test_release_readiness_tag_derivation_supports_release_stages(self) -> None: "v1.0.0-rc.1", ) self.assertEqual( - release_readiness._release_tag_for_version("1.3.1"), - "v1.3.1", + release_readiness._release_tag_for_version("1.4.0"), + "v1.4.0", ) self.assertEqual( - code_mower_versioning.release_tag_for_version("1.3.1"), - "v1.3.1", + code_mower_versioning.release_tag_for_version("1.4.0"), + "v1.4.0", ) def test_public_release_baseline_helpers_derive_announcement_links(self) -> None: self.assertEqual( code_mower_versioning.public_baseline_sentence(__version__), ( - "The current package-index release baseline is `v1.3.1`, " - "with pinned package install spec `code-mower==1.3.1`. " + "The current package-index release baseline is `v1.4.0`, " + "with pinned package install spec `code-mower==1.4.0`. " "Release evidence is recorded on the GitHub release and in the " "first-user install rehearsal." ), @@ -7948,7 +7975,7 @@ def test_public_release_baseline_helpers_derive_announcement_links(self) -> None code_mower_versioning.tagged_doc_url(__version__), ( "https://github.com/codemower-ai/code-mower/blob/" - "v1.3.1/docs/try-in-10-minutes.md" + "v1.4.0/docs/try-in-10-minutes.md" ), ) @@ -8001,12 +8028,12 @@ def test_public_docs_match_current_commands_and_privacy_boundary(self) -> None: readme = (ROOT / "README.md").read_text(encoding="utf-8") sessions = (ROOT / "docs" / "sessions.md").read_text(encoding="utf-8") - release_notes = (ROOT / "docs" / "v131-release-notes.md").read_text( + release_notes = (ROOT / "docs" / "v140-release-notes.md").read_text( encoding="utf-8" ) self.assertIn("Documentation on `main` follows the source on `main`", readme) - self.assertIn("available in `code-mower==1.3.1`", sessions) - self.assertIn("# Code Mower v1.3.1 Release Notes", release_notes) + self.assertIn("available in `code-mower==1.4.0`", sessions) + self.assertIn("# Code Mower v1.4.0 Release Notes", release_notes) self.assertIn("The privacy boundary is unchanged.", release_notes) release_history = (ROOT / "docs" / "release-history.md").read_text( encoding="utf-8" @@ -8028,8 +8055,8 @@ def test_public_docs_match_current_commands_and_privacy_boundary(self) -> None: release_history.index("[v1.2.0 release notes](v12-release-notes.md)"), ) self.assertLess( + release_history.index("[v1.4.0 release notes](v140-release-notes.md)"), release_history.index("[v1.3.1 release notes](v131-release-notes.md)"), - release_history.index("[v1.3.0 release notes](v130-release-notes.md)"), ) def test_current_release_docs_record_package_index_procedure(self) -> None: @@ -8052,15 +8079,15 @@ def test_current_release_docs_record_package_index_procedure(self) -> None: ) current_status = ( - "The current package-index release baseline is `v1.3.1`, with " - "pinned package install spec `code-mower==1.3.1`. Release " + "The current package-index release baseline is `v1.4.0`, with " + "pinned package install spec `code-mower==1.4.0`. Release " "evidence is recorded on the GitHub release and in the first-user " "install rehearsal." ) for text in (readme, current_state, rollout): self.assertIn(current_status, " ".join(text.split())) self.assertIn( - "The current package-index release entrypoint is `code-mower==1.3.1`", + "The current package-index release entrypoint is `code-mower==1.4.0`", public_release, ) self.assertIn("The current supervised-pilot release includes", public_release) @@ -8070,7 +8097,7 @@ def test_current_release_docs_record_package_index_procedure(self) -> None: ) self.assertIn( - "The current public-release baseline is `v1.3.1`", + "The current public-release baseline is `v1.4.0`", oss_checklist, ) self.assertIn( @@ -8101,7 +8128,7 @@ def test_current_release_docs_record_package_index_procedure(self) -> None: " ".join(first_user.split()), ) self.assertIn("never substitute mutable `main`", first_user) - self.assertEqual(first_user.count(" --ref v1.3.1 \\"), 2) + self.assertEqual(first_user.count(" --ref v1.4.0 \\"), 2) self.assertNotIn(" --ref main \\", first_user) self.assertIn("-f publish_testpypi=true", first_user) self.assertIn("-f publish_pypi=false", first_user) @@ -8113,23 +8140,23 @@ def test_current_release_docs_record_package_index_procedure(self) -> None: first_user, ) self.assertNotIn( - "The latest public-package rehearsal for `v1.3.1` was run", + "The latest public-package rehearsal for `v1.4.0` was run", first_user, ) - self.assertNotIn("TestPyPI is not\npublished for `1.3.1`", first_user) + self.assertNotIn("TestPyPI is not\npublished for `1.4.0`", first_user) for text in (readme, current_state, rollout): self.assertNotIn( - "published on PyPI as `code-mower==1.3.1`", + "published on PyPI as `code-mower==1.4.0`", text, ) self.assertNotIn( "latest 10/10 public-package readiness proof for " - "`code-mower==1.3.1`", + "`code-mower==1.4.0`", text, ) self.assertNotIn( "public PyPI package-install rehearsal from " - "`code-mower==1.3.1`", + "`code-mower==1.4.0`", text, ) self.assertNotIn("beta.52: 10/10 first-user readiness", text) @@ -8434,7 +8461,7 @@ def test_install_docs_cover_supported_adoption_paths(self) -> None: self.assertIn("Python 3.12 or newer", install) self.assertIn('pipx install --python "$CODE_MOWER_PYTHON"', install) - self.assertIn("uv tool install --python 3.12 code-mower==1.3.1", install) + self.assertIn("uv tool install --python 3.12 code-mower==1.4.0", install) self.assertIn( 'PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON"', install, @@ -8521,7 +8548,7 @@ def test_active_docs_use_current_release_language(self) -> None: "docs/provider-matrix.md": ROOT / "docs" / "provider-matrix.md", } stale_phrases = ( - "pipx install --python python3.12 code-mower==1.3.1", + "pipx install --python python3.12 code-mower==1.4.0", "Use only after TestPyPI passes.", "future newest betas until 1.0", "during v0.7 adoption", @@ -8817,7 +8844,7 @@ def test_next_steps_includes_cloud_upload_dry_run_after_export(self) -> None: "doctor --adoption --repo codemower-ai/code-mower", doctor_step["command"], ) - self.assertIn("code-mower==1.3.1", package_step["command"]) + self.assertIn("code-mower==1.4.0", package_step["command"]) self.assertIn("--allow-package-index", package_step["command"]) self.assertIn("current published PyPI package", package_step["why"]) self.assertIn("first_user_readiness", package_step["why"]) From b54f9d0d695dc6861938f5637f41502808395cfc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:28:24 +0000 Subject: [PATCH 02/20] Persist the completion rejection before discarding the rejected result Co-Authored-By: bot_apk --- src/code_mower/devin_work_orders.py | 13 +++++++++---- tests/test_devin_work_orders.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/code_mower/devin_work_orders.py b/src/code_mower/devin_work_orders.py index 7fefe1bc..cccae89f 100644 --- a/src/code_mower/devin_work_orders.py +++ b/src/code_mower/devin_work_orders.py @@ -514,20 +514,25 @@ def _run(self, command, order, *, apply, request, prose, reviewed_head, "pull_request_binding_mismatch", }: raise - # Verification consumes no provider mutation. Release only - # this compare-bound local artifact/count so a corrected - # provider result can be collected on the next attempt. - self.remote.discard_private_result(key, claim) reason = ( "stale_completion" if error == "stale_completion" else "invalid_completion" ) + # Persist the bounded rejection before releasing the + # rejected result. An interruption after the release must + # not leave the provider's unchanged completion + # projecting a finished session with no rejection. record["completion_rejection"] = { "state": "rejected", "reason": reason, "next_action": "collect_after_provider_update", } locked.write(record) + # Verification consumes no provider mutation. Release only + # this compare-bound local artifact/count so a corrected + # provider result can be collected on the next attempt; + # a later collect repeats cleanup and verification. + self.remote.discard_private_result(key, claim) raise acu = self.remote.observed_acu(key) if acu is not None and (type(acu) not in (int, float) diff --git a/tests/test_devin_work_orders.py b/tests/test_devin_work_orders.py index 840cee8d..3b521d1f 100644 --- a/tests/test_devin_work_orders.py +++ b/tests/test_devin_work_orders.py @@ -377,6 +377,35 @@ def test_rejected_completion_is_projected_as_running_work(self): self.assertEqual(verified["session"]["state"], "complete") self.assertEqual(self.run_order("status")["session"]["state"], "complete") + def test_interruption_after_the_rejected_result_discard_still_hides_completion(self): + self.run_order("dispatch") + self.run_order("clarify", request="clarification", prose=CANARY) + self.complete(round=0) + discard = self.remote.discard_private_result + + def interrupted(session, expected): + discard(session, expected) + raise KeyboardInterrupt("stopped after the compare-bound discard") + + with patch.object(self.remote, "discard_private_result", interrupted): + with self.assertRaises(KeyboardInterrupt): + self.run_order("collect") + + self.service = DevinWorkOrders(self.root / "builder", self.remote, self.github) + self.assertIsNone(self.remote.private_result(self.key)) + self.assert_rejected_projection(self.run_order("status"), "stale_completion") + with self.assertRaisesRegex(RemoteError, "^stale_completion"): + self.run_order("collect") + self.assert_rejected_projection(self.run_order("status"), "stale_completion") + + replacement_head = "b" * 40 + self.github.pr = replace(self.github.pr, head_sha=replacement_head) + self.complete(round=1, head_sha=replacement_head) + verified = self.run_order("collect") + self.assertEqual(verified["verified_pr"]["head_sha"], replacement_head) + self.assertNotIn("completion", verified) + self.assertEqual(verified["session"]["state"], "complete") + def test_malformed_and_pr_binding_rejections_are_projected_as_running_work(self): self.run_order("dispatch") self.provider.set_state(self.binding(), "complete", result={"raw": CANARY}) From 4fb4398d3aa10bc157f37a3e963fefb76c574a9f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:45:06 +0000 Subject: [PATCH 03/20] Regenerate the package manifest and publish the ordered v1.4.0 runbook Co-Authored-By: bot_apk --- code-mower-package-manifest.json | 1763 +++++++++++++++++---------- docs/pypi-release.md | 228 ++++ docs/v140-release-notes.md | 2 + src/code_mower/package.py | 58 + src/code_mower/package_manifest.py | 1 + src/code_mower/release_readiness.py | 211 +++- tests/test_release_hygiene.py | 132 ++ 7 files changed, 1741 insertions(+), 654 deletions(-) diff --git a/code-mower-package-manifest.json b/code-mower-package-manifest.json index 5d2b3f7b..68d4164e 100644 --- a/code-mower-package-manifest.json +++ b/code-mower-package-manifest.json @@ -7,81 +7,376 @@ } ], "files_written": [ + { + "kind": "package", + "source": "generated", + "target": ".github/workflows/ci.yml" + }, + { + "kind": "package", + "source": "generated", + "target": ".gitignore" + }, { "kind": "package", "source": "LICENSE", "target": "LICENSE" }, + { + "kind": "package", + "source": "generated", + "target": "MANIFEST.in" + }, { "kind": "package", "source": "NOTICE", "target": "NOTICE" }, + { + "kind": "package", + "source": "generated", + "target": "README.md" + }, + { + "kind": "manifest", + "source": "generated", + "target": "code-mower-package-manifest.json" + }, + { + "kind": "doc", + "source": "docs/acp-bridge-spike.md", + "target": "docs/acp-bridge-spike.md" + }, + { + "kind": "doc", + "source": "docs/authoring-intelligence.md", + "target": "docs/authoring-intelligence.md" + }, + { + "kind": "doc", + "source": "docs/build-loop.md", + "target": "docs/build-loop.md" + }, + { + "kind": "doc", + "source": "docs/builder-experiments.md", + "target": "docs/builder-experiments.md" + }, + { + "kind": "doc", + "source": "docs/builders-grok-cursor.md", + "target": "docs/builders-grok-cursor.md" + }, + { + "kind": "doc", + "source": "docs/calibration-notes.md", + "target": "docs/calibration-notes.md" + }, + { + "kind": "doc", + "source": "docs/calibration-pilot.md", + "target": "docs/calibration-pilot.md" + }, + { + "kind": "doc", + "source": "docs/cloud-benchmarking.md", + "target": "docs/cloud-benchmarking.md" + }, + { + "kind": "doc", + "source": "docs/commercial-boundary.md", + "target": "docs/commercial-boundary.md" + }, + { + "kind": "doc", + "source": "docs/context-connections.md", + "target": "docs/context-connections.md" + }, + { + "kind": "doc", + "source": "docs/context-delivery.md", + "target": "docs/context-delivery.md" + }, + { + "kind": "doc", + "source": "docs/context-graph-lifecycle.md", + "target": "docs/context-graph-lifecycle.md" + }, + { + "kind": "doc", + "source": "docs/context-packet-schema.md", + "target": "docs/context-packet-schema.md" + }, + { + "kind": "doc", + "source": "docs/context-provider-contract.md", + "target": "docs/context-provider-contract.md" + }, + { + "kind": "doc", + "source": "docs/context-setup.md", + "target": "docs/context-setup.md" + }, + { + "kind": "doc", + "source": "docs/devin-peer-support-qualification.md", + "target": "docs/devin-peer-support-qualification.md" + }, + { + "kind": "doc", + "source": "docs/devin-review-parity.md", + "target": "docs/devin-review-parity.md" + }, + { + "kind": "doc", + "source": "docs/devin-work-orders.md", + "target": "docs/devin-work-orders.md" + }, + { + "kind": "doc", + "source": "docs/github-setup.md", + "target": "docs/github-setup.md" + }, + { + "kind": "doc", + "source": "docs/graphify-evaluation.md", + "target": "docs/graphify-evaluation.md" + }, + { + "kind": "doc", + "source": "docs/lane-promotion-policy.md", + "target": "docs/lane-promotion-policy.md" + }, + { + "kind": "doc", + "source": "docs/lens-calibration-report.md", + "target": "docs/lens-calibration-report.md" + }, + { + "kind": "doc", + "source": "docs/local-audit-runner.md", + "target": "docs/local-audit-runner.md" + }, + { + "kind": "doc", + "source": "docs/mirror-removal-runbook.md", + "target": "docs/mirror-removal-runbook.md" + }, + { + "kind": "doc", + "source": "docs/oss-v1-checklist.md", + "target": "docs/oss-v1-checklist.md" + }, + { + "kind": "doc", + "source": "docs/package-customization.md", + "target": "docs/package-customization.md" + }, + { + "kind": "doc", + "source": "docs/planning-work-orders.md", + "target": "docs/planning-work-orders.md" + }, + { + "kind": "doc", + "source": "docs/provider-matrix.md", + "target": "docs/provider-matrix.md" + }, + { + "kind": "doc", + "source": "docs/public-release-checklist.md", + "target": "docs/public-release-checklist.md" + }, + { + "kind": "doc", + "source": "docs/release-qualification.md", + "target": "docs/release-qualification.md" + }, + { + "kind": "doc", + "source": "docs/repo-strategy.md", + "target": "docs/repo-strategy.md" + }, + { + "kind": "doc", + "source": "docs/reviewer-value-report.md", + "target": "docs/reviewer-value-report.md" + }, + { + "kind": "doc", + "source": "docs/slack-contract.md", + "target": "docs/slack-contract.md" + }, + { + "kind": "doc", + "source": "docs/slack-ingress.md", + "target": "docs/slack-ingress.md" + }, + { + "kind": "doc", + "source": "docs/troubleshooting.md", + "target": "docs/troubleshooting.md" + }, + { + "kind": "doc", + "source": "docs/v130-context-qualification.md", + "target": "docs/v130-context-qualification.md" + }, + { + "kind": "doc", + "source": "docs/v130-release-notes.md", + "target": "docs/v130-release-notes.md" + }, + { + "kind": "doc", + "source": "docs/v131-guided-context-qualification.md", + "target": "docs/v131-guided-context-qualification.md" + }, + { + "kind": "doc", + "source": "docs/v131-release-notes.md", + "target": "docs/v131-release-notes.md" + }, + { + "kind": "doc", + "source": "docs/v140-release-notes.md", + "target": "docs/v140-release-notes.md" + }, + { + "kind": "package", + "source": "generated", + "target": "pyproject.toml" + }, + { + "kind": "tooling", + "source": "requirements/requirements.txt", + "target": "requirements/requirements.txt" + }, + { + "kind": "package", + "source": "generated", + "target": "scripts/fresh_clone_rehearsal.py" + }, + { + "kind": "package", + "source": "generated", + "target": "scripts/smoke_easy_mode.py" + }, + { + "kind": "package", + "source": "generated", + "target": "src/code_mower/__init__.py" + }, + { + "kind": "adapter", + "source": "src/code_mower/adapters/__init__.py", + "target": "src/code_mower/adapters/__init__.py" + }, + { + "kind": "adapter", + "source": "src/code_mower/adapters/_base.py", + "target": "src/code_mower/adapters/_base.py" + }, + { + "kind": "adapter", + "source": "src/code_mower/adapters/cursor_bugbot.py", + "target": "src/code_mower/adapters/cursor_bugbot.py" + }, + { + "kind": "adapter", + "source": "src/code_mower/adapters/gitar.py", + "target": "src/code_mower/adapters/gitar.py" + }, + { + "kind": "adapter", + "source": "src/code_mower/adapters/greptile.py", + "target": "src/code_mower/adapters/greptile.py" + }, + { + "kind": "adapter", + "source": "src/code_mower/adapters/qodo.py", + "target": "src/code_mower/adapters/qodo.py" + }, + { + "kind": "reviewer", + "source": "src/code_mower/antigravity_cli_audit_pr.py", + "target": "src/code_mower/antigravity_cli_audit_pr.py" + }, { "kind": "core", - "source": "src/code_mower/cli.py", - "target": "src/code_mower/cli.py" + "source": "src/code_mower/antigravity_sdk_probe.py", + "target": "src/code_mower/antigravity_sdk_probe.py" }, { "kind": "core", - "source": "src/code_mower/bootstrap.py", - "target": "src/code_mower/bootstrap.py" + "source": "src/code_mower/audit_handoff_log.py", + "target": "src/code_mower/audit_handoff_log.py" }, { "kind": "core", - "source": "src/code_mower/builder_runs.py", - "target": "src/code_mower/builder_runs.py" + "source": "tools/audit_labeler_lib.py", + "target": "src/code_mower/audit_labeler_lib.py" }, { "kind": "core", - "source": "src/code_mower/builder_experiment.py", - "target": "src/code_mower/builder_experiment.py" + "source": "src/code_mower/audit_limits.py", + "target": "src/code_mower/audit_limits.py" }, { "kind": "core", - "source": "src/code_mower/work_orders.py", - "target": "src/code_mower/work_orders.py" + "source": "src/code_mower/audit_progress.py", + "target": "src/code_mower/audit_progress.py" }, { "kind": "core", - "source": "src/code_mower/plan_context.py", - "target": "src/code_mower/plan_context.py" + "source": "src/code_mower/blind_review_artifacts.py", + "target": "src/code_mower/blind_review_artifacts.py" }, { - "kind": "tooling", - "source": "requirements/requirements.txt", - "target": "requirements/requirements.txt" + "kind": "core", + "source": "src/code_mower/blind_review_coordinator.py", + "target": "src/code_mower/blind_review_coordinator.py" }, { "kind": "core", - "source": "src/code_mower/code_mower_calibration.py", - "target": "src/code_mower/code_mower_calibration.py" + "source": "src/code_mower/board.py", + "target": "src/code_mower/board.py" }, { "kind": "core", - "source": "src/code_mower/checks.py", - "target": "src/code_mower/checks.py" + "source": "src/code_mower/board_store.py", + "target": "src/code_mower/board_store.py" }, { "kind": "core", - "source": "src/code_mower/gate_health.py", - "target": "src/code_mower/gate_health.py" + "source": "src/code_mower/bootstrap.py", + "target": "src/code_mower/bootstrap.py" }, { "kind": "core", - "source": "src/code_mower/calibration/__init__.py", - "target": "src/code_mower/calibration/__init__.py" + "source": "src/code_mower/builder_experiment.py", + "target": "src/code_mower/builder_experiment.py" }, { "kind": "core", - "source": "src/code_mower/calibration/auto_discovery.py", - "target": "src/code_mower/calibration/auto_discovery.py" + "source": "src/code_mower/builder_runs.py", + "target": "src/code_mower/builder_runs.py" + }, + { + "kind": "core", + "source": "src/code_mower/calibration/__init__.py", + "target": "src/code_mower/calibration/__init__.py" }, { "kind": "core", "source": "src/code_mower/calibration/arms.py", "target": "src/code_mower/calibration/arms.py" }, + { + "kind": "core", + "source": "src/code_mower/calibration/auto_discovery.py", + "target": "src/code_mower/calibration/auto_discovery.py" + }, { "kind": "core", "source": "src/code_mower/calibration/commands.py", @@ -97,6 +392,11 @@ "source": "src/code_mower/calibration/corpus.py", "target": "src/code_mower/calibration/corpus.py" }, + { + "kind": "core", + "source": "src/code_mower/calibration/effect_report.py", + "target": "src/code_mower/calibration/effect_report.py" + }, { "kind": "core", "source": "src/code_mower/calibration/evidence.py", @@ -107,11 +407,6 @@ "source": "src/code_mower/calibration/evidence_report.py", "target": "src/code_mower/calibration/evidence_report.py" }, - { - "kind": "core", - "source": "src/code_mower/calibration/effect_report.py", - "target": "src/code_mower/calibration/effect_report.py" - }, { "kind": "core", "source": "src/code_mower/calibration/identity.py", @@ -129,13 +424,13 @@ }, { "kind": "core", - "source": "src/code_mower/calibration/policy.py", - "target": "src/code_mower/calibration/policy.py" + "source": "src/code_mower/calibration/planning.py", + "target": "src/code_mower/calibration/planning.py" }, { "kind": "core", - "source": "src/code_mower/calibration/planning.py", - "target": "src/code_mower/calibration/planning.py" + "source": "src/code_mower/calibration/policy.py", + "target": "src/code_mower/calibration/policy.py" }, { "kind": "core", @@ -149,13 +444,13 @@ }, { "kind": "core", - "source": "src/code_mower/calibration/runner.py", - "target": "src/code_mower/calibration/runner.py" + "source": "src/code_mower/calibration/run_status.py", + "target": "src/code_mower/calibration/run_status.py" }, { "kind": "core", - "source": "src/code_mower/calibration/run_status.py", - "target": "src/code_mower/calibration/run_status.py" + "source": "src/code_mower/calibration/runner.py", + "target": "src/code_mower/calibration/runner.py" }, { "kind": "core", @@ -169,638 +464,628 @@ }, { "kind": "core", - "source": "src/code_mower/cloud.py", - "target": "src/code_mower/cloud.py" - }, - { - "kind": "core", - "source": "src/code_mower/cloud_client/__init__.py", - "target": "src/code_mower/cloud_client/__init__.py" - }, - { - "kind": "core", - "source": "src/code_mower/cloud_client/bundle.py", - "target": "src/code_mower/cloud_client/bundle.py" - }, - { - "kind": "core", - "source": "src/code_mower/cloud_client/dogfood.py", - "target": "src/code_mower/cloud_client/dogfood.py" + "source": "src/code_mower/campaign_adapters.py", + "target": "src/code_mower/campaign_adapters.py" }, { "kind": "core", - "source": "src/code_mower/cloud_client/doctor.py", - "target": "src/code_mower/cloud_client/doctor.py" + "source": "src/code_mower/campaign_discovery.py", + "target": "src/code_mower/campaign_discovery.py" }, { "kind": "core", - "source": "src/code_mower/cloud_client/endpoints.py", - "target": "src/code_mower/cloud_client/endpoints.py" + "source": "src/code_mower/checks.py", + "target": "src/code_mower/checks.py" }, { - "kind": "core", - "source": "src/code_mower/cloud_client/errors.py", - "target": "src/code_mower/cloud_client/errors.py" + "kind": "reviewer", + "source": "src/code_mower/claude_audit_pr.py", + "target": "src/code_mower/claude_audit_pr.py" }, { - "kind": "core", - "source": "src/code_mower/cloud_client/events.py", - "target": "src/code_mower/cloud_client/events.py" + "kind": "reviewer", + "source": "src/code_mower/claude_cli_bounce.py", + "target": "src/code_mower/claude_cli_bounce.py" }, { - "kind": "core", - "source": "src/code_mower/cloud_client/export.py", - "target": "src/code_mower/cloud_client/export.py" + "kind": "reviewer", + "source": "src/code_mower/claude_cli_environment.py", + "target": "src/code_mower/claude_cli_environment.py" }, { - "kind": "core", - "source": "src/code_mower/cloud_client/git_metadata.py", - "target": "src/code_mower/cloud_client/git_metadata.py" + "kind": "labeler", + "source": "src/code_mower/clear_stale.py", + "target": "src/code_mower/clear_stale.py" }, { "kind": "core", - "source": "src/code_mower/cloud_client/manifest.py", - "target": "src/code_mower/cloud_client/manifest.py" + "source": "src/code_mower/cli.py", + "target": "src/code_mower/cli.py" }, { "kind": "core", - "source": "src/code_mower/cloud_client/operations.py", - "target": "src/code_mower/cloud_client/operations.py" + "source": "src/code_mower/cloud.py", + "target": "src/code_mower/cloud.py" }, { "kind": "core", - "source": "src/code_mower/cloud_client/productivity.py", - "target": "src/code_mower/cloud_client/productivity.py" + "source": "src/code_mower/cloud_client/__init__.py", + "target": "src/code_mower/cloud_client/__init__.py" }, { "kind": "core", - "source": "src/code_mower/cloud_client/reports.py", - "target": "src/code_mower/cloud_client/reports.py" + "source": "src/code_mower/cloud_client/adoption_runs.py", + "target": "src/code_mower/cloud_client/adoption_runs.py" }, { "kind": "core", - "source": "src/code_mower/cloud_client/setup.py", - "target": "src/code_mower/cloud_client/setup.py" + "source": "src/code_mower/cloud_client/bundle.py", + "target": "src/code_mower/cloud_client/bundle.py" }, { "kind": "core", - "source": "src/code_mower/cloud_client/tokens.py", - "target": "src/code_mower/cloud_client/tokens.py" + "source": "src/code_mower/cloud_client/doctor.py", + "target": "src/code_mower/cloud_client/doctor.py" }, { "kind": "core", - "source": "src/code_mower/cloud_client/upload.py", - "target": "src/code_mower/cloud_client/upload.py" + "source": "src/code_mower/cloud_client/dogfood.py", + "target": "src/code_mower/cloud_client/dogfood.py" }, { "kind": "core", - "source": "src/code_mower/config.py", - "target": "src/code_mower/config.py" + "source": "src/code_mower/cloud_client/endpoints.py", + "target": "src/code_mower/cloud_client/endpoints.py" }, { "kind": "core", - "source": "src/code_mower/code_mower_context_packs.py", - "target": "src/code_mower/code_mower_context_packs.py" + "source": "src/code_mower/cloud_client/errors.py", + "target": "src/code_mower/cloud_client/errors.py" }, { "kind": "core", - "source": "src/code_mower/doctor.py", - "target": "src/code_mower/doctor.py" + "source": "src/code_mower/cloud_client/events.py", + "target": "src/code_mower/cloud_client/events.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/__init__.py", - "target": "src/code_mower/doctor_checks/__init__.py" + "source": "src/code_mower/cloud_client/export.py", + "target": "src/code_mower/cloud_client/export.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/audit_limits.py", - "target": "src/code_mower/doctor_checks/audit_limits.py" + "source": "src/code_mower/cloud_client/finding_outcomes.py", + "target": "src/code_mower/cloud_client/finding_outcomes.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/cloud.py", - "target": "src/code_mower/doctor_checks/cloud.py" + "source": "src/code_mower/cloud_client/git_metadata.py", + "target": "src/code_mower/cloud_client/git_metadata.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/common.py", - "target": "src/code_mower/doctor_checks/common.py" + "source": "src/code_mower/cloud_client/manifest.py", + "target": "src/code_mower/cloud_client/manifest.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/github_actions.py", - "target": "src/code_mower/doctor_checks/github_actions.py" + "source": "src/code_mower/cloud_client/operations.py", + "target": "src/code_mower/cloud_client/operations.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/github_actions_permissions.py", - "target": "src/code_mower/doctor_checks/github_actions_permissions.py" + "source": "src/code_mower/cloud_client/pr_outcomes.py", + "target": "src/code_mower/cloud_client/pr_outcomes.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/github_actions_cost.py", - "target": "src/code_mower/doctor_checks/github_actions_cost.py" + "source": "src/code_mower/cloud_client/productivity.py", + "target": "src/code_mower/cloud_client/productivity.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/github_actions_cost_summary.py", - "target": "src/code_mower/doctor_checks/github_actions_cost_summary.py" + "source": "src/code_mower/cloud_client/productivity_windows.py", + "target": "src/code_mower/cloud_client/productivity_windows.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/github_actions_failures.py", - "target": "src/code_mower/doctor_checks/github_actions_failures.py" + "source": "src/code_mower/cloud_client/reports.py", + "target": "src/code_mower/cloud_client/reports.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/github_actions_failure_annotations.py", - "target": "src/code_mower/doctor_checks/github_actions_failure_annotations.py" + "source": "src/code_mower/cloud_client/setup.py", + "target": "src/code_mower/cloud_client/setup.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/github_actions_failure_models.py", - "target": "src/code_mower/doctor_checks/github_actions_failure_models.py" + "source": "src/code_mower/cloud_client/tokens.py", + "target": "src/code_mower/cloud_client/tokens.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/github_actions_failure_scan.py", - "target": "src/code_mower/doctor_checks/github_actions_failure_scan.py" + "source": "src/code_mower/cloud_client/upload.py", + "target": "src/code_mower/cloud_client/upload.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/github_actions_failure_selection.py", - "target": "src/code_mower/doctor_checks/github_actions_failure_selection.py" + "source": "src/code_mower/cloud_client/work_types.py", + "target": "src/code_mower/cloud_client/work_types.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/github_api.py", - "target": "src/code_mower/doctor_checks/github_api.py" + "source": "src/code_mower/code_mower_calibration.py", + "target": "src/code_mower/code_mower_calibration.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/github_branch.py", - "target": "src/code_mower/doctor_checks/github_branch.py" + "source": "src/code_mower/code_mower_context_packs.py", + "target": "src/code_mower/code_mower_context_packs.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/github_config.py", - "target": "src/code_mower/doctor_checks/github_config.py" + "source": "src/code_mower/code_mower_merge.py", + "target": "src/code_mower/code_mower_merge.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/github_human_token.py", - "target": "src/code_mower/doctor_checks/github_human_token.py" + "source": "src/code_mower/code_mower_telemetry.py", + "target": "src/code_mower/code_mower_telemetry.py" }, { - "kind": "core", - "source": "src/code_mower/doctor_checks/github.py", - "target": "src/code_mower/doctor_checks/github.py" + "kind": "reviewer", + "source": "src/code_mower/coderabbit_cli_audit_pr.py", + "target": "src/code_mower/coderabbit_cli_audit_pr.py" }, { - "kind": "core", - "source": "src/code_mower/doctor_checks/github_provider.py", - "target": "src/code_mower/doctor_checks/github_provider.py" + "kind": "reviewer", + "source": "src/code_mower/codex_audit_env_preflight.py", + "target": "src/code_mower/codex_audit_env_preflight.py" }, { - "kind": "core", - "source": "src/code_mower/doctor_checks/github_repo.py", - "target": "src/code_mower/doctor_checks/github_repo.py" + "kind": "reviewer", + "source": "src/code_mower/codex_audit_pr.py", + "target": "src/code_mower/codex_audit_pr.py" }, { - "kind": "core", - "source": "src/code_mower/doctor_checks/groups.py", - "target": "src/code_mower/doctor_checks/groups.py" + "kind": "reviewer", + "source": "src/code_mower/codex_audit_schema_smoke.py", + "target": "src/code_mower/codex_audit_schema_smoke.py" }, { - "kind": "core", - "source": "src/code_mower/doctor_checks/models.py", - "target": "src/code_mower/doctor_checks/models.py" + "kind": "reviewer", + "source": "src/code_mower/codex_audit_verdict.schema.json", + "target": "src/code_mower/codex_audit_verdict.schema.json" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/output.py", - "target": "src/code_mower/doctor_checks/output.py" + "source": "src/code_mower/config.py", + "target": "src/code_mower/config.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/presets.py", - "target": "src/code_mower/doctor_checks/presets.py" + "source": "src/code_mower/context_audit.py", + "target": "src/code_mower/context_audit.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/provider_api_model.py", - "target": "src/code_mower/doctor_checks/provider_api_model.py" + "source": "src/code_mower/context_command.py", + "target": "src/code_mower/context_command.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/provider_api_model_openai.py", - "target": "src/code_mower/doctor_checks/provider_api_model_openai.py" + "source": "src/code_mower/context_connections.py", + "target": "src/code_mower/context_connections.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/provider_api_model_profiles.py", - "target": "src/code_mower/doctor_checks/provider_api_model_profiles.py" + "source": "src/code_mower/context_contract.py", + "target": "src/code_mower/context_contract.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/provider_env.py", - "target": "src/code_mower/doctor_checks/provider_env.py" + "source": "src/code_mower/context_delivery.py", + "target": "src/code_mower/context_delivery.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/provider_env_required.py", - "target": "src/code_mower/doctor_checks/provider_env_required.py" + "source": "src/code_mower/context_graph.py", + "target": "src/code_mower/context_graph.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/provider_env_tokens.py", - "target": "src/code_mower/doctor_checks/provider_env_tokens.py" + "source": "src/code_mower/context_graph_command.py", + "target": "src/code_mower/context_graph_command.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/provider_local_cli.py", - "target": "src/code_mower/doctor_checks/provider_local_cli.py" + "source": "src/code_mower/context_graph_lifecycle.py", + "target": "src/code_mower/context_graph_lifecycle.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/provider_local_cli_commands.py", - "target": "src/code_mower/doctor_checks/provider_local_cli_commands.py" + "source": "src/code_mower/context_guided.py", + "target": "src/code_mower/context_guided.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/campaign_auth.py", - "target": "src/code_mower/doctor_checks/campaign_auth.py" + "source": "src/code_mower/context_packets.py", + "target": "src/code_mower/context_packets.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/provider_local_cli_probe_config.py", - "target": "src/code_mower/doctor_checks/provider_local_cli_probe_config.py" + "source": "src/code_mower/context_prepare.py", + "target": "src/code_mower/context_prepare.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/provider_local_audit_setup.py", - "target": "src/code_mower/doctor_checks/provider_local_audit_setup.py" + "source": "src/code_mower/context_readiness.py", + "target": "src/code_mower/context_readiness.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/provider_probe.py", - "target": "src/code_mower/doctor_checks/provider_probe.py" + "source": "src/code_mower/context_review.py", + "target": "src/code_mower/context_review.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/provider_probe_auth.py", - "target": "src/code_mower/doctor_checks/provider_probe_auth.py" + "source": "src/code_mower/context_session.py", + "target": "src/code_mower/context_session.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/provider_probe_evaluation.py", - "target": "src/code_mower/doctor_checks/provider_probe_evaluation.py" + "source": "src/code_mower/context_store.py", + "target": "src/code_mower/context_store.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/provider_probe_json.py", - "target": "src/code_mower/doctor_checks/provider_probe_json.py" + "source": "src/code_mower/controller.py", + "target": "src/code_mower/controller.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/provider_probe_remediation.py", - "target": "src/code_mower/doctor_checks/provider_probe_remediation.py" + "source": "src/code_mower/coworker_mcp.py", + "target": "src/code_mower/coworker_mcp.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/provider_review_hygiene.py", - "target": "src/code_mower/doctor_checks/provider_review_hygiene.py" + "source": "src/code_mower/coworker_retrieval.py", + "target": "src/code_mower/coworker_retrieval.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/privacy.py", - "target": "src/code_mower/doctor_checks/privacy.py" + "source": "src/code_mower/decisions.py", + "target": "src/code_mower/decisions.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/providers.py", - "target": "src/code_mower/doctor_checks/providers.py" + "source": "src/code_mower/devin_api.py", + "target": "src/code_mower/devin_api.py" }, { - "kind": "core", - "source": "src/code_mower/providers/__init__.py", - "target": "src/code_mower/providers/__init__.py" + "kind": "reviewer", + "source": "src/code_mower/devin_cli_audit_pr.py", + "target": "src/code_mower/devin_cli_audit_pr.py" }, { "kind": "core", - "source": "src/code_mower/providers/local_cli.py", - "target": "src/code_mower/providers/local_cli.py" + "source": "src/code_mower/devin_readiness.py", + "target": "src/code_mower/devin_readiness.py" }, { "kind": "core", - "source": "src/code_mower/providers/provenance.py", - "target": "src/code_mower/providers/provenance.py" + "source": "src/code_mower/devin_review.py", + "target": "src/code_mower/devin_review.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/registry.py", - "target": "src/code_mower/doctor_checks/registry.py" + "source": "src/code_mower/devin_sessions.py", + "target": "src/code_mower/devin_sessions.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/runner.py", - "target": "src/code_mower/doctor_checks/runner.py" + "source": "src/code_mower/devin_work_orders.py", + "target": "src/code_mower/devin_work_orders.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/runtime.py", - "target": "src/code_mower/doctor_checks/runtime.py" + "source": "src/code_mower/doctor.py", + "target": "src/code_mower/doctor.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/self_hosted_runner.py", - "target": "src/code_mower/doctor_checks/self_hosted_runner.py" + "source": "src/code_mower/doctor_checks/__init__.py", + "target": "src/code_mower/doctor_checks/__init__.py" }, { "kind": "core", - "source": "src/code_mower/doctor_checks/runtime_github_auth.py", - "target": "src/code_mower/doctor_checks/runtime_github_auth.py" + "source": "src/code_mower/doctor_checks/adoption.py", + "target": "src/code_mower/doctor_checks/adoption.py" }, { "kind": "core", - "source": "src/code_mower/init.py", - "target": "src/code_mower/init.py" + "source": "src/code_mower/doctor_checks/audit_limits.py", + "target": "src/code_mower/doctor_checks/audit_limits.py" }, { "kind": "core", - "source": "src/code_mower/code_mower_merge.py", - "target": "src/code_mower/code_mower_merge.py" + "source": "src/code_mower/doctor_checks/campaign_auth.py", + "target": "src/code_mower/doctor_checks/campaign_auth.py" }, { "kind": "core", - "source": "src/code_mower/migration.py", - "target": "src/code_mower/migration.py" + "source": "src/code_mower/doctor_checks/cloud.py", + "target": "src/code_mower/doctor_checks/cloud.py" }, { "kind": "core", - "source": "src/code_mower/migration_install.py", - "target": "src/code_mower/migration_install.py" + "source": "src/code_mower/doctor_checks/common.py", + "target": "src/code_mower/doctor_checks/common.py" }, { "kind": "core", - "source": "src/code_mower/migration_mirror.py", - "target": "src/code_mower/migration_mirror.py" + "source": "src/code_mower/doctor_checks/devin.py", + "target": "src/code_mower/doctor_checks/devin.py" }, { "kind": "core", - "source": "src/code_mower/migration_readiness.py", - "target": "src/code_mower/migration_readiness.py" + "source": "src/code_mower/doctor_checks/github.py", + "target": "src/code_mower/doctor_checks/github.py" }, { "kind": "core", - "source": "src/code_mower/migration_rehearsal.py", - "target": "src/code_mower/migration_rehearsal.py" + "source": "src/code_mower/doctor_checks/github_actions.py", + "target": "src/code_mower/doctor_checks/github_actions.py" }, { "kind": "core", - "source": "src/code_mower/next_steps.py", - "target": "src/code_mower/next_steps.py" + "source": "src/code_mower/doctor_checks/github_actions_cost.py", + "target": "src/code_mower/doctor_checks/github_actions_cost.py" }, { "kind": "core", - "source": "src/code_mower/package.py", - "target": "src/code_mower/package.py" + "source": "src/code_mower/doctor_checks/github_actions_cost_summary.py", + "target": "src/code_mower/doctor_checks/github_actions_cost_summary.py" }, { "kind": "core", - "source": "src/code_mower/package_content.py", - "target": "src/code_mower/package_content.py" + "source": "src/code_mower/doctor_checks/github_actions_failure_annotations.py", + "target": "src/code_mower/doctor_checks/github_actions_failure_annotations.py" }, { "kind": "core", - "source": "src/code_mower/package_manifest.py", - "target": "src/code_mower/package_manifest.py" + "source": "src/code_mower/doctor_checks/github_actions_failure_models.py", + "target": "src/code_mower/doctor_checks/github_actions_failure_models.py" }, { "kind": "core", - "source": "src/code_mower/package_paths.py", - "target": "src/code_mower/package_paths.py" + "source": "src/code_mower/doctor_checks/github_actions_failure_scan.py", + "target": "src/code_mower/doctor_checks/github_actions_failure_scan.py" }, { "kind": "core", - "source": "src/code_mower/package_rendering.py", - "target": "src/code_mower/package_rendering.py" + "source": "src/code_mower/doctor_checks/github_actions_failure_selection.py", + "target": "src/code_mower/doctor_checks/github_actions_failure_selection.py" }, { "kind": "core", - "source": "src/code_mower/package_static.py", - "target": "src/code_mower/package_static.py" + "source": "src/code_mower/doctor_checks/github_actions_failures.py", + "target": "src/code_mower/doctor_checks/github_actions_failures.py" }, { "kind": "core", - "source": "tools/code_mower_versioning.py", - "target": "src/code_mower/versioning.py" + "source": "src/code_mower/doctor_checks/github_actions_permissions.py", + "target": "src/code_mower/doctor_checks/github_actions_permissions.py" }, { "kind": "core", - "source": "tools/workflow_actionlint.py", - "target": "src/code_mower/workflow_actionlint.py" + "source": "src/code_mower/doctor_checks/github_api.py", + "target": "src/code_mower/doctor_checks/github_api.py" }, { "kind": "core", - "source": "src/code_mower/prompts.py", - "target": "src/code_mower/prompts.py" + "source": "src/code_mower/doctor_checks/github_branch.py", + "target": "src/code_mower/doctor_checks/github_branch.py" }, { "kind": "core", - "source": "src/code_mower/release_readiness.py", - "target": "src/code_mower/release_readiness.py" + "source": "src/code_mower/doctor_checks/github_config.py", + "target": "src/code_mower/doctor_checks/github_config.py" }, { "kind": "core", - "source": "src/code_mower/secrets.py", - "target": "src/code_mower/secrets.py" + "source": "src/code_mower/doctor_checks/github_human_token.py", + "target": "src/code_mower/doctor_checks/github_human_token.py" }, { "kind": "core", - "source": "src/code_mower/code_mower_telemetry.py", - "target": "src/code_mower/code_mower_telemetry.py" + "source": "src/code_mower/doctor_checks/github_provider.py", + "target": "src/code_mower/doctor_checks/github_provider.py" }, { "kind": "core", - "source": "src/code_mower/reviewer_metrics.py", - "target": "src/code_mower/reviewer_metrics.py" + "source": "src/code_mower/doctor_checks/github_repo.py", + "target": "src/code_mower/doctor_checks/github_repo.py" }, { "kind": "core", - "source": "src/code_mower/reviewer_spend.py", - "target": "src/code_mower/reviewer_spend.py" + "source": "src/code_mower/doctor_checks/github_trusted_authors.py", + "target": "src/code_mower/doctor_checks/github_trusted_authors.py" }, { "kind": "core", - "source": "src/code_mower/provider_registry.py", - "target": "src/code_mower/provider_registry.py" + "source": "src/code_mower/doctor_checks/groups.py", + "target": "src/code_mower/doctor_checks/groups.py" }, { "kind": "core", - "source": "src/code_mower/blind_review_coordinator.py", - "target": "src/code_mower/blind_review_coordinator.py" + "source": "src/code_mower/doctor_checks/jira.py", + "target": "src/code_mower/doctor_checks/jira.py" }, { "kind": "core", - "source": "src/code_mower/blind_review_artifacts.py", - "target": "src/code_mower/blind_review_artifacts.py" + "source": "src/code_mower/doctor_checks/models.py", + "target": "src/code_mower/doctor_checks/models.py" }, { "kind": "core", - "source": "src/code_mower/audit_handoff_log.py", - "target": "src/code_mower/audit_handoff_log.py" + "source": "src/code_mower/doctor_checks/output.py", + "target": "src/code_mower/doctor_checks/output.py" }, { "kind": "core", - "source": "src/code_mower/audit_labeler_lib.py", - "target": "src/code_mower/audit_labeler_lib.py" + "source": "src/code_mower/doctor_checks/presets.py", + "target": "src/code_mower/doctor_checks/presets.py" }, { "kind": "core", - "source": "src/code_mower/audit_limits.py", - "target": "src/code_mower/audit_limits.py" + "source": "src/code_mower/doctor_checks/privacy.py", + "target": "src/code_mower/doctor_checks/privacy.py" }, { "kind": "core", - "source": "src/code_mower/audit_progress.py", - "target": "src/code_mower/audit_progress.py" - }, - { - "kind": "reviewer", - "source": "src/code_mower/provider_runners/__init__.py", - "target": "src/code_mower/provider_runners/__init__.py" + "source": "src/code_mower/doctor_checks/provider_api_model.py", + "target": "src/code_mower/doctor_checks/provider_api_model.py" }, { - "kind": "reviewer", - "source": "src/code_mower/provider_runners/comments.py", - "target": "src/code_mower/provider_runners/comments.py" + "kind": "core", + "source": "src/code_mower/doctor_checks/provider_api_model_openai.py", + "target": "src/code_mower/doctor_checks/provider_api_model_openai.py" }, { - "kind": "reviewer", - "source": "src/code_mower/provider_runners/git.py", - "target": "src/code_mower/provider_runners/git.py" + "kind": "core", + "source": "src/code_mower/doctor_checks/provider_api_model_profiles.py", + "target": "src/code_mower/doctor_checks/provider_api_model_profiles.py" }, { - "kind": "reviewer", - "source": "src/code_mower/provider_runners/github_auth.py", - "target": "src/code_mower/provider_runners/github_auth.py" + "kind": "core", + "source": "src/code_mower/doctor_checks/provider_env.py", + "target": "src/code_mower/doctor_checks/provider_env.py" }, { - "kind": "reviewer", - "source": "src/code_mower/provider_runners/github_pr.py", - "target": "src/code_mower/provider_runners/github_pr.py" + "kind": "core", + "source": "src/code_mower/doctor_checks/provider_env_required.py", + "target": "src/code_mower/doctor_checks/provider_env_required.py" }, { - "kind": "reviewer", - "source": "src/code_mower/provider_runners/process.py", - "target": "src/code_mower/provider_runners/process.py" + "kind": "core", + "source": "src/code_mower/doctor_checks/provider_env_tokens.py", + "target": "src/code_mower/doctor_checks/provider_env_tokens.py" }, { - "kind": "reviewer", - "source": "src/code_mower/provider_runners/pr_worktree.py", - "target": "src/code_mower/provider_runners/pr_worktree.py" + "kind": "core", + "source": "src/code_mower/doctor_checks/provider_local_audit_setup.py", + "target": "src/code_mower/doctor_checks/provider_local_audit_setup.py" }, { - "kind": "reviewer", - "source": "src/code_mower/provider_runners/repo_paths.py", - "target": "src/code_mower/provider_runners/repo_paths.py" + "kind": "core", + "source": "src/code_mower/doctor_checks/provider_local_cli.py", + "target": "src/code_mower/doctor_checks/provider_local_cli.py" }, { - "kind": "reviewer", - "source": "src/code_mower/provider_runners/text_schema.py", - "target": "src/code_mower/provider_runners/text_schema.py" + "kind": "core", + "source": "src/code_mower/doctor_checks/provider_local_cli_commands.py", + "target": "src/code_mower/doctor_checks/provider_local_cli_commands.py" }, { - "kind": "reviewer", - "source": "src/code_mower/provider_runners/verdict_artifacts.py", - "target": "src/code_mower/provider_runners/verdict_artifacts.py" + "kind": "core", + "source": "src/code_mower/doctor_checks/provider_local_cli_probe_config.py", + "target": "src/code_mower/doctor_checks/provider_local_cli_probe_config.py" }, { - "kind": "reviewer", - "source": "src/code_mower/provider_runners/workspace.py", - "target": "src/code_mower/provider_runners/workspace.py" + "kind": "core", + "source": "src/code_mower/doctor_checks/provider_probe.py", + "target": "src/code_mower/doctor_checks/provider_probe.py" }, { - "kind": "reviewer", - "source": "src/code_mower/codex_audit_pr.py", - "target": "src/code_mower/codex_audit_pr.py" + "kind": "core", + "source": "src/code_mower/doctor_checks/provider_probe_auth.py", + "target": "src/code_mower/doctor_checks/provider_probe_auth.py" }, { - "kind": "reviewer", - "source": "src/code_mower/codex_audit_env_preflight.py", - "target": "src/code_mower/codex_audit_env_preflight.py" + "kind": "core", + "source": "src/code_mower/doctor_checks/provider_probe_evaluation.py", + "target": "src/code_mower/doctor_checks/provider_probe_evaluation.py" }, { - "kind": "reviewer", - "source": "src/code_mower/codex_audit_schema_smoke.py", - "target": "src/code_mower/codex_audit_schema_smoke.py" + "kind": "core", + "source": "src/code_mower/doctor_checks/provider_probe_json.py", + "target": "src/code_mower/doctor_checks/provider_probe_json.py" }, { - "kind": "reviewer", - "source": "src/code_mower/codex_audit_verdict.schema.json", - "target": "src/code_mower/codex_audit_verdict.schema.json" + "kind": "core", + "source": "src/code_mower/doctor_checks/provider_probe_remediation.py", + "target": "src/code_mower/doctor_checks/provider_probe_remediation.py" }, { - "kind": "reviewer", - "source": "src/code_mower/claude_audit_pr.py", - "target": "src/code_mower/claude_audit_pr.py" + "kind": "core", + "source": "src/code_mower/doctor_checks/provider_review_hygiene.py", + "target": "src/code_mower/doctor_checks/provider_review_hygiene.py" }, { - "kind": "reviewer", - "source": "src/code_mower/claude_cli_bounce.py", - "target": "src/code_mower/claude_cli_bounce.py" + "kind": "core", + "source": "src/code_mower/doctor_checks/providers.py", + "target": "src/code_mower/doctor_checks/providers.py" }, { - "kind": "reviewer", - "source": "src/code_mower/claude_cli_environment.py", - "target": "src/code_mower/claude_cli_environment.py" + "kind": "core", + "source": "src/code_mower/doctor_checks/registry.py", + "target": "src/code_mower/doctor_checks/registry.py" }, { - "kind": "labeler", - "source": "src/code_mower/clear_stale.py", - "target": "src/code_mower/clear_stale.py" + "kind": "core", + "source": "src/code_mower/doctor_checks/runner.py", + "target": "src/code_mower/doctor_checks/runner.py" }, { - "kind": "labeler", - "source": "src/code_mower/trailer_comment_labeler.py", - "target": "src/code_mower/trailer_comment_labeler.py" + "kind": "core", + "source": "src/code_mower/doctor_checks/runtime.py", + "target": "src/code_mower/doctor_checks/runtime.py" }, { - "kind": "labeler", - "source": "src/code_mower/saas_reviewer_labeler.py", - "target": "src/code_mower/saas_reviewer_labeler.py" + "kind": "core", + "source": "src/code_mower/doctor_checks/runtime_github_auth.py", + "target": "src/code_mower/doctor_checks/runtime_github_auth.py" }, { - "kind": "reviewer", - "source": "src/code_mower/local_llm_audit_pr.py", - "target": "src/code_mower/local_llm_audit_pr.py" + "kind": "core", + "source": "src/code_mower/doctor_checks/self_hosted_runner.py", + "target": "src/code_mower/doctor_checks/self_hosted_runner.py" }, { - "kind": "reviewer", - "source": "src/code_mower/local_llm_bakeoff.py", - "target": "src/code_mower/local_llm_bakeoff.py" + "kind": "core", + "source": "src/code_mower/doctor_checks/supervised_pilot.py", + "target": "src/code_mower/doctor_checks/supervised_pilot.py" }, { - "kind": "reviewer", - "source": "src/code_mower/local_llm_calibration.py", - "target": "src/code_mower/local_llm_calibration.py" + "kind": "core", + "source": "src/code_mower/file_locks.py", + "target": "src/code_mower/file_locks.py" }, { - "kind": "reviewer", - "source": "src/code_mower/local_llm_profiles.py", - "target": "src/code_mower/local_llm_profiles.py" + "kind": "core", + "source": "src/code_mower/gate_health.py", + "target": "src/code_mower/gate_health.py" }, { "kind": "reviewer", "source": "src/code_mower/gemini_cli_audit_pr.py", "target": "src/code_mower/gemini_cli_audit_pr.py" }, + { + "kind": "core", + "source": "src/code_mower/git_identity.py", + "target": "src/code_mower/git_identity.py" + }, + { + "kind": "core", + "source": "src/code_mower/github_builder_evidence.py", + "target": "src/code_mower/github_builder_evidence.py" + }, { "kind": "reviewer", - "source": "src/code_mower/antigravity_cli_audit_pr.py", - "target": "src/code_mower/antigravity_cli_audit_pr.py" + "source": "src/code_mower/grok_build_audit_pr.py", + "target": "src/code_mower/grok_build_audit_pr.py" }, { "kind": "reviewer", @@ -808,20 +1093,40 @@ "target": "src/code_mower/hermes_cli_audit_pr.py" }, { - "kind": "reviewer", - "source": "src/code_mower/coderabbit_cli_audit_pr.py", - "target": "src/code_mower/coderabbit_cli_audit_pr.py" + "kind": "core", + "source": "src/code_mower/init.py", + "target": "src/code_mower/init.py" }, { - "kind": "reviewer", - "source": "src/code_mower/grok_build_audit_pr.py", - "target": "src/code_mower/grok_build_audit_pr.py" + "kind": "core", + "source": "src/code_mower/jira_cloud.py", + "target": "src/code_mower/jira_cloud.py" + }, + { + "kind": "core", + "source": "src/code_mower/jira_mutations.py", + "target": "src/code_mower/jira_mutations.py" + }, + { + "kind": "core", + "source": "src/code_mower/jira_pr_sync.py", + "target": "src/code_mower/jira_pr_sync.py" }, { "kind": "lane-config", "source": "src/code_mower/lane_configs/__init__.py", "target": "src/code_mower/lane_configs/__init__.py" }, + { + "kind": "lane-config", + "source": "src/code_mower/lane_configs/aider.py", + "target": "src/code_mower/lane_configs/aider.py" + }, + { + "kind": "lane-config", + "source": "src/code_mower/lane_configs/antigravity_cli.py", + "target": "src/code_mower/lane_configs/antigravity_cli.py" + }, { "kind": "lane-config", "source": "src/code_mower/lane_configs/claude.py", @@ -839,13 +1144,8 @@ }, { "kind": "lane-config", - "source": "src/code_mower/lane_configs/local_llm.py", - "target": "src/code_mower/lane_configs/local_llm.py" - }, - { - "kind": "lane-config", - "source": "src/code_mower/lane_configs/aider.py", - "target": "src/code_mower/lane_configs/aider.py" + "source": "src/code_mower/lane_configs/devin_cli.py", + "target": "src/code_mower/lane_configs/devin_cli.py" }, { "kind": "lane-config", @@ -854,8 +1154,8 @@ }, { "kind": "lane-config", - "source": "src/code_mower/lane_configs/antigravity_cli.py", - "target": "src/code_mower/lane_configs/antigravity_cli.py" + "source": "src/code_mower/lane_configs/grok_build.py", + "target": "src/code_mower/lane_configs/grok_build.py" }, { "kind": "lane-config", @@ -864,428 +1164,513 @@ }, { "kind": "lane-config", - "source": "src/code_mower/lane_configs/grok_build.py", - "target": "src/code_mower/lane_configs/grok_build.py" + "source": "src/code_mower/lane_configs/local_llm.py", + "target": "src/code_mower/lane_configs/local_llm.py" }, { - "kind": "prompt", - "source": "src/code_mower/templates/lane_prompts/base-audit.md", - "target": "src/code_mower/templates/lane_prompts/base-audit.md" + "kind": "lane-config", + "source": "src/code_mower/lane_configs/muse_cli.py", + "target": "src/code_mower/lane_configs/muse_cli.py" }, { - "kind": "prompt", - "source": "src/code_mower/templates/lane_prompts/calibration-policy.md", - "target": "src/code_mower/templates/lane_prompts/calibration-policy.md" + "kind": "core", + "source": "src/code_mower/lane_delivery.py", + "target": "src/code_mower/lane_delivery.py" }, { - "kind": "prompt", - "source": "src/code_mower/templates/lane_prompts/context-driven-quality.md", - "target": "src/code_mower/templates/lane_prompts/context-driven-quality.md" + "kind": "core", + "source": "src/code_mower/lane_status.py", + "target": "src/code_mower/lane_status.py" }, { - "kind": "prompt", - "source": "src/code_mower/templates/lane_prompts/docs-design.md", - "target": "src/code_mower/templates/lane_prompts/docs-design.md" + "kind": "core", + "source": "src/code_mower/local_cli_commands.py", + "target": "src/code_mower/local_cli_commands.py" }, { - "kind": "prompt", - "source": "src/code_mower/templates/lane_prompts/generic-programming.md", - "target": "src/code_mower/templates/lane_prompts/generic-programming.md" + "kind": "reviewer", + "source": "src/code_mower/local_llm_audit_pr.py", + "target": "src/code_mower/local_llm_audit_pr.py" }, { - "kind": "prompt", - "source": "src/code_mower/templates/lane_prompts/operability.md", - "target": "src/code_mower/templates/lane_prompts/operability.md" + "kind": "reviewer", + "source": "src/code_mower/local_llm_bakeoff.py", + "target": "src/code_mower/local_llm_bakeoff.py" }, { - "kind": "prompt", - "source": "src/code_mower/templates/lane_prompts/package-runtime.md", - "target": "src/code_mower/templates/lane_prompts/package-runtime.md" + "kind": "reviewer", + "source": "src/code_mower/local_llm_calibration.py", + "target": "src/code_mower/local_llm_calibration.py" }, { - "kind": "prompt", - "source": "src/code_mower/templates/lane_prompts/plan-conformance.md", - "target": "src/code_mower/templates/lane_prompts/plan-conformance.md" + "kind": "reviewer", + "source": "src/code_mower/local_llm_profiles.py", + "target": "src/code_mower/local_llm_profiles.py" }, { - "kind": "prompt", - "source": "src/code_mower/templates/lane_prompts/security-threat-model.md", - "target": "src/code_mower/templates/lane_prompts/security-threat-model.md" + "kind": "core", + "source": "src/code_mower/migration.py", + "target": "src/code_mower/migration.py" }, { - "kind": "config", - "source": "src/code_mower/templates/code-mower.example.yml", - "target": "src/code_mower/templates/code-mower.example.yml" + "kind": "core", + "source": "src/code_mower/migration_install.py", + "target": "src/code_mower/migration_install.py" }, { - "kind": "config", - "source": "templates/calibration-corpus.json", - "target": "templates/calibration-corpus.json" + "kind": "core", + "source": "src/code_mower/migration_mirror.py", + "target": "src/code_mower/migration_mirror.py" }, { - "kind": "config", - "source": "templates/calibration-corpus.example.json", - "target": "templates/calibration-corpus.example.json" + "kind": "core", + "source": "src/code_mower/migration_readiness.py", + "target": "src/code_mower/migration_readiness.py" }, { - "kind": "config", - "source": "templates/builder-experiment.example.json", - "target": "templates/builder-experiment.example.json" + "kind": "core", + "source": "src/code_mower/migration_rehearsal.py", + "target": "src/code_mower/migration_rehearsal.py" }, { - "kind": "config", - "source": "templates/context-packs.example.json", - "target": "templates/context-packs.example.json" + "kind": "reviewer", + "source": "src/code_mower/muse_cli_audit_pr.py", + "target": "src/code_mower/muse_cli_audit_pr.py" }, { - "kind": "config", - "source": "templates/reviewer-spend.example.json", - "target": "templates/reviewer-spend.example.json" + "kind": "core", + "source": "src/code_mower/next_steps.py", + "target": "src/code_mower/next_steps.py" }, { - "kind": "config", - "source": "tools/reviewer_value_report.example.md", - "target": "templates/reviewer-value-report.example.md" + "kind": "core", + "source": "src/code_mower/package.py", + "target": "src/code_mower/package.py" }, { - "kind": "config", - "source": "templates/cursor/BUGBOT.md", - "target": "templates/cursor/BUGBOT.md" + "kind": "core", + "source": "src/code_mower/package_content.py", + "target": "src/code_mower/package_content.py" }, { - "kind": "config", - "source": "src/code_mower/templates/calibration-corpus.json", - "target": "src/code_mower/templates/calibration-corpus.json" + "kind": "core", + "source": "src/code_mower/package_manifest.py", + "target": "src/code_mower/package_manifest.py" }, { - "kind": "config", - "source": "src/code_mower/templates/calibration-corpus.example.json", - "target": "src/code_mower/templates/calibration-corpus.example.json" + "kind": "core", + "source": "src/code_mower/package_paths.py", + "target": "src/code_mower/package_paths.py" }, { - "kind": "config", - "source": "src/code_mower/templates/builder-experiment.example.json", - "target": "src/code_mower/templates/builder-experiment.example.json" + "kind": "core", + "source": "src/code_mower/package_rendering.py", + "target": "src/code_mower/package_rendering.py" }, { - "kind": "config", - "source": "src/code_mower/templates/context-packs.example.json", - "target": "src/code_mower/templates/context-packs.example.json" + "kind": "core", + "source": "src/code_mower/package_static.py", + "target": "src/code_mower/package_static.py" }, { - "kind": "config", - "source": "src/code_mower/templates/reviewer-spend.example.json", - "target": "src/code_mower/templates/reviewer-spend.example.json" + "kind": "core", + "source": "src/code_mower/participants.py", + "target": "src/code_mower/participants.py" + }, + { + "kind": "core", + "source": "src/code_mower/plan_context.py", + "target": "src/code_mower/plan_context.py" + }, + { + "kind": "core", + "source": "src/code_mower/productivity_report.py", + "target": "src/code_mower/productivity_report.py" + }, + { + "kind": "core", + "source": "src/code_mower/prompts.py", + "target": "src/code_mower/prompts.py" + }, + { + "kind": "core", + "source": "src/code_mower/provider_capabilities.py", + "target": "src/code_mower/provider_capabilities.py" + }, + { + "kind": "schema", + "source": "src/code_mower/provider_capabilities.schema.json", + "target": "src/code_mower/provider_capabilities.schema.json" + }, + { + "kind": "core", + "source": "src/code_mower/provider_credentials.py", + "target": "src/code_mower/provider_credentials.py" + }, + { + "kind": "core", + "source": "src/code_mower/provider_registry.py", + "target": "src/code_mower/provider_registry.py" + }, + { + "kind": "reviewer", + "source": "src/code_mower/provider_runners/__init__.py", + "target": "src/code_mower/provider_runners/__init__.py" + }, + { + "kind": "reviewer", + "source": "src/code_mower/provider_runners/comments.py", + "target": "src/code_mower/provider_runners/comments.py" + }, + { + "kind": "reviewer", + "source": "src/code_mower/provider_runners/exit_codes.py", + "target": "src/code_mower/provider_runners/exit_codes.py" + }, + { + "kind": "reviewer", + "source": "src/code_mower/provider_runners/git.py", + "target": "src/code_mower/provider_runners/git.py" + }, + { + "kind": "reviewer", + "source": "src/code_mower/provider_runners/github_auth.py", + "target": "src/code_mower/provider_runners/github_auth.py" + }, + { + "kind": "reviewer", + "source": "src/code_mower/provider_runners/github_pr.py", + "target": "src/code_mower/provider_runners/github_pr.py" + }, + { + "kind": "reviewer", + "source": "src/code_mower/provider_runners/pr_worktree.py", + "target": "src/code_mower/provider_runners/pr_worktree.py" + }, + { + "kind": "reviewer", + "source": "src/code_mower/provider_runners/process.py", + "target": "src/code_mower/provider_runners/process.py" + }, + { + "kind": "reviewer", + "source": "src/code_mower/provider_runners/repo_paths.py", + "target": "src/code_mower/provider_runners/repo_paths.py" }, { - "kind": "config", - "source": "tools/reviewer_value_report.example.md", - "target": "src/code_mower/templates/reviewer-value-report.example.md" + "kind": "reviewer", + "source": "src/code_mower/provider_runners/text_schema.py", + "target": "src/code_mower/provider_runners/text_schema.py" }, { - "kind": "template", - "source": "src/code_mower/templates/product-support/code_mower", - "target": "src/code_mower/templates/product-support/code_mower" + "kind": "reviewer", + "source": "src/code_mower/provider_runners/verdict_artifacts.py", + "target": "src/code_mower/provider_runners/verdict_artifacts.py" }, { - "kind": "template", - "source": "src/code_mower/templates/product-support/code_mower_standalone_pin.env", - "target": "src/code_mower/templates/product-support/code_mower_standalone_pin.env" + "kind": "reviewer", + "source": "src/code_mower/provider_runners/workspace.py", + "target": "src/code_mower/provider_runners/workspace.py" }, { - "kind": "template", - "source": "src/code_mower/templates/product-support/code_mower_standalone_shadow.sh", - "target": "src/code_mower/templates/product-support/code_mower_standalone_shadow.sh" + "kind": "core", + "source": "src/code_mower/providers/__init__.py", + "target": "src/code_mower/providers/__init__.py" }, { - "kind": "template", - "source": "src/code_mower/templates/product-support/run_claude_audit_pr.sh", - "target": "src/code_mower/templates/product-support/run_claude_audit_pr.sh" + "kind": "core", + "source": "src/code_mower/providers/local_cli.py", + "target": "src/code_mower/providers/local_cli.py" }, { - "kind": "template", - "source": "src/code_mower/templates/product-support/run_codex_audit_pr.sh", - "target": "src/code_mower/templates/product-support/run_codex_audit_pr.sh" + "kind": "core", + "source": "src/code_mower/providers/provenance.py", + "target": "src/code_mower/providers/provenance.py" }, { - "kind": "template", - "source": "src/code_mower/templates/product-support/safe_gh_comment.py", - "target": "src/code_mower/templates/product-support/safe_gh_comment.py" + "kind": "core", + "source": "src/code_mower/release_campaigns.py", + "target": "src/code_mower/release_campaigns.py" }, { - "kind": "template", - "source": "src/code_mower/templates/product-support/status_report.py", - "target": "src/code_mower/templates/product-support/status_report.py" + "kind": "core", + "source": "src/code_mower/release_qualify.py", + "target": "src/code_mower/release_qualify.py" }, { - "kind": "doc", - "source": "docs/acp-bridge-spike.md", - "target": "docs/acp-bridge-spike.md" + "kind": "core", + "source": "src/code_mower/release_readiness.py", + "target": "src/code_mower/release_readiness.py" }, { - "kind": "doc", - "source": "docs/calibration-pilot.md", - "target": "docs/calibration-pilot.md" + "kind": "core", + "source": "src/code_mower/remote_session.py", + "target": "src/code_mower/remote_session.py" }, { - "kind": "doc", - "source": "docs/calibration-notes.md", - "target": "docs/calibration-notes.md" + "kind": "schema", + "source": "src/code_mower/remote_session.schema.json", + "target": "src/code_mower/remote_session.schema.json" }, { - "kind": "doc", - "source": "docs/lens-calibration-report.md", - "target": "docs/lens-calibration-report.md" + "kind": "core", + "source": "src/code_mower/remote_session_cli.py", + "target": "src/code_mower/remote_session_cli.py" }, { - "kind": "doc", - "source": "docs/reviewer-value-report.md", - "target": "docs/reviewer-value-report.md" + "kind": "core", + "source": "src/code_mower/reviewer_metrics.py", + "target": "src/code_mower/reviewer_metrics.py" }, { - "kind": "doc", - "source": "docs/lane-promotion-policy.md", - "target": "docs/lane-promotion-policy.md" + "kind": "core", + "source": "src/code_mower/reviewer_spend.py", + "target": "src/code_mower/reviewer_spend.py" }, { - "kind": "doc", - "source": "docs/package-customization.md", - "target": "docs/package-customization.md" + "kind": "labeler", + "source": "src/code_mower/saas_reviewer_labeler.py", + "target": "src/code_mower/saas_reviewer_labeler.py" }, { - "kind": "doc", - "source": "docs/authoring-intelligence.md", - "target": "docs/authoring-intelligence.md" + "kind": "core", + "source": "src/code_mower/secrets.py", + "target": "src/code_mower/secrets.py" }, { - "kind": "doc", - "source": "docs/builder-experiments.md", - "target": "docs/builder-experiments.md" + "kind": "core", + "source": "src/code_mower/session.py", + "target": "src/code_mower/session.py" }, { - "kind": "doc", - "source": "docs/builders-grok-cursor.md", - "target": "docs/builders-grok-cursor.md" + "kind": "core", + "source": "src/code_mower/session_lease.py", + "target": "src/code_mower/session_lease.py" }, { - "kind": "doc", - "source": "docs/build-loop.md", - "target": "docs/build-loop.md" + "kind": "core", + "source": "src/code_mower/slack_contract.py", + "target": "src/code_mower/slack_contract.py" }, { - "kind": "doc", - "source": "docs/planning-work-orders.md", - "target": "docs/planning-work-orders.md" + "kind": "schema", + "source": "src/code_mower/slack_contract.schema.json", + "target": "src/code_mower/slack_contract.schema.json" }, { - "kind": "doc", - "source": "docs/cloud-benchmarking.md", - "target": "docs/cloud-benchmarking.md" + "kind": "core", + "source": "src/code_mower/slack_ingress.py", + "target": "src/code_mower/slack_ingress.py" }, { - "kind": "doc", - "source": "docs/repo-strategy.md", - "target": "docs/repo-strategy.md" + "kind": "config", + "source": "src/code_mower/templates/builder-experiment.example.json", + "target": "src/code_mower/templates/builder-experiment.example.json" }, { - "kind": "doc", - "source": "docs/commercial-boundary.md", - "target": "docs/commercial-boundary.md" + "kind": "config", + "source": "src/code_mower/templates/calibration-corpus.example.json", + "target": "src/code_mower/templates/calibration-corpus.example.json" }, { - "kind": "doc", - "source": "docs/public-release-checklist.md", - "target": "docs/public-release-checklist.md" + "kind": "config", + "source": "src/code_mower/templates/calibration-corpus.json", + "target": "src/code_mower/templates/calibration-corpus.json" }, { - "kind": "doc", - "source": "docs/github-setup.md", - "target": "docs/github-setup.md" + "kind": "config", + "source": "src/code_mower/templates/code-mower.example.yml", + "target": "src/code_mower/templates/code-mower.example.yml" }, { - "kind": "doc", - "source": "docs/local-audit-runner.md", - "target": "docs/local-audit-runner.md" + "kind": "config", + "source": "src/code_mower/templates/context-packs.example.json", + "target": "src/code_mower/templates/context-packs.example.json" }, { - "kind": "doc", - "source": "docs/troubleshooting.md", - "target": "docs/troubleshooting.md" + "kind": "prompt", + "source": "src/code_mower/templates/lane_prompts/base-audit.md", + "target": "src/code_mower/templates/lane_prompts/base-audit.md" }, { - "kind": "doc", - "source": "docs/mirror-removal-runbook.md", - "target": "docs/mirror-removal-runbook.md" + "kind": "prompt", + "source": "src/code_mower/templates/lane_prompts/calibration-policy.md", + "target": "src/code_mower/templates/lane_prompts/calibration-policy.md" }, { - "kind": "doc", - "source": "docs/provider-matrix.md", - "target": "docs/provider-matrix.md" + "kind": "prompt", + "source": "src/code_mower/templates/lane_prompts/context-driven-quality.md", + "target": "src/code_mower/templates/lane_prompts/context-driven-quality.md" }, { - "kind": "doc", - "source": "docs/oss-v1-checklist.md", - "target": "docs/oss-v1-checklist.md" + "kind": "prompt", + "source": "src/code_mower/templates/lane_prompts/docs-design.md", + "target": "src/code_mower/templates/lane_prompts/docs-design.md" }, { - "kind": "adapter", - "source": "src/code_mower/adapters/__init__.py", - "target": "src/code_mower/adapters/__init__.py" + "kind": "prompt", + "source": "src/code_mower/templates/lane_prompts/generic-programming.md", + "target": "src/code_mower/templates/lane_prompts/generic-programming.md" }, { - "kind": "adapter", - "source": "src/code_mower/adapters/_base.py", - "target": "src/code_mower/adapters/_base.py" + "kind": "prompt", + "source": "src/code_mower/templates/lane_prompts/operability.md", + "target": "src/code_mower/templates/lane_prompts/operability.md" }, { - "kind": "adapter", - "source": "src/code_mower/adapters/cursor_bugbot.py", - "target": "src/code_mower/adapters/cursor_bugbot.py" + "kind": "prompt", + "source": "src/code_mower/templates/lane_prompts/package-runtime.md", + "target": "src/code_mower/templates/lane_prompts/package-runtime.md" }, { - "kind": "adapter", - "source": "src/code_mower/adapters/gitar.py", - "target": "src/code_mower/adapters/gitar.py" + "kind": "prompt", + "source": "src/code_mower/templates/lane_prompts/plan-conformance.md", + "target": "src/code_mower/templates/lane_prompts/plan-conformance.md" }, { - "kind": "adapter", - "source": "src/code_mower/adapters/greptile.py", - "target": "src/code_mower/adapters/greptile.py" + "kind": "prompt", + "source": "src/code_mower/templates/lane_prompts/security-threat-model.md", + "target": "src/code_mower/templates/lane_prompts/security-threat-model.md" }, { - "kind": "adapter", - "source": "src/code_mower/adapters/qodo.py", - "target": "src/code_mower/adapters/qodo.py" + "kind": "lane-template", + "source": "generated", + "target": "src/code_mower/templates/lanes/README.md" }, { - "kind": "package", + "kind": "lane-template", "source": "generated", - "target": "src/code_mower/__init__.py" + "target": "src/code_mower/templates/lanes/claude.md" }, { - "kind": "package", + "kind": "lane-template", "source": "generated", - "target": "README.md" + "target": "src/code_mower/templates/lanes/codex.md" }, { - "kind": "package", + "kind": "lane-template", "source": "generated", - "target": "MANIFEST.in" + "target": "src/code_mower/templates/lanes/cursor.md" }, { - "kind": "package", + "kind": "lane-template", "source": "generated", - "target": ".gitignore" + "target": "src/code_mower/templates/lanes/devin.md" }, { - "kind": "package", + "kind": "lane-template", "source": "generated", - "target": ".github/workflows/ci.yml" + "target": "src/code_mower/templates/lanes/generic.md" }, { - "kind": "package", + "kind": "lane-template", "source": "generated", - "target": "scripts/smoke_easy_mode.py" + "target": "src/code_mower/templates/lanes/run_mac_lane.sh" }, { - "kind": "package", - "source": "generated", - "target": "scripts/fresh_clone_rehearsal.py" + "kind": "template", + "source": "src/code_mower/templates/product-support/code_mower", + "target": "src/code_mower/templates/product-support/code_mower" }, { - "kind": "package", - "source": "generated", - "target": "pyproject.toml" + "kind": "template", + "source": "src/code_mower/templates/product-support/code_mower_standalone_pin.env", + "target": "src/code_mower/templates/product-support/code_mower_standalone_pin.env" }, { - "kind": "provider-catalog", - "source": "generated", - "target": "src/code_mower/templates/providers.yml" + "kind": "template", + "source": "src/code_mower/templates/product-support/code_mower_standalone_shadow.sh", + "target": "src/code_mower/templates/product-support/code_mower_standalone_shadow.sh" }, { - "kind": "workflow", - "source": "generated", - "target": "templates/workflows/trailer-comment-labeler.yml.j2" + "kind": "template", + "source": "src/code_mower/templates/product-support/run_claude_audit_pr.sh", + "target": "src/code_mower/templates/product-support/run_claude_audit_pr.sh" }, { - "kind": "workflow", - "source": "generated", - "target": "templates/workflows/saas-reviewer-labeler.yml.j2" + "kind": "template", + "source": "src/code_mower/templates/product-support/run_codex_audit_pr.sh", + "target": "src/code_mower/templates/product-support/run_codex_audit_pr.sh" }, { - "kind": "workflow", - "source": "generated", - "target": "templates/workflows/local-cli-audit.yml.j2" + "kind": "template", + "source": "src/code_mower/templates/product-support/run_devin_cli_audit_pr.sh", + "target": "src/code_mower/templates/product-support/run_devin_cli_audit_pr.sh" + }, + { + "kind": "template", + "source": "src/code_mower/templates/product-support/safe_gh_comment.py", + "target": "src/code_mower/templates/product-support/safe_gh_comment.py" }, { - "kind": "workflow", - "source": "generated", - "target": "templates/workflows/self-hosted-local-audit.yml.j2" + "kind": "template", + "source": "src/code_mower/templates/product-support/status_report.py", + "target": "src/code_mower/templates/product-support/status_report.py" }, { - "kind": "workflow", + "kind": "provider-catalog", "source": "generated", - "target": "templates/workflows/blind-review-artifacts-dry-run.yml.j2" + "target": "src/code_mower/templates/providers.yml" }, { - "kind": "workflow", - "source": "generated", - "target": "templates/workflows/hosted-bridge.yml.j2" + "kind": "config", + "source": "src/code_mower/templates/reviewer-spend.example.json", + "target": "src/code_mower/templates/reviewer-spend.example.json" }, { - "kind": "workflow", - "source": "generated", - "target": "templates/workflows/builder-provenance.yml.j2" + "kind": "config", + "source": "tools/reviewer_value_report.example.md", + "target": "src/code_mower/templates/reviewer-value-report.example.md" }, { - "kind": "workflow", - "source": "generated", - "target": "templates/workflows/dispatch-lanes.yml.j2" + "kind": "template", + "source": "src/code_mower/templates/slack/app-manifest.json", + "target": "src/code_mower/templates/slack/app-manifest.json" }, { "kind": "workflow", "source": "generated", - "target": "templates/workflows/lane-mac-runner.yml.j2" + "target": "src/code_mower/templates/workflows/audit-label-cleanup.yml.j2" }, { "kind": "workflow", "source": "generated", - "target": "templates/workflows/code-mower-agent-pr-labeler.yml.j2" + "target": "src/code_mower/templates/workflows/builder-provenance.yml.j2" }, { "kind": "workflow", "source": "generated", - "target": "templates/workflows/code-mower-fix-round-dispatch.yml.j2" + "target": "src/code_mower/templates/workflows/code-mower-agent-pr-labeler.yml.j2" }, { "kind": "workflow", "source": "generated", - "target": "templates/workflows/code-mower-gate-health.yml.j2" + "target": "src/code_mower/templates/workflows/code-mower-fix-round-dispatch.yml.j2" }, { "kind": "workflow", "source": "generated", - "target": "templates/workflows/code-mower-gate.yml.j2" + "target": "src/code_mower/templates/workflows/code-mower-gate-health.yml.j2" }, { "kind": "workflow", "source": "generated", - "target": "templates/workflows/audit-label-cleanup.yml.j2" + "target": "src/code_mower/templates/workflows/code-mower-gate.yml.j2" }, { "kind": "workflow", "source": "generated", - "target": "templates/workflows/review-clear-stale.yml.j2" + "target": "src/code_mower/templates/workflows/dispatch-lanes.yml.j2" }, { "kind": "workflow", "source": "generated", - "target": "src/code_mower/templates/workflows/trailer-comment-labeler.yml.j2" + "target": "src/code_mower/templates/workflows/hosted-bridge.yml.j2" }, { "kind": "workflow", "source": "generated", - "target": "src/code_mower/templates/workflows/saas-reviewer-labeler.yml.j2" + "target": "src/code_mower/templates/workflows/lane-mac-runner.yml.j2" }, { "kind": "workflow", @@ -1295,142 +1680,137 @@ { "kind": "workflow", "source": "generated", - "target": "src/code_mower/templates/workflows/self-hosted-local-audit.yml.j2" + "target": "src/code_mower/templates/workflows/needs-owner-notify.yml.j2" }, { "kind": "workflow", "source": "generated", - "target": "src/code_mower/templates/workflows/hosted-bridge.yml.j2" + "target": "src/code_mower/templates/workflows/private-standalone-shadow.yml.j2" }, { "kind": "workflow", "source": "generated", - "target": "src/code_mower/templates/workflows/builder-provenance.yml.j2" + "target": "src/code_mower/templates/workflows/review-clear-stale.yml.j2" }, { "kind": "workflow", "source": "generated", - "target": "src/code_mower/templates/workflows/dispatch-lanes.yml.j2" + "target": "src/code_mower/templates/workflows/saas-reviewer-labeler.yml.j2" }, { "kind": "workflow", "source": "generated", - "target": "src/code_mower/templates/workflows/lane-mac-runner.yml.j2" + "target": "src/code_mower/templates/workflows/self-hosted-local-audit.yml.j2" }, { "kind": "workflow", "source": "generated", - "target": "src/code_mower/templates/workflows/code-mower-agent-pr-labeler.yml.j2" + "target": "src/code_mower/templates/workflows/trailer-comment-labeler.yml.j2" }, { "kind": "workflow", "source": "generated", - "target": "src/code_mower/templates/workflows/code-mower-fix-round-dispatch.yml.j2" + "target": "src/code_mower/templates/workflows/weekly-status.yml.j2" }, { - "kind": "workflow", - "source": "generated", - "target": "src/code_mower/templates/workflows/code-mower-gate-health.yml.j2" + "kind": "core", + "source": "src/code_mower/tracker_contract.py", + "target": "src/code_mower/tracker_contract.py" }, { - "kind": "workflow", - "source": "generated", - "target": "src/code_mower/templates/workflows/code-mower-gate.yml.j2" + "kind": "core", + "source": "src/code_mower/tracker_queue.py", + "target": "src/code_mower/tracker_queue.py" }, { - "kind": "workflow", - "source": "generated", - "target": "src/code_mower/templates/workflows/audit-label-cleanup.yml.j2" + "kind": "labeler", + "source": "src/code_mower/trailer_comment_labeler.py", + "target": "src/code_mower/trailer_comment_labeler.py" }, { - "kind": "workflow", - "source": "generated", - "target": "src/code_mower/templates/workflows/review-clear-stale.yml.j2" + "kind": "core", + "source": "tools/code_mower_versioning.py", + "target": "src/code_mower/versioning.py" }, { - "kind": "workflow", - "source": "generated", - "target": "src/code_mower/templates/workflows/needs-owner-notify.yml.j2" + "kind": "core", + "source": "src/code_mower/work_orders.py", + "target": "src/code_mower/work_orders.py" }, { - "kind": "workflow", - "source": "generated", - "target": "src/code_mower/templates/workflows/weekly-status.yml.j2" + "kind": "core", + "source": "tools/workflow_actionlint.py", + "target": "src/code_mower/workflow_actionlint.py" }, { - "kind": "workflow", - "source": "generated", - "target": "templates/workflows/private-standalone-shadow.yml.j2" + "kind": "core", + "source": "src/code_mower/yaml_subset.py", + "target": "src/code_mower/yaml_subset.py" }, { - "kind": "workflow", - "source": "generated", - "target": "src/code_mower/templates/workflows/private-standalone-shadow.yml.j2" + "kind": "config", + "source": "templates/builder-experiment.example.json", + "target": "templates/builder-experiment.example.json" }, { - "kind": "lane-template", - "source": "generated", - "target": "templates/lanes/README.md" + "kind": "config", + "source": "templates/calibration-corpus.example.json", + "target": "templates/calibration-corpus.example.json" }, { - "kind": "lane-template", - "source": "generated", - "target": "templates/lanes/claude.md" + "kind": "config", + "source": "templates/calibration-corpus.json", + "target": "templates/calibration-corpus.json" }, { - "kind": "lane-template", + "kind": "config", "source": "generated", - "target": "templates/lanes/codex.md" + "target": "templates/code-mower.yml.j2" }, { - "kind": "lane-template", - "source": "generated", - "target": "templates/lanes/generic.md" + "kind": "config", + "source": "templates/context-packs.example.json", + "target": "templates/context-packs.example.json" }, { - "kind": "lane-template", - "source": "generated", - "target": "templates/lanes/cursor.md" + "kind": "config", + "source": "templates/cursor/BUGBOT.md", + "target": "templates/cursor/BUGBOT.md" }, { "kind": "lane-template", "source": "generated", - "target": "templates/lanes/run_mac_lane.sh" + "target": "templates/lanes/README.md" }, { "kind": "lane-template", "source": "generated", - "target": "src/code_mower/templates/lanes/README.md" + "target": "templates/lanes/claude.md" }, { "kind": "lane-template", "source": "generated", - "target": "src/code_mower/templates/lanes/claude.md" + "target": "templates/lanes/codex.md" }, { "kind": "lane-template", "source": "generated", - "target": "src/code_mower/templates/lanes/codex.md" + "target": "templates/lanes/cursor.md" }, { "kind": "lane-template", "source": "generated", - "target": "src/code_mower/templates/lanes/generic.md" + "target": "templates/lanes/devin.md" }, { "kind": "lane-template", "source": "generated", - "target": "src/code_mower/templates/lanes/cursor.md" + "target": "templates/lanes/generic.md" }, { "kind": "lane-template", "source": "generated", - "target": "src/code_mower/templates/lanes/run_mac_lane.sh" - }, - { - "kind": "config", - "source": "generated", - "target": "templates/code-mower.yml.j2" + "target": "templates/lanes/run_mac_lane.sh" }, { "kind": "provider-config", @@ -1477,11 +1857,21 @@ "source": "generated", "target": "templates/providers/cursor_bugbot.yml" }, + { + "kind": "provider-template", + "source": "generated", + "target": "templates/providers/cursor_cloud_agent.yml" + }, { "kind": "provider-template", "source": "generated", "target": "templates/providers/devin.yml" }, + { + "kind": "provider-template", + "source": "generated", + "target": "templates/providers/devin_cli.yml" + }, { "kind": "provider-template", "source": "generated", @@ -1515,17 +1905,22 @@ { "kind": "provider-template", "source": "generated", - "target": "templates/providers/qodo.yml" + "target": "templates/providers/muse_cli.yml" }, { - "kind": "manifest", + "kind": "provider-template", "source": "generated", - "target": "code-mower-package-manifest.json" + "target": "templates/providers/qodo.yml" }, { - "kind": "core", - "source": "src/code_mower/slack_ingress.py", - "target": "src/code_mower/slack_ingress.py" + "kind": "config", + "source": "templates/reviewer-spend.example.json", + "target": "templates/reviewer-spend.example.json" + }, + { + "kind": "config", + "source": "tools/reviewer_value_report.example.md", + "target": "templates/reviewer-value-report.example.md" }, { "kind": "template", @@ -1533,14 +1928,84 @@ "target": "templates/slack/app-manifest.json" }, { - "kind": "template", - "source": "src/code_mower/templates/slack/app-manifest.json", - "target": "src/code_mower/templates/slack/app-manifest.json" + "kind": "workflow", + "source": "generated", + "target": "templates/workflows/audit-label-cleanup.yml.j2" }, { - "kind": "doc", - "source": "docs/slack-ingress.md", - "target": "docs/slack-ingress.md" + "kind": "workflow", + "source": "generated", + "target": "templates/workflows/blind-review-artifacts-dry-run.yml.j2" + }, + { + "kind": "workflow", + "source": "generated", + "target": "templates/workflows/builder-provenance.yml.j2" + }, + { + "kind": "workflow", + "source": "generated", + "target": "templates/workflows/code-mower-agent-pr-labeler.yml.j2" + }, + { + "kind": "workflow", + "source": "generated", + "target": "templates/workflows/code-mower-fix-round-dispatch.yml.j2" + }, + { + "kind": "workflow", + "source": "generated", + "target": "templates/workflows/code-mower-gate-health.yml.j2" + }, + { + "kind": "workflow", + "source": "generated", + "target": "templates/workflows/code-mower-gate.yml.j2" + }, + { + "kind": "workflow", + "source": "generated", + "target": "templates/workflows/dispatch-lanes.yml.j2" + }, + { + "kind": "workflow", + "source": "generated", + "target": "templates/workflows/hosted-bridge.yml.j2" + }, + { + "kind": "workflow", + "source": "generated", + "target": "templates/workflows/lane-mac-runner.yml.j2" + }, + { + "kind": "workflow", + "source": "generated", + "target": "templates/workflows/local-cli-audit.yml.j2" + }, + { + "kind": "workflow", + "source": "generated", + "target": "templates/workflows/private-standalone-shadow.yml.j2" + }, + { + "kind": "workflow", + "source": "generated", + "target": "templates/workflows/review-clear-stale.yml.j2" + }, + { + "kind": "workflow", + "source": "generated", + "target": "templates/workflows/saas-reviewer-labeler.yml.j2" + }, + { + "kind": "workflow", + "source": "generated", + "target": "templates/workflows/self-hosted-local-audit.yml.j2" + }, + { + "kind": "workflow", + "source": "generated", + "target": "templates/workflows/trailer-comment-labeler.yml.j2" } ], "mode": "materialize", diff --git a/docs/pypi-release.md b/docs/pypi-release.md index 4f991e3d..602b8061 100644 --- a/docs/pypi-release.md +++ b/docs/pypi-release.md @@ -128,6 +128,234 @@ publish inputs set to `false` and confirm `build-distributions` and trusted-publishing setup or risky packaging changes; routine publishing can go from the green no-publish verification run to production PyPI. +## v1.4.0 Post-Merge Release Runbook + +Run these steps in this order after the release pull request merges. Every +irreversible step binds its inputs first: the exact merge head, the tag target, +the workflow run ID, and the artifact filenames and digests. Replace each +`REPLACE_WITH_...` value with the exact observed value, and keep tokens, profile +paths, private repository paths, and provider prose out of recorded output. + +### 1. Bind the immutable merge head + +```bash +REPO="codemower-ai/code-mower" +git fetch origin main --tags +RELEASE_SHA="$(git rev-parse origin/main)" +git checkout "$RELEASE_SHA" +test "$(git rev-parse HEAD)" = "$RELEASE_SHA" +code-mower migration release-readiness --json +``` + +### 2. Create and verify the annotated `v1.4.0` tag + +```bash +git tag -a v1.4.0 "$RELEASE_SHA" -m "Code Mower v1.4.0" +git push origin refs/tags/v1.4.0 +test "$(git rev-list -n 1 v1.4.0)" = "$RELEASE_SHA" +test "$(git ls-remote origin 'refs/tags/v1.4.0^{}' | awk '{print $1}')" = "$RELEASE_SHA" +``` + +### 3. Run `release.yml` with no publishing first + +```bash +gh workflow run release.yml --repo "$REPO" --ref v1.4.0 \ + -f publish_testpypi=false -f publish_pypi=false +NO_PUBLISH_RUN_ID="REPLACE_WITH_EXACT_RUN_ID" +gh run watch "$NO_PUBLISH_RUN_ID" --repo "$REPO" --exit-status +gh run view "$NO_PUBLISH_RUN_ID" --repo "$REPO" \ + --json databaseId,headSha,event,status,conclusion,url +``` + +### 4. Publish TestPyPI only, then rehearse it + +```bash +gh workflow run release.yml --repo "$REPO" --ref v1.4.0 \ + -f publish_testpypi=true -f publish_pypi=false +TESTPYPI_RUN_ID="REPLACE_WITH_EXACT_RUN_ID" +gh run watch "$TESTPYPI_RUN_ID" --repo "$REPO" --exit-status +TESTPYPI_WORK_DIR="$(mktemp -d /tmp/code-mower-v140-testpypi-rehearsal.XXXXXX)" +code-mower migration package-install-rehearsal \ + --package-spec code-mower==1.4.0 \ + --python "$(command -v python3.12)" \ + --work-dir "$TESTPYPI_WORK_DIR" \ + --pip-index-url https://test.pypi.org/simple/ \ + --pip-extra-index-url https://pypi.org/simple/ \ + --allow-package-index --upgrade-pip --json +``` + +### 5. Publish production PyPI only, then rehearse it + +```bash +gh workflow run release.yml --repo "$REPO" --ref v1.4.0 \ + -f publish_testpypi=false -f publish_pypi=true +PYPI_RUN_ID="REPLACE_WITH_EXACT_RUN_ID" +gh run watch "$PYPI_RUN_ID" --repo "$REPO" --exit-status +PYPI_WORK_DIR="$(mktemp -d /tmp/code-mower-v140-pypi-rehearsal.XXXXXX)" +code-mower migration package-install-rehearsal \ + --package-spec code-mower==1.4.0 \ + --python "$(command -v python3.12)" \ + --work-dir "$PYPI_WORK_DIR" \ + --allow-package-index --upgrade-pip --json +``` + +### 6. Download the exact workflow artifact + +```bash +PROD_DIST_DIR="$(mktemp -d /tmp/code-mower-v140-prod-dist.XXXXXX)" +gh run download "$PYPI_RUN_ID" --repo "$REPO" \ + --name code-mower-dist --dir "$PROD_DIST_DIR" +ls -1 "$PROD_DIST_DIR" +sha256sum "$PROD_DIST_DIR"/* +``` + +### 7. Compare SHA-256 digests with the files downloaded from PyPI + +```bash +PYPI_DOWNLOAD_DIR="$(mktemp -d /tmp/code-mower-v140-pypi-download.XXXXXX)" +python3.12 -m pip download code-mower==1.4.0 --no-deps --no-binary :all: \ + --dest "$PYPI_DOWNLOAD_DIR" +python3.12 -m pip download code-mower==1.4.0 --no-deps --only-binary :all: \ + --dest "$PYPI_DOWNLOAD_DIR" +sha256sum "$PYPI_DOWNLOAD_DIR"/* +PROD_DIST_DIR="$PROD_DIST_DIR" PYPI_DOWNLOAD_DIR="$PYPI_DOWNLOAD_DIR" python3.12 - <<'PY' +import hashlib +import json +import os +from pathlib import Path + + +def digests(directory): + return { + path.name: hashlib.sha256(path.read_bytes()).hexdigest() + for path in sorted(Path(directory).iterdir()) + if path.is_file() + } + + +workflow = digests(os.environ["PROD_DIST_DIR"]) +published = digests(os.environ["PYPI_DOWNLOAD_DIR"]) +if len(workflow) != 2 or set(workflow) != set(published): + raise SystemExit("workflow and PyPI artifact sets differ") +if any(workflow[name] != published[name] for name in workflow): + raise SystemExit("workflow and PyPI SHA-256 values differ") +print(json.dumps({"artifact_count": len(workflow), "sha256_match": True})) +PY +``` + +Only continue when the artifact set and every digest match. A mismatch is a +release blocker: do not attach unverified files. + +### 8. Create the GitHub Release with those exact assets + +```bash +gh release create v1.4.0 "$PROD_DIST_DIR"/* --repo "$REPO" \ + --verify-tag --title "Code Mower v1.4.0" \ + --notes-file docs/v140-release-notes.md --latest --fail-on-no-commits +gh release view v1.4.0 --repo "$REPO" \ + --json tagName,targetCommitish,isDraft,isPrerelease,publishedAt,url,assets +RELEASE_EVENT_RUN_ID="REPLACE_WITH_EXACT_RELEASE_EVENT_RUN_ID" +gh run watch "$RELEASE_EVENT_RUN_ID" --repo "$REPO" --exit-status +gh run view "$RELEASE_EVENT_RUN_ID" --repo "$REPO" \ + --json databaseId,headSha,event,status,conclusion,url +``` + +Use `gh release upload v1.4.0 "$PROD_DIST_DIR"/* --repo "$REPO" --clobber` when +the release already exists. The publish jobs of the `release`-event run must stay +skipped; it must not publish again. + +### 9. Install locally and verify Devin readiness + +```bash +CODE_MOWER_PYTHON="$(command -v python3.12)" +test -n "$CODE_MOWER_PYTHON" +PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" \ + 'code-mower[coworker]==1.4.0' +code-mower --version +DEVIN_PROVIDER_PROFILE="REPLACE_WITH_PROTECTED_PROFILE_SELECTOR" +code-mower doctor --easy --devin \ + --provider-profile "$DEVIN_PROVIDER_PROFILE" --json +``` + +### 10. Run the required Claude + Codex + Devin campaign + +```bash +RELEASE_PR="REPLACE_WITH_RELEASE_PR_NUMBER" +code-mower release campaign create \ + --release-tag v1.4.0 \ + --package-spec code-mower==1.4.0 \ + --providers claude,codex,devin \ + --required-providers claude,codex,devin \ + --qualification-context cold_install \ + --package-source pypi \ + --repo-slug codemower-ai/code-mower \ + --issue 912 --release-pr "$RELEASE_PR" \ + --provider-profile "$DEVIN_PROVIDER_PROFILE" \ + --apply --json +code-mower release campaign watch --release-tag v1.4.0 \ + --interval 10 --timeout 3600 --json +code-mower release campaign status --release-tag v1.4.0 --json +``` + +All three provider results must pass, and Devin's result must identify the +hosted transport before peer support is claimed. + +### 11. Restart the three Boards from the release + +```bash +CODE_MOWER_RELEASE_CHECKOUT="REPLACE_WITH_EXACT_V140_CHECKOUT" +BOARD_5342_REPO="REUSE_PRIVATE_INVENTORIED_SLUG" +BOARD_5342_REPO_PATH="REUSE_PRIVATE_INVENTORIED_PATH" +BOARD_5344_REPO="REUSE_PRIVATE_INVENTORIED_SLUG" +BOARD_5344_REPO_PATH="REUSE_PRIVATE_INVENTORIED_PATH" +code-mower board list --json +code-mower board stop --port 5332 --yes --json +code-mower board stop --port 5342 --yes --json +code-mower board stop --port 5344 --yes --json +nohup code-mower board serve --repo codemower-ai/code-mower \ + --repo-path "$CODE_MOWER_RELEASE_CHECKOUT" --host 127.0.0.1 \ + --port 5332 --record-events >/tmp/code-mower-board-5332.log 2>&1 & +nohup code-mower board serve --repo "$BOARD_5342_REPO" \ + --repo-path "$BOARD_5342_REPO_PATH" --host 127.0.0.1 \ + --port 5342 --record-events >/tmp/code-mower-board-5342.log 2>&1 & +nohup code-mower board serve --repo "$BOARD_5344_REPO" \ + --repo-path "$BOARD_5344_REPO_PATH" --host 127.0.0.1 \ + --port 5344 --record-events >/tmp/code-mower-board-5344.log 2>&1 & +code-mower board list --json +code-mower board doctor --repo codemower-ai/code-mower \ + --repo-path "$CODE_MOWER_RELEASE_CHECKOUT" --json +``` + +Each inventory row must report serving and installed version `1.4.0`. The port +5332 Board must be restarted from the exact v1.4.0 release checkout because its +pre-release repository path is stale. Run `code-mower board doctor` for the other +two Boards with their privately inventoried slugs and paths. Do not use raw +process kills or Board reset, and do not copy private repository slugs or paths +into public evidence. + +### 12. Dry-run, inspect, then upload metadata-only cloud evidence + +```bash +code-mower cloud doctor --install-id codex-code-mower --probe-service --json +code-mower release campaign upload --release-tag v1.4.0 \ + --install-id codex-code-mower --team-id jeff-internal --json +code-mower release campaign upload --release-tag v1.4.0 \ + --install-id codex-code-mower --team-id jeff-internal --yes --json +BOARD_SNAPSHOT_DIR="$(mktemp -d /tmp/code-mower-v140-board-snapshot.XXXXXX)" +code-mower cloud board-snapshot \ + --repo-path "$CODE_MOWER_RELEASE_CHECKOUT" \ + --repo-slug codemower-ai/code-mower \ + --output-dir "$BOARD_SNAPSHOT_DIR" \ + --install-id codex-code-mower --team-id jeff-internal --json +code-mower cloud upload "$BOARD_SNAPSHOT_DIR" \ + --install-id codex-code-mower --dry-run --json +code-mower cloud upload "$BOARD_SNAPSHOT_DIR" \ + --install-id codex-code-mower --yes --json +``` + +Record accepted event identifiers and counts only, never report prose, profile +paths, tokens, or local configuration. + ## Cache Bypass And Propagation Triage Use cache-bypassing exact-version installs when validating a just-published diff --git a/docs/v140-release-notes.md b/docs/v140-release-notes.md index 644a13bb..9bdc127c 100644 --- a/docs/v140-release-notes.md +++ b/docs/v140-release-notes.md @@ -89,6 +89,8 @@ packet fingerprints, provider prose, or Slack message content. ## Post-merge release steps +The ordered, executable commands live in the +[v1.4.0 post-merge release runbook](pypi-release.md#v140-post-merge-release-runbook). The orchestrator owns every step after this pull request merges: binding the annotated `v1.4.0` tag to the exact release merge commit, the no-publish `release.yml` rehearsal at `--ref v1.4.0`, TestPyPI publication and rehearsal, diff --git a/src/code_mower/package.py b/src/code_mower/package.py index 7285893d..fd155cb8 100644 --- a/src/code_mower/package.py +++ b/src/code_mower/package.py @@ -8,6 +8,7 @@ import re import shutil import sys +import tempfile from pathlib import Path from typing import Any, Mapping @@ -611,6 +612,63 @@ def materialize_package_plan( return RenderedPlan(text="\n".join(lines) + "\n", data=manifest) +COMMITTED_PACKAGE_MANIFEST = "code-mower-package-manifest.json" +GENERATED_OUTPUT_DIR = "" + + +def normalized_package_manifest(manifest: Mapping[str, Any]) -> dict[str, Any]: + """Normalize a materialization manifest so committed copies compare exactly. + + The local output directory is replaced by a placeholder and the written-file + inventory is ordered, so the committed artifact records the package surface + and its source mappings rather than one machine's materialization run. + """ + + files = [ + { + "target": str(entry.get("target", "")), + "source": str(entry.get("source", "")), + "kind": str(entry.get("kind", "")), + } + for entry in manifest.get("files_written", []) + if isinstance(entry, Mapping) + ] + deferred = [ + dict(entry) + for entry in manifest.get("deferred_package_files", []) + if isinstance(entry, Mapping) + ] + return { + "mode": str(manifest.get("mode", "")), + "package": dict(manifest.get("package", {})), + "output_dir": GENERATED_OUTPUT_DIR, + "files_written": sorted( + files, key=lambda entry: (entry["target"], entry["source"], entry["kind"]) + ), + "deferred_package_files": deferred, + } + + +def generate_committed_package_manifest(repo_root: Path) -> dict[str, Any]: + """Materialize the package into a scratch tree and normalize its manifest.""" + + repo_root = Path(repo_root).expanduser().resolve() + templates = repo_root / "src" / "code_mower" / "templates" + plan = render_package_plan( + load_config(templates / "code-mower.example.yml"), + load_provider_templates(templates / "providers.yml"), + ) + with tempfile.TemporaryDirectory() as tmp: + materialized = materialize_package_plan( + plan, output_dir=Path(tmp), repo_root=repo_root, force=True + ) + return normalized_package_manifest(materialized.data) + + +def committed_package_manifest_text(manifest: Mapping[str, Any]) -> str: + return json.dumps(manifest, indent=2, sort_keys=True) + "\n" + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("config", nargs="?") diff --git a/src/code_mower/package_manifest.py b/src/code_mower/package_manifest.py index 14fc74e3..0ac63cbc 100644 --- a/src/code_mower/package_manifest.py +++ b/src/code_mower/package_manifest.py @@ -580,6 +580,7 @@ ("docs/slack-ingress.md", "docs/slack-ingress.md", "doc"), ("docs/slack-contract.md", "docs/slack-contract.md", "doc"), ("docs/graphify-evaluation.md", "docs/graphify-evaluation.md", "doc"), + ("docs/context-graph-lifecycle.md", "docs/context-graph-lifecycle.md", "doc"), ("docs/context-connections.md", "docs/context-connections.md", "doc"), ("docs/context-setup.md", "docs/context-setup.md", "doc"), ("docs/v130-release-notes.md", "docs/v130-release-notes.md", "doc"), diff --git a/src/code_mower/release_readiness.py b/src/code_mower/release_readiness.py index 4dce7ae4..7e7f0a3d 100644 --- a/src/code_mower/release_readiness.py +++ b/src/code_mower/release_readiness.py @@ -126,17 +126,142 @@ def _materialized_package_versions(repo_path: Path) -> dict[str, Any]: } -def _committed_manifest_version(repo_path: Path) -> str: - text = _read_text_if_exists(repo_path / "code-mower-package-manifest.json") +def _committed_manifest(repo_path: Path) -> dict[str, Any] | None: + text = _read_text_if_exists( + repo_path / package_module.COMMITTED_PACKAGE_MANIFEST + ) try: - manifest = json.loads(text) if text.strip() else {} + manifest = json.loads(text) if text.strip() else None except json.JSONDecodeError: - return "" - package = manifest.get("package") if isinstance(manifest, dict) else None + return None + return manifest if isinstance(manifest, dict) else None + + +def _committed_manifest_version(repo_path: Path) -> str: + manifest = _committed_manifest(repo_path) or {} + package = manifest.get("package") version = package.get("version") if isinstance(package, dict) else None return version if isinstance(version, str) else "" +def _manifest_inventory(manifest: dict[str, Any]) -> dict[str, dict[str, str]]: + inventory: dict[str, dict[str, str]] = {} + for entry in manifest.get("files_written", []): + if isinstance(entry, dict): + inventory[str(entry.get("target", ""))] = { + "source": str(entry.get("source", "")), + "kind": str(entry.get("kind", "")), + } + return inventory + + +def _committed_manifest_drift(repo_path: Path) -> dict[str, Any]: + """Compare the whole normalized committed manifest with a fresh generation. + + A stale committed artifact is a release defect: it is the published record of + the standalone package surface, so missing targets, unexpected targets, and + changed source mappings all fail readiness, not only a drifted version. + """ + + committed = _committed_manifest(repo_path) + if committed is None: + return { + "error": "committed package manifest is missing or not valid JSON", + "missing_targets": [], + "unexpected_targets": [], + "changed_targets": [], + "committed_file_count": 0, + "generated_file_count": 0, + "metadata_matches": False, + } + try: + generated = package_module.generate_committed_package_manifest(repo_path) + except Exception as exc: # pragma: no cover - exercised through status output. + return { + "error": str(exc), + "missing_targets": [], + "unexpected_targets": [], + "changed_targets": [], + "committed_file_count": len(_manifest_inventory(committed)), + "generated_file_count": 0, + "metadata_matches": False, + } + normalized = package_module.normalized_package_manifest(committed) + committed_files = _manifest_inventory(normalized) + generated_files = _manifest_inventory(generated) + changed = sorted( + target + for target, entry in generated_files.items() + if target in committed_files and committed_files[target] != entry + ) + return { + "error": "", + "missing_targets": sorted(set(generated_files) - set(committed_files))[:20], + "unexpected_targets": sorted(set(committed_files) - set(generated_files))[:20], + "changed_targets": changed[:20], + "committed_file_count": len(committed_files), + "generated_file_count": len(generated_files), + "metadata_matches": all( + normalized.get(key) == generated.get(key) + for key in ("mode", "package", "output_dir", "deferred_package_files") + ), + } + + +def _manifest_matches_generated(drift: dict[str, Any]) -> bool: + return bool( + not drift["error"] + and drift["metadata_matches"] + and not drift["missing_targets"] + and not drift["unexpected_targets"] + and not drift["changed_targets"] + and drift["committed_file_count"] == drift["generated_file_count"] + ) + + +def _post_merge_runbook_markers(release_tag: str, package_index_spec: str) -> tuple[str, ...]: + """Ordered, exact commands the post-merge runbook must publish in sequence.""" + + return ( + 'RELEASE_SHA="$(git rev-parse origin/main)"', + f'git tag -a {release_tag} "$RELEASE_SHA"', + f"git push origin refs/tags/{release_tag}", + "-f publish_testpypi=false -f publish_pypi=false", + "-f publish_testpypi=true -f publish_pypi=false", + f"--package-spec {package_index_spec}", + "--pip-index-url https://test.pypi.org/simple/", + "-f publish_testpypi=false -f publish_pypi=true", + "gh run download", + "--name code-mower-dist", + "python3.12 -m pip download", + "sha256", + f"gh release create {release_tag}", + "--verify-tag", + "code-mower doctor --easy --devin", + "code-mower release campaign create", + "--required-providers claude,codex,devin", + "--port 5332", + "--port 5342", + "--port 5344", + "code-mower cloud upload", + "--dry-run --json", + ) + + +def _unordered_markers(text: str, markers: tuple[str, ...]) -> list[str]: + """Report markers that are missing or appear before their predecessor.""" + + problems: list[str] = [] + position = -1 + for marker in markers: + found = text.find(marker, position + 1) + if found < 0: + problems.append(marker) + continue + position = found + return problems + + def _release_tag_for_version(version: str) -> str: return code_mower_versioning.release_tag_for_version(version) @@ -228,11 +353,23 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: init_version = _python_package_version(repo_path) pyproject_version = _pyproject_version(repo_path) manifest_version = _committed_manifest_version(repo_path) + manifest_drift = _committed_manifest_drift(repo_path) version = init_version or pyproject_version materialized_versions = _materialized_package_versions(repo_path) release_tag = _release_tag_for_version(version) if version else "" package_index_spec = f"code-mower=={version}" if version else "" doc_blob = "\n".join(docs.values()) + runbook_doc = docs.get("docs/pypi-release.md", "") + runbook_markers = ( + _post_merge_runbook_markers(release_tag, package_index_spec) + if release_tag and package_index_spec + else () + ) + missing_runbook_markers = ( + _unordered_markers(runbook_doc, runbook_markers) + if runbook_markers + else ["unknown release version"] + ) public_hygiene_blobs = { relative_path: text.lower() for relative_path, text in public_hygiene_docs.items() @@ -352,6 +489,17 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: "pyproject_version": pyproject_version, }, ), + _release_check( + check_id="committed-package-manifest-matches-generated", + title="Committed package manifest matches the current package inventory", + status="pass" if _manifest_matches_generated(manifest_drift) else "fail", + evidence=( + f"{package_module.COMMITTED_PACKAGE_MANIFEST}=" + f"{manifest_drift['committed_file_count']} file(s), " + f"generated={manifest_drift['generated_file_count']} file(s)" + ), + detail=manifest_drift, + ), _release_check( check_id="release-workflow-present", title="Release workflow exists", @@ -489,6 +637,17 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: ), evidence="docs/pypi-release.md", ), + _release_check( + check_id="post-merge-release-runbook-ordered", + title="Post-merge runbook publishes the full ordered release sequence", + status="pass" if not missing_runbook_markers else "fail", + evidence="docs/pypi-release.md", + detail={ + "release_tag": release_tag, + "required_commands": list(runbook_markers), + "missing_or_out_of_order": missing_runbook_markers, + }, + ), _release_check( check_id="public-maintainer-docs", title="Public maintainer and community files are present", @@ -558,6 +717,48 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: ), "url": PACKAGE_INDEX_SETUP_URLS["testpypi_project"], }, + { + "id": "publish-pypi-release", + "title": "Publish the verified distribution to production PyPI", + "command": ( + "gh workflow run release.yml --repo codemower-ai/code-mower " + f"--ref {release_workflow_ref} " + "-f publish_testpypi=false -f publish_pypi=true" + ), + "url": PACKAGE_INDEX_SETUP_URLS["release_workflow"], + }, + { + "id": "pypi-install-rehearsal", + "title": "Install the published package from production PyPI", + "command": ( + "code-mower migration package-install-rehearsal " + f"--package-spec {package_index_spec} " + "--allow-package-index " + "--upgrade-pip " + "--json" + ), + "url": PACKAGE_INDEX_SETUP_URLS["pypi_project"], + }, + { + "id": "compare-artifact-digests", + "title": "Compare the workflow artifact digests with PyPI before release assets", + "command": ( + "gh run download \"$PYPI_RUN_ID\" --repo codemower-ai/code-mower " + "--name code-mower-dist --dir \"$PROD_DIST_DIR\" " + "&& sha256sum \"$PROD_DIST_DIR\"/*" + ), + "url": PACKAGE_INDEX_SETUP_URLS["pypi_project"], + }, + { + "id": "create-github-release", + "title": "Attach the exact verified artifacts to the GitHub Release", + "command": ( + f"gh release create {release_tag or 'RELEASE_TAG'} " + "\"$PROD_DIST_DIR\"/* --repo codemower-ai/code-mower --verify-tag " + "--latest --fail-on-no-commits" + ), + "url": PACKAGE_INDEX_SETUP_URLS["release_workflow"], + }, ] return { "mode": "code-mower-release-readiness", diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py index 42b32a4d..c29e9b8f 100644 --- a/tests/test_release_hygiene.py +++ b/tests/test_release_hygiene.py @@ -15,6 +15,7 @@ import unittest import urllib.error from argparse import Namespace +from collections.abc import Callable from contextlib import ExitStack, nullcontext, redirect_stdout from io import StringIO from pathlib import Path @@ -7852,6 +7853,137 @@ def test_release_readiness_fails_on_committed_manifest_version_drift(self) -> No self.assertEqual(check["detail"]["manifest_version"], "0.5.0b53") self.assertEqual(check["detail"]["init_version"], "1.4.0") + def _manifest_drift_check(self, mutate: Callable[[dict], None]) -> dict: + committed = json.loads( + (ROOT / "code-mower-package-manifest.json").read_text(encoding="utf-8") + ) + generated = code_mower_package.normalized_package_manifest(committed) + mutate(committed) + with mock.patch.object( + release_readiness, "_committed_manifest", return_value=committed + ), mock.patch.object( + release_readiness.package_module, + "generate_committed_package_manifest", + return_value=generated, + ): + payload = release_readiness.render_release_readiness(ROOT) + checks = {check["id"]: check for check in payload["checks"]} + return checks["committed-package-manifest-matches-generated"] + + def test_committed_package_manifest_matches_freshly_generated_inventory(self) -> None: + committed_text = (ROOT / "code-mower-package-manifest.json").read_text( + encoding="utf-8" + ) + generated = code_mower_package.generate_committed_package_manifest(ROOT) + + self.assertEqual( + committed_text, + code_mower_package.committed_package_manifest_text(generated), + ) + self.assertEqual(generated["output_dir"], "") + self.assertEqual(generated["package"]["version"], __version__) + targets = {entry["target"] for entry in generated["files_written"]} + self.assertIn("docs/context-graph-lifecycle.md", targets) + self.assertIn("docs/graphify-evaluation.md", targets) + + def test_release_readiness_accepts_the_exact_regenerated_manifest(self) -> None: + check = self._manifest_drift_check(lambda manifest: None) + + self.assertEqual(check["status"], "pass") + self.assertEqual(check["detail"]["missing_targets"], []) + self.assertEqual(check["detail"]["unexpected_targets"], []) + self.assertEqual(check["detail"]["changed_targets"], []) + self.assertEqual( + check["detail"]["committed_file_count"], + check["detail"]["generated_file_count"], + ) + + def test_release_readiness_fails_on_stale_committed_manifest(self) -> None: + def truncate(manifest: dict) -> None: + manifest["files_written"] = manifest["files_written"][:307] + + check = self._manifest_drift_check(truncate) + + self.assertEqual(check["status"], "fail") + self.assertEqual(check["detail"]["committed_file_count"], 307) + self.assertGreater( + check["detail"]["generated_file_count"], + check["detail"]["committed_file_count"], + ) + self.assertTrue(check["detail"]["missing_targets"]) + + def test_release_readiness_fails_on_missing_manifest_entry(self) -> None: + def drop_lifecycle_doc(manifest: dict) -> None: + manifest["files_written"] = [ + entry + for entry in manifest["files_written"] + if entry["target"] != "docs/context-graph-lifecycle.md" + ] + + check = self._manifest_drift_check(drop_lifecycle_doc) + + self.assertEqual(check["status"], "fail") + self.assertEqual( + check["detail"]["missing_targets"], ["docs/context-graph-lifecycle.md"] + ) + + def test_release_readiness_fails_on_changed_manifest_source_mapping(self) -> None: + def rewrite_source(manifest: dict) -> None: + for entry in manifest["files_written"]: + if entry["target"] == "docs/context-graph-lifecycle.md": + entry["source"] = "docs/graphify-evaluation.md" + + check = self._manifest_drift_check(rewrite_source) + + self.assertEqual(check["status"], "fail") + self.assertEqual( + check["detail"]["changed_targets"], ["docs/context-graph-lifecycle.md"] + ) + + def test_packaged_graph_docs_link_target_is_packaged(self) -> None: + packaged = {target for _source, target, _kind in code_mower_package.PACKAGE_FILES} + + self.assertIn("docs/context-graph-lifecycle.md", packaged) + self.assertTrue((ROOT / "docs" / "context-graph-lifecycle.md").is_file()) + for doc in ("docs/graphify-evaluation.md", "docs/context-provider-contract.md"): + text = (ROOT / doc).read_text(encoding="utf-8") + if "context-graph-lifecycle.md" in text: + self.assertIn(doc, packaged) + + def test_release_readiness_requires_the_ordered_post_merge_runbook(self) -> None: + payload = release_readiness.render_release_readiness(ROOT) + checks = {check["id"]: check for check in payload["checks"]} + runbook = checks["post-merge-release-runbook-ordered"] + + self.assertEqual(runbook["status"], "pass") + self.assertEqual(runbook["detail"]["missing_or_out_of_order"], []) + self.assertIn( + "-f publish_testpypi=false -f publish_pypi=true", + runbook["detail"]["required_commands"], + ) + self.assertIn("gh release create v1.4.0", runbook["detail"]["required_commands"]) + commands = {action["id"]: action["command"] for action in payload["next_actions"]} + self.assertIn("publish_pypi=true", commands["publish-pypi-release"]) + self.assertIn("--name code-mower-dist", commands["compare-artifact-digests"]) + self.assertIn("--verify-tag", commands["create-github-release"]) + + def test_release_readiness_fails_when_the_runbook_stops_at_testpypi(self) -> None: + docs = release_readiness._release_docs(ROOT) + docs["docs/pypi-release.md"] = docs["docs/pypi-release.md"].partition( + "### 5. Publish production PyPI only" + )[0] + + with mock.patch.object(release_readiness, "_release_docs", return_value=docs): + payload = release_readiness.render_release_readiness(ROOT) + + checks = {check["id"]: check for check in payload["checks"]} + runbook = checks["post-merge-release-runbook-ordered"] + self.assertEqual(payload["status"], "fail") + self.assertEqual(runbook["status"], "fail") + self.assertIn( + "gh release create v1.4.0", runbook["detail"]["missing_or_out_of_order"] + ) + def test_public_support_docs_are_packaged_and_privacy_forward(self) -> None: manifest = (ROOT / "MANIFEST.in").read_text(encoding="utf-8") readme = (ROOT / "README.md").read_text(encoding="utf-8") From 7e77803ccf001f7847c09171848d68851b24ca0c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:12:02 +0000 Subject: [PATCH 04/20] Assert every irreversible v1.4.0 release gate and require strict manifest equality Co-Authored-By: bot_apk --- docs/first-user-install-rehearsal.md | 10 +- docs/pypi-release.md | 524 ++++++++++++++++++++++++--- src/code_mower/release_readiness.py | 246 +++++++++++-- tests/test_release_hygiene.py | 302 ++++++++++++++- 4 files changed, 977 insertions(+), 105 deletions(-) diff --git a/docs/first-user-install-rehearsal.md b/docs/first-user-install-rehearsal.md index 14e227bc..bb9a7c0b 100644 --- a/docs/first-user-install-rehearsal.md +++ b/docs/first-user-install-rehearsal.md @@ -317,7 +317,11 @@ gh workflow run release.yml \ ``` After that workflow run finishes, record its workflow run link and rehearse the -candidate from TestPyPI: +candidate from TestPyPI. This command only shows that the candidate installs +alongside canonical PyPI: pip does not prefer `--index-url` over +`--extra-index-url`, so use the source-exclusive TestPyPI fetch in the +[v1.4.0 post-merge release runbook](pypi-release.md#6-publish-testpypi-only-then-rehearse-the-exact-candidate-from-testpypi) +when TestPyPI must be proven as the artifact source: ```bash code-mower migration package-install-rehearsal \ @@ -327,7 +331,7 @@ code-mower migration package-install-rehearsal \ --pip-index-url https://test.pypi.org/simple/ \ --pip-extra-index-url https://pypi.org/simple/ \ --python "$(command -v python3.12)" \ - --work-dir /tmp/code-mower-v100-testpypi-rehearsal \ + --work-dir /tmp/code-mower-v140-testpypi-rehearsal \ --json ``` @@ -350,7 +354,7 @@ code-mower migration package-install-rehearsal \ --allow-package-index \ --upgrade-pip \ --python "$(command -v python3.12)" \ - --work-dir /tmp/code-mower-v100-pypi-rehearsal \ + --work-dir /tmp/code-mower-v140-pypi-rehearsal \ --json ``` diff --git a/docs/pypi-release.md b/docs/pypi-release.md index 602b8061..8544526a 100644 --- a/docs/pypi-release.md +++ b/docs/pypi-release.md @@ -131,23 +131,68 @@ publish inputs set to `false` and confirm `build-distributions` and ## v1.4.0 Post-Merge Release Runbook Run these steps in this order after the release pull request merges. Every -irreversible step binds its inputs first: the exact merge head, the tag target, -the workflow run ID, and the artifact filenames and digests. Replace each +irreversible step binds its inputs and asserts them before it runs: the exact +merge commit OID, the tag target, the workflow run identity and job posture, and +the artifact filenames and digests. Every check below is an assertion that exits +nonzero on mismatch; printed JSON alone is not evidence. Replace each `REPLACE_WITH_...` value with the exact observed value, and keep tokens, profile paths, private repository paths, and provider prose out of recorded output. -### 1. Bind the immutable merge head +### 1. Bind the immutable release commit from the merged release PR + +`origin/main` is mutable and may already carry later commits, so the release +commit is the release pull request's own merge commit OID. ```bash REPO="codemower-ai/code-mower" -git fetch origin main --tags -RELEASE_SHA="$(git rev-parse origin/main)" -git checkout "$RELEASE_SHA" +RELEASE_PR="REPLACE_WITH_RELEASE_PR_NUMBER" +test "$(gh pr view "$RELEASE_PR" --repo "$REPO" --json state --jq '.state')" = "MERGED" +RELEASE_SHA="$(gh pr view "$RELEASE_PR" --repo "$REPO" \ + --json mergeCommit --jq '.mergeCommit.oid')" +printf '%s\n' "$RELEASE_SHA" | grep -Eq '^[0-9a-f]{40}$' +git fetch origin "$RELEASE_SHA" +git checkout --detach "$RELEASE_SHA" test "$(git rev-parse HEAD)" = "$RELEASE_SHA" -code-mower migration release-readiness --json +test "$(git cat-file -t "$RELEASE_SHA")" = "commit" +``` + +### 2. Run release readiness from a fresh source environment at that commit + +The ambient `code-mower` executable is still the previous release, so readiness +runs from a clean virtual environment built out of the exact `RELEASE_SHA` +checkout. + +```bash +RELEASE_ENV="$(mktemp -d /tmp/code-mower-v140-release-env.XXXXXX)" +python3.12 -m venv "$RELEASE_ENV/venv" +RELEASE_PYTHON="$RELEASE_ENV/venv/bin/python" +PIP_CONFIG_FILE=/dev/null PIP_INDEX_URL= PIP_EXTRA_INDEX_URL= \ + "$RELEASE_PYTHON" -m pip install --no-cache-dir --index-url https://pypi.org/simple/ . +RELEASE_CLI="$RELEASE_ENV/venv/bin/code-mower" +test "$("$RELEASE_CLI" --version)" = "code-mower 1.4.0" +"$RELEASE_CLI" migration release-readiness --json >"$RELEASE_ENV/readiness.json" +READINESS_JSON="$RELEASE_ENV/readiness.json" "$RELEASE_PYTHON" - <<'PY' +import json +import os + +report = json.loads(open(os.environ["READINESS_JSON"], encoding="utf-8").read()) +checks = {row["id"]: row["status"] for row in report["checks"]} +required = [ + "package-version-consistency", + "committed-package-manifest-version", + "committed-package-manifest-matches-generated", + "post-merge-release-runbook-ordered", + "post-merge-release-runbook-asserted", +] +missing = [check for check in required if checks.get(check) != "pass"] +failing = sorted(check for check, status in checks.items() if status == "fail") +if missing or failing: + raise SystemExit(f"release readiness is not ready: {missing or failing}") +print(json.dumps({"checks": len(checks), "required_pass": required})) +PY ``` -### 2. Create and verify the annotated `v1.4.0` tag +### 3. Create and verify the annotated `v1.4.0` tag on that exact commit ```bash git tag -a v1.4.0 "$RELEASE_SHA" -m "Code Mower v1.4.0" @@ -156,69 +201,202 @@ test "$(git rev-list -n 1 v1.4.0)" = "$RELEASE_SHA" test "$(git ls-remote origin 'refs/tags/v1.4.0^{}' | awk '{print $1}')" = "$RELEASE_SHA" ``` -### 3. Run `release.yml` with no publishing first +### 4. Install the workflow-run assertion helper + +Every workflow run below is asserted with this helper: workflow identity, +triggering event, exact head SHA, `success` conclusion, and the exact posture of +both publish jobs. A job that is expected to skip must be reported skipped or be +absent from the run; a job that is expected to publish must report `success`. + +```bash +cat >"$RELEASE_ENV/assert_release_run.py" <<'PY' +"""Assert one release workflow run's identity, head, conclusion, and job posture.""" + +import json +import os +import subprocess +import sys + +EXPECTED_WORKFLOW = "Code Mower Release" +SKIPPED = {"skipped", "absent"} + + +def run_view(repo: str, run_id: str) -> dict: + completed = subprocess.run( + [ + "gh", "run", "view", run_id, "--repo", repo, "--json", + "databaseId,workflowName,headSha,event,status,conclusion,url,jobs", + ], + check=True, + capture_output=True, + text=True, + ) + return json.loads(completed.stdout) + + +def job_posture(run: dict, job_name: str) -> str: + for job in run.get("jobs") or []: + if job.get("name") == job_name: + return str(job.get("conclusion") or job.get("status") or "unknown") + return "absent" + + +def main() -> None: + repo, run_id, event, head_sha, testpypi, pypi = sys.argv[1:7] + run = run_view(repo, run_id) + problems = [] + if str(run.get("databaseId")) != run_id: + problems.append("run id does not match the inspected run") + if run.get("workflowName") != EXPECTED_WORKFLOW: + problems.append("run belongs to another workflow") + if run.get("event") != event: + problems.append(f"event is {run.get('event')}, not {event}") + if run.get("headSha") != head_sha: + problems.append("run head is not the exact release commit") + if run.get("status") != "completed" or run.get("conclusion") != "success": + problems.append("run did not complete successfully") + for job_name, expected in ( + ("publish-testpypi", testpypi), + ("publish-pypi", pypi), + ): + actual = job_posture(run, job_name) + if expected == "skipped" and actual not in SKIPPED: + problems.append(f"{job_name} is {actual}, expected skipped") + if expected == "success" and actual != "success": + problems.append(f"{job_name} is {actual}, expected success") + if problems: + raise SystemExit("; ".join(problems)) + print(json.dumps({ + "run_id": run_id, + "event": event, + "head_sha": head_sha, + "publish_testpypi": job_posture(run, "publish-testpypi"), + "publish_pypi": job_posture(run, "publish-pypi"), + "url": run.get("url"), + })) + + +main() +PY +``` + +### 5. Run `release.yml` with no publishing first + +Both publish jobs must skip on this run. ```bash gh workflow run release.yml --repo "$REPO" --ref v1.4.0 \ -f publish_testpypi=false -f publish_pypi=false NO_PUBLISH_RUN_ID="REPLACE_WITH_EXACT_RUN_ID" gh run watch "$NO_PUBLISH_RUN_ID" --repo "$REPO" --exit-status -gh run view "$NO_PUBLISH_RUN_ID" --repo "$REPO" \ - --json databaseId,headSha,event,status,conclusion,url +"$RELEASE_PYTHON" "$RELEASE_ENV/assert_release_run.py" "$REPO" \ + "$NO_PUBLISH_RUN_ID" workflow_dispatch "$RELEASE_SHA" skipped skipped ``` -### 4. Publish TestPyPI only, then rehearse it +### 6. Publish TestPyPI only, then rehearse the exact candidate from TestPyPI + +TestPyPI must publish while production PyPI skips. pip does not prefer +`--index-url` over `--extra-index-url`, so the candidate artifacts are fetched +from TestPyPI alone, with no cache, no dependency resolution, and no ambient pip +configuration; their exact filenames and digests are bound before the rehearsal, +which then installs those local files. Dependencies resolve separately from +canonical PyPI. ```bash gh workflow run release.yml --repo "$REPO" --ref v1.4.0 \ -f publish_testpypi=true -f publish_pypi=false TESTPYPI_RUN_ID="REPLACE_WITH_EXACT_RUN_ID" gh run watch "$TESTPYPI_RUN_ID" --repo "$REPO" --exit-status +"$RELEASE_PYTHON" "$RELEASE_ENV/assert_release_run.py" "$REPO" \ + "$TESTPYPI_RUN_ID" workflow_dispatch "$RELEASE_SHA" success skipped + +TESTPYPI_DIST_DIR="$(mktemp -d /tmp/code-mower-v140-testpypi-dist.XXXXXX)" +env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS \ + PIP_CONFIG_FILE=/dev/null python3.12 -m pip download code-mower==1.4.0 \ + --no-cache-dir --no-deps --only-binary :all: \ + --index-url https://test.pypi.org/simple/ --dest "$TESTPYPI_DIST_DIR" +env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS \ + PIP_CONFIG_FILE=/dev/null python3.12 -m pip download code-mower==1.4.0 \ + --no-cache-dir --no-deps --no-binary :all: \ + --index-url https://test.pypi.org/simple/ --dest "$TESTPYPI_DIST_DIR" +TESTPYPI_DIST_DIR="$TESTPYPI_DIST_DIR" "$RELEASE_PYTHON" - <<'PY' +import hashlib +import json +import os +from pathlib import Path + +files = sorted( + path for path in Path(os.environ["TESTPYPI_DIST_DIR"]).iterdir() if path.is_file() +) +digests = {path.name: hashlib.sha256(path.read_bytes()).hexdigest() for path in files} +wheels = [name for name in digests if name == "code_mower-1.4.0-py3-none-any.whl"] +sdists = [name for name in digests if name == "code_mower-1.4.0.tar.gz"] +if len(digests) != 2 or len(wheels) != 1 or len(sdists) != 1: + raise SystemExit(f"TestPyPI candidate artifact set is unexpected: {sorted(digests)}") +print(json.dumps({"source": "testpypi", "artifacts": digests}, sort_keys=True)) +PY +TESTPYPI_WHEEL="$TESTPYPI_DIST_DIR/code_mower-1.4.0-py3-none-any.whl" +test -f "$TESTPYPI_WHEEL" TESTPYPI_WORK_DIR="$(mktemp -d /tmp/code-mower-v140-testpypi-rehearsal.XXXXXX)" -code-mower migration package-install-rehearsal \ - --package-spec code-mower==1.4.0 \ +env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL PIP_CONFIG_FILE=/dev/null \ + "$RELEASE_CLI" migration package-install-rehearsal \ + --package-spec "$TESTPYPI_WHEEL" \ --python "$(command -v python3.12)" \ --work-dir "$TESTPYPI_WORK_DIR" \ - --pip-index-url https://test.pypi.org/simple/ \ - --pip-extra-index-url https://pypi.org/simple/ \ - --allow-package-index --upgrade-pip --json + --pip-index-url https://pypi.org/simple/ \ + --pip-no-cache --upgrade-pip --json ``` -### 5. Publish production PyPI only, then rehearse it +The rehearsal installs the exact TestPyPI file, so production PyPI cannot satisfy +this step; only its dependencies come from canonical PyPI. + +### 7. Publish production PyPI only, then rehearse the published package + +Production PyPI must publish while TestPyPI skips, and the rehearsal must reach +canonical `https://pypi.org/simple/` explicitly with no cache, so no ambient +`pip.conf`, `PIP_INDEX_URL`, or mirror can satisfy a production-labelled gate. ```bash gh workflow run release.yml --repo "$REPO" --ref v1.4.0 \ -f publish_testpypi=false -f publish_pypi=true PYPI_RUN_ID="REPLACE_WITH_EXACT_RUN_ID" gh run watch "$PYPI_RUN_ID" --repo "$REPO" --exit-status +"$RELEASE_PYTHON" "$RELEASE_ENV/assert_release_run.py" "$REPO" \ + "$PYPI_RUN_ID" workflow_dispatch "$RELEASE_SHA" skipped success + PYPI_WORK_DIR="$(mktemp -d /tmp/code-mower-v140-pypi-rehearsal.XXXXXX)" -code-mower migration package-install-rehearsal \ +env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS \ + PIP_CONFIG_FILE=/dev/null "$RELEASE_CLI" migration package-install-rehearsal \ --package-spec code-mower==1.4.0 \ --python "$(command -v python3.12)" \ --work-dir "$PYPI_WORK_DIR" \ - --allow-package-index --upgrade-pip --json + --pip-index-url https://pypi.org/simple/ \ + --allow-package-index --pip-no-cache --upgrade-pip --json ``` -### 6. Download the exact workflow artifact +### 8. Download the exact workflow artifact ```bash PROD_DIST_DIR="$(mktemp -d /tmp/code-mower-v140-prod-dist.XXXXXX)" gh run download "$PYPI_RUN_ID" --repo "$REPO" \ --name code-mower-dist --dir "$PROD_DIST_DIR" -ls -1 "$PROD_DIST_DIR" sha256sum "$PROD_DIST_DIR"/* ``` -### 7. Compare SHA-256 digests with the files downloaded from PyPI +### 9. Compare SHA-256 digests with the files downloaded from canonical PyPI ```bash PYPI_DOWNLOAD_DIR="$(mktemp -d /tmp/code-mower-v140-pypi-download.XXXXXX)" -python3.12 -m pip download code-mower==1.4.0 --no-deps --no-binary :all: \ - --dest "$PYPI_DOWNLOAD_DIR" -python3.12 -m pip download code-mower==1.4.0 --no-deps --only-binary :all: \ - --dest "$PYPI_DOWNLOAD_DIR" -sha256sum "$PYPI_DOWNLOAD_DIR"/* -PROD_DIST_DIR="$PROD_DIST_DIR" PYPI_DOWNLOAD_DIR="$PYPI_DOWNLOAD_DIR" python3.12 - <<'PY' +env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS \ + PIP_CONFIG_FILE=/dev/null python3.12 -m pip download code-mower==1.4.0 \ + --no-cache-dir --no-deps --no-binary :all: \ + --index-url https://pypi.org/simple/ --dest "$PYPI_DOWNLOAD_DIR" +env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS \ + PIP_CONFIG_FILE=/dev/null python3.12 -m pip download code-mower==1.4.0 \ + --no-cache-dir --no-deps --only-binary :all: \ + --index-url https://pypi.org/simple/ --dest "$PYPI_DOWNLOAD_DIR" +PROD_DIST_DIR="$PROD_DIST_DIR" PYPI_DOWNLOAD_DIR="$PYPI_DOWNLOAD_DIR" \ + "$RELEASE_PYTHON" - <<'PY' import hashlib import json import os @@ -235,7 +413,8 @@ def digests(directory): workflow = digests(os.environ["PROD_DIST_DIR"]) published = digests(os.environ["PYPI_DOWNLOAD_DIR"]) -if len(workflow) != 2 or set(workflow) != set(published): +expected = {"code_mower-1.4.0-py3-none-any.whl", "code_mower-1.4.0.tar.gz"} +if set(workflow) != expected or set(published) != expected: raise SystemExit("workflow and PyPI artifact sets differ") if any(workflow[name] != published[name] for name in workflow): raise SystemExit("workflow and PyPI SHA-256 values differ") @@ -246,41 +425,201 @@ PY Only continue when the artifact set and every digest match. A mismatch is a release blocker: do not attach unverified files. -### 8. Create the GitHub Release with those exact assets +### 10. Assert the publish variables are off before creating the Release + +The published Release triggers one `release`-event run whose publish jobs are +gated on repository variables. Assert they cannot republish before the +irreversible release creation, not afterwards. ```bash -gh release create v1.4.0 "$PROD_DIST_DIR"/* --repo "$REPO" \ - --verify-tag --title "Code Mower v1.4.0" \ - --notes-file docs/v140-release-notes.md --latest --fail-on-no-commits +REPO="$REPO" "$RELEASE_PYTHON" - <<'PY' +import json +import os +import subprocess + +repo = os.environ["REPO"] +completed = subprocess.run( + ["gh", "api", f"repos/{repo}/actions/variables", "--paginate"], + check=True, capture_output=True, text=True, +) +values = { + row["name"]: str(row.get("value", "")) + for row in json.loads(completed.stdout).get("variables", []) +} +enabled = [ + name + for name in ("CODE_MOWER_TESTPYPI_PUBLISH", "CODE_MOWER_PYPI_PUBLISH") + if values.get(name, "false").strip().lower() == "true" +] +if enabled: + raise SystemExit(f"release-event republishing is enabled: {enabled}") +print(json.dumps({"republish_variables_off": True})) +PY +``` + +### 11. Create the GitHub Release with those exact assets and verify them + +An existing `v1.4.0` release is never clobbered: inspect it first and stop +unless its tag and its exact asset set and digests already match +`PROD_DIST_DIR`. Install the asset assertion first. It downloads the Release's +own assets and requires the exact filename set and every SHA-256 value to equal +`PROD_DIST_DIR`, with exactly one wheel and one sdist: + +```bash +cat >"$RELEASE_ENV/assert_release_assets.py" <<'PY' +"""Assert the GitHub Release tag and its downloaded assets match PROD_DIST_DIR.""" + +import hashlib +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +EXPECTED = {"code_mower-1.4.0-py3-none-any.whl", "code_mower-1.4.0.tar.gz"} + + +def digests(directory: Path) -> dict[str, str]: + return { + path.name: hashlib.sha256(path.read_bytes()).hexdigest() + for path in sorted(directory.iterdir()) + if path.is_file() + } + + +def main() -> None: + mode = sys.argv[1] + repo = os.environ["REPO"] + release_sha = os.environ["RELEASE_SHA"] + local = digests(Path(os.environ["PROD_DIST_DIR"])) + view = json.loads(subprocess.run( + ["gh", "release", "view", "v1.4.0", "--repo", repo, "--json", + "tagName,isDraft,isPrerelease,assets"], + check=True, capture_output=True, text=True, + ).stdout) + problems = [] + if view.get("tagName") != "v1.4.0": + problems.append("release tag is not v1.4.0") + if view.get("isDraft") or view.get("isPrerelease"): + problems.append("release is a draft or prerelease") + tag_target = subprocess.run( + ["git", "rev-list", "-n", "1", "v1.4.0"], + check=True, capture_output=True, text=True, + ).stdout.strip() + if tag_target != release_sha: + problems.append("release tag does not target the exact release commit") + if set(local) != EXPECTED: + problems.append(f"local artifact set is unexpected: {sorted(local)}") + asset_names = {asset["name"] for asset in view.get("assets") or []} + if asset_names != set(local): + problems.append(f"release asset set differs: {sorted(asset_names)}") + with tempfile.TemporaryDirectory() as scratch: + target = Path(scratch) + subprocess.run( + ["gh", "release", "download", "v1.4.0", "--repo", repo, + "--dir", str(target), "--clobber"], + check=True, capture_output=True, text=True, + ) + downloaded = digests(target) + if downloaded != local: + problems.append("release asset SHA-256 values differ from PROD_DIST_DIR") + if problems: + raise SystemExit(f"{mode} release assets are not acceptable: {problems}") + print(json.dumps({ + "mode": mode, + "assets": sorted(local), + "sha256_match": True, + }, sort_keys=True)) + + +main() +PY +``` + +The `--clobber` flag above only writes into the private scratch download +directory; Release assets are never overwritten, and a `v1.4.0` release whose +assets differ stops the runbook for inspection. + +```bash +if gh release view v1.4.0 --repo "$REPO" >/dev/null 2>&1; then + REPO="$REPO" PROD_DIST_DIR="$PROD_DIST_DIR" RELEASE_SHA="$RELEASE_SHA" \ + "$RELEASE_PYTHON" "$RELEASE_ENV/assert_release_assets.py" existing +else + gh release create v1.4.0 "$PROD_DIST_DIR"/* --repo "$REPO" \ + --verify-tag --title "Code Mower v1.4.0" \ + --notes-file docs/v140-release-notes.md --latest --fail-on-no-commits +fi +REPO="$REPO" PROD_DIST_DIR="$PROD_DIST_DIR" RELEASE_SHA="$RELEASE_SHA" \ + "$RELEASE_PYTHON" "$RELEASE_ENV/assert_release_assets.py" created gh release view v1.4.0 --repo "$REPO" \ --json tagName,targetCommitish,isDraft,isPrerelease,publishedAt,url,assets +``` + +### 12. Assert the `release`-event run published nothing + +```bash RELEASE_EVENT_RUN_ID="REPLACE_WITH_EXACT_RELEASE_EVENT_RUN_ID" gh run watch "$RELEASE_EVENT_RUN_ID" --repo "$REPO" --exit-status -gh run view "$RELEASE_EVENT_RUN_ID" --repo "$REPO" \ - --json databaseId,headSha,event,status,conclusion,url +"$RELEASE_PYTHON" "$RELEASE_ENV/assert_release_run.py" "$REPO" \ + "$RELEASE_EVENT_RUN_ID" release "$RELEASE_SHA" skipped skipped ``` -Use `gh release upload v1.4.0 "$PROD_DIST_DIR"/* --repo "$REPO" --clobber` when -the release already exists. The publish jobs of the `release`-event run must stay -skipped; it must not publish again. +### 13. Install locally and require hosted Devin readiness -### 9. Install locally and verify Devin readiness +`code-mower doctor --easy --devin` reports the unselected posture and can exit 0 +with `skip`, so it does not prove readiness. Select hosted Devin explicitly with +a supported generated configuration, then require the hosted checks to pass for +the exact `codemower-ai/code-mower` scope. `provider.devin.permissions` is +reported, never probed: it is the account owner's confirmation and is the only +non-`pass` status accepted. ```bash CODE_MOWER_PYTHON="$(command -v python3.12)" test -n "$CODE_MOWER_PYTHON" PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" \ 'code-mower[coworker]==1.4.0' -code-mower --version +test "$(code-mower --version)" = "code-mower 1.4.0" + DEVIN_PROVIDER_PROFILE="REPLACE_WITH_PROTECTED_PROFILE_SELECTOR" -code-mower doctor --easy --devin \ - --provider-profile "$DEVIN_PROVIDER_PROFILE" --json +DEVIN_DOCTOR_DIR="$(mktemp -d /tmp/code-mower-v140-devin-doctor.XXXXXX)" +code-mower init code-mower.yml --profile recommended \ + --set-transport devin=devin_api_v3 --apply --output-dir "$DEVIN_DOCTOR_DIR" +code-mower doctor "$DEVIN_DOCTOR_DIR/code-mower.yml" --profile recommended \ + --devin --repo codemower-ai/code-mower \ + --provider-profile "$DEVIN_PROVIDER_PROFILE" --json >"$DEVIN_DOCTOR_DIR/doctor.json" +DEVIN_DOCTOR_JSON="$DEVIN_DOCTOR_DIR/doctor.json" "$RELEASE_PYTHON" - <<'PY' +import json +import os + +report = json.loads(open(os.environ["DEVIN_DOCTOR_JSON"], encoding="utf-8").read()) +checks = { + row["name"]: row["status"] + for row in report.get("checks", []) + if row["name"].startswith("provider.devin.") +} +required = ( + "provider.devin.selection", + "provider.devin.capabilities", + "provider.devin.hosted_credentials", + "provider.devin.repository_scope", + "provider.devin.lifecycle", +) +blocked = [name for name in required if checks.get(name) != "pass"] +if blocked: + raise SystemExit(f"hosted Devin readiness is blocked: {blocked}") +if checks.get("provider.devin.permissions") not in {"pass", "skip"}: + raise SystemExit("Devin permission requirements are not confirmable") +print(json.dumps({"devin_transport": "hosted", "required_pass": list(required)})) +PY ``` -### 10. Run the required Claude + Codex + Devin campaign +Keep the generated directory, profile selector, credential values, organization +identifier, and repository inventory out of recorded evidence. + +### 14. Run the required Claude + Codex + Devin campaign ```bash -RELEASE_PR="REPLACE_WITH_RELEASE_PR_NUMBER" code-mower release campaign create \ --release-tag v1.4.0 \ --package-spec code-mower==1.4.0 \ @@ -300,7 +639,13 @@ code-mower release campaign status --release-tag v1.4.0 --json All three provider results must pass, and Devin's result must identify the hosted transport before peer support is claimed. -### 11. Restart the three Boards from the release +### 15. Restart the three Boards from the release, waiting on each stop + +The port 5332 Board must serve the exact v1.4.0 release checkout because its +pre-release repository path is stale. Assert that checkout first, then stop each +Board and wait through the bounded Board inventory until its listener is gone +before starting the replacement, so no start races a dying listener on a fixed +port. ```bash CODE_MOWER_RELEASE_CHECKOUT="REPLACE_WITH_EXACT_V140_CHECKOUT" @@ -308,10 +653,74 @@ BOARD_5342_REPO="REUSE_PRIVATE_INVENTORIED_SLUG" BOARD_5342_REPO_PATH="REUSE_PRIVATE_INVENTORIED_PATH" BOARD_5344_REPO="REUSE_PRIVATE_INVENTORIED_SLUG" BOARD_5344_REPO_PATH="REUSE_PRIVATE_INVENTORIED_PATH" +test "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA" +test "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" rev-list -n 1 v1.4.0)" = "$RELEASE_SHA" + +cat >"$RELEASE_ENV/board_wait.py" <<'PY' +"""Bounded waits on the Board inventory: gone after a stop, serving after a start.""" + +import json +import subprocess +import sys +import time + +DEADLINE_SECONDS = 120 +INTERVAL_SECONDS = 3 + + +def inventory() -> list[dict]: + completed = subprocess.run( + ["code-mower", "board", "list", "--json"], + check=True, capture_output=True, text=True, + ) + payload = json.loads(completed.stdout) + return [row for row in payload.get("boards") or [] if isinstance(row, dict)] + + +def row_for(port: int) -> dict | None: + for row in inventory(): + if int(row.get("port") or 0) == port: + return row + return None + + +def main() -> None: + mode = sys.argv[1] + ports = [int(value) for value in sys.argv[2:]] + deadline = time.monotonic() + DEADLINE_SECONDS + pending = list(ports) + while pending and time.monotonic() < deadline: + remaining = [] + for port in pending: + row = row_for(port) + if mode == "gone" and row is None: + continue + if ( + mode == "serving" + and row is not None + and row.get("health") == "ok" + and row.get("serving_version") == "1.4.0" + and row.get("installed_version") == "1.4.0" + ): + continue + remaining.append(port) + pending = remaining + if pending: + time.sleep(INTERVAL_SECONDS) + if pending: + raise SystemExit(f"ports still not {mode} within {DEADLINE_SECONDS}s: {pending}") + print(json.dumps({"mode": mode, "ports": ports})) + + +main() +PY + code-mower board list --json -code-mower board stop --port 5332 --yes --json -code-mower board stop --port 5342 --yes --json -code-mower board stop --port 5344 --yes --json +for BOARD_PORT in 5332 5342 5344; do + code-mower board stop --port "$BOARD_PORT" --yes --json + "$RELEASE_PYTHON" "$RELEASE_ENV/board_wait.py" gone "$BOARD_PORT" +done + nohup code-mower board serve --repo codemower-ai/code-mower \ --repo-path "$CODE_MOWER_RELEASE_CHECKOUT" --host 127.0.0.1 \ --port 5332 --record-events >/tmp/code-mower-board-5332.log 2>&1 & @@ -321,19 +730,20 @@ nohup code-mower board serve --repo "$BOARD_5342_REPO" \ nohup code-mower board serve --repo "$BOARD_5344_REPO" \ --repo-path "$BOARD_5344_REPO_PATH" --host 127.0.0.1 \ --port 5344 --record-events >/tmp/code-mower-board-5344.log 2>&1 & -code-mower board list --json +"$RELEASE_PYTHON" "$RELEASE_ENV/board_wait.py" serving 5332 5342 5344 + code-mower board doctor --repo codemower-ai/code-mower \ --repo-path "$CODE_MOWER_RELEASE_CHECKOUT" --json +code-mower board doctor --repo "$BOARD_5342_REPO" \ + --repo-path "$BOARD_5342_REPO_PATH" --json +code-mower board doctor --repo "$BOARD_5344_REPO" \ + --repo-path "$BOARD_5344_REPO_PATH" --json ``` -Each inventory row must report serving and installed version `1.4.0`. The port -5332 Board must be restarted from the exact v1.4.0 release checkout because its -pre-release repository path is stale. Run `code-mower board doctor` for the other -two Boards with their privately inventoried slugs and paths. Do not use raw -process kills or Board reset, and do not copy private repository slugs or paths -into public evidence. +Every `board doctor` run must pass. Do not use raw process kills or Board reset, +and never copy private repository slugs or paths into public evidence. -### 12. Dry-run, inspect, then upload metadata-only cloud evidence +### 16. Dry-run, inspect, then upload metadata-only cloud evidence ```bash code-mower cloud doctor --install-id codex-code-mower --probe-service --json diff --git a/src/code_mower/release_readiness.py b/src/code_mower/release_readiness.py index 7e7f0a3d..f19a4605 100644 --- a/src/code_mower/release_readiness.py +++ b/src/code_mower/release_readiness.py @@ -144,9 +144,58 @@ def _committed_manifest_version(repo_path: Path) -> str: return version if isinstance(version, str) else "" +MANIFEST_ENTRY_KEYS = ("target", "source", "kind") + +POST_MERGE_RUNBOOK_HEADING = "Post-Merge Release Runbook" + + +def _manifest_rows(manifest: dict[str, Any]) -> list[Any]: + rows = manifest.get("files_written") + return list(rows) if isinstance(rows, list) else [] + + +def _malformed_manifest_rows(manifest: dict[str, Any]) -> list[str]: + """Describe rows that cannot be compared, instead of dropping them.""" + + problems: list[str] = [] + for index, row in enumerate(_manifest_rows(manifest)): + if not isinstance(row, dict): + problems.append(f"row {index} is not an object") + continue + missing = [key for key in MANIFEST_ENTRY_KEYS if key not in row] + if missing: + problems.append(f"row {index} is missing {', '.join(missing)}") + unexpected = sorted(set(row) - set(MANIFEST_ENTRY_KEYS)) + if unexpected: + problems.append(f"row {index} has unexpected {', '.join(unexpected)}") + non_text = sorted( + key + for key in MANIFEST_ENTRY_KEYS + if key in row and not isinstance(row[key], str) + ) + if non_text: + problems.append(f"row {index} has non-string {', '.join(non_text)}") + return problems + + +def _duplicate_manifest_targets(manifest: dict[str, Any]) -> list[str]: + seen: set[str] = set() + duplicates: set[str] = set() + for row in _manifest_rows(manifest): + if not isinstance(row, dict): + continue + target = row.get("target") + if not isinstance(target, str): + continue + if target in seen: + duplicates.add(target) + seen.add(target) + return sorted(duplicates) + + def _manifest_inventory(manifest: dict[str, Any]) -> dict[str, dict[str, str]]: inventory: dict[str, dict[str, str]] = {} - for entry in manifest.get("files_written", []): + for entry in _manifest_rows(manifest): if isinstance(entry, dict): inventory[str(entry.get("target", ""))] = { "source": str(entry.get("source", "")), @@ -159,33 +208,40 @@ def _committed_manifest_drift(repo_path: Path) -> dict[str, Any]: """Compare the whole normalized committed manifest with a fresh generation. A stale committed artifact is a release defect: it is the published record of - the standalone package surface, so missing targets, unexpected targets, and - changed source mappings all fail readiness, not only a drifted version. + the standalone package surface. The gate is strict equality of the whole + normalized manifest, so malformed rows, duplicated targets, and extra rows + cannot normalize into apparent agreement; the target diagnostics are only + bounded reporting on top of that equality. """ + empty = { + "matches": False, + "error": "", + "malformed_rows": [], + "duplicate_targets": [], + "missing_targets": [], + "unexpected_targets": [], + "changed_targets": [], + "committed_row_count": 0, + "generated_row_count": 0, + "metadata_matches": False, + } committed = _committed_manifest(repo_path) if committed is None: return { + **empty, "error": "committed package manifest is missing or not valid JSON", - "missing_targets": [], - "unexpected_targets": [], - "changed_targets": [], - "committed_file_count": 0, - "generated_file_count": 0, - "metadata_matches": False, } try: generated = package_module.generate_committed_package_manifest(repo_path) except Exception as exc: # pragma: no cover - exercised through status output. return { + **empty, "error": str(exc), - "missing_targets": [], - "unexpected_targets": [], - "changed_targets": [], - "committed_file_count": len(_manifest_inventory(committed)), - "generated_file_count": 0, - "metadata_matches": False, + "committed_row_count": len(_manifest_rows(committed)), } + malformed = _malformed_manifest_rows(committed) + duplicates = _duplicate_manifest_targets(committed) normalized = package_module.normalized_package_manifest(committed) committed_files = _manifest_inventory(normalized) generated_files = _manifest_inventory(generated) @@ -194,60 +250,142 @@ def _committed_manifest_drift(repo_path: Path) -> dict[str, Any]: for target, entry in generated_files.items() if target in committed_files and committed_files[target] != entry ) + metadata_matches = all( + normalized.get(key) == generated.get(key) + for key in ("mode", "package", "output_dir", "deferred_package_files") + ) + committed_rows = _manifest_rows(committed) return { + "matches": bool( + not malformed + and not duplicates + and normalized == generated + and len(committed_rows) == len(_manifest_rows(generated)) + ), "error": "", + "malformed_rows": malformed[:20], + "duplicate_targets": duplicates[:20], "missing_targets": sorted(set(generated_files) - set(committed_files))[:20], "unexpected_targets": sorted(set(committed_files) - set(generated_files))[:20], "changed_targets": changed[:20], - "committed_file_count": len(committed_files), - "generated_file_count": len(generated_files), - "metadata_matches": all( - normalized.get(key) == generated.get(key) - for key in ("mode", "package", "output_dir", "deferred_package_files") - ), + "committed_row_count": len(committed_rows), + "generated_row_count": len(_manifest_rows(generated)), + "metadata_matches": metadata_matches, } def _manifest_matches_generated(drift: dict[str, Any]) -> bool: - return bool( - not drift["error"] - and drift["metadata_matches"] - and not drift["missing_targets"] - and not drift["unexpected_targets"] - and not drift["changed_targets"] - and drift["committed_file_count"] == drift["generated_file_count"] - ) + return bool(drift["matches"] and not drift["error"]) def _post_merge_runbook_markers(release_tag: str, package_index_spec: str) -> tuple[str, ...]: """Ordered, exact commands the post-merge runbook must publish in sequence.""" return ( - 'RELEASE_SHA="$(git rev-parse origin/main)"', + "gh pr view \"$RELEASE_PR\" --repo \"$REPO\" --json state --jq '.state'", + 'git fetch origin "$RELEASE_SHA"', + "migration release-readiness --json", f'git tag -a {release_tag} "$RELEASE_SHA"', f"git push origin refs/tags/{release_tag}", "-f publish_testpypi=false -f publish_pypi=false", "-f publish_testpypi=true -f publish_pypi=false", - f"--package-spec {package_index_spec}", - "--pip-index-url https://test.pypi.org/simple/", + "--index-url https://test.pypi.org/simple/", "-f publish_testpypi=false -f publish_pypi=true", + f"--package-spec {package_index_spec}", "gh run download", "--name code-mower-dist", "python3.12 -m pip download", "sha256", + "CODE_MOWER_PYPI_PUBLISH", f"gh release create {release_tag}", "--verify-tag", - "code-mower doctor --easy --devin", + "provider.devin.repository_scope", "code-mower release campaign create", "--required-providers claude,codex,devin", - "--port 5332", - "--port 5342", - "--port 5344", + "code-mower board stop --port", + 'board_wait.py" serving 5332 5342 5344', + "code-mower board doctor", "code-mower cloud upload", "--dry-run --json", ) +def _post_merge_runbook_assertions(version: str, release_tag: str) -> tuple[str, ...]: + """Assertions the post-merge runbook must contain, not merely describe. + + Ordered presence of commands cannot show that an irreversible step is gated: + each entry here is the assertion whose removal would let the release proceed + on an unverified merge commit, workflow run, publish-job posture, artifact + source, Release asset, Devin posture, publish variable, or Board. + """ + + return ( + # The release commit is the merged pull request's own merge commit. + "--json mergeCommit --jq '.mergeCommit.oid'", + 'test "$(git rev-parse HEAD)" = "$RELEASE_SHA"', + # Readiness runs from a fresh environment built at that exact commit. + f'test "$("$RELEASE_CLI" --version)" = "code-mower {version}"', + "committed-package-manifest-matches-generated", + "post-merge-release-runbook-asserted", + "raise SystemExit(f\"release readiness is not ready: {missing or failing}\")", + # The tag dereferences to that commit locally and on the remote. + f'test "$(git rev-list -n 1 {release_tag})" = "$RELEASE_SHA"', + f"test \"$(git ls-remote origin 'refs/tags/{release_tag}^{{}}' | awk '{{print $1}}')\"" + ' = "$RELEASE_SHA"', + # Every workflow run is asserted, including both publish-job postures. + 'if run.get("workflowName") != EXPECTED_WORKFLOW:', + 'if run.get("headSha") != head_sha:', + 'problems.append(f"{job_name} is {actual}, expected skipped")', + 'problems.append(f"{job_name} is {actual}, expected success")', + '"$NO_PUBLISH_RUN_ID" workflow_dispatch "$RELEASE_SHA" skipped skipped', + '"$TESTPYPI_RUN_ID" workflow_dispatch "$RELEASE_SHA" success skipped', + '"$PYPI_RUN_ID" workflow_dispatch "$RELEASE_SHA" skipped success', + '"$RELEASE_EVENT_RUN_ID" release "$RELEASE_SHA" skipped skipped', + # TestPyPI is the exclusive source of the candidate artifacts. + f"PIP_CONFIG_FILE=/dev/null python3.12 -m pip download code-mower=={version}", + "--index-url https://test.pypi.org/simple/ --dest \"$TESTPYPI_DIST_DIR\"", + '--package-spec "$TESTPYPI_WHEEL"', + # Production commands reach canonical PyPI explicitly, without caches. + "--index-url https://pypi.org/simple/ --dest \"$PYPI_DOWNLOAD_DIR\"", + "--pip-index-url https://pypi.org/simple/", + "--pip-no-cache", + # Republishing is impossible before the irreversible release creation. + 'if values.get(name, "false").strip().lower() == "true"', + 'raise SystemExit(f"release-event republishing is enabled: {enabled}")', + # The Release's own assets are downloaded and compared digest by digest. + 'raise SystemExit(f"{mode} release assets are not acceptable: {problems}")', + 'problems.append("release asset SHA-256 values differ from PROD_DIST_DIR")', + 'assert_release_assets.py" existing', + # Hosted Devin readiness is required, not reported. + "--set-transport devin=devin_api_v3", + 'raise SystemExit(f"hosted Devin readiness is blocked: {blocked}")', + # Boards stop, are waited for, and only then restart from the release. + 'test "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA"', + 'board_wait.py" gone "$BOARD_PORT"', + 'raise SystemExit(f"ports still not {mode} within {DEADLINE_SECONDS}s: {pending}")', + ) + + +def _forbidden_runbook_markers() -> tuple[str, ...]: + """Commands the post-merge runbook must not publish.""" + + return ( + 'RELEASE_SHA="$(git rev-parse origin/main)"', + "gh release upload", + "--pip-extra-index-url https://pypi.org/simple/", + ) + + +def _document_section(text: str, heading: str) -> str: + """Return one Markdown section, so a gate reads the runbook and nothing else.""" + + start = text.find(heading) if heading else -1 + if start < 0: + return "" + end = text.find("\n## ", start + len(heading)) + return text[start:] if end < 0 else text[start:end] + + def _unordered_markers(text: str, markers: tuple[str, ...]) -> list[str]: """Report markers that are missing or appear before their predecessor.""" @@ -359,7 +497,10 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: release_tag = _release_tag_for_version(version) if version else "" package_index_spec = f"code-mower=={version}" if version else "" doc_blob = "\n".join(docs.values()) - runbook_doc = docs.get("docs/pypi-release.md", "") + runbook_doc = _document_section( + docs.get("docs/pypi-release.md", ""), + f"## {release_tag} {POST_MERGE_RUNBOOK_HEADING}" if release_tag else "", + ) runbook_markers = ( _post_merge_runbook_markers(release_tag, package_index_spec) if release_tag and package_index_spec @@ -370,6 +511,17 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: if runbook_markers else ["unknown release version"] ) + runbook_assertions = ( + _post_merge_runbook_assertions(version, release_tag) if release_tag else () + ) + missing_runbook_assertions = ( + [marker for marker in runbook_assertions if marker not in runbook_doc] + if runbook_assertions + else ["unknown release version"] + ) + forbidden_runbook_markers = [ + marker for marker in _forbidden_runbook_markers() if marker in runbook_doc + ] public_hygiene_blobs = { relative_path: text.lower() for relative_path, text in public_hygiene_docs.items() @@ -495,8 +647,8 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: status="pass" if _manifest_matches_generated(manifest_drift) else "fail", evidence=( f"{package_module.COMMITTED_PACKAGE_MANIFEST}=" - f"{manifest_drift['committed_file_count']} file(s), " - f"generated={manifest_drift['generated_file_count']} file(s)" + f"{manifest_drift['committed_row_count']} row(s), " + f"generated={manifest_drift['generated_row_count']} row(s)" ), detail=manifest_drift, ), @@ -648,6 +800,22 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: "missing_or_out_of_order": missing_runbook_markers, }, ), + _release_check( + check_id="post-merge-release-runbook-asserted", + title="Post-merge runbook asserts every irreversible release gate", + status=( + "pass" + if not missing_runbook_assertions and not forbidden_runbook_markers + else "fail" + ), + evidence="docs/pypi-release.md", + detail={ + "release_tag": release_tag, + "required_assertions": list(runbook_assertions), + "missing_assertions": missing_runbook_assertions, + "forbidden_commands": forbidden_runbook_markers, + }, + ), _release_check( check_id="public-maintainer-docs", title="Public maintainer and community files are present", diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py index c29e9b8f..253973d5 100644 --- a/tests/test_release_hygiene.py +++ b/tests/test_release_hygiene.py @@ -7893,9 +7893,11 @@ def test_release_readiness_accepts_the_exact_regenerated_manifest(self) -> None: self.assertEqual(check["detail"]["missing_targets"], []) self.assertEqual(check["detail"]["unexpected_targets"], []) self.assertEqual(check["detail"]["changed_targets"], []) + self.assertEqual(check["detail"]["malformed_rows"], []) + self.assertEqual(check["detail"]["duplicate_targets"], []) self.assertEqual( - check["detail"]["committed_file_count"], - check["detail"]["generated_file_count"], + check["detail"]["committed_row_count"], + check["detail"]["generated_row_count"], ) def test_release_readiness_fails_on_stale_committed_manifest(self) -> None: @@ -7905,10 +7907,10 @@ def truncate(manifest: dict) -> None: check = self._manifest_drift_check(truncate) self.assertEqual(check["status"], "fail") - self.assertEqual(check["detail"]["committed_file_count"], 307) + self.assertEqual(check["detail"]["committed_row_count"], 307) self.assertGreater( - check["detail"]["generated_file_count"], - check["detail"]["committed_file_count"], + check["detail"]["generated_row_count"], + check["detail"]["committed_row_count"], ) self.assertTrue(check["detail"]["missing_targets"]) @@ -7940,6 +7942,90 @@ def rewrite_source(manifest: dict) -> None: check["detail"]["changed_targets"], ["docs/context-graph-lifecycle.md"] ) + def test_release_readiness_fails_on_changed_manifest_kind_mapping(self) -> None: + def rewrite_kind(manifest: dict) -> None: + for entry in manifest["files_written"]: + if entry["target"] == "docs/context-graph-lifecycle.md": + entry["kind"] = "source" + + check = self._manifest_drift_check(rewrite_kind) + + self.assertEqual(check["status"], "fail") + self.assertEqual( + check["detail"]["changed_targets"], ["docs/context-graph-lifecycle.md"] + ) + + def test_release_readiness_fails_on_extra_manifest_entry(self) -> None: + def add_unexpected(manifest: dict) -> None: + manifest["files_written"].append( + { + "target": "docs/not-packaged.md", + "source": "docs/not-packaged.md", + "kind": "doc", + } + ) + + check = self._manifest_drift_check(add_unexpected) + + self.assertEqual(check["status"], "fail") + self.assertEqual(check["detail"]["unexpected_targets"], ["docs/not-packaged.md"]) + + def test_release_readiness_fails_on_duplicate_manifest_targets(self) -> None: + def duplicate_row(manifest: dict) -> None: + rows = manifest["files_written"] + duplicated = next( + row + for row in rows + if row["target"] == "docs/context-graph-lifecycle.md" + ) + rows.append(dict(duplicated)) + + check = self._manifest_drift_check(duplicate_row) + + self.assertEqual(check["status"], "fail") + self.assertEqual( + check["detail"]["duplicate_targets"], ["docs/context-graph-lifecycle.md"] + ) + self.assertEqual(check["detail"]["missing_targets"], []) + self.assertEqual(check["detail"]["unexpected_targets"], []) + self.assertEqual(check["detail"]["changed_targets"], []) + + def test_release_readiness_fails_on_non_object_manifest_row(self) -> None: + def replace_with_string(manifest: dict) -> None: + manifest["files_written"][0] = "docs/context-graph-lifecycle.md" + + check = self._manifest_drift_check(replace_with_string) + + self.assertEqual(check["status"], "fail") + self.assertIn("row 0 is not an object", check["detail"]["malformed_rows"]) + + def test_release_readiness_fails_on_manifest_row_missing_keys(self) -> None: + def drop_kind(manifest: dict) -> None: + manifest["files_written"][0].pop("kind") + + check = self._manifest_drift_check(drop_kind) + + self.assertEqual(check["status"], "fail") + self.assertIn("row 0 is missing kind", check["detail"]["malformed_rows"]) + + def test_release_readiness_fails_on_unexpected_manifest_row_key(self) -> None: + def add_key(manifest: dict) -> None: + manifest["files_written"][0]["mode"] = "0644" + + check = self._manifest_drift_check(add_key) + + self.assertEqual(check["status"], "fail") + self.assertIn("row 0 has unexpected mode", check["detail"]["malformed_rows"]) + + def test_release_readiness_fails_on_non_string_manifest_value(self) -> None: + def replace_kind(manifest: dict) -> None: + manifest["files_written"][0]["kind"] = ["doc"] + + check = self._manifest_drift_check(replace_kind) + + self.assertEqual(check["status"], "fail") + self.assertIn("row 0 has non-string kind", check["detail"]["malformed_rows"]) + def test_packaged_graph_docs_link_target_is_packaged(self) -> None: packaged = {target for _source, target, _kind in code_mower_package.PACKAGE_FILES} @@ -7970,7 +8056,7 @@ def test_release_readiness_requires_the_ordered_post_merge_runbook(self) -> None def test_release_readiness_fails_when_the_runbook_stops_at_testpypi(self) -> None: docs = release_readiness._release_docs(ROOT) docs["docs/pypi-release.md"] = docs["docs/pypi-release.md"].partition( - "### 5. Publish production PyPI only" + "### 7. Publish production PyPI only" )[0] with mock.patch.object(release_readiness, "_release_docs", return_value=docs): @@ -7984,6 +8070,210 @@ def test_release_readiness_fails_when_the_runbook_stops_at_testpypi(self) -> Non "gh release create v1.4.0", runbook["detail"]["missing_or_out_of_order"] ) + def _runbook_section(self) -> str: + return release_readiness._document_section( + (ROOT / "docs" / "pypi-release.md").read_text(encoding="utf-8"), + f"## v1.4.0 {release_readiness.POST_MERGE_RUNBOOK_HEADING}", + ) + + def _asserted_runbook_check(self, mutate: Callable[[str], str]) -> dict: + docs = release_readiness._release_docs(ROOT) + docs["docs/pypi-release.md"] = mutate(docs["docs/pypi-release.md"]) + with mock.patch.object(release_readiness, "_release_docs", return_value=docs): + payload = release_readiness.render_release_readiness(ROOT) + checks = {check["id"]: check for check in payload["checks"]} + return checks["post-merge-release-runbook-asserted"] + + def test_release_readiness_requires_asserted_release_gates(self) -> None: + payload = release_readiness.render_release_readiness(ROOT) + checks = {check["id"]: check for check in payload["checks"]} + asserted = checks["post-merge-release-runbook-asserted"] + required = asserted["detail"]["required_assertions"] + + self.assertEqual(asserted["status"], "pass") + self.assertEqual(asserted["detail"]["missing_assertions"], []) + self.assertEqual(asserted["detail"]["forbidden_commands"], []) + self.assertIn("--json mergeCommit --jq '.mergeCommit.oid'", required) + self.assertIn('test "$(git rev-list -n 1 v1.4.0)" = "$RELEASE_SHA"', required) + self.assertIn('if run.get("headSha") != head_sha:', required) + self.assertIn("--set-transport devin=devin_api_v3", required) + + def test_release_readiness_rejects_mutable_main_release_binding(self) -> None: + check = self._asserted_runbook_check( + lambda doc: doc.replace( + "RELEASE_SHA=\"$(gh pr view \"$RELEASE_PR\" --repo \"$REPO\" \\\n" + " --json mergeCommit --jq '.mergeCommit.oid')\"", + 'RELEASE_SHA="$(git rev-parse origin/main)"', + ) + ) + + self.assertEqual(check["status"], "fail") + self.assertIn( + 'RELEASE_SHA="$(git rev-parse origin/main)"', + check["detail"]["forbidden_commands"], + ) + + def test_release_readiness_fails_when_job_posture_assertions_are_deleted(self) -> None: + check = self._asserted_runbook_check( + lambda doc: doc.replace( + 'problems.append(f"{job_name} is {actual}, expected skipped")', + "pass", + ) + ) + + self.assertEqual(check["status"], "fail") + self.assertIn( + 'problems.append(f"{job_name} is {actual}, expected skipped")', + check["detail"]["missing_assertions"], + ) + + def test_release_readiness_fails_when_a_run_assertion_is_dropped(self) -> None: + for removed in ( + '"$NO_PUBLISH_RUN_ID" workflow_dispatch "$RELEASE_SHA" skipped skipped', + '"$TESTPYPI_RUN_ID" workflow_dispatch "$RELEASE_SHA" success skipped', + '"$PYPI_RUN_ID" workflow_dispatch "$RELEASE_SHA" skipped success', + '"$RELEASE_EVENT_RUN_ID" release "$RELEASE_SHA" skipped skipped', + ): + with self.subTest(removed=removed): + check = self._asserted_runbook_check( + lambda doc, removed=removed: doc.replace(removed, "") + ) + + self.assertEqual(check["status"], "fail") + self.assertIn(removed, check["detail"]["missing_assertions"]) + + def test_release_readiness_fails_when_release_asset_gate_is_deleted(self) -> None: + check = self._asserted_runbook_check( + lambda doc: doc.replace( + 'problems.append("release asset SHA-256 values differ from PROD_DIST_DIR")', + "pass", + ) + ) + + self.assertEqual(check["status"], "fail") + self.assertIn( + 'problems.append("release asset SHA-256 values differ from PROD_DIST_DIR")', + check["detail"]["missing_assertions"], + ) + + def test_release_readiness_fails_when_republish_guard_is_deleted(self) -> None: + check = self._asserted_runbook_check( + lambda doc: doc.replace( + 'raise SystemExit(f"release-event republishing is enabled: {enabled}")', + "pass", + ) + ) + + self.assertEqual(check["status"], "fail") + self.assertIn( + 'raise SystemExit(f"release-event republishing is enabled: {enabled}")', + check["detail"]["missing_assertions"], + ) + + def test_release_readiness_rejects_release_asset_clobbering(self) -> None: + check = self._asserted_runbook_check( + lambda doc: doc.replace( + 'assert_release_assets.py" existing', + 'assert_release_assets.py" existing\ngh release upload v1.4.0 --clobber', + ) + ) + + self.assertEqual(check["status"], "fail") + self.assertIn("gh release upload", check["detail"]["forbidden_commands"]) + + def test_runbook_proves_testpypi_is_the_exclusive_candidate_source(self) -> None: + runbook = self._runbook_section() + + self.assertIn( + "env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS", + runbook, + ) + self.assertIn("PIP_CONFIG_FILE=/dev/null", runbook) + self.assertIn( + "--index-url https://test.pypi.org/simple/ --dest \"$TESTPYPI_DIST_DIR\"", + runbook, + ) + self.assertIn('--package-spec "$TESTPYPI_WHEEL"', runbook) + self.assertNotIn("--pip-extra-index-url https://pypi.org/simple/", runbook) + + def test_runbook_production_steps_use_canonical_pypi_only(self) -> None: + runbook = self._runbook_section() + production = runbook.partition("### 7. Publish production PyPI only")[2] + + self.assertIn("--pip-index-url https://pypi.org/simple/", production) + self.assertIn("--pip-no-cache", production) + self.assertIn( + "--index-url https://pypi.org/simple/ --dest \"$PYPI_DOWNLOAD_DIR\"", + production, + ) + self.assertNotIn("test.pypi.org", production.partition("### 10.")[0]) + + def test_runbook_rehearsal_flags_exist_on_the_migration_cli(self) -> None: + runbook = self._runbook_section() + stream = StringIO() + with redirect_stdout(stream), self.assertRaises(SystemExit): + code_mower_migration.main(["package-install-rehearsal", "--help"]) + help_text = stream.getvalue() + + flags = { + token + for line in runbook.splitlines() + for token in line.split() + if token.startswith("--") + } + rehearsal_flags = { + flag + for flag in flags + if flag.startswith(("--package-spec", "--pip-", "--work-dir", "--python")) + } + self.assertIn("--package-spec", rehearsal_flags) + for flag in sorted(rehearsal_flags): + with self.subTest(flag=flag): + self.assertIn(flag, help_text) + + def test_runbook_requires_hosted_devin_readiness_checks(self) -> None: + runbook = self._runbook_section() + required = { + "provider.devin.selection", + "provider.devin.capabilities", + "provider.devin.hosted_credentials", + "provider.devin.repository_scope", + "provider.devin.lifecycle", + } + + self.assertIn("--set-transport devin=devin_api_v3", runbook) + self.assertIn( + 'blocked = [name for name in required if checks.get(name) != "pass"]', + runbook, + ) + self.assertIn( + 'raise SystemExit(f"hosted Devin readiness is blocked: {blocked}")', runbook + ) + for check_id in sorted(required): + with self.subTest(check_id=check_id): + self.assertIn(check_id, runbook) + for private in ("provider-config-dir\"", "DEVIN_API_KEY="): + self.assertNotIn(private, runbook) + + def test_runbook_board_restart_waits_and_polls_every_port(self) -> None: + runbook = self._runbook_section() + boards = runbook.partition("### 15. Restart the three Boards")[2] + + self.assertIn( + 'test "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" rev-parse HEAD)"' + ' = "$RELEASE_SHA"', + boards, + ) + self.assertIn("code-mower board stop --port \"$BOARD_PORT\" --yes --json", boards) + self.assertIn('board_wait.py" gone "$BOARD_PORT"', boards) + self.assertIn('board_wait.py" serving 5332 5342 5344', boards) + self.assertIn("code-mower board doctor", boards) + self.assertNotIn("board reset --", boards) + self.assertNotIn("pkill", boards) + for port in ("5332", "5342", "5344"): + with self.subTest(port=port): + self.assertIn(port, boards) + def test_public_support_docs_are_packaged_and_privacy_forward(self) -> None: manifest = (ROOT / "MANIFEST.in").read_text(encoding="utf-8") readme = (ROOT / "README.md").read_text(encoding="utf-8") From 5caab6abec3e277229d4a4c28a219902526759b5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:40:07 +0000 Subject: [PATCH 05/20] Make v1.4.0 release gates strict for manifests, campaigns, Boards and uploads Co-Authored-By: bot_apk --- docs/pypi-release.md | 324 ++++++++++++++++++++++++---- src/code_mower/package.py | 139 ++++++++++-- src/code_mower/release_readiness.py | 60 +++++- tests/test_release_hygiene.py | 193 ++++++++++++++++- 4 files changed, 640 insertions(+), 76 deletions(-) diff --git a/docs/pypi-release.md b/docs/pypi-release.md index 8544526a..0027f21a 100644 --- a/docs/pypi-release.md +++ b/docs/pypi-release.md @@ -151,26 +151,39 @@ RELEASE_SHA="$(gh pr view "$RELEASE_PR" --repo "$REPO" \ --json mergeCommit --jq '.mergeCommit.oid')" printf '%s\n' "$RELEASE_SHA" | grep -Eq '^[0-9a-f]{40}$' git fetch origin "$RELEASE_SHA" -git checkout --detach "$RELEASE_SHA" -test "$(git rev-parse HEAD)" = "$RELEASE_SHA" test "$(git cat-file -t "$RELEASE_SHA")" = "commit" ``` +A working checkout can retain dirty or untracked files, so the release source is +a fresh clone bound to that commit and machine-asserted clean before anything is +built or installed from it. + +```bash +RELEASE_CHECKOUT="$(mktemp -d /tmp/code-mower-v140-release-src.XXXXXX)/code-mower" +git clone --no-checkout "https://github.com/$REPO.git" "$RELEASE_CHECKOUT" +git -C "$RELEASE_CHECKOUT" fetch origin "$RELEASE_SHA" +git -C "$RELEASE_CHECKOUT" checkout --detach "$RELEASE_SHA" +test "$(git -C "$RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA" +test -z "$(git -C "$RELEASE_CHECKOUT" status --porcelain --untracked-files=all)" +``` + ### 2. Run release readiness from a fresh source environment at that commit The ambient `code-mower` executable is still the previous release, so readiness -runs from a clean virtual environment built out of the exact `RELEASE_SHA` -checkout. +runs from a clean virtual environment built out of the clean `RELEASE_CHECKOUT` +clone of `RELEASE_SHA`. ```bash RELEASE_ENV="$(mktemp -d /tmp/code-mower-v140-release-env.XXXXXX)" python3.12 -m venv "$RELEASE_ENV/venv" RELEASE_PYTHON="$RELEASE_ENV/venv/bin/python" PIP_CONFIG_FILE=/dev/null PIP_INDEX_URL= PIP_EXTRA_INDEX_URL= \ - "$RELEASE_PYTHON" -m pip install --no-cache-dir --index-url https://pypi.org/simple/ . + "$RELEASE_PYTHON" -m pip install --no-cache-dir \ + --index-url https://pypi.org/simple/ "$RELEASE_CHECKOUT" RELEASE_CLI="$RELEASE_ENV/venv/bin/code-mower" test "$("$RELEASE_CLI" --version)" = "code-mower 1.4.0" -"$RELEASE_CLI" migration release-readiness --json >"$RELEASE_ENV/readiness.json" +(cd "$RELEASE_CHECKOUT" && "$RELEASE_CLI" migration release-readiness --json) \ + >"$RELEASE_ENV/readiness.json" READINESS_JSON="$RELEASE_ENV/readiness.json" "$RELEASE_PYTHON" - <<'PY' import json import os @@ -204,9 +217,11 @@ test "$(git ls-remote origin 'refs/tags/v1.4.0^{}' | awk '{print $1}')" = "$RELE ### 4. Install the workflow-run assertion helper Every workflow run below is asserted with this helper: workflow identity, -triggering event, exact head SHA, `success` conclusion, and the exact posture of -both publish jobs. A job that is expected to skip must be reported skipped or be -absent from the run; a job that is expected to publish must report `success`. +triggering event, exact head SHA, `success` conclusion, successful +`build-distributions` and `verify-distributions` jobs, and the exact posture of +both publish jobs. A run whose only reported jobs are skipped publish jobs fails. +A job that is expected to skip must be reported skipped or be absent from the +run; a job that is expected to publish must report `success`. ```bash cat >"$RELEASE_ENV/assert_release_run.py" <<'PY' @@ -218,6 +233,7 @@ import subprocess import sys EXPECTED_WORKFLOW = "Code Mower Release" +BUILD_JOBS = ("build-distributions", "verify-distributions") SKIPPED = {"skipped", "absent"} @@ -255,6 +271,10 @@ def main() -> None: problems.append("run head is not the exact release commit") if run.get("status") != "completed" or run.get("conclusion") != "success": problems.append("run did not complete successfully") + for job_name in BUILD_JOBS: + actual = job_posture(run, job_name) + if actual != "success": + problems.append(f"{job_name} is {actual}, expected success") for job_name, expected in ( ("publish-testpypi", testpypi), ("publish-pypi", pypi), @@ -270,6 +290,8 @@ def main() -> None: "run_id": run_id, "event": event, "head_sha": head_sha, + "build_distributions": job_posture(run, "build-distributions"), + "verify_distributions": job_posture(run, "verify-distributions"), "publish_testpypi": job_posture(run, "publish-testpypi"), "publish_pypi": job_posture(run, "publish-pypi"), "url": run.get("url"), @@ -431,6 +453,13 @@ The published Release triggers one `release`-event run whose publish jobs are gated on repository variables. Assert they cannot republish before the irreversible release creation, not afterwards. +`vars.CODE_MOWER_TESTPYPI_PUBLISH` and `vars.CODE_MOWER_PYPI_PUBLISH` resolve +through organization scope when the repository does not define them, so an +absent repository variable is not a false value. Each variable must exist at +repository scope and equal `false`, which is also what overrides an inherited +organization value. Anything else -- absent, `true`, or unparseable -- fails +closed. + ```bash REPO="$REPO" "$RELEASE_PYTHON" - <<'PY' import json @@ -438,22 +467,37 @@ import os import subprocess repo = os.environ["REPO"] -completed = subprocess.run( - ["gh", "api", f"repos/{repo}/actions/variables", "--paginate"], - check=True, capture_output=True, text=True, -) -values = { - row["name"]: str(row.get("value", "")) - for row in json.loads(completed.stdout).get("variables", []) -} -enabled = [ +REQUIRED_VARIABLES = ("CODE_MOWER_TESTPYPI_PUBLISH", "CODE_MOWER_PYPI_PUBLISH") + + +def repository_variable(name: str) -> str | None: + """Read one repository-scoped variable, never an inherited organization one.""" + completed = subprocess.run( + ["gh", "api", f"repos/{repo}/actions/variables/{name}"], + capture_output=True, + text=True, + ) + if completed.returncode != 0: + return None + try: + payload = json.loads(completed.stdout) + except json.JSONDecodeError: + return None + value = payload.get("value") + return value if isinstance(value, str) else None + + +values = {name: repository_variable(name) for name in REQUIRED_VARIABLES} +blocked = sorted( name - for name in ("CODE_MOWER_TESTPYPI_PUBLISH", "CODE_MOWER_PYPI_PUBLISH") - if values.get(name, "false").strip().lower() == "true" -] -if enabled: - raise SystemExit(f"release-event republishing is enabled: {enabled}") -print(json.dumps({"republish_variables_off": True})) + for name, value in values.items() + if value is None or value.strip().lower() != "false" +) +if blocked: + raise SystemExit( + "repository-scope publish variables must exist and equal false: " f"{blocked}" + ) +print(json.dumps({"republish_repository_variables_false": sorted(values)})) PY ``` @@ -518,7 +562,7 @@ def main() -> None: target = Path(scratch) subprocess.run( ["gh", "release", "download", "v1.4.0", "--repo", repo, - "--dir", str(target), "--clobber"], + "--dir", str(target)], check=True, capture_output=True, text=True, ) downloaded = digests(target) @@ -537,9 +581,9 @@ main() PY ``` -The `--clobber` flag above only writes into the private scratch download -directory; Release assets are never overwritten, and a `v1.4.0` release whose -assets differ stops the runbook for inspection. +Assets are downloaded into a private empty scratch directory, so nothing is +overwritten anywhere, and a `v1.4.0` release whose assets differ stops the +runbook for inspection. ```bash if gh release view v1.4.0 --repo "$REPO" >/dev/null 2>&1; then @@ -619,7 +663,16 @@ identifier, and repository inventory out of recorded evidence. ### 14. Run the required Claude + Codex + Devin campaign +The campaign is a gate, so its watch and status output is saved and asserted: +the campaign must finish `complete`, the selected and required provider sets +must be exactly Claude, Codex, and Devin, every required lane must hold a +passing adoption result, and the Devin lane must report the verified hosted +bridge transport (`devin_api_v3`, the only hosted Code Mower Devin transport, +already selected explicitly in step 13). The protected profile is named on +watch and status too, so a protected or ambiguous profile stays selected. + ```bash +CAMPAIGN_DIR="$(mktemp -d /tmp/code-mower-v140-campaign.XXXXXX)" code-mower release campaign create \ --release-tag v1.4.0 \ --package-spec code-mower==1.4.0 \ @@ -630,14 +683,69 @@ code-mower release campaign create \ --repo-slug codemower-ai/code-mower \ --issue 912 --release-pr "$RELEASE_PR" \ --provider-profile "$DEVIN_PROVIDER_PROFILE" \ - --apply --json + --apply --json >"$CAMPAIGN_DIR/create.json" code-mower release campaign watch --release-tag v1.4.0 \ - --interval 10 --timeout 3600 --json -code-mower release campaign status --release-tag v1.4.0 --json + --provider-profile "$DEVIN_PROVIDER_PROFILE" \ + --interval 10 --timeout 3600 --json >"$CAMPAIGN_DIR/watch.json" +code-mower release campaign status --release-tag v1.4.0 \ + --provider-profile "$DEVIN_PROVIDER_PROFILE" --json >"$CAMPAIGN_DIR/status.json" +CAMPAIGN_DIR="$CAMPAIGN_DIR" "$RELEASE_PYTHON" - <<'PY' +import json +import os +from pathlib import Path + +campaign_dir = Path(os.environ["CAMPAIGN_DIR"]) +REQUIRED_PROVIDERS = {"claude", "codex", "devin"} +PASSING_OUTCOMES = {"pass", "pass_with_warnings"} + + +def load(name: str) -> dict: + return json.loads((campaign_dir / name).read_text(encoding="utf-8")) + + +watch = load("watch.json") +status = load("status.json") +problems = [] +if watch.get("status") != "complete" or watch.get("stop_reason") != "complete": + problems.append( + f"watch stopped as {watch.get('stop_reason')!r} with status {watch.get('status')!r}" + ) +if status.get("status") != "complete": + problems.append(f"campaign status is {status.get('status')!r}, not complete") +lanes = { + str(row.get("provider") or ""): row + for row in status.get("providers") or [] + if isinstance(row, dict) +} +if set(lanes) != REQUIRED_PROVIDERS: + problems.append(f"campaign provider set is {sorted(lanes)}") +required = {name for name, row in lanes.items() if row.get("posture") == "required"} +if required != REQUIRED_PROVIDERS: + problems.append(f"required provider set is {sorted(required)}") +for name in sorted(REQUIRED_PROVIDERS & set(lanes)): + lane = lanes[name] + if lane.get("state") != "complete": + problems.append(f"{name} lane state is {lane.get('state')!r}") + result = lane.get("adoption_result") + outcome = result.get("outcome") if isinstance(result, dict) else None + if outcome not in PASSING_OUTCOMES: + problems.append(f"{name} lane result outcome is {outcome!r}") +devin = lanes.get("devin") or {} +if devin.get("driver") != "hosted_bridge" or devin.get("transport_verified") is not True: + problems.append("Devin lane did not verify the hosted bridge transport") +if problems: + raise SystemExit(f"release qualification campaign is not a pass: {problems}") +print(json.dumps({ + "campaign": "complete", + "required_providers": sorted(REQUIRED_PROVIDERS), + "devin_transport": "hosted_bridge", +})) +PY ``` All three provider results must pass, and Devin's result must identify the -hosted transport before peer support is claimed. +hosted transport before peer support is claimed. Keep the profile selector, +credentials, and result prose out of recorded evidence. ### 15. Restart the three Boards from the release, waiting on each stop @@ -732,35 +840,165 @@ nohup code-mower board serve --repo "$BOARD_5344_REPO" \ --port 5344 --record-events >/tmp/code-mower-board-5344.log 2>&1 & "$RELEASE_PYTHON" "$RELEASE_ENV/board_wait.py" serving 5332 5342 5344 +BOARD_DOCTOR_DIR="$(mktemp -d /tmp/code-mower-v140-board-doctor.XXXXXX)" code-mower board doctor --repo codemower-ai/code-mower \ - --repo-path "$CODE_MOWER_RELEASE_CHECKOUT" --json + --repo-path "$CODE_MOWER_RELEASE_CHECKOUT" --json >"$BOARD_DOCTOR_DIR/5332.json" code-mower board doctor --repo "$BOARD_5342_REPO" \ - --repo-path "$BOARD_5342_REPO_PATH" --json + --repo-path "$BOARD_5342_REPO_PATH" --json >"$BOARD_DOCTOR_DIR/5342.json" code-mower board doctor --repo "$BOARD_5344_REPO" \ - --repo-path "$BOARD_5344_REPO_PATH" --json + --repo-path "$BOARD_5344_REPO_PATH" --json >"$BOARD_DOCTOR_DIR/5344.json" +BOARD_DOCTOR_DIR="$BOARD_DOCTOR_DIR" "$RELEASE_PYTHON" - <<'PY' +import json +import os +from pathlib import Path + +doctor_dir = Path(os.environ["BOARD_DOCTOR_DIR"]) +problems = [] +for port in ("5332", "5342", "5344"): + report = json.loads((doctor_dir / f"{port}.json").read_text(encoding="utf-8")) + if report.get("status") != "pass": + problems.append(f"board {port} doctor status is {report.get('status')!r}") +if problems: + raise SystemExit(f"restarted Board doctors are not all pass: {problems}") +print(json.dumps({"board_doctors_pass": ["5332", "5342", "5344"]})) +PY ``` -Every `board doctor` run must pass. Do not use raw process kills or Board reset, -and never copy private repository slugs or paths into public evidence. +`code-mower board doctor` exits zero for `warn`, so each report's own top-level +status is parsed and required to be `pass`; printing the JSON is not the gate. +Do not use raw process kills or Board reset, and never copy private repository +slugs or paths into public evidence. ### 16. Dry-run, inspect, then upload metadata-only cloud evidence +Both uploads are gates: the preview and the applied result are saved and +parsed. A preview must be metadata-only, carry zero reports, require explicit +application, and report the event identifiers and counts it would send; the +applied upload must be accepted by the service and carry exactly the previewed +identifiers and counts. + ```bash -code-mower cloud doctor --install-id codex-code-mower --probe-service --json +CLOUD_DIR="$(mktemp -d /tmp/code-mower-v140-cloud.XXXXXX)" +code-mower cloud doctor --install-id codex-code-mower --probe-service --json \ + >"$CLOUD_DIR/doctor.json" code-mower release campaign upload --release-tag v1.4.0 \ - --install-id codex-code-mower --team-id jeff-internal --json + --install-id codex-code-mower --team-id jeff-internal --json \ + >"$CLOUD_DIR/campaign-preview.json" code-mower release campaign upload --release-tag v1.4.0 \ - --install-id codex-code-mower --team-id jeff-internal --yes --json + --install-id codex-code-mower --team-id jeff-internal --yes --json \ + >"$CLOUD_DIR/campaign-applied.json" +CLOUD_DIR="$CLOUD_DIR" "$RELEASE_PYTHON" - <<'PY' +import json +import os +from pathlib import Path + +cloud_dir = Path(os.environ["CLOUD_DIR"]) + + +def load(name: str) -> dict: + return json.loads((cloud_dir / name).read_text(encoding="utf-8")) + + +preview = load("campaign-preview.json") +applied = load("campaign-applied.json") +preview_upload = preview.get("upload") or {} +applied_upload = applied.get("upload") or {} +problems = [] +if preview.get("status") != "dry_run" or preview.get("would_upload") is not False: + problems.append(f"preview status is {preview.get('status')!r}") +if preview.get("requires_yes") is not True or preview_upload.get("requires_yes") is not True: + problems.append("preview does not require explicit application") +if preview.get("upload_mode") != "metadata_only": + problems.append(f"preview upload mode is {preview.get('upload_mode')!r}") +if preview_upload.get("upload_mode") != "metadata_only": + problems.append(f"preview payload mode is {preview_upload.get('upload_mode')!r}") +if preview_upload.get("report_count") != 0: + problems.append(f"preview carries {preview_upload.get('report_count')!r} reports") +preview_events = [str(value) for value in preview.get("event_ids") or []] +if not preview_events or preview_upload.get("event_count") != len(preview_events): + problems.append("preview event identifiers and count disagree") +if applied.get("status") != "uploaded" or applied.get("would_upload") is not True: + problems.append(f"applied status is {applied.get('status')!r}") +if applied.get("upload_mode") != "metadata_only": + problems.append(f"applied upload mode is {applied.get('upload_mode')!r}") +if applied_upload.get("mode") != "cloud-upload": + problems.append(f"applied upload mode is {applied_upload.get('mode')!r}") +if not 200 <= int(applied_upload.get("status") or 0) < 300: + problems.append(f"applied upload was not accepted: {applied_upload.get('status')!r}") +if [str(value) for value in applied.get("event_ids") or []] != preview_events: + problems.append("applied event identifiers differ from the preview") +if applied.get("counts") != preview.get("counts"): + problems.append("applied counts differ from the preview") +if problems: + raise SystemExit(f"campaign metadata upload is not a verified gate: {problems}") +print(json.dumps({ + "campaign_upload": "accepted", + "event_count": len(preview_events), + "reports": 0, +})) +PY + BOARD_SNAPSHOT_DIR="$(mktemp -d /tmp/code-mower-v140-board-snapshot.XXXXXX)" code-mower cloud board-snapshot \ --repo-path "$CODE_MOWER_RELEASE_CHECKOUT" \ --repo-slug codemower-ai/code-mower \ --output-dir "$BOARD_SNAPSHOT_DIR" \ - --install-id codex-code-mower --team-id jeff-internal --json + --install-id codex-code-mower --team-id jeff-internal --json \ + >"$CLOUD_DIR/board-snapshot.json" code-mower cloud upload "$BOARD_SNAPSHOT_DIR" \ - --install-id codex-code-mower --dry-run --json + --install-id codex-code-mower --dry-run --json \ + >"$CLOUD_DIR/board-preview.json" code-mower cloud upload "$BOARD_SNAPSHOT_DIR" \ - --install-id codex-code-mower --yes --json + --install-id codex-code-mower --yes --json \ + >"$CLOUD_DIR/board-applied.json" +CLOUD_DIR="$CLOUD_DIR" BOARD_SNAPSHOT_DIR="$BOARD_SNAPSHOT_DIR" "$RELEASE_PYTHON" - <<'PY' +import json +import os +from pathlib import Path + +cloud_dir = Path(os.environ["CLOUD_DIR"]) +bundle_dir = Path(os.environ["BOARD_SNAPSHOT_DIR"]) + + +def load(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +snapshot = load(cloud_dir / "board-snapshot.json") +preview = load(cloud_dir / "board-preview.json") +applied = load(cloud_dir / "board-applied.json") +manifest = load(bundle_dir / "code-mower-cloud-bundle.json") +events = [row for row in manifest.get("events") or [] if isinstance(row, dict)] +event_types = sorted({str(row.get("event_type") or "") for row in events}) +problems = [] +if snapshot.get("status") not in {"dry_run", "uploaded"}: + problems.append(f"board snapshot status is {snapshot.get('status')!r}") +if event_types != ["board_snapshot"] or len(events) != 1: + problems.append(f"board bundle carries {event_types} events") +if manifest.get("included_reports"): + problems.append("board bundle carries report content") +if preview.get("mode") != "cloud-upload-dry-run" or preview.get("would_upload") is not False: + problems.append(f"board preview mode is {preview.get('mode')!r}") +if preview.get("requires_yes") is not True: + problems.append("board preview does not require explicit application") +if preview.get("upload_mode") != "metadata_only": + problems.append(f"board preview upload mode is {preview.get('upload_mode')!r}") +if preview.get("report_count") != 0: + problems.append(f"board preview carries {preview.get('report_count')!r} reports") +if preview.get("event_count") != len(events): + problems.append("board preview event count differs from the bundle") +if applied.get("mode") != "cloud-upload": + problems.append(f"board applied mode is {applied.get('mode')!r}") +if not 200 <= int(applied.get("status") or 0) < 300: + problems.append(f"board upload was not accepted: {applied.get('status')!r}") +if problems: + raise SystemExit(f"board snapshot upload is not a verified gate: {problems}") +print(json.dumps({ + "board_upload": "accepted", + "event_types": event_types, + "reports": 0, +})) +PY ``` Record accepted event identifiers and counts only, never report prose, profile diff --git a/src/code_mower/package.py b/src/code_mower/package.py index fd155cb8..ab90edab 100644 --- a/src/code_mower/package.py +++ b/src/code_mower/package.py @@ -10,7 +10,7 @@ import sys import tempfile from pathlib import Path -from typing import Any, Mapping +from typing import Any, Mapping, Sequence def _render_provider_catalog_json_fallback(data: Mapping[str, Any]) -> str: @@ -614,6 +614,111 @@ def materialize_package_plan( COMMITTED_PACKAGE_MANIFEST = "code-mower-package-manifest.json" GENERATED_OUTPUT_DIR = "" +MANIFEST_TEXT_FIELDS = ("mode", "output_dir") +MANIFEST_MAPPING_FIELDS = ("package",) +MANIFEST_LIST_FIELDS = ("files_written", "deferred_package_files") +MANIFEST_TOP_LEVEL_FIELDS = ( + *MANIFEST_TEXT_FIELDS, + *MANIFEST_MAPPING_FIELDS, + *MANIFEST_LIST_FIELDS, +) +MANIFEST_FILE_FIELDS = ("target", "source", "kind") +MANIFEST_DEFERRED_FIELDS = ("target", "source", "reason") + + +class PackageManifestError(ValueError): + """A package manifest that cannot be compared, with bounded problem text.""" + + def __init__(self, problems: Sequence[str]) -> None: + self.problems = list(problems) + super().__init__("; ".join(self.problems)) + + +def _manifest_row_problems( + label: str, rows: object, fields: Sequence[str], unique_targets: bool +) -> list[str]: + if not isinstance(rows, list): + return [f"{label} is not a list"] + problems: list[str] = [] + seen: set[str] = set() + for index, row in enumerate(rows): + if not isinstance(row, Mapping): + problems.append(f"{label} row {index} is not an object") + continue + missing = [field for field in fields if field not in row] + if missing: + problems.append(f"{label} row {index} is missing {', '.join(missing)}") + unexpected = sorted(set(row) - set(fields)) + if unexpected: + problems.append(f"{label} row {index} has unexpected {', '.join(unexpected)}") + non_text = sorted( + field + for field in fields + if field in row and not isinstance(row[field], str) + ) + if non_text: + problems.append(f"{label} row {index} has non-string {', '.join(non_text)}") + target = row.get("target") + if unique_targets and isinstance(target, str): + if target in seen: + problems.append(f"{label} has a duplicate target {target}") + seen.add(target) + return problems + + +def package_manifest_problems(manifest: object) -> list[str]: + """Describe every structural problem that blocks an exact manifest comparison. + + Normalization is a comparison surface, not a repair step: filtering unknown + fields or malformed rows would let a corrupted committed artifact normalize + into apparent agreement with a freshly generated one. + """ + + if not isinstance(manifest, Mapping): + return ["manifest is not an object"] + problems: list[str] = [] + missing = [field for field in MANIFEST_TOP_LEVEL_FIELDS if field not in manifest] + if missing: + problems.append(f"manifest is missing {', '.join(missing)}") + unexpected = sorted(set(manifest) - set(MANIFEST_TOP_LEVEL_FIELDS)) + if unexpected: + problems.append(f"manifest has unexpected {', '.join(unexpected)}") + for field in MANIFEST_TEXT_FIELDS: + if field in manifest and not isinstance(manifest[field], str): + problems.append(f"{field} is not a string") + for field in MANIFEST_MAPPING_FIELDS: + value = manifest.get(field) + if field in manifest and not isinstance(value, Mapping): + problems.append(f"{field} is not an object") + elif isinstance(value, Mapping): + invalid = sorted( + str(key) + for key, item in value.items() + if not isinstance(key, str) or not isinstance(item, str) + ) + if invalid: + problems.append(f"{field} has non-string {', '.join(invalid)}") + problems.extend( + _manifest_row_problems( + "files_written", + manifest.get("files_written") if "files_written" in manifest else [], + MANIFEST_FILE_FIELDS, + unique_targets=True, + ) + ) + problems.extend( + _manifest_row_problems( + "deferred_package_files", + ( + manifest.get("deferred_package_files") + if "deferred_package_files" in manifest + else [] + ), + MANIFEST_DEFERRED_FIELDS, + unique_targets=False, + ) + ) + return problems def normalized_package_manifest(manifest: Mapping[str, Any]) -> dict[str, Any]: @@ -621,29 +726,29 @@ def normalized_package_manifest(manifest: Mapping[str, Any]) -> dict[str, Any]: The local output directory is replaced by a placeholder and the written-file inventory is ordered, so the committed artifact records the package surface - and its source mappings rather than one machine's materialization run. + and its source mappings rather than one machine's materialization run. A + manifest that cannot be compared raises ``PackageManifestError`` instead of + being silently repaired. """ + problems = package_manifest_problems(manifest) + if problems: + raise PackageManifestError(problems) files = [ - { - "target": str(entry.get("target", "")), - "source": str(entry.get("source", "")), - "kind": str(entry.get("kind", "")), - } - for entry in manifest.get("files_written", []) - if isinstance(entry, Mapping) - ] - deferred = [ - dict(entry) - for entry in manifest.get("deferred_package_files", []) - if isinstance(entry, Mapping) + {field: str(entry[field]) for field in MANIFEST_FILE_FIELDS} + for entry in manifest["files_written"] ] + deferred = sorted( + ({field: str(entry[field]) for field in MANIFEST_DEFERRED_FIELDS} + for entry in manifest["deferred_package_files"]), + key=lambda entry: tuple(entry[field] for field in MANIFEST_DEFERRED_FIELDS), + ) return { - "mode": str(manifest.get("mode", "")), - "package": dict(manifest.get("package", {})), + "mode": str(manifest["mode"]), + "package": {str(key): str(value) for key, value in manifest["package"].items()}, "output_dir": GENERATED_OUTPUT_DIR, "files_written": sorted( - files, key=lambda entry: (entry["target"], entry["source"], entry["kind"]) + files, key=lambda entry: tuple(entry[field] for field in MANIFEST_FILE_FIELDS) ), "deferred_package_files": deferred, } diff --git a/src/code_mower/release_readiness.py b/src/code_mower/release_readiness.py index f19a4605..265325cb 100644 --- a/src/code_mower/release_readiness.py +++ b/src/code_mower/release_readiness.py @@ -241,7 +241,21 @@ def _committed_manifest_drift(repo_path: Path) -> dict[str, Any]: "committed_row_count": len(_manifest_rows(committed)), } malformed = _malformed_manifest_rows(committed) + malformed.extend( + problem + for problem in package_module.package_manifest_problems(committed) + if problem not in malformed + ) duplicates = _duplicate_manifest_targets(committed) + committed_rows = _manifest_rows(committed) + if malformed: + return { + **empty, + "malformed_rows": malformed[:20], + "duplicate_targets": duplicates[:20], + "committed_row_count": len(committed_rows), + "generated_row_count": len(_manifest_rows(generated)), + } normalized = package_module.normalized_package_manifest(committed) committed_files = _manifest_inventory(normalized) generated_files = _manifest_inventory(generated) @@ -254,7 +268,6 @@ def _committed_manifest_drift(repo_path: Path) -> dict[str, Any]: normalized.get(key) == generated.get(key) for key in ("mode", "package", "output_dir", "deferred_package_files") ) - committed_rows = _manifest_rows(committed) return { "matches": bool( not malformed @@ -305,6 +318,8 @@ def _post_merge_runbook_markers(release_tag: str, package_index_spec: str) -> tu "code-mower board stop --port", 'board_wait.py" serving 5332 5342 5344', "code-mower board doctor", + "code-mower release campaign upload --release-tag", + "code-mower cloud board-snapshot", "code-mower cloud upload", "--dry-run --json", ) @@ -322,8 +337,11 @@ def _post_merge_runbook_assertions(version: str, release_tag: str) -> tuple[str, return ( # The release commit is the merged pull request's own merge commit. "--json mergeCommit --jq '.mergeCommit.oid'", - 'test "$(git rev-parse HEAD)" = "$RELEASE_SHA"', - # Readiness runs from a fresh environment built at that exact commit. + 'test "$(git cat-file -t "$RELEASE_SHA")" = "commit"', + # Readiness runs from a fresh, machine-asserted clean clone of that commit. + 'git clone --no-checkout "https://github.com/$REPO.git" "$RELEASE_CHECKOUT"', + 'test "$(git -C "$RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA"', + 'test -z "$(git -C "$RELEASE_CHECKOUT" status --porcelain --untracked-files=all)"', f'test "$("$RELEASE_CLI" --version)" = "code-mower {version}"', "committed-package-manifest-matches-generated", "post-merge-release-runbook-asserted", @@ -333,8 +351,12 @@ def _post_merge_runbook_assertions(version: str, release_tag: str) -> tuple[str, f"test \"$(git ls-remote origin 'refs/tags/{release_tag}^{{}}' | awk '{{print $1}}')\"" ' = "$RELEASE_SHA"', # Every workflow run is asserted, including both publish-job postures. + 'BUILD_JOBS = ("build-distributions", "verify-distributions")', + 'if str(run.get("databaseId")) != run_id:', 'if run.get("workflowName") != EXPECTED_WORKFLOW:', + 'if run.get("event") != event:', 'if run.get("headSha") != head_sha:', + 'if run.get("status") != "completed" or run.get("conclusion") != "success":', 'problems.append(f"{job_name} is {actual}, expected skipped")', 'problems.append(f"{job_name} is {actual}, expected success")', '"$NO_PUBLISH_RUN_ID" workflow_dispatch "$RELEASE_SHA" skipped skipped', @@ -350,8 +372,11 @@ def _post_merge_runbook_assertions(version: str, release_tag: str) -> tuple[str, "--pip-index-url https://pypi.org/simple/", "--pip-no-cache", # Republishing is impossible before the irreversible release creation. - 'if values.get(name, "false").strip().lower() == "true"', - 'raise SystemExit(f"release-event republishing is enabled: {enabled}")', + # An absent repository variable can inherit an organization value, so + # each one must exist at repository scope and read false. + '["gh", "api", f"repos/{repo}/actions/variables/{name}"]', + 'if value is None or value.strip().lower() != "false"', + '"repository-scope publish variables must exist and equal false: "', # The Release's own assets are downloaded and compared digest by digest. 'raise SystemExit(f"{mode} release assets are not acceptable: {problems}")', 'problems.append("release asset SHA-256 values differ from PROD_DIST_DIR")', @@ -363,6 +388,31 @@ def _post_merge_runbook_assertions(version: str, release_tag: str) -> tuple[str, 'test "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA"', 'board_wait.py" gone "$BOARD_PORT"', 'raise SystemExit(f"ports still not {mode} within {DEADLINE_SECONDS}s: {pending}")', + # Every restarted Board's own doctor verdict is parsed; the CLI exits + # zero on warn, so exit status is not the gate. + '--json >"$BOARD_DOCTOR_DIR/5332.json"', + '--json >"$BOARD_DOCTOR_DIR/5342.json"', + '--json >"$BOARD_DOCTOR_DIR/5344.json"', + 'if report.get("status") != "pass":', + 'raise SystemExit(f"restarted Board doctors are not all pass: {problems}")', + # The required campaign is a parsed gate, not printed output. + '>"$CAMPAIGN_DIR/watch.json"', + '>"$CAMPAIGN_DIR/status.json"', + 'if set(lanes) != REQUIRED_PROVIDERS:', + 'if required != REQUIRED_PROVIDERS:', + 'if outcome not in PASSING_OUTCOMES:', + 'if devin.get("driver") != "hosted_bridge" or devin.get("transport_verified") is not True:', + 'raise SystemExit(f"release qualification campaign is not a pass: {problems}")', + # Both metadata-only uploads are previewed, applied, and correlated. + '--team-id jeff-internal --yes --json', + 'if preview.get("status") != "dry_run" or preview.get("would_upload") is not False:', + 'if preview_upload.get("report_count") != 0:', + 'if applied.get("counts") != preview.get("counts"):', + 'raise SystemExit(f"campaign metadata upload is not a verified gate: {problems}")', + '--install-id codex-code-mower --yes --json', + 'if event_types != ["board_snapshot"] or len(events) != 1:', + 'if manifest.get("included_reports"):', + 'raise SystemExit(f"board snapshot upload is not a verified gate: {problems}")', ) diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py index 253973d5..3d587e48 100644 --- a/tests/test_release_hygiene.py +++ b/tests/test_release_hygiene.py @@ -8026,6 +8026,89 @@ def replace_kind(manifest: dict) -> None: self.assertEqual(check["status"], "fail") self.assertIn("row 0 has non-string kind", check["detail"]["malformed_rows"]) + def test_release_readiness_fails_on_unexpected_top_level_manifest_field(self) -> None: + def add_field(manifest: dict) -> None: + manifest["generated_at"] = "2026-09-13T00:00:00Z" + + check = self._manifest_drift_check(add_field) + + self.assertEqual(check["status"], "fail") + self.assertIn("manifest has unexpected generated_at", check["detail"]["malformed_rows"]) + + def test_release_readiness_fails_on_missing_top_level_manifest_field(self) -> None: + def drop_output_dir(manifest: dict) -> None: + del manifest["output_dir"] + + check = self._manifest_drift_check(drop_output_dir) + + self.assertEqual(check["status"], "fail") + self.assertIn("manifest is missing output_dir", check["detail"]["malformed_rows"]) + + def test_release_readiness_fails_on_non_list_manifest_fields(self) -> None: + for field in ("files_written", "deferred_package_files"): + with self.subTest(field=field): + check = self._manifest_drift_check( + lambda manifest, field=field: manifest.__setitem__(field, "everything") + ) + + self.assertEqual(check["status"], "fail") + self.assertIn(f"{field} is not a list", check["detail"]["malformed_rows"]) + + def test_release_readiness_fails_on_non_object_manifest_package_field(self) -> None: + check = self._manifest_drift_check( + lambda manifest: manifest.__setitem__("package", ["code-mower", "1.4.0"]) + ) + + self.assertEqual(check["status"], "fail") + self.assertIn("package is not an object", check["detail"]["malformed_rows"]) + + def test_release_readiness_fails_on_malformed_deferred_manifest_rows(self) -> None: + cases = { + "deferred_package_files row 0 is not an object": "docs/missing.md", + "deferred_package_files row 0 is missing reason": { + "target": "docs/missing.md", + "source": "docs/missing.md", + }, + "deferred_package_files row 0 has unexpected kind": { + "target": "docs/missing.md", + "source": "docs/missing.md", + "reason": "absent", + "kind": "doc", + }, + "deferred_package_files row 0 has non-string reason": { + "target": "docs/missing.md", + "source": "docs/missing.md", + "reason": ["absent"], + }, + } + for problem, row in cases.items(): + with self.subTest(problem=problem): + check = self._manifest_drift_check( + lambda manifest, row=row: manifest.__setitem__( + "deferred_package_files", [row] + ) + ) + + self.assertEqual(check["status"], "fail") + self.assertIn(problem, check["detail"]["malformed_rows"]) + + def test_normalized_package_manifest_rejects_invalid_schemas(self) -> None: + for manifest in ( + "not a manifest", + {"mode": "standalone"}, + { + "mode": "standalone", + "package": {"version": 1}, + "output_dir": "", + "files_written": [], + "deferred_package_files": [], + }, + ): + with self.subTest(manifest=manifest): + with self.assertRaises(code_mower_package.PackageManifestError) as raised: + code_mower_package.normalized_package_manifest(manifest) + self.assertTrue(raised.exception.problems) + def test_packaged_graph_docs_link_target_is_packaged(self) -> None: packaged = {target for _source, target, _kind in code_mower_package.PACKAGE_FILES} @@ -8157,18 +8240,106 @@ def test_release_readiness_fails_when_release_asset_gate_is_deleted(self) -> Non ) def test_release_readiness_fails_when_republish_guard_is_deleted(self) -> None: - check = self._asserted_runbook_check( - lambda doc: doc.replace( - 'raise SystemExit(f"release-event republishing is enabled: {enabled}")', - "pass", - ) - ) + for assertion in ( + '["gh", "api", f"repos/{repo}/actions/variables/{name}"]', + 'if value is None or value.strip().lower() != "false"', + '"repository-scope publish variables must exist and equal false: "', + ): + with self.subTest(assertion=assertion): + check = self._asserted_runbook_check( + lambda doc, assertion=assertion: doc.replace(assertion, "pass") + ) - self.assertEqual(check["status"], "fail") - self.assertIn( - 'raise SystemExit(f"release-event republishing is enabled: {enabled}")', - check["detail"]["missing_assertions"], - ) + self.assertEqual(check["status"], "fail") + self.assertIn(assertion, check["detail"]["missing_assertions"]) + + def test_release_readiness_fails_when_a_workflow_identity_assertion_is_deleted( + self, + ) -> None: + for assertion in ( + 'BUILD_JOBS = ("build-distributions", "verify-distributions")', + 'if str(run.get("databaseId")) != run_id:', + 'if run.get("workflowName") != EXPECTED_WORKFLOW:', + 'if run.get("event") != event:', + 'if run.get("headSha") != head_sha:', + 'if run.get("status") != "completed" or run.get("conclusion") != "success":', + 'problems.append(f"{job_name} is {actual}, expected success")', + ): + with self.subTest(assertion=assertion): + check = self._asserted_runbook_check( + lambda doc, assertion=assertion: doc.replace(assertion, "pass") + ) + + self.assertEqual(check["status"], "fail") + self.assertIn(assertion, check["detail"]["missing_assertions"]) + + def test_release_readiness_requires_a_clean_release_sha_checkout(self) -> None: + for assertion in ( + 'git clone --no-checkout "https://github.com/$REPO.git" "$RELEASE_CHECKOUT"', + 'test "$(git -C "$RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA"', + 'test -z "$(git -C "$RELEASE_CHECKOUT" status --porcelain --untracked-files=all)"', + ): + with self.subTest(assertion=assertion): + check = self._asserted_runbook_check( + lambda doc, assertion=assertion: doc.replace(assertion, "true") + ) + + self.assertEqual(check["status"], "fail") + self.assertIn(assertion, check["detail"]["missing_assertions"]) + + def test_release_readiness_fails_when_a_board_doctor_assertion_is_deleted(self) -> None: + for assertion in ( + '--json >"$BOARD_DOCTOR_DIR/5332.json"', + '--json >"$BOARD_DOCTOR_DIR/5342.json"', + '--json >"$BOARD_DOCTOR_DIR/5344.json"', + 'if report.get("status") != "pass":', + 'raise SystemExit(f"restarted Board doctors are not all pass: {problems}")', + ): + with self.subTest(assertion=assertion): + check = self._asserted_runbook_check( + lambda doc, assertion=assertion: doc.replace(assertion, "--json") + ) + + self.assertEqual(check["status"], "fail") + self.assertIn(assertion, check["detail"]["missing_assertions"]) + + def test_release_readiness_fails_when_the_campaign_gate_is_print_only(self) -> None: + for assertion in ( + '>"$CAMPAIGN_DIR/watch.json"', + '>"$CAMPAIGN_DIR/status.json"', + 'if required != REQUIRED_PROVIDERS:', + 'if outcome not in PASSING_OUTCOMES:', + 'if devin.get("driver") != "hosted_bridge"' + ' or devin.get("transport_verified") is not True:', + 'raise SystemExit(f"release qualification campaign is not a pass: {problems}")', + ): + with self.subTest(assertion=assertion): + check = self._asserted_runbook_check( + lambda doc, assertion=assertion: doc.replace(assertion, "") + ) + + self.assertEqual(check["status"], "fail") + self.assertIn(assertion, check["detail"]["missing_assertions"]) + + def test_release_readiness_fails_when_a_metadata_upload_gate_is_deleted(self) -> None: + for assertion in ( + "--team-id jeff-internal --yes --json", + 'if preview.get("status") != "dry_run" or preview.get("would_upload") is not False:', + 'if preview_upload.get("report_count") != 0:', + 'if applied.get("counts") != preview.get("counts"):', + 'raise SystemExit(f"campaign metadata upload is not a verified gate: {problems}")', + "--install-id codex-code-mower --yes --json", + 'if event_types != ["board_snapshot"] or len(events) != 1:', + 'if manifest.get("included_reports"):', + 'raise SystemExit(f"board snapshot upload is not a verified gate: {problems}")', + ): + with self.subTest(assertion=assertion): + check = self._asserted_runbook_check( + lambda doc, assertion=assertion: doc.replace(assertion, "") + ) + + self.assertEqual(check["status"], "fail") + self.assertIn(assertion, check["detail"]["missing_assertions"]) def test_release_readiness_rejects_release_asset_clobbering(self) -> None: check = self._asserted_runbook_check( From 844fff50256a748a75cacbc67bbb0e4a647aeca7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 05:01:05 +0000 Subject: [PATCH 06/20] Bind v1.4.0 release evidence to real schemas, identities and owner confirmation Require the post-create Release asset check, fail-closed Devin permission owner confirmation, a fully isolated fresh-source install, port-to-repository Board binding, and schema/identity-bound assertions for Board doctors, campaign watch/status/upload and Board snapshot evidence. Reject repeated JSON object keys in the committed manifest so a duplicated key cannot normalize into apparent exactness. Co-Authored-By: bot_apk --- docs/pypi-release.md | 244 +++++++++++++++++++++++++--- src/code_mower/release_readiness.py | 79 ++++++++- tests/test_release_hygiene.py | 184 ++++++++++++++++++++- 3 files changed, 476 insertions(+), 31 deletions(-) diff --git a/docs/pypi-release.md b/docs/pypi-release.md index 0027f21a..940f3c5f 100644 --- a/docs/pypi-release.md +++ b/docs/pypi-release.md @@ -177,8 +177,8 @@ clone of `RELEASE_SHA`. RELEASE_ENV="$(mktemp -d /tmp/code-mower-v140-release-env.XXXXXX)" python3.12 -m venv "$RELEASE_ENV/venv" RELEASE_PYTHON="$RELEASE_ENV/venv/bin/python" -PIP_CONFIG_FILE=/dev/null PIP_INDEX_URL= PIP_EXTRA_INDEX_URL= \ - "$RELEASE_PYTHON" -m pip install --no-cache-dir \ +env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS \ + PIP_CONFIG_FILE=/dev/null "$RELEASE_PYTHON" -m pip install --no-cache-dir \ --index-url https://pypi.org/simple/ "$RELEASE_CHECKOUT" RELEASE_CLI="$RELEASE_ENV/venv/bin/code-mower" test "$("$RELEASE_CLI" --version)" = "code-mower 1.4.0" @@ -615,8 +615,10 @@ gh run watch "$RELEASE_EVENT_RUN_ID" --repo "$REPO" --exit-status with `skip`, so it does not prove readiness. Select hosted Devin explicitly with a supported generated configuration, then require the hosted checks to pass for the exact `codemower-ai/code-mower` scope. `provider.devin.permissions` is -reported, never probed: it is the account owner's confirmation and is the only -non-`pass` status accepted. +reported, never probed: the doctor cannot read the account's own permission +settings, so `skip` is accepted only when the account owner separately confirms +them. Supply that confirmation privately as `confirmed`; any other value, an +unset variable, or any other check status fails closed. ```bash CODE_MOWER_PYTHON="$(command -v python3.12)" @@ -626,13 +628,16 @@ PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" \ test "$(code-mower --version)" = "code-mower 1.4.0" DEVIN_PROVIDER_PROFILE="REPLACE_WITH_PROTECTED_PROFILE_SELECTOR" +DEVIN_PERMISSIONS_OWNER_CONFIRMED="REPLACE_WITH_OWNER_CONFIRMATION" DEVIN_DOCTOR_DIR="$(mktemp -d /tmp/code-mower-v140-devin-doctor.XXXXXX)" code-mower init code-mower.yml --profile recommended \ --set-transport devin=devin_api_v3 --apply --output-dir "$DEVIN_DOCTOR_DIR" code-mower doctor "$DEVIN_DOCTOR_DIR/code-mower.yml" --profile recommended \ --devin --repo codemower-ai/code-mower \ --provider-profile "$DEVIN_PROVIDER_PROFILE" --json >"$DEVIN_DOCTOR_DIR/doctor.json" -DEVIN_DOCTOR_JSON="$DEVIN_DOCTOR_DIR/doctor.json" "$RELEASE_PYTHON" - <<'PY' +DEVIN_DOCTOR_JSON="$DEVIN_DOCTOR_DIR/doctor.json" \ + DEVIN_PERMISSIONS_OWNER_CONFIRMED="$DEVIN_PERMISSIONS_OWNER_CONFIRMED" \ + "$RELEASE_PYTHON" - <<'PY' import json import os @@ -652,9 +657,21 @@ required = ( blocked = [name for name in required if checks.get(name) != "pass"] if blocked: raise SystemExit(f"hosted Devin readiness is blocked: {blocked}") -if checks.get("provider.devin.permissions") not in {"pass", "skip"}: - raise SystemExit("Devin permission requirements are not confirmable") -print(json.dumps({"devin_transport": "hosted", "required_pass": list(required)})) +permissions = checks.get("provider.devin.permissions") +owner_confirmed = os.environ.get("DEVIN_PERMISSIONS_OWNER_CONFIRMED", "").strip().lower() +if permissions == "skip" and owner_confirmed != "confirmed": + raise SystemExit( + "Devin permissions are reported skip and the account owner has not " + "separately confirmed them" + ) +if permissions not in {"pass", "skip"}: + raise SystemExit(f"Devin permission check is {permissions!r}") +print(json.dumps({ + "devin_transport": "hosted", + "required_pass": list(required), + "permissions": permissions, + "owner_confirmed": permissions == "pass" or owner_confirmed == "confirmed", +})) PY ``` @@ -697,6 +714,13 @@ from pathlib import Path campaign_dir = Path(os.environ["CAMPAIGN_DIR"]) REQUIRED_PROVIDERS = {"claude", "codex", "devin"} PASSING_OUTCOMES = {"pass", "pass_with_warnings"} +CAMPAIGN_SCHEMA = "code_mower.releaseCampaign.v1" +WATCH_SCHEMA = "code_mower.releaseCampaignWatch.v1" +ADOPTION_RESULT_SCHEMA = "code_mower.adoptionResult.v1" +CAMPAIGN_ID = "campaign-v1.4.0" +RELEASE_TAG = "v1.4.0" +PACKAGE_IDENTITY = "code-mower" +VERSION = "1.4.0" def load(name: str) -> dict: @@ -706,12 +730,55 @@ def load(name: str) -> dict: watch = load("watch.json") status = load("status.json") problems = [] +if watch.get("schema") != WATCH_SCHEMA or watch.get("mode") != "release-campaign-watch": + problems.append(f"watch schema/mode is {watch.get('schema')!r}/{watch.get('mode')!r}") +watch_identity = { + "campaign_id": CAMPAIGN_ID, + "release_tag": RELEASE_TAG, + "package_identity": PACKAGE_IDENTITY, + "qualification_context": "cold_install", +} +for key, expected in watch_identity.items(): + if watch.get(key) != expected: + problems.append(f"watch {key} is {watch.get(key)!r}, expected {expected!r}") if watch.get("status") != "complete" or watch.get("stop_reason") != "complete": problems.append( f"watch stopped as {watch.get('stop_reason')!r} with status {watch.get('status')!r}" ) +watch_lanes = { + str(row.get("provider") or ""): row + for row in watch.get("providers") or [] + if isinstance(row, dict) +} +if set(watch_lanes) != REQUIRED_PROVIDERS: + problems.append(f"watch provider set is {sorted(watch_lanes)}") +for name in sorted(REQUIRED_PROVIDERS & set(watch_lanes)): + row = watch_lanes[name] + if row.get("posture") != "required" or row.get("state") != "complete" or row.get("error"): + problems.append( + f"watch {name} summary is {row.get('posture')!r}/{row.get('state')!r}" + ) +if status.get("schema") != CAMPAIGN_SCHEMA: + problems.append(f"campaign schema is {status.get('schema')!r}") +status_identity = { + "campaign_id": CAMPAIGN_ID, + "release_tag": RELEASE_TAG, + "package_identity": PACKAGE_IDENTITY, + "package_spec": f"{PACKAGE_IDENTITY}=={VERSION}", + "normalized_version": VERSION, + "qualification_context": "cold_install", + "package_source": "pypi", + "repo_slug": "codemower-ai/code-mower", +} +for key, expected in status_identity.items(): + if status.get(key) != expected: + problems.append(f"campaign {key} is {status.get(key)!r}, expected {expected!r}") if status.get("status") != "complete": problems.append(f"campaign status is {status.get('status')!r}, not complete") +if status.get("dry_run") is not False: + problems.append(f"campaign dry_run is {status.get('dry_run')!r}, expected False") +if status.get("provider_posture_configured") is not True: + problems.append("campaign provider posture was not explicitly configured") lanes = { str(row.get("provider") or ""): row for row in status.get("providers") or [] @@ -726,13 +793,28 @@ for name in sorted(REQUIRED_PROVIDERS & set(lanes)): lane = lanes[name] if lane.get("state") != "complete": problems.append(f"{name} lane state is {lane.get('state')!r}") + if lane.get("dispatch_mode") != "applied" or lane.get("error"): + problems.append(f"{name} lane dispatch is {lane.get('dispatch_mode')!r}") result = lane.get("adoption_result") - outcome = result.get("outcome") if isinstance(result, dict) else None + result = result if isinstance(result, dict) else {} + if result.get("schema") != ADOPTION_RESULT_SCHEMA: + problems.append(f"{name} lane result schema is {result.get('schema')!r}") + if ( + result.get("release_tag") != RELEASE_TAG + or result.get("package_identity") != PACKAGE_IDENTITY + or result.get("normalized_version") != VERSION + ): + problems.append(f"{name} lane result is not bound to {RELEASE_TAG}") + outcome = result.get("outcome") if outcome not in PASSING_OUTCOMES: problems.append(f"{name} lane result outcome is {outcome!r}") devin = lanes.get("devin") or {} +devin_ref = devin.get("dispatch_ref") +devin_ref = devin_ref if isinstance(devin_ref, dict) else {} if devin.get("driver") != "hosted_bridge" or devin.get("transport_verified") is not True: problems.append("Devin lane did not verify the hosted bridge transport") +if devin_ref.get("transport_kind") != "devin_api_v3": + problems.append(f"Devin transport kind is {devin_ref.get('transport_kind')!r}") if problems: raise SystemExit(f"release qualification campaign is not a pass: {problems}") print(json.dumps({ @@ -765,7 +847,13 @@ test "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA" test "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" rev-list -n 1 v1.4.0)" = "$RELEASE_SHA" cat >"$RELEASE_ENV/board_wait.py" <<'PY' -"""Bounded waits on the Board inventory: gone after a stop, serving after a start.""" +"""Bounded waits on the Board inventory: gone after a stop, serving after a start. + +Serving mode takes `PORT=REPO` arguments and requires each port to serve exactly +its expected repository as well as healthy 1.4.0 serving/installed versions, so a +Board that came back on the wrong repository cannot satisfy another port's gate. +Only ports are printed; the expected slugs stay in the private arguments. +""" import json import subprocess @@ -792,24 +880,34 @@ def row_for(port: int) -> dict | None: return None +def serving(row: dict | None, expected_repo: str) -> bool: + return bool( + row is not None + and expected_repo + and row.get("repo") == expected_repo + and row.get("health") == "ok" + and row.get("serving_version") == "1.4.0" + and row.get("installed_version") == "1.4.0" + ) + + def main() -> None: mode = sys.argv[1] - ports = [int(value) for value in sys.argv[2:]] + expected: dict[int, str] = {} + for value in sys.argv[2:]: + port, _, repo = value.partition("=") + expected[int(port)] = repo + if mode == "serving" and not all(expected.values()): + raise SystemExit("serving mode requires PORT=REPO for every port") deadline = time.monotonic() + DEADLINE_SECONDS - pending = list(ports) + pending = list(expected) while pending and time.monotonic() < deadline: remaining = [] for port in pending: row = row_for(port) if mode == "gone" and row is None: continue - if ( - mode == "serving" - and row is not None - and row.get("health") == "ok" - and row.get("serving_version") == "1.4.0" - and row.get("installed_version") == "1.4.0" - ): + if mode == "serving" and serving(row, expected[port]): continue remaining.append(port) pending = remaining @@ -817,7 +915,7 @@ def main() -> None: time.sleep(INTERVAL_SECONDS) if pending: raise SystemExit(f"ports still not {mode} within {DEADLINE_SECONDS}s: {pending}") - print(json.dumps({"mode": mode, "ports": ports})) + print(json.dumps({"mode": mode, "ports": sorted(expected)})) main() @@ -838,7 +936,8 @@ nohup code-mower board serve --repo "$BOARD_5342_REPO" \ nohup code-mower board serve --repo "$BOARD_5344_REPO" \ --repo-path "$BOARD_5344_REPO_PATH" --host 127.0.0.1 \ --port 5344 --record-events >/tmp/code-mower-board-5344.log 2>&1 & -"$RELEASE_PYTHON" "$RELEASE_ENV/board_wait.py" serving 5332 5342 5344 +"$RELEASE_PYTHON" "$RELEASE_ENV/board_wait.py" serving \ + "5332=codemower-ai/code-mower" "5342=$BOARD_5342_REPO" "5344=$BOARD_5344_REPO" BOARD_DOCTOR_DIR="$(mktemp -d /tmp/code-mower-v140-board-doctor.XXXXXX)" code-mower board doctor --repo codemower-ai/code-mower \ @@ -847,25 +946,57 @@ code-mower board doctor --repo "$BOARD_5342_REPO" \ --repo-path "$BOARD_5342_REPO_PATH" --json >"$BOARD_DOCTOR_DIR/5342.json" code-mower board doctor --repo "$BOARD_5344_REPO" \ --repo-path "$BOARD_5344_REPO_PATH" --json >"$BOARD_DOCTOR_DIR/5344.json" -BOARD_DOCTOR_DIR="$BOARD_DOCTOR_DIR" "$RELEASE_PYTHON" - <<'PY' +BOARD_DOCTOR_DIR="$BOARD_DOCTOR_DIR" \ + BOARD_5332_REPO="codemower-ai/code-mower" \ + BOARD_5342_REPO="$BOARD_5342_REPO" BOARD_5344_REPO="$BOARD_5344_REPO" \ + "$RELEASE_PYTHON" - <<'PY' import json import os from pathlib import Path +BOARD_DOCTOR_SCHEMA = "code_mower.boardDoctor.v1" +EXPECTED_CHECK_IDS = { + "repo.path", + "github.remote", + "gate.health", + "store.events", + "owner.queue", + "agent.adapters", + "spend.timeline", +} doctor_dir = Path(os.environ["BOARD_DOCTOR_DIR"]) problems = [] for port in ("5332", "5342", "5344"): + expected_repo = os.environ[f"BOARD_{port}_REPO"] report = json.loads((doctor_dir / f"{port}.json").read_text(encoding="utf-8")) + if report.get("schema") != BOARD_DOCTOR_SCHEMA: + problems.append(f"board {port} doctor schema is {report.get('schema')!r}") + if report.get("repo") != expected_repo: + problems.append(f"board {port} doctor reports another repository") if report.get("status") != "pass": problems.append(f"board {port} doctor status is {report.get('status')!r}") + checks = { + str(row.get("id") or ""): str(row.get("status") or "") + for row in report.get("checks") or [] + if isinstance(row, dict) + } + if not EXPECTED_CHECK_IDS or not EXPECTED_CHECK_IDS <= set(checks): + problems.append( + f"board {port} doctor is missing {sorted(EXPECTED_CHECK_IDS - set(checks))}" + ) + failing = sorted(name for name, value in checks.items() if value != "pass") + if failing: + problems.append(f"board {port} doctor checks are not pass: {failing}") if problems: raise SystemExit(f"restarted Board doctors are not all pass: {problems}") print(json.dumps({"board_doctors_pass": ["5332", "5342", "5344"]})) PY ``` -`code-mower board doctor` exits zero for `warn`, so each report's own top-level -status is parsed and required to be `pass`; printing the JSON is not the gate. +`code-mower board doctor` exits zero for `warn`, so each report is parsed and +required to carry the `code_mower.boardDoctor.v1` schema, the expected +repository, a top-level `pass`, the full expected check inventory, and a `pass` +on every individual check; printing the JSON is not the gate. Do not use raw process kills or Board reset, and never copy private repository slugs or paths into public evidence. @@ -899,11 +1030,49 @@ def load(name: str) -> dict: return json.loads((cloud_dir / name).read_text(encoding="utf-8")) +CAMPAIGN_UPLOAD_SCHEMA = "code_mower.releaseCampaignUpload.v1" +REQUIRED_PROVIDERS = ["claude", "codex", "devin"] +EXPECTED_POSTURES = {name: "required" for name in REQUIRED_PROVIDERS} +EXPECTED_COUNTS = { + "providers": 3, + "complete": 3, + "skipped": 0, + "accepted": 3, + "rejected": 0, + "events": 3, +} preview = load("campaign-preview.json") applied = load("campaign-applied.json") preview_upload = preview.get("upload") or {} applied_upload = applied.get("upload") or {} problems = [] +for name, payload in (("preview", preview), ("applied", applied)): + if payload.get("schema") != CAMPAIGN_UPLOAD_SCHEMA: + problems.append(f"{name} schema is {payload.get('schema')!r}") + if payload.get("mode") != "release-campaign-upload": + problems.append(f"{name} mode is {payload.get('mode')!r}") + if ( + payload.get("campaign_id") != "campaign-v1.4.0" + or payload.get("release_tag") != "v1.4.0" + or payload.get("package_identity") != "code-mower" + or payload.get("qualification_context") != "cold_install" + ): + problems.append(f"{name} campaign identity is not the v1.4.0 campaign") + if payload.get("provider_postures") != EXPECTED_POSTURES: + problems.append(f"{name} provider postures are {payload.get('provider_postures')!r}") + if payload.get("counts") != EXPECTED_COUNTS: + problems.append(f"{name} counts are {payload.get('counts')!r}") + if sorted(payload.get("accepted_providers") or []) != REQUIRED_PROVIDERS: + problems.append(f"{name} accepted providers are {payload.get('accepted_providers')!r}") + if payload.get("skipped_providers") or payload.get("rejected_providers"): + problems.append(f"{name} skipped or rejected a provider") + ids = [str(value) for value in payload.get("event_ids") or []] + if len(ids) != 3 or len(set(ids)) != 3 or not all(ids): + problems.append(f"{name} does not carry three unique event identifiers") +if preview_upload.get("event_types") != {"adoption_run": 3}: + problems.append(f"preview event types are {preview_upload.get('event_types')!r}") +if preview_upload.get("would_upload") is not False: + problems.append("preview payload would upload without --yes") if preview.get("status") != "dry_run" or preview.get("would_upload") is not False: problems.append(f"preview status is {preview.get('status')!r}") if preview.get("requires_yes") is not True or preview_upload.get("requires_yes") is not True: @@ -919,6 +1088,8 @@ if not preview_events or preview_upload.get("event_count") != len(preview_events problems.append("preview event identifiers and count disagree") if applied.get("status") != "uploaded" or applied.get("would_upload") is not True: problems.append(f"applied status is {applied.get('status')!r}") +if applied.get("requires_yes") is not False: + problems.append("applied result is still a preview") if applied.get("upload_mode") != "metadata_only": problems.append(f"applied upload mode is {applied.get('upload_mode')!r}") if applied_upload.get("mode") != "cloud-upload": @@ -964,19 +1135,40 @@ def load(path: Path) -> dict: return json.loads(path.read_text(encoding="utf-8")) +BUNDLE_SCHEMA = "code_mower.cloudBenchmarkBundle.v1" +EVENT_SCHEMA = "code_mower.benchmarkEvent.v1" +SNAPSHOT_SCHEMA = "code_mower.cloudBoardSnapshot.v1" snapshot = load(cloud_dir / "board-snapshot.json") preview = load(cloud_dir / "board-preview.json") applied = load(cloud_dir / "board-applied.json") manifest = load(bundle_dir / "code-mower-cloud-bundle.json") +export = snapshot.get("export") or {} events = [row for row in manifest.get("events") or [] if isinstance(row, dict)] event_types = sorted({str(row.get("event_type") or "") for row in events}) problems = [] -if snapshot.get("status") not in {"dry_run", "uploaded"}: - problems.append(f"board snapshot status is {snapshot.get('status')!r}") +if snapshot.get("mode") != "cloud-board-snapshot" or snapshot.get("status") != "dry_run": + problems.append( + f"board snapshot is {snapshot.get('mode')!r}/{snapshot.get('status')!r}" + ) +if snapshot.get("repo_slug") != "codemower-ai/code-mower": + problems.append("board snapshot is not bound to the release repository") +if snapshot.get("event_count") != 1: + problems.append(f"board snapshot carries {snapshot.get('event_count')!r} events") +if export.get("event_types") != {"board_snapshot": 1} or export.get("included_reports"): + problems.append(f"board export carries {export.get('event_types')!r}") +if manifest.get("schema") != BUNDLE_SCHEMA: + problems.append(f"board bundle schema is {manifest.get('schema')!r}") if event_types != ["board_snapshot"] or len(events) != 1: problems.append(f"board bundle carries {event_types} events") if manifest.get("included_reports"): problems.append("board bundle carries report content") +event = events[0] if events else {} +dimensions = event.get("dimensions") +dimensions = dimensions if isinstance(dimensions, dict) else {} +if event.get("schema") != EVENT_SCHEMA or not str(event.get("event_id") or ""): + problems.append(f"board event schema/id is {event.get('schema')!r}") +if dimensions.get("snapshot_schema") != SNAPSHOT_SCHEMA: + problems.append(f"board event snapshot schema is {dimensions.get('snapshot_schema')!r}") if preview.get("mode") != "cloud-upload-dry-run" or preview.get("would_upload") is not False: problems.append(f"board preview mode is {preview.get('mode')!r}") if preview.get("requires_yes") is not True: diff --git a/src/code_mower/release_readiness.py b/src/code_mower/release_readiness.py index 265325cb..5838f4a3 100644 --- a/src/code_mower/release_readiness.py +++ b/src/code_mower/release_readiness.py @@ -126,13 +126,33 @@ def _materialized_package_versions(repo_path: Path) -> dict[str, Any]: } +def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Build one JSON object, refusing repeated keys instead of keeping the last. + + ``json.loads`` keeps only the final value for a repeated key, so a committed + manifest carrying two values for one key -- at the top level or inside any + row -- would compare as exact while publishing something else. + """ + + seen: set[str] = set() + for key, _value in pairs: + if key in seen: + raise ValueError(f"duplicate JSON key {key!r}") + seen.add(key) + return dict(pairs) + + def _committed_manifest(repo_path: Path) -> dict[str, Any] | None: text = _read_text_if_exists( repo_path / package_module.COMMITTED_PACKAGE_MANIFEST ) try: - manifest = json.loads(text) if text.strip() else None - except json.JSONDecodeError: + manifest = ( + json.loads(text, object_pairs_hook=_reject_duplicate_keys) + if text.strip() + else None + ) + except (json.JSONDecodeError, ValueError): return None return manifest if isinstance(manifest, dict) else None @@ -230,7 +250,10 @@ def _committed_manifest_drift(repo_path: Path) -> dict[str, Any]: if committed is None: return { **empty, - "error": "committed package manifest is missing or not valid JSON", + "error": ( + "committed package manifest is missing, is not a JSON object, " + "or repeats an object key" + ), } try: generated = package_module.generate_committed_package_manifest(repo_path) @@ -316,7 +339,7 @@ def _post_merge_runbook_markers(release_tag: str, package_index_spec: str) -> tu "code-mower release campaign create", "--required-providers claude,codex,devin", "code-mower board stop --port", - 'board_wait.py" serving 5332 5342 5344', + 'board_wait.py" serving \\', "code-mower board doctor", "code-mower release campaign upload --release-tag", "code-mower cloud board-snapshot", @@ -381,37 +404,85 @@ def _post_merge_runbook_assertions(version: str, release_tag: str) -> tuple[str, 'raise SystemExit(f"{mode} release assets are not acceptable: {problems}")', 'problems.append("release asset SHA-256 values differ from PROD_DIST_DIR")', 'assert_release_assets.py" existing', + # The post-create verification is unconditional: it runs for a release + # this runbook created as well as one it found already present. + 'assert_release_assets.py" created', # Hosted Devin readiness is required, not reported. "--set-transport devin=devin_api_v3", 'raise SystemExit(f"hosted Devin readiness is blocked: {blocked}")', + # A reported `skip` on permissions is only acceptable with the account + # owner's separately supplied confirmation. + 'if permissions == "skip" and owner_confirmed != "confirmed":', + 'if permissions not in {"pass", "skip"}:', + # The exact-release source rehearsal cannot reach ambient packages. + "env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS", # Boards stop, are waited for, and only then restart from the release. 'test "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA"', 'board_wait.py" gone "$BOARD_PORT"', + # Serving is only satisfied by the expected repository on each port. + 'and row.get("repo") == expected_repo', + 'raise SystemExit("serving mode requires PORT=REPO for every port")', + '"5332=codemower-ai/code-mower" "5342=$BOARD_5342_REPO" "5344=$BOARD_5344_REPO"', 'raise SystemExit(f"ports still not {mode} within {DEADLINE_SECONDS}s: {pending}")', # Every restarted Board's own doctor verdict is parsed; the CLI exits # zero on warn, so exit status is not the gate. '--json >"$BOARD_DOCTOR_DIR/5332.json"', '--json >"$BOARD_DOCTOR_DIR/5342.json"', '--json >"$BOARD_DOCTOR_DIR/5344.json"', + 'BOARD_DOCTOR_SCHEMA = "code_mower.boardDoctor.v1"', + 'if report.get("schema") != BOARD_DOCTOR_SCHEMA:', + 'if report.get("repo") != expected_repo:', 'if report.get("status") != "pass":', + "if not EXPECTED_CHECK_IDS or not EXPECTED_CHECK_IDS <= set(checks):", + 'failing = sorted(name for name, value in checks.items() if value != "pass")', 'raise SystemExit(f"restarted Board doctors are not all pass: {problems}")', # The required campaign is a parsed gate, not printed output. '>"$CAMPAIGN_DIR/watch.json"', '>"$CAMPAIGN_DIR/status.json"', + 'WATCH_SCHEMA = "code_mower.releaseCampaignWatch.v1"', + 'if watch.get("schema") != WATCH_SCHEMA or watch.get("mode") != "release-campaign-watch":', + 'if watch.get("status") != "complete" or watch.get("stop_reason") != "complete":', + 'if set(watch_lanes) != REQUIRED_PROVIDERS:', + 'CAMPAIGN_SCHEMA = "code_mower.releaseCampaign.v1"', + 'if status.get("schema") != CAMPAIGN_SCHEMA:', + 'if status.get("dry_run") is not False:', 'if set(lanes) != REQUIRED_PROVIDERS:', 'if required != REQUIRED_PROVIDERS:', + 'ADOPTION_RESULT_SCHEMA = "code_mower.adoptionResult.v1"', + 'if result.get("schema") != ADOPTION_RESULT_SCHEMA:', 'if outcome not in PASSING_OUTCOMES:', 'if devin.get("driver") != "hosted_bridge" or devin.get("transport_verified") is not True:', + 'if devin_ref.get("transport_kind") != "devin_api_v3":', 'raise SystemExit(f"release qualification campaign is not a pass: {problems}")', # Both metadata-only uploads are previewed, applied, and correlated. '--team-id jeff-internal --yes --json', + 'CAMPAIGN_UPLOAD_SCHEMA = "code_mower.releaseCampaignUpload.v1"', + 'if payload.get("schema") != CAMPAIGN_UPLOAD_SCHEMA:', + 'if payload.get("mode") != "release-campaign-upload":', + 'problems.append(f"{name} campaign identity is not the v1.4.0 campaign")', + 'if payload.get("provider_postures") != EXPECTED_POSTURES:', + 'if payload.get("counts") != EXPECTED_COUNTS:', + "if len(ids) != 3 or len(set(ids)) != 3 or not all(ids):", + 'if preview_upload.get("event_types") != {"adoption_run": 3}:', 'if preview.get("status") != "dry_run" or preview.get("would_upload") is not False:', 'if preview_upload.get("report_count") != 0:', + 'if applied.get("requires_yes") is not False:', + 'if not 200 <= int(applied_upload.get("status") or 0) < 300:', + 'if [str(value) for value in applied.get("event_ids") or []] != preview_events:', 'if applied.get("counts") != preview.get("counts"):', 'raise SystemExit(f"campaign metadata upload is not a verified gate: {problems}")', '--install-id codex-code-mower --yes --json', + 'BUNDLE_SCHEMA = "code_mower.cloudBenchmarkBundle.v1"', + 'if snapshot.get("mode") != "cloud-board-snapshot" or snapshot.get("status") != "dry_run":', + 'if snapshot.get("repo_slug") != "codemower-ai/code-mower":', + 'if snapshot.get("event_count") != 1:', + 'if export.get("event_types") != {"board_snapshot": 1} or export.get("included_reports"):', + 'if manifest.get("schema") != BUNDLE_SCHEMA:', 'if event_types != ["board_snapshot"] or len(events) != 1:', 'if manifest.get("included_reports"):', + 'if event.get("schema") != EVENT_SCHEMA or not str(event.get("event_id") or ""):', + 'if dimensions.get("snapshot_schema") != SNAPSHOT_SCHEMA:', + 'if not 200 <= int(applied.get("status") or 0) < 300:', 'raise SystemExit(f"board snapshot upload is not a verified gate: {problems}")', ) diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py index 3d587e48..9129ae66 100644 --- a/tests/test_release_hygiene.py +++ b/tests/test_release_hygiene.py @@ -7900,6 +7900,48 @@ def test_release_readiness_accepts_the_exact_regenerated_manifest(self) -> None: check["detail"]["generated_row_count"], ) + def _duplicated_manifest_text(self, replace: str, duplicate: str) -> str: + text = (ROOT / "code-mower-package-manifest.json").read_text(encoding="utf-8") + self.assertIn(replace, text) + return text.replace(replace, duplicate, 1) + + def test_committed_manifest_rejects_duplicate_object_keys(self) -> None: + cases = { + "top-level": ('"mode":', '"mode": "package", "mode":'), + "nested package": ('"version":', '"version": "0.0.1", "version":'), + "file row": ('"target":', '"target": "docs/other.md", "target":'), + "deferred row": ('"reason":', '"reason": "other", "reason":'), + } + for label, (replace, duplicate) in cases.items(): + with self.subTest(case=label), tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) + (repo / code_mower_package.COMMITTED_PACKAGE_MANIFEST).write_text( + self._duplicated_manifest_text(replace, duplicate), + encoding="utf-8", + ) + + self.assertIsNone(release_readiness._committed_manifest(repo)) + + drift = release_readiness._committed_manifest_drift(repo) + self.assertFalse(drift["matches"]) + self.assertIn("repeats an object key", drift["error"]) + + def test_committed_manifest_accepts_reordered_keys_and_whitespace(self) -> None: + manifest = json.loads( + (ROOT / "code-mower-package-manifest.json").read_text(encoding="utf-8") + ) + reordered = dict(reversed(list(manifest.items()))) + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) + (repo / code_mower_package.COMMITTED_PACKAGE_MANIFEST).write_text( + json.dumps(reordered, indent=4, sort_keys=False) + "\n\n", + encoding="utf-8", + ) + + loaded = release_readiness._committed_manifest(repo) + + self.assertEqual(loaded, manifest) + def test_release_readiness_fails_on_stale_committed_manifest(self) -> None: def truncate(manifest: dict) -> None: manifest["files_written"] = manifest["files_written"][:307] @@ -8341,6 +8383,136 @@ def test_release_readiness_fails_when_a_metadata_upload_gate_is_deleted(self) -> self.assertEqual(check["status"], "fail") self.assertIn(assertion, check["detail"]["missing_assertions"]) + def test_release_readiness_fails_without_post_create_asset_verification(self) -> None: + check = self._asserted_runbook_check( + lambda doc: doc.replace('assert_release_assets.py" created', "true") + ) + + self.assertEqual(check["status"], "fail") + self.assertIn( + 'assert_release_assets.py" created', check["detail"]["missing_assertions"] + ) + + def test_release_readiness_requires_devin_owner_confirmation(self) -> None: + for assertion in ( + 'if permissions == "skip" and owner_confirmed != "confirmed":', + 'if permissions not in {"pass", "skip"}:', + ): + with self.subTest(assertion=assertion): + check = self._asserted_runbook_check( + lambda doc, assertion=assertion: doc.replace(assertion, "if False:") + ) + + self.assertEqual(check["status"], "fail") + self.assertIn(assertion, check["detail"]["missing_assertions"]) + + def test_release_readiness_requires_an_isolated_fresh_source_install(self) -> None: + isolation = "env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS" + check = self._asserted_runbook_check(lambda doc: doc.replace(isolation, "env")) + + self.assertEqual(check["status"], "fail") + self.assertIn(isolation, check["detail"]["missing_assertions"]) + + def test_release_readiness_requires_board_port_repository_binding(self) -> None: + for assertion in ( + 'and row.get("repo") == expected_repo', + 'raise SystemExit("serving mode requires PORT=REPO for every port")', + '"5332=codemower-ai/code-mower" "5342=$BOARD_5342_REPO"' + ' "5344=$BOARD_5344_REPO"', + ): + with self.subTest(assertion=assertion): + check = self._asserted_runbook_check( + lambda doc, assertion=assertion: doc.replace(assertion, "") + ) + + self.assertEqual(check["status"], "fail") + self.assertIn(assertion, check["detail"]["missing_assertions"]) + + def test_release_readiness_requires_board_doctor_schema_and_identity(self) -> None: + for assertion in ( + 'BOARD_DOCTOR_SCHEMA = "code_mower.boardDoctor.v1"', + 'if report.get("schema") != BOARD_DOCTOR_SCHEMA:', + 'if report.get("repo") != expected_repo:', + "if not EXPECTED_CHECK_IDS or not EXPECTED_CHECK_IDS <= set(checks):", + 'failing = sorted(name for name, value in checks.items() if value != "pass")', + ): + with self.subTest(assertion=assertion): + check = self._asserted_runbook_check( + lambda doc, assertion=assertion: doc.replace(assertion, "") + ) + + self.assertEqual(check["status"], "fail") + self.assertIn(assertion, check["detail"]["missing_assertions"]) + + def test_release_readiness_requires_campaign_schema_and_identity(self) -> None: + for assertion in ( + 'WATCH_SCHEMA = "code_mower.releaseCampaignWatch.v1"', + 'if watch.get("schema") != WATCH_SCHEMA' + ' or watch.get("mode") != "release-campaign-watch":', + 'if watch.get("status") != "complete"' + ' or watch.get("stop_reason") != "complete":', + "if set(watch_lanes) != REQUIRED_PROVIDERS:", + 'CAMPAIGN_SCHEMA = "code_mower.releaseCampaign.v1"', + 'if status.get("schema") != CAMPAIGN_SCHEMA:', + 'if status.get("dry_run") is not False:', + 'ADOPTION_RESULT_SCHEMA = "code_mower.adoptionResult.v1"', + 'if result.get("schema") != ADOPTION_RESULT_SCHEMA:', + 'if devin_ref.get("transport_kind") != "devin_api_v3":', + ): + with self.subTest(assertion=assertion): + check = self._asserted_runbook_check( + lambda doc, assertion=assertion: doc.replace(assertion, "") + ) + + self.assertEqual(check["status"], "fail") + self.assertIn(assertion, check["detail"]["missing_assertions"]) + + def test_release_readiness_requires_upload_schema_counts_and_correlation(self) -> None: + for assertion in ( + 'CAMPAIGN_UPLOAD_SCHEMA = "code_mower.releaseCampaignUpload.v1"', + 'if payload.get("schema") != CAMPAIGN_UPLOAD_SCHEMA:', + 'if payload.get("mode") != "release-campaign-upload":', + 'problems.append(f"{name} campaign identity is not the v1.4.0 campaign")', + 'if payload.get("provider_postures") != EXPECTED_POSTURES:', + 'if payload.get("counts") != EXPECTED_COUNTS:', + "if len(ids) != 3 or len(set(ids)) != 3 or not all(ids):", + 'if preview_upload.get("event_types") != {"adoption_run": 3}:', + 'if applied.get("requires_yes") is not False:', + 'if not 200 <= int(applied_upload.get("status") or 0) < 300:', + 'if [str(value) for value in applied.get("event_ids") or []]' + " != preview_events:", + ): + with self.subTest(assertion=assertion): + check = self._asserted_runbook_check( + lambda doc, assertion=assertion: doc.replace(assertion, "") + ) + + self.assertEqual(check["status"], "fail") + self.assertIn(assertion, check["detail"]["missing_assertions"]) + + def test_release_readiness_requires_board_snapshot_schema_assertions(self) -> None: + for assertion in ( + 'BUNDLE_SCHEMA = "code_mower.cloudBenchmarkBundle.v1"', + 'if snapshot.get("mode") != "cloud-board-snapshot"' + ' or snapshot.get("status") != "dry_run":', + 'if snapshot.get("repo_slug") != "codemower-ai/code-mower":', + 'if snapshot.get("event_count") != 1:', + 'if export.get("event_types") != {"board_snapshot": 1}' + ' or export.get("included_reports"):', + 'if manifest.get("schema") != BUNDLE_SCHEMA:', + 'if event.get("schema") != EVENT_SCHEMA' + ' or not str(event.get("event_id") or ""):', + 'if dimensions.get("snapshot_schema") != SNAPSHOT_SCHEMA:', + 'if not 200 <= int(applied.get("status") or 0) < 300:', + ): + with self.subTest(assertion=assertion): + check = self._asserted_runbook_check( + lambda doc, assertion=assertion: doc.replace(assertion, "") + ) + + self.assertEqual(check["status"], "fail") + self.assertIn(assertion, check["detail"]["missing_assertions"]) + def test_release_readiness_rejects_release_asset_clobbering(self) -> None: check = self._asserted_runbook_check( lambda doc: doc.replace( @@ -8437,7 +8609,17 @@ def test_runbook_board_restart_waits_and_polls_every_port(self) -> None: ) self.assertIn("code-mower board stop --port \"$BOARD_PORT\" --yes --json", boards) self.assertIn('board_wait.py" gone "$BOARD_PORT"', boards) - self.assertIn('board_wait.py" serving 5332 5342 5344', boards) + self.assertIn('board_wait.py" serving \\', boards) + self.assertIn( + '"5332=codemower-ai/code-mower" "5342=$BOARD_5342_REPO"' + ' "5344=$BOARD_5344_REPO"', + boards, + ) + self.assertIn('and row.get("repo") == expected_repo', boards) + self.assertIn( + 'raise SystemExit("serving mode requires PORT=REPO for every port")', + boards, + ) self.assertIn("code-mower board doctor", boards) self.assertNotIn("board reset --", boards) self.assertNotIn("pkill", boards) From b87d408866ba51a655efd68be2797b581424d148 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 05:24:24 +0000 Subject: [PATCH 07/20] Reject duplicate release-evidence identities and isolate every pip site Validate raw campaign provider rows and Board doctor check rows before building dictionaries, bind adoption results to their provider and the cold_install context, bind the Board snapshot bundle and event to the release repository with exact preview correlation, gate both cloud uploads behind a parsed cloud doctor probe, allow only a queued owner warning in Board doctor release semantics, standardize pip isolation across all eight post-merge package-source sites, and replace literal cloud identifiers with private variables. Co-Authored-By: bot_apk --- docs/pypi-release.md | 312 ++++++++++--- src/code_mower/release_readiness.py | 177 +++++++- tests/test_release_hygiene.py | 658 +++++++++++++++++++++++++++- 3 files changed, 1053 insertions(+), 94 deletions(-) diff --git a/docs/pypi-release.md b/docs/pypi-release.md index 940f3c5f..bf332464 100644 --- a/docs/pypi-release.md +++ b/docs/pypi-release.md @@ -173,13 +173,20 @@ The ambient `code-mower` executable is still the previous release, so readiness runs from a clean virtual environment built out of the clean `RELEASE_CHECKOUT` clone of `RELEASE_SHA`. +Every pip-backed command in this runbook uses the same package-source +isolation: the outer environment drops `PIP_INDEX_URL`, +`PIP_EXTRA_INDEX_URL`, `PIP_FIND_LINKS`, and `PIP_NO_INDEX`, reads no pip +configuration file, names its index explicitly, and bypasses caches. Direct +pip commands add `--isolated` so no ambient environment or configuration can +reintroduce another package source. + ```bash RELEASE_ENV="$(mktemp -d /tmp/code-mower-v140-release-env.XXXXXX)" python3.12 -m venv "$RELEASE_ENV/venv" RELEASE_PYTHON="$RELEASE_ENV/venv/bin/python" -env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS \ - PIP_CONFIG_FILE=/dev/null "$RELEASE_PYTHON" -m pip install --no-cache-dir \ - --index-url https://pypi.org/simple/ "$RELEASE_CHECKOUT" +env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS -u PIP_NO_INDEX \ + PIP_CONFIG_FILE=/dev/null "$RELEASE_PYTHON" -m pip --isolated install \ + --no-cache-dir --index-url https://pypi.org/simple/ "$RELEASE_CHECKOUT" RELEASE_CLI="$RELEASE_ENV/venv/bin/code-mower" test "$("$RELEASE_CLI" --version)" = "code-mower 1.4.0" (cd "$RELEASE_CHECKOUT" && "$RELEASE_CLI" migration release-readiness --json) \ @@ -333,12 +340,12 @@ gh run watch "$TESTPYPI_RUN_ID" --repo "$REPO" --exit-status "$TESTPYPI_RUN_ID" workflow_dispatch "$RELEASE_SHA" success skipped TESTPYPI_DIST_DIR="$(mktemp -d /tmp/code-mower-v140-testpypi-dist.XXXXXX)" -env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS \ - PIP_CONFIG_FILE=/dev/null python3.12 -m pip download code-mower==1.4.0 \ +env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS -u PIP_NO_INDEX \ + PIP_CONFIG_FILE=/dev/null python3.12 -m pip --isolated download code-mower==1.4.0 \ --no-cache-dir --no-deps --only-binary :all: \ --index-url https://test.pypi.org/simple/ --dest "$TESTPYPI_DIST_DIR" -env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS \ - PIP_CONFIG_FILE=/dev/null python3.12 -m pip download code-mower==1.4.0 \ +env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS -u PIP_NO_INDEX \ + PIP_CONFIG_FILE=/dev/null python3.12 -m pip --isolated download code-mower==1.4.0 \ --no-cache-dir --no-deps --no-binary :all: \ --index-url https://test.pypi.org/simple/ --dest "$TESTPYPI_DIST_DIR" TESTPYPI_DIST_DIR="$TESTPYPI_DIST_DIR" "$RELEASE_PYTHON" - <<'PY' @@ -360,7 +367,8 @@ PY TESTPYPI_WHEEL="$TESTPYPI_DIST_DIR/code_mower-1.4.0-py3-none-any.whl" test -f "$TESTPYPI_WHEEL" TESTPYPI_WORK_DIR="$(mktemp -d /tmp/code-mower-v140-testpypi-rehearsal.XXXXXX)" -env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL PIP_CONFIG_FILE=/dev/null \ +env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS -u PIP_NO_INDEX \ + PIP_CONFIG_FILE=/dev/null \ "$RELEASE_CLI" migration package-install-rehearsal \ --package-spec "$TESTPYPI_WHEEL" \ --python "$(command -v python3.12)" \ @@ -370,7 +378,10 @@ env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL PIP_CONFIG_FILE=/dev/null \ ``` The rehearsal installs the exact TestPyPI file, so production PyPI cannot satisfy -this step; only its dependencies come from canonical PyPI. +this step; only its dependencies come from canonical PyPI. The outer +environment cleanup covers the rehearsal's own `--upgrade-pip` subprocess as +well as its install, so neither can inherit an ambient index, find-links +directory, offline flag, or pip configuration file. ### 7. Publish production PyPI only, then rehearse the published package @@ -387,7 +398,7 @@ gh run watch "$PYPI_RUN_ID" --repo "$REPO" --exit-status "$PYPI_RUN_ID" workflow_dispatch "$RELEASE_SHA" skipped success PYPI_WORK_DIR="$(mktemp -d /tmp/code-mower-v140-pypi-rehearsal.XXXXXX)" -env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS \ +env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS -u PIP_NO_INDEX \ PIP_CONFIG_FILE=/dev/null "$RELEASE_CLI" migration package-install-rehearsal \ --package-spec code-mower==1.4.0 \ --python "$(command -v python3.12)" \ @@ -409,12 +420,12 @@ sha256sum "$PROD_DIST_DIR"/* ```bash PYPI_DOWNLOAD_DIR="$(mktemp -d /tmp/code-mower-v140-pypi-download.XXXXXX)" -env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS \ - PIP_CONFIG_FILE=/dev/null python3.12 -m pip download code-mower==1.4.0 \ +env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS -u PIP_NO_INDEX \ + PIP_CONFIG_FILE=/dev/null python3.12 -m pip --isolated download code-mower==1.4.0 \ --no-cache-dir --no-deps --no-binary :all: \ --index-url https://pypi.org/simple/ --dest "$PYPI_DOWNLOAD_DIR" -env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS \ - PIP_CONFIG_FILE=/dev/null python3.12 -m pip download code-mower==1.4.0 \ +env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS -u PIP_NO_INDEX \ + PIP_CONFIG_FILE=/dev/null python3.12 -m pip --isolated download code-mower==1.4.0 \ --no-cache-dir --no-deps --only-binary :all: \ --index-url https://pypi.org/simple/ --dest "$PYPI_DOWNLOAD_DIR" PROD_DIST_DIR="$PROD_DIST_DIR" PYPI_DOWNLOAD_DIR="$PYPI_DOWNLOAD_DIR" \ @@ -623,8 +634,10 @@ unset variable, or any other check status fails closed. ```bash CODE_MOWER_PYTHON="$(command -v python3.12)" test -n "$CODE_MOWER_PYTHON" -PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" \ - 'code-mower[coworker]==1.4.0' +env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS -u PIP_NO_INDEX \ + PIP_CONFIG_FILE=/dev/null pipx install --force --backend pip \ + --python "$CODE_MOWER_PYTHON" --index-url https://pypi.org/simple/ \ + --pip-args='--isolated --no-cache-dir' 'code-mower[coworker]==1.4.0' test "$(code-mower --version)" = "code-mower 1.4.0" DEVIN_PROVIDER_PROFILE="REPLACE_WITH_PROTECTED_PROFILE_SELECTOR" @@ -682,8 +695,10 @@ identifier, and repository inventory out of recorded evidence. The campaign is a gate, so its watch and status output is saved and asserted: the campaign must finish `complete`, the selected and required provider sets -must be exactly Claude, Codex, and Devin, every required lane must hold a -passing adoption result, and the Devin lane must report the verified hosted +must be exactly Claude, Codex, and Devin -- one raw row each, so a duplicate +row cannot hide a failing lane behind a passing one -- every required lane must +hold a passing adoption result bound to its own provider and the `cold_install` +qualification context, and the Devin lane must report the verified hosted bridge transport (`devin_api_v3`, the only hosted Code Mower Devin transport, already selected explicitly in step 13). The protected profile is named on watch and status too, so a protected or ambiguous profile stays selected. @@ -727,6 +742,37 @@ def load(name: str) -> dict: return json.loads((campaign_dir / name).read_text(encoding="utf-8")) +def exact_provider_rows(rows: object, label: str) -> tuple[dict | None, list[str]]: + """Index provider rows only after the raw list holds each provider exactly once. + + Building a provider-keyed dictionary first would silently discard a + duplicate row: a failing Devin lane followed by a passing Devin lane would + read as one passing lane. The raw list is validated instead, so duplicate, + unknown, missing, or malformed rows fail before any indexing happens. + """ + + if not isinstance(rows, list): + return None, [f"{label} provider list is {type(rows).__name__}, not a list"] + problems: list[str] = [] + indexed: dict[str, dict] = {} + for row in rows: + if not isinstance(row, dict): + problems.append(f"{label} provider row is malformed") + continue + name = row.get("provider") + if not isinstance(name, str) or name not in REQUIRED_PROVIDERS: + problems.append(f"{label} provider row identity is {name!r}") + continue + if name in indexed: + problems.append(f"{label} provider {name!r} appears more than once") + continue + indexed[name] = row + missing = sorted(REQUIRED_PROVIDERS - set(indexed)) + if missing: + problems.append(f"{label} provider rows are missing {missing}") + return (None if problems else indexed), problems + + watch = load("watch.json") status = load("status.json") problems = [] @@ -745,14 +791,11 @@ if watch.get("status") != "complete" or watch.get("stop_reason") != "complete": problems.append( f"watch stopped as {watch.get('stop_reason')!r} with status {watch.get('status')!r}" ) -watch_lanes = { - str(row.get("provider") or ""): row - for row in watch.get("providers") or [] - if isinstance(row, dict) -} -if set(watch_lanes) != REQUIRED_PROVIDERS: +watch_lanes, watch_row_problems = exact_provider_rows(watch.get("providers"), "watch") +problems.extend(watch_row_problems) +if watch_lanes is not None and set(watch_lanes) != REQUIRED_PROVIDERS: problems.append(f"watch provider set is {sorted(watch_lanes)}") -for name in sorted(REQUIRED_PROVIDERS & set(watch_lanes)): +for name in sorted(watch_lanes or {}): row = watch_lanes[name] if row.get("posture") != "required" or row.get("state") != "complete" or row.get("error"): problems.append( @@ -779,17 +822,15 @@ if status.get("dry_run") is not False: problems.append(f"campaign dry_run is {status.get('dry_run')!r}, expected False") if status.get("provider_posture_configured") is not True: problems.append("campaign provider posture was not explicitly configured") -lanes = { - str(row.get("provider") or ""): row - for row in status.get("providers") or [] - if isinstance(row, dict) -} +lanes, lane_row_problems = exact_provider_rows(status.get("providers"), "campaign") +problems.extend(lane_row_problems) +lanes = lanes or {} if set(lanes) != REQUIRED_PROVIDERS: problems.append(f"campaign provider set is {sorted(lanes)}") required = {name for name, row in lanes.items() if row.get("posture") == "required"} if required != REQUIRED_PROVIDERS: problems.append(f"required provider set is {sorted(required)}") -for name in sorted(REQUIRED_PROVIDERS & set(lanes)): +for name in sorted(lanes): lane = lanes[name] if lane.get("state") != "complete": problems.append(f"{name} lane state is {lane.get('state')!r}") @@ -805,6 +846,12 @@ for name in sorted(REQUIRED_PROVIDERS & set(lanes)): or result.get("normalized_version") != VERSION ): problems.append(f"{name} lane result is not bound to {RELEASE_TAG}") + if result.get("provider") != name: + problems.append(f"{name} lane result provider is {result.get('provider')!r}") + if result.get("qualification_context") != "cold_install": + problems.append( + f"{name} lane result context is {result.get('qualification_context')!r}" + ) outcome = result.get("outcome") if outcome not in PASSING_OUTCOMES: problems.append(f"{name} lane result outcome is {outcome!r}") @@ -955,15 +1002,53 @@ import os from pathlib import Path BOARD_DOCTOR_SCHEMA = "code_mower.boardDoctor.v1" -EXPECTED_CHECK_IDS = { +REQUIRED_PASS_CHECK_IDS = ( "repo.path", "github.remote", "gate.health", "store.events", - "owner.queue", "agent.adapters", "spend.timeline", -} +) +OWNER_QUEUE_CHECK_ID = "owner.queue" +EXPECTED_CHECK_IDS = {*REQUIRED_PASS_CHECK_IDS, OWNER_QUEUE_CHECK_ID} +OWNER_QUEUE_STATUSES = {"pass", "warn"} + + +def exact_doctor_checks(rows: object, port: str) -> tuple[dict | None, list[str]]: + """Index doctor checks only after the raw list holds each check exactly once. + + Indexing first would keep the last row for a repeated check id, so a failing + check followed by a passing duplicate would read as healthy. Duplicate, + unknown, missing, or malformed check rows fail before indexing. + """ + + if not isinstance(rows, list): + return None, [f"board {port} doctor check list is not a list"] + problems: list[str] = [] + indexed: dict[str, str] = {} + for row in rows: + if not isinstance(row, dict): + problems.append(f"board {port} doctor check row is malformed") + continue + check_id = row.get("id") + status = row.get("status") + if not isinstance(check_id, str) or check_id not in EXPECTED_CHECK_IDS: + problems.append(f"board {port} doctor check id is {check_id!r}") + continue + if not isinstance(status, str): + problems.append(f"board {port} doctor check {check_id!r} status is malformed") + continue + if check_id in indexed: + problems.append(f"board {port} doctor check {check_id!r} appears more than once") + continue + indexed[check_id] = status + missing = sorted(EXPECTED_CHECK_IDS - set(indexed)) + if missing: + problems.append(f"board {port} doctor is missing {missing}") + return (None if problems else indexed), problems + + doctor_dir = Path(os.environ["BOARD_DOCTOR_DIR"]) problems = [] for port in ("5332", "5342", "5344"): @@ -973,30 +1058,38 @@ for port in ("5332", "5342", "5344"): problems.append(f"board {port} doctor schema is {report.get('schema')!r}") if report.get("repo") != expected_repo: problems.append(f"board {port} doctor reports another repository") - if report.get("status") != "pass": - problems.append(f"board {port} doctor status is {report.get('status')!r}") - checks = { - str(row.get("id") or ""): str(row.get("status") or "") - for row in report.get("checks") or [] - if isinstance(row, dict) - } - if not EXPECTED_CHECK_IDS or not EXPECTED_CHECK_IDS <= set(checks): - problems.append( - f"board {port} doctor is missing {sorted(EXPECTED_CHECK_IDS - set(checks))}" - ) - failing = sorted(name for name, value in checks.items() if value != "pass") + checks, check_problems = exact_doctor_checks(report.get("checks"), port) + problems.extend(check_problems) + if checks is None: + continue + failing = sorted(name for name in REQUIRED_PASS_CHECK_IDS if checks[name] != "pass") if failing: problems.append(f"board {port} doctor checks are not pass: {failing}") + owner_queue = checks[OWNER_QUEUE_CHECK_ID] + if owner_queue not in OWNER_QUEUE_STATUSES: + problems.append(f"board {port} owner queue check is {owner_queue!r}") + continue + if report.get("status") != owner_queue: + problems.append( + f"board {port} doctor status is {report.get('status')!r}," + f" expected {owner_queue!r}" + ) if problems: - raise SystemExit(f"restarted Board doctors are not all pass: {problems}") -print(json.dumps({"board_doctors_pass": ["5332", "5342", "5344"]})) + raise SystemExit(f"restarted Board doctors are not release-ready: {problems}") +print(json.dumps({"board_doctors_release_ready": ["5332", "5342", "5344"]})) PY ``` `code-mower board doctor` exits zero for `warn`, so each report is parsed and required to carry the `code_mower.boardDoctor.v1` schema, the expected -repository, a top-level `pass`, the full expected check inventory, and a `pass` -on every individual check; printing the JSON is not the gate. +repository, and exactly one row for each expected check id; printing the JSON is +not the gate. `repo.path`, `github.remote`, `gate.health`, `store.events`, +`agent.adapters`, and `spend.timeline` must pass. Only `owner.queue` may be +`warn`, because a nonempty owner queue reports ordinary queued drafts, rebases, +stale pull requests, and owner work rather than a degraded Board; the top-level +status must then be exactly that `warn`, and exactly `pass` when the queue is +empty. Every other warning, failure, unknown status, or unexpected top-level +verdict blocks the release. Do not use raw process kills or Board reset, and never copy private repository slugs or paths into public evidence. @@ -1008,15 +1101,67 @@ application, and report the event identifiers and counts it would send; the applied upload must be accepted by the service and carry exactly the previewed identifiers and counts. +The cloud identifiers are account-specific, so they are supplied privately and +only ever passed as variables; their values are never printed or recorded. The +service itself is probed and asserted before either upload, and the probe +report is saved privately because it names the endpoint and describes the token +resolution. + ```bash CLOUD_DIR="$(mktemp -d /tmp/code-mower-v140-cloud.XXXXXX)" -code-mower cloud doctor --install-id codex-code-mower --probe-service --json \ - >"$CLOUD_DIR/doctor.json" +CODE_MOWER_CLOUD_TEAM_ID="REPLACE_WITH_PRIVATE_CLOUD_TEAM_ID" +CODE_MOWER_INSTALL_ID="REPLACE_WITH_PRIVATE_CLOUD_INSTALL_ID" +test -n "$CODE_MOWER_CLOUD_TEAM_ID" +test -n "$CODE_MOWER_INSTALL_ID" +code-mower cloud doctor --install-id "$CODE_MOWER_INSTALL_ID" \ + --probe-service --json >"$CLOUD_DIR/doctor.json" +CLOUD_DIR="$CLOUD_DIR" "$RELEASE_PYTHON" - <<'PY' +import json +import os +from pathlib import Path + +REQUIRED_CLOUD_CHECKS = ("endpoint", "service", "token") +report = json.loads( + (Path(os.environ["CLOUD_DIR"]) / "doctor.json").read_text(encoding="utf-8") +) +problems = [] +if report.get("mode") != "cloud-doctor": + problems.append(f"cloud doctor mode is {report.get('mode')!r}") +if report.get("status") != "pass": + problems.append(f"cloud doctor status is {report.get('status')!r}") +if report.get("failures") != 0: + problems.append(f"cloud doctor reports {report.get('failures')!r} failures") +rows = report.get("checks") +statuses = {} +if not isinstance(rows, list): + problems.append("cloud doctor check list is not a list") + rows = [] +for row in rows: + if not isinstance(row, dict): + problems.append("cloud doctor check row is malformed") + continue + name = row.get("name") + status = row.get("status") + if not isinstance(name, str) or not isinstance(status, str): + problems.append("cloud doctor check identity is malformed") + continue + if name in statuses: + problems.append(f"cloud doctor check {name!r} appears more than once") + continue + statuses[name] = status +for name in REQUIRED_CLOUD_CHECKS: + if statuses.get(name) != "pass": + problems.append(f"cloud doctor {name} check is {statuses.get(name)!r}") +if problems: + raise SystemExit(f"cloud service readiness is not a pass: {problems}") +print(json.dumps({"cloud_doctor": "pass", "checks": sorted(REQUIRED_CLOUD_CHECKS)})) +PY + code-mower release campaign upload --release-tag v1.4.0 \ - --install-id codex-code-mower --team-id jeff-internal --json \ + --install-id "$CODE_MOWER_INSTALL_ID" --team-id "$CODE_MOWER_CLOUD_TEAM_ID" --json \ >"$CLOUD_DIR/campaign-preview.json" code-mower release campaign upload --release-tag v1.4.0 \ - --install-id codex-code-mower --team-id jeff-internal --yes --json \ + --install-id "$CODE_MOWER_INSTALL_ID" --team-id "$CODE_MOWER_CLOUD_TEAM_ID" --yes --json \ >"$CLOUD_DIR/campaign-applied.json" CLOUD_DIR="$CLOUD_DIR" "$RELEASE_PYTHON" - <<'PY' import json @@ -1114,13 +1259,13 @@ code-mower cloud board-snapshot \ --repo-path "$CODE_MOWER_RELEASE_CHECKOUT" \ --repo-slug codemower-ai/code-mower \ --output-dir "$BOARD_SNAPSHOT_DIR" \ - --install-id codex-code-mower --team-id jeff-internal --json \ + --install-id "$CODE_MOWER_INSTALL_ID" --team-id "$CODE_MOWER_CLOUD_TEAM_ID" --json \ >"$CLOUD_DIR/board-snapshot.json" code-mower cloud upload "$BOARD_SNAPSHOT_DIR" \ - --install-id codex-code-mower --dry-run --json \ + --install-id "$CODE_MOWER_INSTALL_ID" --dry-run --json \ >"$CLOUD_DIR/board-preview.json" code-mower cloud upload "$BOARD_SNAPSHOT_DIR" \ - --install-id codex-code-mower --yes --json \ + --install-id "$CODE_MOWER_INSTALL_ID" --yes --json \ >"$CLOUD_DIR/board-applied.json" CLOUD_DIR="$CLOUD_DIR" BOARD_SNAPSHOT_DIR="$BOARD_SNAPSHOT_DIR" "$RELEASE_PYTHON" - <<'PY' import json @@ -1138,6 +1283,8 @@ def load(path: Path) -> dict: BUNDLE_SCHEMA = "code_mower.cloudBenchmarkBundle.v1" EVENT_SCHEMA = "code_mower.benchmarkEvent.v1" SNAPSHOT_SCHEMA = "code_mower.cloudBoardSnapshot.v1" +EXPECTED_REPO_SLUG = "codemower-ai/code-mower" +EXPECTED_EVENT_TYPES = {"board_snapshot": 1} snapshot = load(cloud_dir / "board-snapshot.json") preview = load(cloud_dir / "board-preview.json") applied = load(cloud_dir / "board-applied.json") @@ -1150,14 +1297,16 @@ if snapshot.get("mode") != "cloud-board-snapshot" or snapshot.get("status") != " problems.append( f"board snapshot is {snapshot.get('mode')!r}/{snapshot.get('status')!r}" ) -if snapshot.get("repo_slug") != "codemower-ai/code-mower": +if snapshot.get("repo_slug") != EXPECTED_REPO_SLUG: problems.append("board snapshot is not bound to the release repository") if snapshot.get("event_count") != 1: problems.append(f"board snapshot carries {snapshot.get('event_count')!r} events") -if export.get("event_types") != {"board_snapshot": 1} or export.get("included_reports"): +if export.get("event_types") != EXPECTED_EVENT_TYPES or export.get("included_reports"): problems.append(f"board export carries {export.get('event_types')!r}") if manifest.get("schema") != BUNDLE_SCHEMA: problems.append(f"board bundle schema is {manifest.get('schema')!r}") +if manifest.get("repo_slug") != EXPECTED_REPO_SLUG: + problems.append("board bundle is not bound to the release repository") if event_types != ["board_snapshot"] or len(events) != 1: problems.append(f"board bundle carries {event_types} events") if manifest.get("included_reports"): @@ -1167,6 +1316,8 @@ dimensions = event.get("dimensions") dimensions = dimensions if isinstance(dimensions, dict) else {} if event.get("schema") != EVENT_SCHEMA or not str(event.get("event_id") or ""): problems.append(f"board event schema/id is {event.get('schema')!r}") +if event.get("repo_slug") != EXPECTED_REPO_SLUG: + problems.append("board event is not bound to the release repository") if dimensions.get("snapshot_schema") != SNAPSHOT_SCHEMA: problems.append(f"board event snapshot schema is {dimensions.get('snapshot_schema')!r}") if preview.get("mode") != "cloud-upload-dry-run" or preview.get("would_upload") is not False: @@ -1177,8 +1328,10 @@ if preview.get("upload_mode") != "metadata_only": problems.append(f"board preview upload mode is {preview.get('upload_mode')!r}") if preview.get("report_count") != 0: problems.append(f"board preview carries {preview.get('report_count')!r} reports") -if preview.get("event_count") != len(events): - problems.append("board preview event count differs from the bundle") +if preview.get("event_count") != len(events) or preview.get("event_count") != 1: + problems.append("board preview event count is not the single bundled event") +if preview.get("event_types") != EXPECTED_EVENT_TYPES: + problems.append(f"board preview event types are {preview.get('event_types')!r}") if applied.get("mode") != "cloud-upload": problems.append(f"board applied mode is {applied.get('mode')!r}") if not 200 <= int(applied.get("status") or 0) < 300: @@ -1194,7 +1347,13 @@ PY ``` Record accepted event identifiers and counts only, never report prose, profile -paths, tokens, or local configuration. +paths, tokens, cloud team or install identifiers, endpoints, or local +configuration. The bundle manifest and its single event must name the release +repository, not only the top-level summary, so a truthful summary cannot cover +evidence gathered from another repository. `cloud doctor` may still warn that no +bundle exists: the release-specific Board bundle is created after the probe, so +that warning is informational and the gate is the `endpoint`, `service`, and +`token` checks plus zero failures. ## Cache Bypass And Propagation Triage @@ -1207,10 +1366,17 @@ For pipx: ```bash python3.12 --version export CODE_MOWER_PYTHON="$(command -v python3.12)" -PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" code-mower==1.4.0 +env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS -u PIP_NO_INDEX \ + PIP_CONFIG_FILE=/dev/null pipx install --force --backend pip \ + --python "$CODE_MOWER_PYTHON" --index-url https://pypi.org/simple/ \ + --pip-args='--isolated --no-cache-dir' code-mower==1.4.0 code-mower --version ``` +The environment cleanup matters as much as the cache flag: an ambient +`PIP_INDEX_URL`, `PIP_FIND_LINKS`, or `pip.conf` can otherwise supply the +"canonical" artifact from somewhere else entirely. + For uv: ```bash @@ -1225,8 +1391,12 @@ from the release checkout: ```bash scripts/dev-python -m build export CODE_MOWER_PYTHON="$(command -v python3.12)" -PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" dist/code_mower-*.whl -uv tool install --python 3.12 --reinstall dist/code_mower-*.whl +env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS -u PIP_NO_INDEX \ + PIP_CONFIG_FILE=/dev/null pipx install --force --backend pip \ + --python "$CODE_MOWER_PYTHON" --index-url https://pypi.org/simple/ \ + --pip-args='--isolated --no-cache-dir' dist/code_mower-*.whl +uv tool install --python 3.12 --reinstall --index-url https://pypi.org/simple/ \ + dist/code_mower-*.whl ``` If an exact-version install fails within a few minutes of publication, retry @@ -1243,17 +1413,23 @@ For production PyPI verification: ```bash python3.12 -m venv /tmp/code-mower-pypi-smoke -/tmp/code-mower-pypi-smoke/bin/python -m pip install --upgrade pip -/tmp/code-mower-pypi-smoke/bin/python -m pip install code-mower==1.4.0 +env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS -u PIP_NO_INDEX \ + PIP_CONFIG_FILE=/dev/null /tmp/code-mower-pypi-smoke/bin/python -m pip --isolated \ + install --no-cache-dir --index-url https://pypi.org/simple/ --upgrade pip +env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS -u PIP_NO_INDEX \ + PIP_CONFIG_FILE=/dev/null /tmp/code-mower-pypi-smoke/bin/python -m pip --isolated \ + install --no-cache-dir --index-url https://pypi.org/simple/ code-mower==1.4.0 /tmp/code-mower-pypi-smoke/bin/code-mower --version ``` Then run the release-gate first-user rehearsal against the same package: ```bash -code-mower migration package-install-rehearsal \ +env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS -u PIP_NO_INDEX \ + PIP_CONFIG_FILE=/dev/null code-mower migration package-install-rehearsal \ --package-spec code-mower==1.4.0 \ --allow-package-index \ + --pip-index-url https://pypi.org/simple/ \ --upgrade-pip \ --python "$(command -v python3.12)" \ --json diff --git a/src/code_mower/release_readiness.py b/src/code_mower/release_readiness.py index 5838f4a3..19fd532c 100644 --- a/src/code_mower/release_readiness.py +++ b/src/code_mower/release_readiness.py @@ -330,7 +330,7 @@ def _post_merge_runbook_markers(release_tag: str, package_index_spec: str) -> tu f"--package-spec {package_index_spec}", "gh run download", "--name code-mower-dist", - "python3.12 -m pip download", + "python3.12 -m pip --isolated download", "sha256", "CODE_MOWER_PYPI_PUBLISH", f"gh release create {release_tag}", @@ -387,7 +387,7 @@ def _post_merge_runbook_assertions(version: str, release_tag: str) -> tuple[str, '"$PYPI_RUN_ID" workflow_dispatch "$RELEASE_SHA" skipped success', '"$RELEASE_EVENT_RUN_ID" release "$RELEASE_SHA" skipped skipped', # TestPyPI is the exclusive source of the candidate artifacts. - f"PIP_CONFIG_FILE=/dev/null python3.12 -m pip download code-mower=={version}", + f"python3.12 -m pip --isolated download code-mower=={version}", "--index-url https://test.pypi.org/simple/ --dest \"$TESTPYPI_DIST_DIR\"", '--package-spec "$TESTPYPI_WHEEL"', # Production commands reach canonical PyPI explicitly, without caches. @@ -416,6 +416,8 @@ def _post_merge_runbook_assertions(version: str, release_tag: str) -> tuple[str, 'if permissions not in {"pass", "skip"}:', # The exact-release source rehearsal cannot reach ambient packages. "env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS", + "-u PIP_NO_INDEX", + "--pip-args='--isolated --no-cache-dir'", # Boards stop, are waited for, and only then restart from the release. 'test "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA"', 'board_wait.py" gone "$BOARD_PORT"', @@ -432,30 +434,65 @@ def _post_merge_runbook_assertions(version: str, release_tag: str) -> tuple[str, 'BOARD_DOCTOR_SCHEMA = "code_mower.boardDoctor.v1"', 'if report.get("schema") != BOARD_DOCTOR_SCHEMA:', 'if report.get("repo") != expected_repo:', - 'if report.get("status") != "pass":', - "if not EXPECTED_CHECK_IDS or not EXPECTED_CHECK_IDS <= set(checks):", - 'failing = sorted(name for name, value in checks.items() if value != "pass")', - 'raise SystemExit(f"restarted Board doctors are not all pass: {problems}")', + # Doctor rows are validated before they are indexed, so a repeated + # check id cannot replace a failing row with a passing one. + 'if check_id in indexed:', + "missing = sorted(EXPECTED_CHECK_IDS - set(indexed))", + 'failing = sorted(name for name in REQUIRED_PASS_CHECK_IDS if checks[name] != "pass")', + # Only a queued owner surface may warn, and the top-level verdict must + # be exactly that check's status. + "owner_queue = checks[OWNER_QUEUE_CHECK_ID]", + "if owner_queue not in OWNER_QUEUE_STATUSES:", + 'if report.get("status") != owner_queue:', + 'raise SystemExit(f"restarted Board doctors are not release-ready: {problems}")', # The required campaign is a parsed gate, not printed output. '>"$CAMPAIGN_DIR/watch.json"', '>"$CAMPAIGN_DIR/status.json"', 'WATCH_SCHEMA = "code_mower.releaseCampaignWatch.v1"', 'if watch.get("schema") != WATCH_SCHEMA or watch.get("mode") != "release-campaign-watch":', 'if watch.get("status") != "complete" or watch.get("stop_reason") != "complete":', - 'if set(watch_lanes) != REQUIRED_PROVIDERS:', + 'problems.append(f"watch {key} is {watch.get(key)!r}, expected {expected!r}")', + # Provider rows are validated before they are indexed, so a duplicate + # row cannot hide a failing lane behind a later passing lane. + 'if name in indexed:', + "missing = sorted(REQUIRED_PROVIDERS - set(indexed))", + 'watch_lanes, watch_row_problems = exact_provider_rows(watch.get("providers"), "watch")', + 'if watch_lanes is not None and set(watch_lanes) != REQUIRED_PROVIDERS:', 'CAMPAIGN_SCHEMA = "code_mower.releaseCampaign.v1"', 'if status.get("schema") != CAMPAIGN_SCHEMA:', + 'if status.get("status") != "complete":', 'if status.get("dry_run") is not False:', + 'lanes, lane_row_problems = exact_provider_rows(status.get("providers"), "campaign")', 'if set(lanes) != REQUIRED_PROVIDERS:', 'if required != REQUIRED_PROVIDERS:', + 'if lane.get("state") != "complete":', 'ADOPTION_RESULT_SCHEMA = "code_mower.adoptionResult.v1"', 'if result.get("schema") != ADOPTION_RESULT_SCHEMA:', + 'problems.append(f"{name} lane result is not bound to {RELEASE_TAG}")', + # Each adoption result belongs to its own lane and cold-install context. + 'if result.get("provider") != name:', + 'if result.get("qualification_context") != "cold_install":', 'if outcome not in PASSING_OUTCOMES:', 'if devin.get("driver") != "hosted_bridge" or devin.get("transport_verified") is not True:', 'if devin_ref.get("transport_kind") != "devin_api_v3":', 'raise SystemExit(f"release qualification campaign is not a pass: {problems}")', + # Account-specific cloud identifiers stay private: they are supplied as + # variables, required to be nonempty, and never printed. + 'test -n "$CODE_MOWER_CLOUD_TEAM_ID"', + 'test -n "$CODE_MOWER_INSTALL_ID"', + '--install-id "$CODE_MOWER_INSTALL_ID"', + '--team-id "$CODE_MOWER_CLOUD_TEAM_ID"', + # The cloud service itself is probed and parsed before either upload. + 'code-mower cloud doctor --install-id "$CODE_MOWER_INSTALL_ID"', + '--probe-service --json >"$CLOUD_DIR/doctor.json"', + 'REQUIRED_CLOUD_CHECKS = ("endpoint", "service", "token")', + 'if report.get("mode") != "cloud-doctor":', + 'if report.get("failures") != 0:', + 'if name in statuses:', + 'problems.append(f"cloud doctor {name} check is {statuses.get(name)!r}")', + 'raise SystemExit(f"cloud service readiness is not a pass: {problems}")', # Both metadata-only uploads are previewed, applied, and correlated. - '--team-id jeff-internal --yes --json', + '--team-id "$CODE_MOWER_CLOUD_TEAM_ID" --yes --json', 'CAMPAIGN_UPLOAD_SCHEMA = "code_mower.releaseCampaignUpload.v1"', 'if payload.get("schema") != CAMPAIGN_UPLOAD_SCHEMA:', 'if payload.get("mode") != "release-campaign-upload":', @@ -465,23 +502,31 @@ def _post_merge_runbook_assertions(version: str, release_tag: str) -> tuple[str, "if len(ids) != 3 or len(set(ids)) != 3 or not all(ids):", 'if preview_upload.get("event_types") != {"adoption_run": 3}:', 'if preview.get("status") != "dry_run" or preview.get("would_upload") is not False:', + 'if preview.get("requires_yes") is not True:', 'if preview_upload.get("report_count") != 0:', 'if applied.get("requires_yes") is not False:', 'if not 200 <= int(applied_upload.get("status") or 0) < 300:', 'if [str(value) for value in applied.get("event_ids") or []] != preview_events:', 'if applied.get("counts") != preview.get("counts"):', 'raise SystemExit(f"campaign metadata upload is not a verified gate: {problems}")', - '--install-id codex-code-mower --yes --json', + '--install-id "$CODE_MOWER_INSTALL_ID" --yes --json', 'BUNDLE_SCHEMA = "code_mower.cloudBenchmarkBundle.v1"', + 'EXPECTED_REPO_SLUG = "codemower-ai/code-mower"', 'if snapshot.get("mode") != "cloud-board-snapshot" or snapshot.get("status") != "dry_run":', - 'if snapshot.get("repo_slug") != "codemower-ai/code-mower":', + 'if snapshot.get("repo_slug") != EXPECTED_REPO_SLUG:', 'if snapshot.get("event_count") != 1:', - 'if export.get("event_types") != {"board_snapshot": 1} or export.get("included_reports"):', + 'if export.get("event_types") != EXPECTED_EVENT_TYPES or export.get("included_reports"):', 'if manifest.get("schema") != BUNDLE_SCHEMA:', + # The bundle and its single event name the release repository too, so a + # truthful summary cannot cover evidence from another repository. + 'if manifest.get("repo_slug") != EXPECTED_REPO_SLUG:', + 'if event.get("repo_slug") != EXPECTED_REPO_SLUG:', 'if event_types != ["board_snapshot"] or len(events) != 1:', 'if manifest.get("included_reports"):', 'if event.get("schema") != EVENT_SCHEMA or not str(event.get("event_id") or ""):', 'if dimensions.get("snapshot_schema") != SNAPSHOT_SCHEMA:', + 'if preview.get("event_count") != len(events) or preview.get("event_count") != 1:', + 'if preview.get("event_types") != EXPECTED_EVENT_TYPES:', 'if not 200 <= int(applied.get("status") or 0) < 300:', 'raise SystemExit(f"board snapshot upload is not a verified gate: {problems}")', ) @@ -494,9 +539,109 @@ def _forbidden_runbook_markers() -> tuple[str, ...]: 'RELEASE_SHA="$(git rev-parse origin/main)"', "gh release upload", "--pip-extra-index-url https://pypi.org/simple/", + # Account-specific cloud identifiers belong in private variables. + "jeff-internal", + "--install-id codex-code-mower", + 'echo "$CODE_MOWER_CLOUD_TEAM_ID"', + 'echo "$CODE_MOWER_INSTALL_ID"', ) +PIP_ISOLATION_SITE_COUNT = 8 +PIP_ISOLATION_ENVIRONMENT = ( + "-u PIP_INDEX_URL", + "-u PIP_EXTRA_INDEX_URL", + "-u PIP_FIND_LINKS", + "-u PIP_NO_INDEX", + "PIP_CONFIG_FILE=/dev/null", +) + + +def _shell_commands(text: str) -> list[str]: + """Return one string per shell command in fenced bash blocks. + + Continuation lines are joined so a command's options can be inspected + together with the environment its outer `env` invocation established. + """ + + commands: list[str] = [] + pending: list[str] = [] + in_shell_block = False + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("```"): + in_shell_block = stripped == "```bash" + pending = [] + continue + if not in_shell_block: + continue + if stripped.endswith("\\"): + pending.append(stripped[:-1].strip()) + continue + pending.append(stripped) + commands.append(" ".join(part for part in pending if part)) + pending = [] + return commands + + +def _pip_command_kind(command: str) -> str: + """Classify a runbook command by which package-source contract it must meet.""" + + if " -m pip " in command and (" install" in command or " download" in command): + return "pip" + if "package-install-rehearsal" in command: + return "rehearsal" + if "pipx install" in command: + return "pipx" + return "" + + +def _post_merge_pip_isolation_problems(runbook_doc: str) -> list[str]: + """Report post-merge pip-backed commands that could resolve another source. + + An explicit index proves nothing while an ambient `PIP_INDEX_URL`, + find-links directory, offline flag, or `pip.conf` is still readable, so + every site is required to establish the same isolated environment. + """ + + sites = [ + (kind, command) + for command in _shell_commands(runbook_doc) + if (kind := _pip_command_kind(command)) + ] + problems: list[str] = [] + if len(sites) != PIP_ISOLATION_SITE_COUNT: + problems.append( + f"expected {PIP_ISOLATION_SITE_COUNT} pip-backed commands," + f" found {len(sites)}" + ) + for kind, command in sites: + label = command.split("PIP_CONFIG_FILE=/dev/null", 1)[-1].strip()[:72] + problems.extend( + f"{label} does not set {fragment}" + for fragment in PIP_ISOLATION_ENVIRONMENT + if fragment not in command + ) + if kind == "pip": + if "-m pip --isolated" not in command: + problems.append(f"{label} does not run an isolated pip") + if "--no-cache-dir" not in command: + problems.append(f"{label} does not bypass the pip cache") + if kind == "rehearsal": + if "--pip-index-url https://pypi.org/simple/" not in command: + problems.append(f"{label} does not name the canonical index") + if "--pip-no-cache" not in command: + problems.append(f"{label} does not bypass the pip cache") + if kind == "pipx": + if "--backend pip" not in command: + problems.append(f"{label} does not use the pip backend") + if "--pip-args='--isolated --no-cache-dir'" not in command: + problems.append(f"{label} does not isolate its pip arguments") + if kind != "rehearsal" and "--index-url http" not in command: + problems.append(f"{label} does not name an explicit index") + return problems + + def _document_section(text: str, heading: str) -> str: """Return one Markdown section, so a gate reads the runbook and nothing else.""" @@ -643,6 +788,9 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: forbidden_runbook_markers = [ marker for marker in _forbidden_runbook_markers() if marker in runbook_doc ] + pip_isolation_problems = ( + _post_merge_pip_isolation_problems(runbook_doc) if runbook_doc else [] + ) public_hygiene_blobs = { relative_path: text.lower() for relative_path, text in public_hygiene_docs.items() @@ -926,7 +1074,11 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: title="Post-merge runbook asserts every irreversible release gate", status=( "pass" - if not missing_runbook_assertions and not forbidden_runbook_markers + if ( + not missing_runbook_assertions + and not forbidden_runbook_markers + and not pip_isolation_problems + ) else "fail" ), evidence="docs/pypi-release.md", @@ -935,6 +1087,7 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: "required_assertions": list(runbook_assertions), "missing_assertions": missing_runbook_assertions, "forbidden_commands": forbidden_runbook_markers, + "pip_isolation_problems": pip_isolation_problems[:20], }, ), _release_check( diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py index 9129ae66..9622afe1 100644 --- a/tests/test_release_hygiene.py +++ b/tests/test_release_hygiene.py @@ -8334,8 +8334,9 @@ def test_release_readiness_fails_when_a_board_doctor_assertion_is_deleted(self) '--json >"$BOARD_DOCTOR_DIR/5332.json"', '--json >"$BOARD_DOCTOR_DIR/5342.json"', '--json >"$BOARD_DOCTOR_DIR/5344.json"', - 'if report.get("status") != "pass":', - 'raise SystemExit(f"restarted Board doctors are not all pass: {problems}")', + 'if report.get("status") != owner_queue:', + 'raise SystemExit(f"restarted Board doctors are not release-ready:' + ' {problems}")', ): with self.subTest(assertion=assertion): check = self._asserted_runbook_check( @@ -8365,12 +8366,12 @@ def test_release_readiness_fails_when_the_campaign_gate_is_print_only(self) -> N def test_release_readiness_fails_when_a_metadata_upload_gate_is_deleted(self) -> None: for assertion in ( - "--team-id jeff-internal --yes --json", + '--team-id "$CODE_MOWER_CLOUD_TEAM_ID" --yes --json', 'if preview.get("status") != "dry_run" or preview.get("would_upload") is not False:', 'if preview_upload.get("report_count") != 0:', 'if applied.get("counts") != preview.get("counts"):', 'raise SystemExit(f"campaign metadata upload is not a verified gate: {problems}")', - "--install-id codex-code-mower --yes --json", + '--install-id "$CODE_MOWER_INSTALL_ID" --yes --json', 'if event_types != ["board_snapshot"] or len(events) != 1:', 'if manifest.get("included_reports"):', 'raise SystemExit(f"board snapshot upload is not a verified gate: {problems}")', @@ -8407,11 +8408,18 @@ def test_release_readiness_requires_devin_owner_confirmation(self) -> None: self.assertIn(assertion, check["detail"]["missing_assertions"]) def test_release_readiness_requires_an_isolated_fresh_source_install(self) -> None: - isolation = "env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS" - check = self._asserted_runbook_check(lambda doc: doc.replace(isolation, "env")) + for assertion in ( + "env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS", + "-u PIP_NO_INDEX", + "--pip-args='--isolated --no-cache-dir'", + ): + with self.subTest(assertion=assertion): + check = self._asserted_runbook_check( + lambda doc, assertion=assertion: doc.replace(assertion, "") + ) - self.assertEqual(check["status"], "fail") - self.assertIn(isolation, check["detail"]["missing_assertions"]) + self.assertEqual(check["status"], "fail") + self.assertIn(assertion, check["detail"]["missing_assertions"]) def test_release_readiness_requires_board_port_repository_binding(self) -> None: for assertion in ( @@ -8433,8 +8441,11 @@ def test_release_readiness_requires_board_doctor_schema_and_identity(self) -> No 'BOARD_DOCTOR_SCHEMA = "code_mower.boardDoctor.v1"', 'if report.get("schema") != BOARD_DOCTOR_SCHEMA:', 'if report.get("repo") != expected_repo:', - "if not EXPECTED_CHECK_IDS or not EXPECTED_CHECK_IDS <= set(checks):", - 'failing = sorted(name for name, value in checks.items() if value != "pass")', + "missing = sorted(EXPECTED_CHECK_IDS - set(indexed))", + 'failing = sorted(name for name in REQUIRED_PASS_CHECK_IDS' + ' if checks[name] != "pass")', + "owner_queue = checks[OWNER_QUEUE_CHECK_ID]", + "if owner_queue not in OWNER_QUEUE_STATUSES:", ): with self.subTest(assertion=assertion): check = self._asserted_runbook_check( @@ -8451,12 +8462,23 @@ def test_release_readiness_requires_campaign_schema_and_identity(self) -> None: ' or watch.get("mode") != "release-campaign-watch":', 'if watch.get("status") != "complete"' ' or watch.get("stop_reason") != "complete":', - "if set(watch_lanes) != REQUIRED_PROVIDERS:", + 'problems.append(f"watch {key} is {watch.get(key)!r},' + ' expected {expected!r}")', + 'watch_lanes, watch_row_problems = exact_provider_rows(' + 'watch.get("providers"), "watch")', + 'if watch_lanes is not None and set(watch_lanes) != REQUIRED_PROVIDERS:', 'CAMPAIGN_SCHEMA = "code_mower.releaseCampaign.v1"', 'if status.get("schema") != CAMPAIGN_SCHEMA:', + 'if status.get("status") != "complete":', 'if status.get("dry_run") is not False:', + 'lanes, lane_row_problems = exact_provider_rows(' + 'status.get("providers"), "campaign")', + 'if lane.get("state") != "complete":', 'ADOPTION_RESULT_SCHEMA = "code_mower.adoptionResult.v1"', 'if result.get("schema") != ADOPTION_RESULT_SCHEMA:', + 'problems.append(f"{name} lane result is not bound to {RELEASE_TAG}")', + 'if result.get("provider") != name:', + 'if result.get("qualification_context") != "cold_install":', 'if devin_ref.get("transport_kind") != "devin_api_v3":', ): with self.subTest(assertion=assertion): @@ -8495,11 +8517,17 @@ def test_release_readiness_requires_board_snapshot_schema_assertions(self) -> No 'BUNDLE_SCHEMA = "code_mower.cloudBenchmarkBundle.v1"', 'if snapshot.get("mode") != "cloud-board-snapshot"' ' or snapshot.get("status") != "dry_run":', - 'if snapshot.get("repo_slug") != "codemower-ai/code-mower":', + 'EXPECTED_REPO_SLUG = "codemower-ai/code-mower"', + 'if snapshot.get("repo_slug") != EXPECTED_REPO_SLUG:', 'if snapshot.get("event_count") != 1:', - 'if export.get("event_types") != {"board_snapshot": 1}' + 'if export.get("event_types") != EXPECTED_EVENT_TYPES' ' or export.get("included_reports"):', 'if manifest.get("schema") != BUNDLE_SCHEMA:', + 'if manifest.get("repo_slug") != EXPECTED_REPO_SLUG:', + 'if event.get("repo_slug") != EXPECTED_REPO_SLUG:', + 'if preview.get("event_count") != len(events)' + ' or preview.get("event_count") != 1:', + 'if preview.get("event_types") != EXPECTED_EVENT_TYPES:', 'if event.get("schema") != EVENT_SCHEMA' ' or not str(event.get("event_id") or ""):', 'if dimensions.get("snapshot_schema") != SNAPSHOT_SCHEMA:', @@ -8568,6 +8596,8 @@ def test_runbook_rehearsal_flags_exist_on_the_migration_cli(self) -> None: flag for flag in flags if flag.startswith(("--package-spec", "--pip-", "--work-dir", "--python")) + # `--pip-args` belongs to pipx, not the rehearsal CLI. + and not flag.startswith("--pip-args") } self.assertIn("--package-spec", rehearsal_flags) for flag in sorted(rehearsal_flags): @@ -8627,6 +8657,602 @@ def test_runbook_board_restart_waits_and_polls_every_port(self) -> None: with self.subTest(port=port): self.assertIn(port, boards) + def test_release_readiness_requires_duplicate_identity_rejection(self) -> None: + for assertion in ( + 'if name in indexed:', + 'if check_id in indexed:', + 'if name in statuses:', + ): + with self.subTest(assertion=assertion): + check = self._asserted_runbook_check( + lambda doc, assertion=assertion: doc.replace(assertion, "if False:") + ) + + self.assertEqual(check["status"], "fail") + self.assertIn(assertion, check["detail"]["missing_assertions"]) + + def test_release_readiness_requires_the_cloud_doctor_probe(self) -> None: + for assertion in ( + 'code-mower cloud doctor --install-id "$CODE_MOWER_INSTALL_ID"', + '--probe-service --json >"$CLOUD_DIR/doctor.json"', + 'REQUIRED_CLOUD_CHECKS = ("endpoint", "service", "token")', + 'if report.get("mode") != "cloud-doctor":', + 'if report.get("failures") != 0:', + 'problems.append(f"cloud doctor {name} check is {statuses.get(name)!r}")', + 'raise SystemExit(f"cloud service readiness is not a pass: {problems}")', + ): + with self.subTest(assertion=assertion): + check = self._asserted_runbook_check( + lambda doc, assertion=assertion: doc.replace(assertion, "") + ) + + self.assertEqual(check["status"], "fail") + self.assertIn(assertion, check["detail"]["missing_assertions"]) + + def test_release_readiness_requires_private_cloud_identifiers(self) -> None: + runbook = self._runbook_section() + + self.assertNotIn("jeff-internal", runbook) + self.assertNotIn("--install-id codex-code-mower", runbook) + for assertion in ( + 'test -n "$CODE_MOWER_CLOUD_TEAM_ID"', + 'test -n "$CODE_MOWER_INSTALL_ID"', + '--install-id "$CODE_MOWER_INSTALL_ID"', + '--team-id "$CODE_MOWER_CLOUD_TEAM_ID"', + ): + with self.subTest(assertion=assertion): + self.assertIn(assertion, runbook) + check = self._asserted_runbook_check( + lambda doc, assertion=assertion: doc.replace(assertion, "") + ) + + self.assertEqual(check["status"], "fail") + self.assertIn(assertion, check["detail"]["missing_assertions"]) + + def test_release_readiness_rejects_printed_cloud_identifiers(self) -> None: + for printed in ( + 'echo "$CODE_MOWER_CLOUD_TEAM_ID"', + 'echo "$CODE_MOWER_INSTALL_ID"', + ): + with self.subTest(printed=printed): + check = self._asserted_runbook_check( + lambda doc, printed=printed: doc.replace( + 'test -n "$CODE_MOWER_INSTALL_ID"', + f'test -n "$CODE_MOWER_INSTALL_ID"\n{printed}', + ) + ) + + self.assertEqual(check["status"], "fail") + self.assertIn(printed, check["detail"]["forbidden_commands"]) + + def test_release_readiness_requires_isolation_at_every_pip_site(self) -> None: + runbook = self._runbook_section() + sites = [ + (release_readiness._pip_command_kind(command), command) + for command in release_readiness._shell_commands(runbook) + if release_readiness._pip_command_kind(command) + ] + kinds = sorted(kind for kind, _ in sites) + + self.assertEqual( + len(sites), release_readiness.PIP_ISOLATION_SITE_COUNT, sites + ) + self.assertEqual( + kinds, ["pip"] * 5 + ["pipx"] + ["rehearsal"] * 2 + ) + self.assertEqual( + release_readiness._post_merge_pip_isolation_problems(runbook), [] + ) + + def test_release_readiness_fails_when_a_pip_site_loses_isolation(self) -> None: + mutations = ( + ("-u PIP_NO_INDEX", "", "does not set -u PIP_NO_INDEX"), + ("PIP_CONFIG_FILE=/dev/null", "", "does not set PIP_CONFIG_FILE=/dev/null"), + ("-m pip --isolated", "-m pip", "does not run an isolated pip"), + ("--backend pip", "", "does not use the pip backend"), + ("--pip-no-cache", "", "does not bypass the pip cache"), + ) + for old, new, expected in mutations: + with self.subTest(expected=expected): + check = self._asserted_runbook_check( + lambda doc, old=old, new=new: doc.replace(old, new) + ) + problems = check["detail"]["pip_isolation_problems"] + + self.assertEqual(check["status"], "fail") + self.assertTrue( + any(problem.endswith(expected) for problem in problems), problems + ) + + def test_release_readiness_fails_when_a_pip_site_disappears(self) -> None: + check = self._asserted_runbook_check( + lambda doc: doc.replace( + "PIP_CONFIG_FILE=/dev/null pipx install --force --backend pip", + "PIP_CONFIG_FILE=/dev/null true", + ) + ) + + self.assertEqual(check["status"], "fail") + self.assertIn( + "expected 8 pip-backed commands, found 7", + check["detail"]["pip_isolation_problems"], + ) + + def _runbook_python_snippet(self, marker: str) -> str: + snippets = [ + snippet + for snippet in re.findall( + r"<<'PY'\n(.*?)\nPY\n", self._runbook_section(), flags=re.DOTALL + ) + if marker in snippet + ] + self.assertEqual(len(snippets), 1, f"expected one snippet with {marker!r}") + return snippets[0] + + def _run_runbook_snippet( + self, snippet: str, env: dict[str, str] + ) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-c", snippet], + capture_output=True, + text=True, + env={**os.environ, **env}, + ) + + def _campaign_watch_payload(self) -> dict: + return { + "schema": "code_mower.releaseCampaignWatch.v1", + "mode": "release-campaign-watch", + "campaign_id": "campaign-v1.4.0", + "release_tag": "v1.4.0", + "package_identity": "code-mower", + "qualification_context": "cold_install", + "status": "complete", + "stop_reason": "complete", + "providers": [ + { + "provider": name, + "posture": "required", + "state": "complete", + "error": None, + } + for name in ("claude", "codex", "devin") + ], + } + + def _campaign_lane_payload(self, provider: str) -> dict: + lane = { + "provider": provider, + "posture": "required", + "state": "complete", + "dispatch_mode": "applied", + "error": None, + "adoption_result": { + "schema": "code_mower.adoptionResult.v1", + "provider": provider, + "release_tag": "v1.4.0", + "package_identity": "code-mower", + "normalized_version": "1.4.0", + "qualification_context": "cold_install", + "outcome": "pass", + }, + } + if provider == "devin": + lane["driver"] = "hosted_bridge" + lane["transport_verified"] = True + lane["dispatch_ref"] = {"transport_kind": "devin_api_v3"} + return lane + + def _campaign_status_payload(self) -> dict: + return { + "schema": "code_mower.releaseCampaign.v1", + "campaign_id": "campaign-v1.4.0", + "release_tag": "v1.4.0", + "package_identity": "code-mower", + "package_spec": "code-mower==1.4.0", + "normalized_version": "1.4.0", + "qualification_context": "cold_install", + "package_source": "pypi", + "repo_slug": "codemower-ai/code-mower", + "status": "complete", + "dry_run": False, + "provider_posture_configured": True, + "providers": [ + self._campaign_lane_payload(name) + for name in ("claude", "codex", "devin") + ], + } + + def _run_campaign_gate( + self, watch: dict, status: dict + ) -> subprocess.CompletedProcess: + snippet = self._runbook_python_snippet("exact_provider_rows") + with tempfile.TemporaryDirectory() as tmp: + campaign_dir = Path(tmp) + (campaign_dir / "watch.json").write_text( + json.dumps(watch), encoding="utf-8" + ) + (campaign_dir / "status.json").write_text( + json.dumps(status), encoding="utf-8" + ) + return self._run_runbook_snippet( + snippet, {"CAMPAIGN_DIR": str(campaign_dir)} + ) + + def test_runbook_campaign_gate_accepts_the_required_campaign(self) -> None: + result = self._run_campaign_gate( + self._campaign_watch_payload(), self._campaign_status_payload() + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout)["campaign"], "complete") + + def test_runbook_campaign_gate_rejects_a_passing_duplicate_lane(self) -> None: + for surface in ("watch", "status"): + with self.subTest(surface=surface): + watch = self._campaign_watch_payload() + status = self._campaign_status_payload() + if surface == "watch": + failing = dict(watch["providers"][2], state="error", error="boom") + watch["providers"] = [ + watch["providers"][0], + watch["providers"][1], + failing, + watch["providers"][2], + ] + else: + failing = dict(status["providers"][2], state="error", error="boom") + status["providers"] = [ + status["providers"][0], + status["providers"][1], + failing, + status["providers"][2], + ] + + result = self._run_campaign_gate(watch, status) + + self.assertEqual(result.returncode, 1) + self.assertIn("appears more than once", result.stderr) + + def test_runbook_campaign_gate_rejects_malformed_or_unknown_rows(self) -> None: + cases = { + "malformed": ["claude", {"provider": "codex"}, {"provider": "devin"}], + "unknown": [ + {"provider": "gemini"}, + {"provider": "codex"}, + {"provider": "devin"}, + ], + "missing": [{"provider": "codex"}, {"provider": "devin"}], + } + for label, rows in cases.items(): + with self.subTest(rows=label): + status = self._campaign_status_payload() + status["providers"] = rows + + result = self._run_campaign_gate( + self._campaign_watch_payload(), status + ) + + self.assertEqual(result.returncode, 1) + self.assertIn("campaign provider", result.stderr) + + def test_runbook_campaign_gate_rejects_an_unbound_adoption_result(self) -> None: + for field, value in ( + ("provider", "claude"), + ("qualification_context", "warm_install"), + ): + with self.subTest(field=field): + status = self._campaign_status_payload() + status["providers"][2]["adoption_result"][field] = value + + result = self._run_campaign_gate( + self._campaign_watch_payload(), status + ) + + self.assertEqual(result.returncode, 1) + self.assertIn("devin lane result", result.stderr) + + def _board_doctor_report( + self, repo: str, *, owner_queue: str = "pass", status: str | None = None + ) -> dict: + checks = [ + {"id": check_id, "status": "pass"} + for check_id in ( + "repo.path", + "github.remote", + "gate.health", + "store.events", + "agent.adapters", + "spend.timeline", + ) + ] + checks.append({"id": "owner.queue", "status": owner_queue}) + return { + "schema": "code_mower.boardDoctor.v1", + "repo": repo, + "status": status or owner_queue, + "checks": checks, + } + + def _run_board_doctor_gate( + self, reports: dict[str, dict] + ) -> subprocess.CompletedProcess: + snippet = self._runbook_python_snippet("exact_doctor_checks") + with tempfile.TemporaryDirectory() as tmp: + doctor_dir = Path(tmp) + for port, report in reports.items(): + (doctor_dir / f"{port}.json").write_text( + json.dumps(report), encoding="utf-8" + ) + return self._run_runbook_snippet( + snippet, + { + "BOARD_DOCTOR_DIR": str(doctor_dir), + "BOARD_5332_REPO": "codemower-ai/code-mower", + "BOARD_5342_REPO": "private-owner/second", + "BOARD_5344_REPO": "private-owner/third", + }, + ) + + def _board_doctor_reports(self, **overrides: dict) -> dict[str, dict]: + reports = { + "5332": self._board_doctor_report("codemower-ai/code-mower"), + "5342": self._board_doctor_report("private-owner/second"), + "5344": self._board_doctor_report("private-owner/third"), + } + reports.update(overrides) + return reports + + def test_runbook_board_doctor_gate_allows_only_a_queued_owner_warning(self) -> None: + passing = self._run_board_doctor_gate(self._board_doctor_reports()) + warned = self._run_board_doctor_gate( + self._board_doctor_reports( + **{ + "5342": self._board_doctor_report( + "private-owner/second", owner_queue="warn" + ) + } + ) + ) + + self.assertEqual(passing.returncode, 0, passing.stderr) + self.assertEqual(warned.returncode, 0, warned.stderr) + self.assertEqual( + json.loads(warned.stdout)["board_doctors_release_ready"], + ["5332", "5342", "5344"], + ) + + def test_runbook_board_doctor_gate_rejects_unrelated_degradation(self) -> None: + degraded = self._board_doctor_report("private-owner/second") + degraded["checks"][2]["status"] = "warn" + degraded["status"] = "warn" + + result = self._run_board_doctor_gate( + self._board_doctor_reports(**{"5342": degraded}) + ) + + self.assertEqual(result.returncode, 1) + self.assertIn("gate.health", result.stderr) + + def test_runbook_board_doctor_gate_requires_the_owner_queue_verdict(self) -> None: + mismatched = self._board_doctor_report( + "private-owner/second", owner_queue="warn", status="pass" + ) + + result = self._run_board_doctor_gate( + self._board_doctor_reports(**{"5342": mismatched}) + ) + + self.assertEqual(result.returncode, 1) + self.assertIn("expected 'warn'", result.stderr) + + def test_runbook_board_doctor_gate_rejects_a_passing_duplicate_check(self) -> None: + duplicated = self._board_doctor_report("private-owner/second") + duplicated["checks"].insert(0, {"id": "gate.health", "status": "fail"}) + + result = self._run_board_doctor_gate( + self._board_doctor_reports(**{"5342": duplicated}) + ) + + self.assertEqual(result.returncode, 1) + self.assertIn("appears more than once", result.stderr) + + def test_runbook_board_doctor_gate_rejects_an_incomplete_inventory(self) -> None: + cases = {} + missing = self._board_doctor_report("private-owner/second") + missing["checks"] = missing["checks"][1:] + cases["missing"] = missing + unknown = self._board_doctor_report("private-owner/second") + unknown["checks"].append({"id": "owner.mood", "status": "pass"}) + cases["unknown"] = unknown + malformed = self._board_doctor_report("private-owner/second") + malformed["checks"].append("gate.health=pass") + cases["malformed"] = malformed + + for label, report in cases.items(): + with self.subTest(report=label): + result = self._run_board_doctor_gate( + self._board_doctor_reports(**{"5342": report}) + ) + + self.assertEqual(result.returncode, 1) + self.assertIn("5342 doctor", result.stderr) + + def _cloud_doctor_report(self, checks: list[dict] | None = None, **overrides) -> dict: + report = { + "mode": "cloud-doctor", + "status": "pass", + "failures": 0, + "warnings": 1, + "checks": checks + if checks is not None + else [ + {"name": "endpoint", "status": "pass"}, + {"name": "service", "status": "pass"}, + {"name": "token", "status": "pass"}, + {"name": "bundle", "status": "warn"}, + ], + } + report.update(overrides) + return report + + def _run_cloud_doctor_gate(self, report: dict) -> subprocess.CompletedProcess: + snippet = self._runbook_python_snippet("REQUIRED_CLOUD_CHECKS") + with tempfile.TemporaryDirectory() as tmp: + cloud_dir = Path(tmp) + (cloud_dir / "doctor.json").write_text( + json.dumps(report), encoding="utf-8" + ) + return self._run_runbook_snippet(snippet, {"CLOUD_DIR": str(cloud_dir)}) + + def test_runbook_cloud_doctor_gate_tolerates_the_missing_bundle_warning(self) -> None: + result = self._run_cloud_doctor_gate(self._cloud_doctor_report()) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout)["cloud_doctor"], "pass") + + def test_runbook_cloud_doctor_gate_rejects_a_degraded_service(self) -> None: + cases = { + "mode": self._cloud_doctor_report(mode="cloud-dogfood"), + "status": self._cloud_doctor_report(status="fail"), + "failures": self._cloud_doctor_report(failures=1), + "service": self._cloud_doctor_report( + [ + {"name": "endpoint", "status": "pass"}, + {"name": "service", "status": "fail"}, + {"name": "token", "status": "pass"}, + ] + ), + "missing": self._cloud_doctor_report( + [ + {"name": "endpoint", "status": "pass"}, + {"name": "token", "status": "pass"}, + ] + ), + "duplicate": self._cloud_doctor_report( + [ + {"name": "endpoint", "status": "pass"}, + {"name": "service", "status": "fail"}, + {"name": "service", "status": "pass"}, + {"name": "token", "status": "pass"}, + ] + ), + } + for label, report in cases.items(): + with self.subTest(report=label): + result = self._run_cloud_doctor_gate(report) + + self.assertEqual(result.returncode, 1) + self.assertIn("cloud", result.stderr) + + def _board_snapshot_fixtures(self) -> tuple[dict, dict, dict, dict]: + snapshot = { + "mode": "cloud-board-snapshot", + "status": "dry_run", + "repo_slug": "codemower-ai/code-mower", + "event_count": 1, + "export": {"event_types": {"board_snapshot": 1}, "included_reports": 0}, + } + manifest = { + "schema": "code_mower.cloudBenchmarkBundle.v1", + "repo_slug": "codemower-ai/code-mower", + "included_reports": 0, + "events": [ + { + "schema": "code_mower.benchmarkEvent.v1", + "event_id": "evt-board-1", + "event_type": "board_snapshot", + "repo_slug": "codemower-ai/code-mower", + "dimensions": { + "snapshot_schema": "code_mower.cloudBoardSnapshot.v1" + }, + } + ], + } + preview = { + "mode": "cloud-upload-dry-run", + "would_upload": False, + "requires_yes": True, + "upload_mode": "metadata_only", + "report_count": 0, + "event_count": 1, + "event_types": {"board_snapshot": 1}, + } + applied = {"mode": "cloud-upload", "status": 200} + return snapshot, manifest, preview, applied + + def _run_board_snapshot_gate( + self, snapshot: dict, manifest: dict, preview: dict, applied: dict + ) -> subprocess.CompletedProcess: + snippet = self._runbook_python_snippet("board snapshot upload is not a verified") + with tempfile.TemporaryDirectory() as tmp: + cloud_dir = Path(tmp) / "cloud" + bundle_dir = Path(tmp) / "bundle" + cloud_dir.mkdir() + bundle_dir.mkdir() + (cloud_dir / "board-snapshot.json").write_text( + json.dumps(snapshot), encoding="utf-8" + ) + (cloud_dir / "board-preview.json").write_text( + json.dumps(preview), encoding="utf-8" + ) + (cloud_dir / "board-applied.json").write_text( + json.dumps(applied), encoding="utf-8" + ) + (bundle_dir / "code-mower-cloud-bundle.json").write_text( + json.dumps(manifest), encoding="utf-8" + ) + return self._run_runbook_snippet( + snippet, + { + "CLOUD_DIR": str(cloud_dir), + "BOARD_SNAPSHOT_DIR": str(bundle_dir), + }, + ) + + def test_runbook_board_snapshot_gate_accepts_the_release_bundle(self) -> None: + result = self._run_board_snapshot_gate(*self._board_snapshot_fixtures()) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout)["board_upload"], "accepted") + + def test_runbook_board_snapshot_gate_rejects_another_repository(self) -> None: + snapshot, manifest, preview, applied = self._board_snapshot_fixtures() + foreign_manifest = copy.deepcopy(manifest) + foreign_manifest["repo_slug"] = "private-owner/second" + foreign_event = copy.deepcopy(manifest) + foreign_event["events"][0]["repo_slug"] = "private-owner/second" + + for label, candidate in ( + ("bundle", foreign_manifest), + ("event", foreign_event), + ): + with self.subTest(surface=label): + result = self._run_board_snapshot_gate( + snapshot, candidate, preview, applied + ) + + self.assertEqual(result.returncode, 1) + self.assertIn( + f"board {label} is not bound to the release repository", + result.stderr, + ) + + def test_runbook_board_snapshot_gate_rejects_uncorrelated_previews(self) -> None: + snapshot, manifest, preview, applied = self._board_snapshot_fixtures() + cases = { + "event_types": dict(preview, event_types={"adoption_run": 1}), + "event_count": dict(preview, event_count=2), + } + for label, candidate in cases.items(): + with self.subTest(preview=label): + result = self._run_board_snapshot_gate( + snapshot, manifest, candidate, applied + ) + + self.assertEqual(result.returncode, 1) + self.assertIn("board preview event", result.stderr) + def test_public_support_docs_are_packaged_and_privacy_forward(self) -> None: manifest = (ROOT / "MANIFEST.in").read_text(encoding="utf-8") readme = (ROOT / "README.md").read_text(encoding="utf-8") @@ -8893,8 +9519,12 @@ def test_current_release_docs_record_package_index_procedure(self) -> None: self.assertIn("## Stable Package-Index Release Procedure", first_user) self.assertIn("## Cache-Bypass And Local Wheel Checks", first_user) self.assertIn("## Cache Bypass And Propagation Triage", pypi_release) + self.assertIn("PIP_NO_CACHE_DIR=1 pipx install --force", first_user) + self.assertIn( + "PIP_CONFIG_FILE=/dev/null pipx install --force --backend pip", + pypi_release, + ) for text in (first_user, pypi_release): - self.assertIn("PIP_NO_CACHE_DIR=1 pipx install --force", text) self.assertIn("--reinstall --refresh-package code-mower", text) self.assertIn("dist/code_mower-*.whl", text) self.assertIn("no matching distribution", text) From 108ad6d0ff6815f7f766d2c00bf49b6d55494dc3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 06:07:39 +0000 Subject: [PATCH 08/20] Harden the v1.4.0 release runbook and rehearsal version proof Make ordered runbook bash blocks fail fast, bind the GitHub Release body to the exact release checkout notes, bind every workflow run to the v1.4.0 tag branch, prove the rehearsed CLI version equals the installed distribution and requested candidate version, and bind private cloud identities to the selected install profile. Co-Authored-By: bot_apk --- docs/first-user-install-rehearsal.md | 10 +- docs/pypi-release.md | 548 ++++++++++++++++++++++---- src/code_mower/migration_install.py | 27 ++ src/code_mower/migration_readiness.py | 48 ++- src/code_mower/migration_rehearsal.py | 19 + src/code_mower/release_readiness.py | 301 +++++++++++++- tests/test_release_hygiene.py | 426 +++++++++++++++++++- 7 files changed, 1262 insertions(+), 117 deletions(-) diff --git a/docs/first-user-install-rehearsal.md b/docs/first-user-install-rehearsal.md index bb9a7c0b..787852bd 100644 --- a/docs/first-user-install-rehearsal.md +++ b/docs/first-user-install-rehearsal.md @@ -120,7 +120,10 @@ code-mower --version For uv: ```bash -uv tool install --python 3.12 --reinstall --refresh-package code-mower code-mower==1.4.0 +env -u UV_INDEX -u UV_DEFAULT_INDEX -u UV_INDEX_URL -u UV_EXTRA_INDEX_URL \ + -u UV_FIND_LINKS -u UV_NO_INDEX -u UV_OFFLINE \ + uv --no-config --no-cache tool install --python 3.12 --reinstall \ + --default-index https://pypi.org/simple/ code-mower==1.4.0 code-mower --version ``` @@ -131,7 +134,10 @@ release checkout: scripts/dev-python -m build export CODE_MOWER_PYTHON="$(command -v python3.12)" PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" dist/code_mower-*.whl -uv tool install --python 3.12 --reinstall dist/code_mower-*.whl +env -u UV_INDEX -u UV_DEFAULT_INDEX -u UV_INDEX_URL -u UV_EXTRA_INDEX_URL \ + -u UV_FIND_LINKS -u UV_NO_INDEX -u UV_OFFLINE \ + uv --no-config --no-cache tool install --python 3.12 --reinstall \ + --default-index https://pypi.org/simple/ dist/code_mower-*.whl ``` If exact-version installs repeatedly report "no matching distribution" or index diff --git a/docs/pypi-release.md b/docs/pypi-release.md index bf332464..23bb3838 100644 --- a/docs/pypi-release.md +++ b/docs/pypi-release.md @@ -144,6 +144,7 @@ paths, private repository paths, and provider prose out of recorded output. commit is the release pull request's own merge commit OID. ```bash +set -euo pipefail REPO="codemower-ai/code-mower" RELEASE_PR="REPLACE_WITH_RELEASE_PR_NUMBER" test "$(gh pr view "$RELEASE_PR" --repo "$REPO" --json state --jq '.state')" = "MERGED" @@ -159,6 +160,7 @@ a fresh clone bound to that commit and machine-asserted clean before anything is built or installed from it. ```bash +set -euo pipefail RELEASE_CHECKOUT="$(mktemp -d /tmp/code-mower-v140-release-src.XXXXXX)/code-mower" git clone --no-checkout "https://github.com/$REPO.git" "$RELEASE_CHECKOUT" git -C "$RELEASE_CHECKOUT" fetch origin "$RELEASE_SHA" @@ -181,6 +183,7 @@ pip commands add `--isolated` so no ambient environment or configuration can reintroduce another package source. ```bash +set -euo pipefail RELEASE_ENV="$(mktemp -d /tmp/code-mower-v140-release-env.XXXXXX)" python3.12 -m venv "$RELEASE_ENV/venv" RELEASE_PYTHON="$RELEASE_ENV/venv/bin/python" @@ -215,6 +218,7 @@ PY ### 3. Create and verify the annotated `v1.4.0` tag on that exact commit ```bash +set -euo pipefail git tag -a v1.4.0 "$RELEASE_SHA" -m "Code Mower v1.4.0" git push origin refs/tags/v1.4.0 test "$(git rev-list -n 1 v1.4.0)" = "$RELEASE_SHA" @@ -231,6 +235,7 @@ A job that is expected to skip must be reported skipped or be absent from the run; a job that is expected to publish must report `success`. ```bash +set -euo pipefail cat >"$RELEASE_ENV/assert_release_run.py" <<'PY' """Assert one release workflow run's identity, head, conclusion, and job posture.""" @@ -248,7 +253,7 @@ def run_view(repo: str, run_id: str) -> dict: completed = subprocess.run( [ "gh", "run", "view", run_id, "--repo", repo, "--json", - "databaseId,workflowName,headSha,event,status,conclusion,url,jobs", + "databaseId,workflowName,headSha,headBranch,event,status,conclusion,url,jobs", ], check=True, capture_output=True, @@ -265,7 +270,7 @@ def job_posture(run: dict, job_name: str) -> str: def main() -> None: - repo, run_id, event, head_sha, testpypi, pypi = sys.argv[1:7] + repo, run_id, event, head_sha, head_branch, testpypi, pypi = sys.argv[1:8] run = run_view(repo, run_id) problems = [] if str(run.get("databaseId")) != run_id: @@ -276,6 +281,10 @@ def main() -> None: problems.append(f"event is {run.get('event')}, not {event}") if run.get("headSha") != head_sha: problems.append("run head is not the exact release commit") + # A commit can carry several tags, so the commit alone does not prove the + # run was dispatched for the v1.4.0 tag. + if run.get("headBranch") != head_branch: + problems.append(f"head branch is {run.get('headBranch')}, not {head_branch}") if run.get("status") != "completed" or run.get("conclusion") != "success": problems.append("run did not complete successfully") for job_name in BUILD_JOBS: @@ -297,6 +306,7 @@ def main() -> None: "run_id": run_id, "event": event, "head_sha": head_sha, + "head_branch": head_branch, "build_distributions": job_posture(run, "build-distributions"), "verify_distributions": job_posture(run, "verify-distributions"), "publish_testpypi": job_posture(run, "publish-testpypi"), @@ -314,12 +324,13 @@ PY Both publish jobs must skip on this run. ```bash +set -euo pipefail gh workflow run release.yml --repo "$REPO" --ref v1.4.0 \ -f publish_testpypi=false -f publish_pypi=false NO_PUBLISH_RUN_ID="REPLACE_WITH_EXACT_RUN_ID" gh run watch "$NO_PUBLISH_RUN_ID" --repo "$REPO" --exit-status "$RELEASE_PYTHON" "$RELEASE_ENV/assert_release_run.py" "$REPO" \ - "$NO_PUBLISH_RUN_ID" workflow_dispatch "$RELEASE_SHA" skipped skipped + "$NO_PUBLISH_RUN_ID" workflow_dispatch "$RELEASE_SHA" v1.4.0 skipped skipped ``` ### 6. Publish TestPyPI only, then rehearse the exact candidate from TestPyPI @@ -332,12 +343,13 @@ which then installs those local files. Dependencies resolve separately from canonical PyPI. ```bash +set -euo pipefail gh workflow run release.yml --repo "$REPO" --ref v1.4.0 \ -f publish_testpypi=true -f publish_pypi=false TESTPYPI_RUN_ID="REPLACE_WITH_EXACT_RUN_ID" gh run watch "$TESTPYPI_RUN_ID" --repo "$REPO" --exit-status "$RELEASE_PYTHON" "$RELEASE_ENV/assert_release_run.py" "$REPO" \ - "$TESTPYPI_RUN_ID" workflow_dispatch "$RELEASE_SHA" success skipped + "$TESTPYPI_RUN_ID" workflow_dispatch "$RELEASE_SHA" v1.4.0 success skipped TESTPYPI_DIST_DIR="$(mktemp -d /tmp/code-mower-v140-testpypi-dist.XXXXXX)" env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS -u PIP_NO_INDEX \ @@ -390,12 +402,13 @@ canonical `https://pypi.org/simple/` explicitly with no cache, so no ambient `pip.conf`, `PIP_INDEX_URL`, or mirror can satisfy a production-labelled gate. ```bash +set -euo pipefail gh workflow run release.yml --repo "$REPO" --ref v1.4.0 \ -f publish_testpypi=false -f publish_pypi=true PYPI_RUN_ID="REPLACE_WITH_EXACT_RUN_ID" gh run watch "$PYPI_RUN_ID" --repo "$REPO" --exit-status "$RELEASE_PYTHON" "$RELEASE_ENV/assert_release_run.py" "$REPO" \ - "$PYPI_RUN_ID" workflow_dispatch "$RELEASE_SHA" skipped success + "$PYPI_RUN_ID" workflow_dispatch "$RELEASE_SHA" v1.4.0 skipped success PYPI_WORK_DIR="$(mktemp -d /tmp/code-mower-v140-pypi-rehearsal.XXXXXX)" env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS -u PIP_NO_INDEX \ @@ -410,6 +423,7 @@ env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS -u PIP_NO_INDEX \ ### 8. Download the exact workflow artifact ```bash +set -euo pipefail PROD_DIST_DIR="$(mktemp -d /tmp/code-mower-v140-prod-dist.XXXXXX)" gh run download "$PYPI_RUN_ID" --repo "$REPO" \ --name code-mower-dist --dir "$PROD_DIST_DIR" @@ -419,6 +433,7 @@ sha256sum "$PROD_DIST_DIR"/* ### 9. Compare SHA-256 digests with the files downloaded from canonical PyPI ```bash +set -euo pipefail PYPI_DOWNLOAD_DIR="$(mktemp -d /tmp/code-mower-v140-pypi-download.XXXXXX)" env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS -u PIP_NO_INDEX \ PIP_CONFIG_FILE=/dev/null python3.12 -m pip --isolated download code-mower==1.4.0 \ @@ -472,6 +487,7 @@ organization value. Anything else -- absent, `true`, or unparseable -- fails closed. ```bash +set -euo pipefail REPO="$REPO" "$RELEASE_PYTHON" - <<'PY' import json import os @@ -521,6 +537,7 @@ own assets and requires the exact filename set and every SHA-256 value to equal `PROD_DIST_DIR`, with exactly one wheel and one sdist: ```bash +set -euo pipefail cat >"$RELEASE_ENV/assert_release_assets.py" <<'PY' """Assert the GitHub Release tag and its downloaded assets match PROD_DIST_DIR.""" @@ -533,6 +550,8 @@ import tempfile from pathlib import Path EXPECTED = {"code_mower-1.4.0-py3-none-any.whl", "code_mower-1.4.0.tar.gz"} +EXPECTED_TITLE = "Code Mower v1.4.0" +RELEASE_NOTES_RELPATH = "docs/v140-release-notes.md" def digests(directory: Path) -> dict[str, str]: @@ -548,14 +567,24 @@ def main() -> None: repo = os.environ["REPO"] release_sha = os.environ["RELEASE_SHA"] local = digests(Path(os.environ["PROD_DIST_DIR"])) + # The notes are read from the clean checkout of the exact release commit, so + # an ambient working copy cannot describe the published release. + notes_path = Path(os.environ["CODE_MOWER_RELEASE_CHECKOUT"]) / RELEASE_NOTES_RELPATH + expected_notes = notes_path.read_text(encoding="utf-8").strip() view = json.loads(subprocess.run( ["gh", "release", "view", "v1.4.0", "--repo", repo, "--json", - "tagName,isDraft,isPrerelease,assets"], + "tagName,isDraft,isPrerelease,assets,body,name"], check=True, capture_output=True, text=True, ).stdout) problems = [] if view.get("tagName") != "v1.4.0": problems.append("release tag is not v1.4.0") + if not expected_notes: + problems.append("release notes in the exact checkout are empty") + if str(view.get("body") or "").replace("\r\n", "\n").strip() != expected_notes: + problems.append("release body does not match the exact checkout release notes") + if view.get("name") != EXPECTED_TITLE: + problems.append("release title is not the expected v1.4.0 title") if view.get("isDraft") or view.get("isPrerelease"): problems.append("release is a draft or prerelease") tag_target = subprocess.run( @@ -585,6 +614,8 @@ def main() -> None: "mode": mode, "assets": sorted(local), "sha256_match": True, + "notes_match": True, + "title_match": True, }, sort_keys=True)) @@ -597,15 +628,20 @@ overwritten anywhere, and a `v1.4.0` release whose assets differ stops the runbook for inspection. ```bash +set -euo pipefail +test -f "$CODE_MOWER_RELEASE_CHECKOUT/docs/v140-release-notes.md" if gh release view v1.4.0 --repo "$REPO" >/dev/null 2>&1; then REPO="$REPO" PROD_DIST_DIR="$PROD_DIST_DIR" RELEASE_SHA="$RELEASE_SHA" \ + CODE_MOWER_RELEASE_CHECKOUT="$CODE_MOWER_RELEASE_CHECKOUT" \ "$RELEASE_PYTHON" "$RELEASE_ENV/assert_release_assets.py" existing else gh release create v1.4.0 "$PROD_DIST_DIR"/* --repo "$REPO" \ --verify-tag --title "Code Mower v1.4.0" \ - --notes-file docs/v140-release-notes.md --latest --fail-on-no-commits + --notes-file "$CODE_MOWER_RELEASE_CHECKOUT/docs/v140-release-notes.md" \ + --latest --fail-on-no-commits fi REPO="$REPO" PROD_DIST_DIR="$PROD_DIST_DIR" RELEASE_SHA="$RELEASE_SHA" \ + CODE_MOWER_RELEASE_CHECKOUT="$CODE_MOWER_RELEASE_CHECKOUT" \ "$RELEASE_PYTHON" "$RELEASE_ENV/assert_release_assets.py" created gh release view v1.4.0 --repo "$REPO" \ --json tagName,targetCommitish,isDraft,isPrerelease,publishedAt,url,assets @@ -614,10 +650,11 @@ gh release view v1.4.0 --repo "$REPO" \ ### 12. Assert the `release`-event run published nothing ```bash +set -euo pipefail RELEASE_EVENT_RUN_ID="REPLACE_WITH_EXACT_RELEASE_EVENT_RUN_ID" gh run watch "$RELEASE_EVENT_RUN_ID" --repo "$REPO" --exit-status "$RELEASE_PYTHON" "$RELEASE_ENV/assert_release_run.py" "$REPO" \ - "$RELEASE_EVENT_RUN_ID" release "$RELEASE_SHA" skipped skipped + "$RELEASE_EVENT_RUN_ID" release "$RELEASE_SHA" v1.4.0 skipped skipped ``` ### 13. Install locally and require hosted Devin readiness @@ -632,6 +669,7 @@ them. Supply that confirmation privately as `confirmed`; any other value, an unset variable, or any other check status fails closed. ```bash +set -euo pipefail CODE_MOWER_PYTHON="$(command -v python3.12)" test -n "$CODE_MOWER_PYTHON" env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS -u PIP_NO_INDEX \ @@ -654,34 +692,77 @@ DEVIN_DOCTOR_JSON="$DEVIN_DOCTOR_DIR/doctor.json" \ import json import os -report = json.loads(open(os.environ["DEVIN_DOCTOR_JSON"], encoding="utf-8").read()) -checks = { - row["name"]: row["status"] - for row in report.get("checks", []) - if row["name"].startswith("provider.devin.") -} -required = ( +REQUIRED_DEVIN_CHECKS = ( "provider.devin.selection", "provider.devin.capabilities", "provider.devin.hosted_credentials", "provider.devin.repository_scope", "provider.devin.lifecycle", ) -blocked = [name for name in required if checks.get(name) != "pass"] -if blocked: - raise SystemExit(f"hosted Devin readiness is blocked: {blocked}") -permissions = checks.get("provider.devin.permissions") +PERMISSIONS_CHECK = "provider.devin.permissions" +EXPECTED_DEVIN_CHECKS = frozenset(REQUIRED_DEVIN_CHECKS) | {PERMISSIONS_CHECK} +report = json.loads(open(os.environ["DEVIN_DOCTOR_JSON"], encoding="utf-8").read()) +problems = [] +if not isinstance(report, dict) or report.get("mode") != "doctor": + raise SystemExit("hosted Devin readiness output is not a doctor report") +summary = report.get("summary") +summary = summary if isinstance(summary, dict) else {} +if report.get("status") not in {"pass", "warn"}: + problems.append(f"doctor verdict is {report.get('status')!r}") +if summary.get("failures") != 0: + problems.append(f"doctor summary reports {summary.get('failures')!r} failures") +rows = report.get("checks") +if not isinstance(rows, list): + raise SystemExit("hosted Devin doctor check list is not a list") +# The aggregate verdict is cross-checked against the raw rows, so a falsified +# summary cannot hide a failing check, and the Devin rows are validated in the +# raw list: indexing first would let a failing row be overwritten by a later +# duplicate that passes. +statuses = {} +raw_failures = 0 +for row in rows: + if not isinstance(row, dict): + problems.append("doctor check row is malformed") + continue + name = row.get("name") + status = row.get("status") + if not isinstance(name, str) or not isinstance(status, str): + problems.append("doctor check identity is malformed") + continue + if status == "fail": + raw_failures += 1 + problems.append(f"doctor check {name!r} failed") + if not name.startswith("provider.devin."): + continue + if name not in EXPECTED_DEVIN_CHECKS: + problems.append(f"unexpected Devin check {name!r}") + continue + if name in statuses: + problems.append(f"Devin check {name!r} appears more than once") + continue + statuses[name] = status +missing = sorted(EXPECTED_DEVIN_CHECKS - set(statuses)) +if missing: + problems.append(f"Devin checks are missing {missing}") +if raw_failures or raw_failures != summary.get("failures"): + problems.append(f"doctor rows report {raw_failures} failures") +for name in REQUIRED_DEVIN_CHECKS: + if statuses.get(name) != "pass": + problems.append(f"Devin check {name!r} is {statuses.get(name)!r}") +permissions = statuses.get(PERMISSIONS_CHECK) owner_confirmed = os.environ.get("DEVIN_PERMISSIONS_OWNER_CONFIRMED", "").strip().lower() -if permissions == "skip" and owner_confirmed != "confirmed": - raise SystemExit( +if permissions not in {"pass", "skip"}: + problems.append(f"Devin permission check is {permissions!r}") +elif permissions == "skip" and owner_confirmed != "confirmed": + problems.append( "Devin permissions are reported skip and the account owner has not " "separately confirmed them" ) -if permissions not in {"pass", "skip"}: - raise SystemExit(f"Devin permission check is {permissions!r}") +if problems: + raise SystemExit(f"hosted Devin readiness is blocked: {problems}") print(json.dumps({ "devin_transport": "hosted", - "required_pass": list(required), + "required_pass": list(REQUIRED_DEVIN_CHECKS), "permissions": permissions, "owner_confirmed": permissions == "pass" or owner_confirmed == "confirmed", })) @@ -704,6 +785,7 @@ already selected explicitly in step 13). The protected profile is named on watch and status too, so a protected or ambiguous profile stays selected. ```bash +set -euo pipefail CAMPAIGN_DIR="$(mktemp -d /tmp/code-mower-v140-campaign.XXXXXX)" code-mower release campaign create \ --release-tag v1.4.0 \ @@ -885,6 +967,7 @@ before starting the replacement, so no start races a dying listener on a fixed port. ```bash +set -euo pipefail CODE_MOWER_RELEASE_CHECKOUT="REPLACE_WITH_EXACT_V140_CHECKOUT" BOARD_5342_REPO="REUSE_PRIVATE_INVENTORIED_SLUG" BOARD_5342_REPO_PATH="REUSE_PRIVATE_INVENTORIED_PATH" @@ -1095,35 +1178,91 @@ slugs or paths into public evidence. ### 16. Dry-run, inspect, then upload metadata-only cloud evidence -Both uploads are gates: the preview and the applied result are saved and -parsed. A preview must be metadata-only, carry zero reports, require explicit -application, and report the event identifiers and counts it would send; the -applied upload must be accepted by the service and carry exactly the previewed -identifiers and counts. +Both uploads run in two phases, so "dry-run, inspect, then upload" is true of +the commands and not only of the prose: the preview is saved and fully +validated, its accepted verdict is required to exist, and only then may the +`--yes` mutation run. A preview must be metadata-only, carry zero reports, +require explicit application, target the probed service, and report the event +identifiers and counts it would send; the applied upload is validated +separately and must be accepted by the service, target the same endpoint, and +carry exactly the previewed identifiers and counts. The cloud identifiers are account-specific, so they are supplied privately and -only ever passed as variables; their values are never printed or recorded. The -service itself is probed and asserted before either upload, and the probe -report is saved privately because it names the endpoint and describes the token -resolution. +only ever passed as variables; their values are never printed or recorded. An +empty value or a forgotten `REPLACE_WITH_...` placeholder fails before the +probe, and the selected install profile is resolved privately so a conflicting +explicit team identity fails before any preview or application rather than +uploading under the wrong account. The service itself is probed and asserted +before either upload against a newly created empty bundle directory, so the +expected check inventory is exactly +`endpoint`, `service`, `token`, and the `bundle` warning for the bundle that is +exported later; the probe report is saved privately because it names the +endpoint and describes the token resolution. Every later producer re-resolves +the install profile on its own, so each preview and applied payload is required +to report the endpoint read back from that private probe report; the value is +compared, never printed. ```bash +set -euo pipefail CLOUD_DIR="$(mktemp -d /tmp/code-mower-v140-cloud.XXXXXX)" -CODE_MOWER_CLOUD_TEAM_ID="REPLACE_WITH_PRIVATE_CLOUD_TEAM_ID" -CODE_MOWER_INSTALL_ID="REPLACE_WITH_PRIVATE_CLOUD_INSTALL_ID" +# Supply both privately, for example by sourcing a protected token env file. +# Never echo them and never write them into release evidence. +: "${CODE_MOWER_CLOUD_TEAM_ID:?private cloud team id is required}" +: "${CODE_MOWER_INSTALL_ID:?private cloud install id is required}" test -n "$CODE_MOWER_CLOUD_TEAM_ID" test -n "$CODE_MOWER_INSTALL_ID" -code-mower cloud doctor --install-id "$CODE_MOWER_INSTALL_ID" \ +case "$CODE_MOWER_CLOUD_TEAM_ID" in REPLACE_WITH_*) exit 1 ;; esac +case "$CODE_MOWER_INSTALL_ID" in REPLACE_WITH_*) exit 1 ;; esac +CODE_MOWER_CLOUD_TEAM_ID="$CODE_MOWER_CLOUD_TEAM_ID" \ + CODE_MOWER_INSTALL_ID="$CODE_MOWER_INSTALL_ID" "$RELEASE_PYTHON" - \ + >"$CLOUD_DIR/identity.json" <<'PY' +import json +import os + +from code_mower.cloud_client import DEFAULT_TOKEN_ENV, resolve_cloud_token + +# The stored profile is resolved privately and only compared: the supplied +# identifiers must be exactly the ones the selected install profile holds, so +# an upload cannot silently target another install or team. Only the verdict is +# printed. +install_id = os.environ["CODE_MOWER_INSTALL_ID"].strip() +team_id = os.environ["CODE_MOWER_CLOUD_TEAM_ID"].strip() +resolution = resolve_cloud_token(token_env=DEFAULT_TOKEN_ENV, install_id=install_id) +problems = [] +if not install_id or install_id.startswith("REPLACE_WITH_"): + problems.append("the private install identifier is empty or a placeholder") +if not team_id or team_id.startswith("REPLACE_WITH_"): + problems.append("the private team identifier is empty or a placeholder") +if not resolution.has_token: + problems.append("the selected install profile has no usable cloud token") +if not resolution.install_id or resolution.install_id.strip() != install_id: + problems.append("the selected install profile stores a different install identity") +if not resolution.team_id or resolution.team_id.strip() != team_id: + problems.append("the selected install profile stores a different team identity") +if problems: + raise SystemExit(f"cloud identity is not bound to the selected profile: {problems}") +print(json.dumps({"cloud_identity": "bound", "source": resolution.source})) +PY +grep -q '"cloud_identity": "bound"' "$CLOUD_DIR/identity.json" +CLOUD_DOCTOR_BUNDLE_DIR="$(mktemp -d /tmp/code-mower-v140-cloud-doctor.XXXXXX)" +code-mower cloud doctor "$CLOUD_DOCTOR_BUNDLE_DIR" \ + --install-id "$CODE_MOWER_INSTALL_ID" \ --probe-service --json >"$CLOUD_DIR/doctor.json" CLOUD_DIR="$CLOUD_DIR" "$RELEASE_PYTHON" - <<'PY' import json import os from pathlib import Path -REQUIRED_CLOUD_CHECKS = ("endpoint", "service", "token") +# The empty bundle directory makes the inventory deterministic: endpoint, +# service, and token must pass, and the only tolerated condition is the bundle +# warning for the release bundle that is exported later in this step. +PASSING_CLOUD_CHECKS = ("endpoint", "service", "token") +EXPECTED_CLOUD_CHECKS = frozenset(PASSING_CLOUD_CHECKS) | {"bundle"} report = json.loads( (Path(os.environ["CLOUD_DIR"]) / "doctor.json").read_text(encoding="utf-8") ) +if not isinstance(report, dict): + raise SystemExit("cloud doctor output is not a report") problems = [] if report.get("mode") != "cloud-doctor": problems.append(f"cloud doctor mode is {report.get('mode')!r}") @@ -1132,10 +1271,12 @@ if report.get("status") != "pass": if report.get("failures") != 0: problems.append(f"cloud doctor reports {report.get('failures')!r} failures") rows = report.get("checks") -statuses = {} if not isinstance(rows, list): - problems.append("cloud doctor check list is not a list") - rows = [] + raise SystemExit("cloud doctor check list is not a list") +# Health is derived from the raw rows and only then compared with the aggregate +# fields, so falsified status/failures values cannot hide a degraded check. +statuses = {} +raw_failures = 0 for row in rows: if not isinstance(row, dict): problems.append("cloud doctor check row is malformed") @@ -1145,36 +1286,40 @@ for row in rows: if not isinstance(name, str) or not isinstance(status, str): problems.append("cloud doctor check identity is malformed") continue + if status == "fail": + raw_failures += 1 if name in statuses: problems.append(f"cloud doctor check {name!r} appears more than once") continue statuses[name] = status -for name in REQUIRED_CLOUD_CHECKS: +unexpected = sorted(set(statuses) - EXPECTED_CLOUD_CHECKS) +missing = sorted(EXPECTED_CLOUD_CHECKS - set(statuses)) +if unexpected: + problems.append(f"cloud doctor reported unexpected checks {unexpected}") +if missing: + problems.append(f"cloud doctor is missing checks {missing}") +for name in PASSING_CLOUD_CHECKS: if statuses.get(name) != "pass": problems.append(f"cloud doctor {name} check is {statuses.get(name)!r}") +if statuses.get("bundle") != "warn": + problems.append(f"cloud doctor bundle check is {statuses.get('bundle')!r}") +if raw_failures or raw_failures != report.get("failures"): + problems.append(f"cloud doctor rows report {raw_failures} failures") if problems: raise SystemExit(f"cloud service readiness is not a pass: {problems}") -print(json.dumps({"cloud_doctor": "pass", "checks": sorted(REQUIRED_CLOUD_CHECKS)})) +print(json.dumps({"cloud_doctor": "pass", "checks": sorted(PASSING_CLOUD_CHECKS)})) PY code-mower release campaign upload --release-tag v1.4.0 \ --install-id "$CODE_MOWER_INSTALL_ID" --team-id "$CODE_MOWER_CLOUD_TEAM_ID" --json \ >"$CLOUD_DIR/campaign-preview.json" -code-mower release campaign upload --release-tag v1.4.0 \ - --install-id "$CODE_MOWER_INSTALL_ID" --team-id "$CODE_MOWER_CLOUD_TEAM_ID" --yes --json \ - >"$CLOUD_DIR/campaign-applied.json" -CLOUD_DIR="$CLOUD_DIR" "$RELEASE_PYTHON" - <<'PY' +CLOUD_DIR="$CLOUD_DIR" "$RELEASE_PYTHON" - \ + >"$CLOUD_DIR/campaign-preflight.json" <<'PY' import json import os from pathlib import Path cloud_dir = Path(os.environ["CLOUD_DIR"]) - - -def load(name: str) -> dict: - return json.loads((cloud_dir / name).read_text(encoding="utf-8")) - - CAMPAIGN_UPLOAD_SCHEMA = "code_mower.releaseCampaignUpload.v1" REQUIRED_PROVIDERS = ["claude", "codex", "devin"] EXPECTED_POSTURES = {name: "required" for name in REQUIRED_PROVIDERS} @@ -1186,12 +1331,10 @@ EXPECTED_COUNTS = { "rejected": 0, "events": 3, } -preview = load("campaign-preview.json") -applied = load("campaign-applied.json") -preview_upload = preview.get("upload") or {} -applied_upload = applied.get("upload") or {} -problems = [] -for name, payload in (("preview", preview), ("applied", applied)): + + +def identity_problems(name: str, payload: dict) -> list: + problems = [] if payload.get("schema") != CAMPAIGN_UPLOAD_SCHEMA: problems.append(f"{name} schema is {payload.get('schema')!r}") if payload.get("mode") != "release-campaign-upload": @@ -1214,6 +1357,25 @@ for name, payload in (("preview", preview), ("applied", applied)): ids = [str(value) for value in payload.get("event_ids") or []] if len(ids) != 3 or len(set(ids)) != 3 or not all(ids): problems.append(f"{name} does not carry three unique event identifiers") + return problems + + +# Nothing has been sent yet: the preview alone decides whether the applied +# upload may run at all, so it is validated before the --yes command exists. +preview = json.loads((cloud_dir / "campaign-preview.json").read_text(encoding="utf-8")) +preview_upload = preview.get("upload") or {} +# The endpoint the probe actually reached is read back from the private doctor +# report, so an upload that re-resolved a different install profile cannot be +# accepted. It is compared, never printed. +probed_endpoint = str( + json.loads((cloud_dir / "doctor.json").read_text(encoding="utf-8")).get("endpoint") + or "" +) +problems = identity_problems("preview", preview) +if not probed_endpoint: + problems.append("the probed cloud endpoint was not recorded") +if preview_upload.get("endpoint") != probed_endpoint: + problems.append("campaign upload preview does not target the probed service") if preview_upload.get("event_types") != {"adoption_run": 3}: problems.append(f"preview event types are {preview_upload.get('event_types')!r}") if preview_upload.get("would_upload") is not False: @@ -1231,6 +1393,79 @@ if preview_upload.get("report_count") != 0: preview_events = [str(value) for value in preview.get("event_ids") or []] if not preview_events or preview_upload.get("event_count") != len(preview_events): problems.append("preview event identifiers and count disagree") +if problems: + raise SystemExit(f"campaign upload preview is not an acceptable payload: {problems}") +print(json.dumps({ + "campaign_preview": "accepted", + "event_count": len(preview_events), + "reports": 0, +})) +PY +grep -q '"campaign_preview": "accepted"' "$CLOUD_DIR/campaign-preflight.json" + +code-mower release campaign upload --release-tag v1.4.0 \ + --install-id "$CODE_MOWER_INSTALL_ID" --team-id "$CODE_MOWER_CLOUD_TEAM_ID" --yes --json \ + >"$CLOUD_DIR/campaign-applied.json" +CLOUD_DIR="$CLOUD_DIR" "$RELEASE_PYTHON" - <<'PY' +import json +import os +from pathlib import Path + +cloud_dir = Path(os.environ["CLOUD_DIR"]) + + +def load(name: str) -> dict: + return json.loads((cloud_dir / name).read_text(encoding="utf-8")) + + +CAMPAIGN_UPLOAD_SCHEMA = "code_mower.releaseCampaignUpload.v1" +REQUIRED_PROVIDERS = ["claude", "codex", "devin"] +EXPECTED_POSTURES = {name: "required" for name in REQUIRED_PROVIDERS} +EXPECTED_COUNTS = { + "providers": 3, + "complete": 3, + "skipped": 0, + "accepted": 3, + "rejected": 0, + "events": 3, +} + + +def identity_problems(name: str, payload: dict) -> list: + problems = [] + if payload.get("schema") != CAMPAIGN_UPLOAD_SCHEMA: + problems.append(f"{name} schema is {payload.get('schema')!r}") + if payload.get("mode") != "release-campaign-upload": + problems.append(f"{name} mode is {payload.get('mode')!r}") + if ( + payload.get("campaign_id") != "campaign-v1.4.0" + or payload.get("release_tag") != "v1.4.0" + or payload.get("package_identity") != "code-mower" + or payload.get("qualification_context") != "cold_install" + ): + problems.append(f"{name} campaign identity is not the v1.4.0 campaign") + if payload.get("provider_postures") != EXPECTED_POSTURES: + problems.append(f"{name} provider postures are {payload.get('provider_postures')!r}") + if payload.get("counts") != EXPECTED_COUNTS: + problems.append(f"{name} counts are {payload.get('counts')!r}") + if sorted(payload.get("accepted_providers") or []) != REQUIRED_PROVIDERS: + problems.append(f"{name} accepted providers are {payload.get('accepted_providers')!r}") + if payload.get("skipped_providers") or payload.get("rejected_providers"): + problems.append(f"{name} skipped or rejected a provider") + ids = [str(value) for value in payload.get("event_ids") or []] + if len(ids) != 3 or len(set(ids)) != 3 or not all(ids): + problems.append(f"{name} does not carry three unique event identifiers") + return problems + + +preview = load("campaign-preview.json") +applied = load("campaign-applied.json") +applied_upload = applied.get("upload") or {} +probed_endpoint = str(load("doctor.json").get("endpoint") or "") +preview_events = [str(value) for value in preview.get("event_ids") or []] +problems = identity_problems("applied", applied) +if not probed_endpoint or applied_upload.get("endpoint") != probed_endpoint: + problems.append("campaign applied upload does not target the probed service") if applied.get("status") != "uploaded" or applied.get("would_upload") is not True: problems.append(f"applied status is {applied.get('status')!r}") if applied.get("requires_yes") is not False: @@ -1255,6 +1490,11 @@ print(json.dumps({ PY BOARD_SNAPSHOT_DIR="$(mktemp -d /tmp/code-mower-v140-board-snapshot.XXXXXX)" +# The Board snapshot event carries no commit or dirty-state field, so the +# checkout it reads is re-bound to the released commit immediately before the +# snapshot runs: an earlier assertion cannot speak for a checkout that moved. +test "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA" +test -z "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" status --porcelain --untracked-files=all)" code-mower cloud board-snapshot \ --repo-path "$CODE_MOWER_RELEASE_CHECKOUT" \ --repo-slug codemower-ai/code-mower \ @@ -1264,10 +1504,8 @@ code-mower cloud board-snapshot \ code-mower cloud upload "$BOARD_SNAPSHOT_DIR" \ --install-id "$CODE_MOWER_INSTALL_ID" --dry-run --json \ >"$CLOUD_DIR/board-preview.json" -code-mower cloud upload "$BOARD_SNAPSHOT_DIR" \ - --install-id "$CODE_MOWER_INSTALL_ID" --yes --json \ - >"$CLOUD_DIR/board-applied.json" -CLOUD_DIR="$CLOUD_DIR" BOARD_SNAPSHOT_DIR="$BOARD_SNAPSHOT_DIR" "$RELEASE_PYTHON" - <<'PY' +CLOUD_DIR="$CLOUD_DIR" BOARD_SNAPSHOT_DIR="$BOARD_SNAPSHOT_DIR" "$RELEASE_PYTHON" - \ + >"$CLOUD_DIR/board-preflight.json" <<'PY' import json import os from pathlib import Path @@ -1287,12 +1525,30 @@ EXPECTED_REPO_SLUG = "codemower-ai/code-mower" EXPECTED_EVENT_TYPES = {"board_snapshot": 1} snapshot = load(cloud_dir / "board-snapshot.json") preview = load(cloud_dir / "board-preview.json") -applied = load(cloud_dir / "board-applied.json") -manifest = load(bundle_dir / "code-mower-cloud-bundle.json") +# The endpoint the probe actually reached is read back from the private doctor +# report, so a producer that re-resolved a different install profile cannot be +# accepted. It is compared, never printed. +probed_endpoint = str(load(cloud_dir / "doctor.json").get("endpoint") or "") +manifest_path = bundle_dir / "code-mower-cloud-bundle.json" +manifest = load(manifest_path) export = snapshot.get("export") or {} -events = [row for row in manifest.get("events") or [] if isinstance(row, dict)] -event_types = sorted({str(row.get("event_type") or "") for row in events}) +snapshot_preview = snapshot.get("upload") or {} +raw_events = manifest.get("events") problems = [] +# Malformed rows are reported instead of being filtered away, so a bundle that +# carries a valid event plus anything else cannot look like a single event. +if not isinstance(raw_events, list) or len(raw_events) != 1 or not isinstance( + raw_events[0], dict +): + problems.append("board bundle does not carry exactly one structured event") + events = [] +else: + events = list(raw_events) +event_types = sorted({str(row.get("event_type") or "") for row in events}) +event_type_counts = {} +for row in events: + key = str(row.get("event_type") or "") + event_type_counts[key] = event_type_counts.get(key, 0) + 1 if snapshot.get("mode") != "cloud-board-snapshot" or snapshot.get("status") != "dry_run": problems.append( f"board snapshot is {snapshot.get('mode')!r}/{snapshot.get('status')!r}" @@ -1320,6 +1576,47 @@ if event.get("repo_slug") != EXPECTED_REPO_SLUG: problems.append("board event is not bound to the release repository") if dimensions.get("snapshot_schema") != SNAPSHOT_SCHEMA: problems.append(f"board event snapshot schema is {dimensions.get('snapshot_schema')!r}") +snapshot_doctor = snapshot.get("doctor") +if not isinstance(snapshot_doctor, dict): + problems.append("board snapshot carries no doctor report") + snapshot_doctor = {} +if snapshot_doctor.get("mode") != "cloud-doctor": + problems.append(f"board snapshot doctor mode is {snapshot_doctor.get('mode')!r}") +if snapshot_doctor.get("status") != "pass": + problems.append(f"board snapshot doctor status is {snapshot_doctor.get('status')!r}") +if snapshot_doctor.get("failures") != 0: + problems.append( + f"board snapshot doctor reports {snapshot_doctor.get('failures')!r} failures" + ) +doctor_rows = snapshot_doctor.get("checks") +if not isinstance(doctor_rows, list) or not doctor_rows: + problems.append("board snapshot doctor check list is not a nonempty list") + doctor_rows = [] +# The nested report is produced without --probe-service, so the skipped service +# probe stays acceptable while any failing or unrecognized status does not. +doctor_statuses = {} +doctor_failures = 0 +for row in doctor_rows: + if not isinstance(row, dict): + problems.append("board snapshot doctor check row is malformed") + continue + name = row.get("name") + status = row.get("status") + if not isinstance(name, str) or not isinstance(status, str): + problems.append("board snapshot doctor check identity is malformed") + continue + if status not in {"pass", "warn", "skip"}: + doctor_failures += 1 + problems.append(f"board snapshot doctor check {name!r} is {status!r}") + if name in doctor_statuses: + problems.append(f"board snapshot doctor check {name!r} appears more than once") + continue + doctor_statuses[name] = status +missing_doctor = sorted({"endpoint", "service", "token", "bundle"} - set(doctor_statuses)) +if missing_doctor: + problems.append(f"board snapshot doctor is missing checks {missing_doctor}") +if doctor_failures != snapshot_doctor.get("failures"): + problems.append(f"board snapshot doctor rows report {doctor_failures} failures") if preview.get("mode") != "cloud-upload-dry-run" or preview.get("would_upload") is not False: problems.append(f"board preview mode is {preview.get('mode')!r}") if preview.get("requires_yes") is not True: @@ -1330,17 +1627,88 @@ if preview.get("report_count") != 0: problems.append(f"board preview carries {preview.get('report_count')!r} reports") if preview.get("event_count") != len(events) or preview.get("event_count") != 1: problems.append("board preview event count is not the single bundled event") -if preview.get("event_types") != EXPECTED_EVENT_TYPES: - problems.append(f"board preview event types are {preview.get('event_types')!r}") +# Generic `cloud upload --dry-run` reports no event-type map, so the exact event +# types come from the manifest this preview describes, and that manifest is the +# one bound to the applied upload by digest. +if event_type_counts != EXPECTED_EVENT_TYPES: + problems.append(f"board bundle event types are {event_type_counts}") +if not probed_endpoint: + problems.append("the probed cloud endpoint was not recorded") +if snapshot_preview.get("endpoint") != probed_endpoint: + problems.append("board snapshot preview does not target the probed service") +if preview.get("endpoint") != probed_endpoint: + problems.append("board upload preview does not target the probed service") +if problems: + raise SystemExit(f"board snapshot preview is not an acceptable payload: {problems}") +print(json.dumps({ + "board_preview": "accepted", + "event_types": event_types, + "reports": 0, +})) +PY +grep -q '"board_preview": "accepted"' "$CLOUD_DIR/board-preflight.json" + +sha256sum "$BOARD_SNAPSHOT_DIR/code-mower-cloud-bundle.json" \ + >"$CLOUD_DIR/board-bundle-before-apply.sha256" +code-mower cloud upload "$BOARD_SNAPSHOT_DIR" \ + --install-id "$CODE_MOWER_INSTALL_ID" --yes --json \ + >"$CLOUD_DIR/board-applied.json" +sha256sum "$BOARD_SNAPSHOT_DIR/code-mower-cloud-bundle.json" \ + >"$CLOUD_DIR/board-bundle-after-apply.sha256" +CLOUD_DIR="$CLOUD_DIR" BOARD_SNAPSHOT_DIR="$BOARD_SNAPSHOT_DIR" "$RELEASE_PYTHON" - <<'PY' +import hashlib +import json +import os +from pathlib import Path + +cloud_dir = Path(os.environ["CLOUD_DIR"]) +bundle_dir = Path(os.environ["BOARD_SNAPSHOT_DIR"]) + + +def load(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def digest_of(path: Path) -> str: + return path.read_text(encoding="utf-8").split()[0] + + +EXPECTED_EVENT_TYPES = {"board_snapshot": 1} +applied = load(cloud_dir / "board-applied.json") +probed_endpoint = str(load(cloud_dir / "doctor.json").get("endpoint") or "") +manifest_path = bundle_dir / "code-mower-cloud-bundle.json" +manifest = load(manifest_path) +raw_events = manifest.get("events") +events = list(raw_events) if isinstance(raw_events, list) else [] +event_type_counts = {} +for row in events: + if not isinstance(row, dict): + event_type_counts = {} + break + key = str(row.get("event_type") or "") + event_type_counts[key] = event_type_counts.get(key, 0) + 1 +problems = [] +if event_type_counts != EXPECTED_EVENT_TYPES: + problems.append(f"board bundle event types are {event_type_counts}") if applied.get("mode") != "cloud-upload": problems.append(f"board applied mode is {applied.get('mode')!r}") if not 200 <= int(applied.get("status") or 0) < 300: problems.append(f"board upload was not accepted: {applied.get('status')!r}") +if not probed_endpoint or applied.get("endpoint") != probed_endpoint: + problems.append("board applied upload does not target the probed service") +# Generic cloud upload returns no event identifiers, so the applied upload is +# bound to the inspected bundle by digest instead: the manifest may not change +# between the accepted preview and the applied upload. +before_digest = digest_of(cloud_dir / "board-bundle-before-apply.sha256") +after_digest = digest_of(cloud_dir / "board-bundle-after-apply.sha256") +current_digest = hashlib.sha256(manifest_path.read_bytes()).hexdigest() +if before_digest != after_digest or current_digest != before_digest: + problems.append("board bundle changed between the preview and the applied upload") if problems: raise SystemExit(f"board snapshot upload is not a verified gate: {problems}") print(json.dumps({ "board_upload": "accepted", - "event_types": event_types, + "event_types": sorted(event_type_counts), "reports": 0, })) PY @@ -1350,10 +1718,14 @@ Record accepted event identifiers and counts only, never report prose, profile paths, tokens, cloud team or install identifiers, endpoints, or local configuration. The bundle manifest and its single event must name the release repository, not only the top-level summary, so a truthful summary cannot cover -evidence gathered from another repository. `cloud doctor` may still warn that no -bundle exists: the release-specific Board bundle is created after the probe, so -that warning is informational and the gate is the `endpoint`, `service`, and -`token` checks plus zero failures. +evidence gathered from another repository. The health of both `cloud doctor` +reports -- the standalone probe and the one nested in the Board snapshot -- is +derived from their raw check rows, so a falsified `status` or `failures` cannot +hide a degraded check; the nested report is produced without `--probe-service`, +so its skipped service probe stays acceptable. Generic `cloud upload` returns +no event identifiers, so the applied upload is bound to the previewed bundle by +the SHA-256 digest taken immediately after the preview and recomputed +immediately after the upload. ## Cache Bypass And Propagation Triage @@ -1381,10 +1753,18 @@ For uv: ```bash uv python install 3.12 -uv tool install --python 3.12 --reinstall --refresh-package code-mower code-mower==1.4.0 +env -u UV_INDEX -u UV_DEFAULT_INDEX -u UV_INDEX_URL -u UV_EXTRA_INDEX_URL \ + -u UV_FIND_LINKS -u UV_NO_INDEX -u UV_OFFLINE \ + uv --no-config --no-cache tool install --python 3.12 --reinstall \ + --default-index https://pypi.org/simple/ code-mower==1.4.0 code-mower --version ``` +`--no-cache` is what bypasses the cache; `--refresh-package` only refreshes +resolution metadata. `--no-config` and the cleared `UV_*` variables keep a +project or user configuration from redirecting the index the same way an +ambient `pip.conf` can. + Before the candidate is available on TestPyPI or PyPI, validate the local wheel from the release checkout: @@ -1395,8 +1775,10 @@ env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS -u PIP_NO_INDEX \ PIP_CONFIG_FILE=/dev/null pipx install --force --backend pip \ --python "$CODE_MOWER_PYTHON" --index-url https://pypi.org/simple/ \ --pip-args='--isolated --no-cache-dir' dist/code_mower-*.whl -uv tool install --python 3.12 --reinstall --index-url https://pypi.org/simple/ \ - dist/code_mower-*.whl +env -u UV_INDEX -u UV_DEFAULT_INDEX -u UV_INDEX_URL -u UV_EXTRA_INDEX_URL \ + -u UV_FIND_LINKS -u UV_NO_INDEX -u UV_OFFLINE \ + uv --no-config --no-cache tool install --python 3.12 --reinstall \ + --default-index https://pypi.org/simple/ dist/code_mower-*.whl ``` If an exact-version install fails within a few minutes of publication, retry @@ -1435,12 +1817,14 @@ env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS -u PIP_NO_INDEX \ --json ``` -For a TestPyPI candidate, add: - -```bash - --pip-index-url https://test.pypi.org/simple/ \ - --pip-extra-index-url https://pypi.org/simple/ -``` +Do not rehearse a TestPyPI candidate by adding production PyPI as an extra +index: pip gives the primary index no priority, so production PyPI can satisfy +`code-mower` and the run proves nothing about the candidate. Rehearse the +candidate the way the v1.4.0 runbook does instead -- download the exact +candidate wheel in an isolated, no-deps, TestPyPI-only step, bind its filename +and SHA-256, then rehearse that local wheel with +`--package-spec /path/to/code_mower-1.4.0-py3-none-any.whl` while dependencies +resolve from canonical PyPI. `code-mower release qualify` and `code-mower release campaign` accept the equivalent closed `--package-source testpypi` flag (default: `pypi`) to diff --git a/src/code_mower/migration_install.py b/src/code_mower/migration_install.py index f98b4722..d0775fbe 100644 --- a/src/code_mower/migration_install.py +++ b/src/code_mower/migration_install.py @@ -468,6 +468,33 @@ def _parse_downloaded_artifact_identity(filename: str) -> tuple[str, str]: return _normalize_distribution_name(match.group("name")), match.group("version") +def requested_candidate_version(package_spec: str, *, distribution: str = "code-mower") -> str: + """Return the exact ``distribution`` version a rehearsal spec requested. + + Returns an empty string for any spec whose candidate version is not + derivable -- a source tree, a URL, an inexact requirement, or an artifact + naming another distribution -- so callers can bind the version only when + the spec itself establishes it. + """ + candidate_text = package_spec.strip() + if not candidate_text: + return "" + expected_identity = _normalize_distribution_name(distribution) + if _package_spec_uses_package_index(candidate_text): + try: + identity, version = _parse_exact_name_version_spec(candidate_text) + except ValueError: + return "" + else: + try: + identity, version = _parse_downloaded_artifact_identity( + Path(candidate_text).name + ) + except ValueError: + return "" + return version if identity == expected_identity else "" + + def _pip_download_candidate_command( venv_python: Path, package_spec: str, diff --git a/src/code_mower/migration_readiness.py b/src/code_mower/migration_readiness.py index a007c1dd..ff390947 100644 --- a/src/code_mower/migration_readiness.py +++ b/src/code_mower/migration_readiness.py @@ -30,6 +30,37 @@ ) +def installed_version_problems( + *, + version: str, + distribution_version: str, + requested_version: str = "", +) -> list[str]: + """Require the CLI, the installed distribution, and the requested candidate to agree. + + A ``code-mower `` prefix proves only that some Code Mower CLI is + on the path: it can be an older ambient build whose distribution metadata + says otherwise, and for an exact ``code-mower==VERSION`` or wheel rehearsal + it does not establish that the requested candidate is what got installed. + """ + problems: list[str] = [] + reported = version.strip() + installed = distribution_version.strip() + if not installed: + problems.append("installed distribution version is missing") + if not reported: + problems.append("CLI version output is missing") + if installed and reported != f"code-mower {installed}": + problems.append( + "CLI version output does not match the installed distribution version" + ) + if requested_version and installed and requested_version != installed: + problems.append( + "installed distribution version does not match the requested candidate" + ) + return problems + + def first_user_artifacts(toy_repo: Path) -> dict[str, str]: return {key: str(toy_repo / relative_path) for key, relative_path in FIRST_USER_ARTIFACTS} @@ -131,6 +162,8 @@ def first_user_readiness_scorecard( outputs: Path, version: str, steps: Sequence[dict[str, Any]], + distribution_version: str = "", + requested_version: str = "", ) -> dict[str, Any]: artifacts = first_user_artifacts(toy_repo) generated_dir = toy_repo / ".code-mower.generated" @@ -139,13 +172,24 @@ def first_user_readiness_scorecard( cloud_export_payload = _read_json_file(Path(artifacts["cloud_export"])) dogfood_payload = _read_json_file(dogfood_path) dogfood_upload = dogfood_payload.get("upload") if isinstance(dogfood_payload, dict) else None + version_problems = installed_version_problems( + version=version, + distribution_version=distribution_version, + requested_version=requested_version, + ) checks = [ _readiness_check( check_id="package-installed", - title="Package installs and exposes the CLI", - status="pass" if version.startswith("code-mower ") else "fail", + title="Installed CLI version matches the installed distribution", + status="pass" if not version_problems else "fail", evidence=version, + detail={ + "cli_version": version, + "distribution_version": distribution_version, + "requested_version": requested_version, + "problems": version_problems, + }, ), _readiness_check( check_id="easy-init-generated", diff --git a/src/code_mower/migration_rehearsal.py b/src/code_mower/migration_rehearsal.py index aff78a9f..bd064afb 100644 --- a/src/code_mower/migration_rehearsal.py +++ b/src/code_mower/migration_rehearsal.py @@ -40,6 +40,7 @@ _venv_python, _write_json, _write_public_rehearsal_toy_repo, + requested_candidate_version, ) from code_mower.migration_readiness import ( FIRST_USER_ARTIFACTS as FIRST_USER_ARTIFACTS, @@ -76,6 +77,7 @@ _venv_python, _write_json, _write_public_rehearsal_toy_repo, + requested_candidate_version, ) from .migration_readiness import ( FIRST_USER_ARTIFACTS as FIRST_USER_ARTIFACTS, @@ -114,6 +116,7 @@ "_write_public_rehearsal_toy_repo", "_write_rehearsal_auto_discovery_fixture", "render_package_install_rehearsal_text", + "requested_candidate_version", "run_package_install_rehearsal", ] @@ -548,6 +551,18 @@ def run_package_install_rehearsal( steps=steps, timeout=timeout, ).stdout.strip() + distribution_version = _run_rehearsal_step( + [ + str(venv_python), + "-c", + 'import importlib.metadata as m; print(m.version("code-mower"))', + ], + cwd=work_dir, + env=None, + steps=steps, + timeout=timeout, + ).stdout.strip() + requested_version = requested_candidate_version(requested_package_spec) env = os.environ.copy() env["PATH"] = f"{code_mower_bin.parent}{os.pathsep}{env.get('PATH', '')}" @@ -864,6 +879,8 @@ def run_package_install_rehearsal( outputs=outputs, version=version, steps=steps, + distribution_version=distribution_version, + requested_version=requested_version, ) _write_json(outputs / "first-user-readiness.json", readiness) if readiness.get("status") != "pass": @@ -892,6 +909,8 @@ def run_package_install_rehearsal( "venv_dir": str(venv_dir), "code_mower_bin": str(code_mower_bin), "version": version, + "distribution_version": distribution_version, + "requested_version": requested_version, "toy_repo": str(toy_repo), "first_user_artifacts": _first_user_artifacts(toy_repo), "first_user_readiness": readiness, diff --git a/src/code_mower/release_readiness.py b/src/code_mower/release_readiness.py index 19fd532c..3cc8c7cf 100644 --- a/src/code_mower/release_readiness.py +++ b/src/code_mower/release_readiness.py @@ -379,13 +379,16 @@ def _post_merge_runbook_assertions(version: str, release_tag: str) -> tuple[str, 'if run.get("workflowName") != EXPECTED_WORKFLOW:', 'if run.get("event") != event:', 'if run.get("headSha") != head_sha:', + # A commit can carry more than one tag, so each run is also bound to the + # release tag it was dispatched for. + 'if run.get("headBranch") != head_branch:', 'if run.get("status") != "completed" or run.get("conclusion") != "success":', 'problems.append(f"{job_name} is {actual}, expected skipped")', 'problems.append(f"{job_name} is {actual}, expected success")', - '"$NO_PUBLISH_RUN_ID" workflow_dispatch "$RELEASE_SHA" skipped skipped', - '"$TESTPYPI_RUN_ID" workflow_dispatch "$RELEASE_SHA" success skipped', - '"$PYPI_RUN_ID" workflow_dispatch "$RELEASE_SHA" skipped success', - '"$RELEASE_EVENT_RUN_ID" release "$RELEASE_SHA" skipped skipped', + f'"$NO_PUBLISH_RUN_ID" workflow_dispatch "$RELEASE_SHA" {release_tag} skipped skipped', + f'"$TESTPYPI_RUN_ID" workflow_dispatch "$RELEASE_SHA" {release_tag} success skipped', + f'"$PYPI_RUN_ID" workflow_dispatch "$RELEASE_SHA" {release_tag} skipped success', + f'"$RELEASE_EVENT_RUN_ID" release "$RELEASE_SHA" {release_tag} skipped skipped', # TestPyPI is the exclusive source of the candidate artifacts. f"python3.12 -m pip --isolated download code-mower=={version}", "--index-url https://test.pypi.org/simple/ --dest \"$TESTPYPI_DIST_DIR\"", @@ -407,9 +410,28 @@ def _post_merge_runbook_assertions(version: str, release_tag: str) -> tuple[str, # The post-create verification is unconditional: it runs for a release # this runbook created as well as one it found already present. 'assert_release_assets.py" created', + # Notes come from the clean checkout of the exact release commit, and + # the published body and title are compared with that file. + '--notes-file "$CODE_MOWER_RELEASE_CHECKOUT/docs/v140-release-notes.md"', + 'notes_path = Path(os.environ["CODE_MOWER_RELEASE_CHECKOUT"]) / RELEASE_NOTES_RELPATH', + 'problems.append("release notes in the exact checkout are empty")', + 'problems.append("release body does not match the exact checkout release notes")', + 'problems.append("release title is not the expected v1.4.0 title")', # Hosted Devin readiness is required, not reported. "--set-transport devin=devin_api_v3", - 'raise SystemExit(f"hosted Devin readiness is blocked: {blocked}")', + 'raise SystemExit(f"hosted Devin readiness is blocked: {problems}")', + # The doctor report is validated before any row is indexed, so a + # falsified verdict, a malformed row, or a duplicate identity cannot + # stand in for a hosted posture. + 'if not isinstance(report, dict) or report.get("mode") != "doctor":', + 'if report.get("status") not in {"pass", "warn"}:', + 'if summary.get("failures") != 0:', + 'raise SystemExit("hosted Devin doctor check list is not a list")', + 'problems.append(f"doctor check {name!r} failed")', + 'problems.append(f"unexpected Devin check {name!r}")', + 'problems.append(f"Devin check {name!r} appears more than once")', + 'problems.append(f"Devin checks are missing {missing}")', + 'problems.append(f"Devin check {name!r} is {statuses.get(name)!r}")', # A reported `skip` on permissions is only acceptable with the account # owner's separately supplied confirmation. 'if permissions == "skip" and owner_confirmed != "confirmed":', @@ -480,19 +502,60 @@ def _post_merge_runbook_assertions(version: str, release_tag: str) -> tuple[str, # variables, required to be nonempty, and never printed. 'test -n "$CODE_MOWER_CLOUD_TEAM_ID"', 'test -n "$CODE_MOWER_INSTALL_ID"', + # A forgotten placeholder, and an identity the selected install profile + # does not hold, both fail before the service is probed. + ': "${CODE_MOWER_CLOUD_TEAM_ID:?private cloud team id is required}"', + ': "${CODE_MOWER_INSTALL_ID:?private cloud install id is required}"', + 'case "$CODE_MOWER_CLOUD_TEAM_ID" in REPLACE_WITH_*) exit 1 ;; esac', + 'case "$CODE_MOWER_INSTALL_ID" in REPLACE_WITH_*) exit 1 ;; esac', + 'resolution = resolve_cloud_token(token_env=DEFAULT_TOKEN_ENV, install_id=install_id)', + 'problems.append("the selected install profile stores a different install identity")', + 'problems.append("the selected install profile stores a different team identity")', + 'raise SystemExit(f"cloud identity is not bound to the selected profile: {problems}")', + 'grep -q \'"cloud_identity": "bound"\' "$CLOUD_DIR/identity.json"', '--install-id "$CODE_MOWER_INSTALL_ID"', '--team-id "$CODE_MOWER_CLOUD_TEAM_ID"', - # The cloud service itself is probed and parsed before either upload. - 'code-mower cloud doctor --install-id "$CODE_MOWER_INSTALL_ID"', + # The cloud service itself is probed and parsed before either upload, + # against a newly created empty bundle directory so the expected check + # inventory is deterministic. + 'CLOUD_DOCTOR_BUNDLE_DIR="$(mktemp -d', + 'code-mower cloud doctor "$CLOUD_DOCTOR_BUNDLE_DIR"', '--probe-service --json >"$CLOUD_DIR/doctor.json"', - 'REQUIRED_CLOUD_CHECKS = ("endpoint", "service", "token")', + 'PASSING_CLOUD_CHECKS = ("endpoint", "service", "token")', + 'EXPECTED_CLOUD_CHECKS = frozenset(PASSING_CLOUD_CHECKS) | {"bundle"}', 'if report.get("mode") != "cloud-doctor":', 'if report.get("failures") != 0:', + 'raise SystemExit("cloud doctor check list is not a list")', 'if name in statuses:', 'problems.append(f"cloud doctor {name} check is {statuses.get(name)!r}")', + # Health comes from the raw rows, so falsified aggregate fields cannot + # hide a degraded, missing, extra, or duplicated check. + 'problems.append(f"cloud doctor reported unexpected checks {unexpected}")', + 'problems.append(f"cloud doctor is missing checks {missing}")', + 'if statuses.get("bundle") != "warn":', + 'if raw_failures or raw_failures != report.get("failures"):', 'raise SystemExit(f"cloud service readiness is not a pass: {problems}")', - # Both metadata-only uploads are previewed, applied, and correlated. + # Both metadata-only uploads are previewed, accepted, applied, and + # correlated. The preview verdict is a required file, so the applied + # mutation cannot run on an unvalidated payload. '--team-id "$CODE_MOWER_CLOUD_TEAM_ID" --yes --json', + '>"$CLOUD_DIR/campaign-preflight.json"', + 'raise SystemExit(f"campaign upload preview is not an acceptable payload: {problems}")', + '"campaign_preview": "accepted",', + 'grep -q \'"campaign_preview": "accepted"\' "$CLOUD_DIR/campaign-preflight.json"', + '>"$CLOUD_DIR/board-preflight.json"', + 'raise SystemExit(f"board snapshot preview is not an acceptable payload: {problems}")', + '"board_preview": "accepted",', + 'grep -q \'"board_preview": "accepted"\' "$CLOUD_DIR/board-preflight.json"', + # Every preview and applied producer must name the endpoint the probe + # actually reached, so a re-resolved install profile cannot be accepted. + 'probed_endpoint = str(load("doctor.json").get("endpoint") or "")', + 'problems.append("the probed cloud endpoint was not recorded")', + 'problems.append("campaign upload preview does not target the probed service")', + 'problems.append("campaign applied upload does not target the probed service")', + 'problems.append("board snapshot preview does not target the probed service")', + 'problems.append("board upload preview does not target the probed service")', + 'problems.append("board applied upload does not target the probed service")', 'CAMPAIGN_UPLOAD_SCHEMA = "code_mower.releaseCampaignUpload.v1"', 'if payload.get("schema") != CAMPAIGN_UPLOAD_SCHEMA:', 'if payload.get("mode") != "release-campaign-upload":', @@ -522,16 +585,119 @@ def _post_merge_runbook_assertions(version: str, release_tag: str) -> tuple[str, 'if manifest.get("repo_slug") != EXPECTED_REPO_SLUG:', 'if event.get("repo_slug") != EXPECTED_REPO_SLUG:', 'if event_types != ["board_snapshot"] or len(events) != 1:', + # Malformed rows are reported, never filtered, so one valid event plus + # anything else cannot look like a single-event bundle. + 'problems.append("board bundle does not carry exactly one structured event")', 'if manifest.get("included_reports"):', 'if event.get("schema") != EVENT_SCHEMA or not str(event.get("event_id") or ""):', 'if dimensions.get("snapshot_schema") != SNAPSHOT_SCHEMA:', 'if preview.get("event_count") != len(events) or preview.get("event_count") != 1:', - 'if preview.get("event_types") != EXPECTED_EVENT_TYPES:', + # Generic `cloud upload --dry-run` emits no event-type map, so exact + # event types come from the digest-bound manifest instead. + 'if event_type_counts != EXPECTED_EVENT_TYPES:', 'if not 200 <= int(applied.get("status") or 0) < 300:', + # The Board snapshot reads a checkout, and its event carries no commit + # or dirty-state field, so the checkout is re-bound immediately before. + 'test -z "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" status --porcelain --untracked-files=all)"', + # The Board snapshot's own nested cloud doctor must be healthy on its + # raw rows; only the producer's skipped service probe is tolerated. + 'snapshot_doctor = snapshot.get("doctor")', + 'if snapshot_doctor.get("mode") != "cloud-doctor":', + 'if snapshot_doctor.get("status") != "pass":', + 'if status not in {"pass", "warn", "skip"}:', + 'problems.append(f"board snapshot doctor check {name!r} appears more than once")', + 'problems.append(f"board snapshot doctor is missing checks {missing_doctor}")', + 'if doctor_failures != snapshot_doctor.get("failures"):', + # Generic cloud upload returns no event identifiers, so the applied + # upload is bound to the previewed bundle by digest. + '>"$CLOUD_DIR/board-bundle-before-apply.sha256"', + '>"$CLOUD_DIR/board-bundle-after-apply.sha256"', + 'before_digest = digest_of(cloud_dir / "board-bundle-before-apply.sha256")', + 'after_digest = digest_of(cloud_dir / "board-bundle-after-apply.sha256")', + 'current_digest = hashlib.sha256(manifest_path.read_bytes()).hexdigest()', + 'if before_digest != after_digest or current_digest != before_digest:', + 'problems.append("board bundle changed between the preview and the applied upload")', 'raise SystemExit(f"board snapshot upload is not a verified gate: {problems}")', ) +def _post_merge_runbook_gate_orders() -> tuple[tuple[str, tuple[str, ...]], ...]: + """Ordered command sequences the post-merge runbook must publish in order. + + Presence alone cannot show that a network mutation is gated: a validator + that runs after its own `--yes` command has already uploaded whatever the + producer built. Each sequence below fails if a preflight validator is + deleted or moved behind the mutation it guards. + """ + + return ( + ( + "campaign-preflight-before-apply", + ( + 'grep -q \'"cloud_identity": "bound"\' "$CLOUD_DIR/identity.json"', + '>"$CLOUD_DIR/campaign-preview.json"', + 'raise SystemExit(f"campaign upload preview is not an acceptable payload: {problems}")', + 'grep -q \'"campaign_preview": "accepted"\' "$CLOUD_DIR/campaign-preflight.json"', + '--team-id "$CODE_MOWER_CLOUD_TEAM_ID" --yes --json', + 'raise SystemExit(f"campaign metadata upload is not a verified gate: {problems}")', + ), + ), + ( + "board-preflight-before-apply", + ( + 'test -z "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" status --porcelain --untracked-files=all)"', + "code-mower cloud board-snapshot", + '--dry-run --json \\\n >"$CLOUD_DIR/board-preview.json"', + 'raise SystemExit(f"board snapshot preview is not an acceptable payload: {problems}")', + 'grep -q \'"board_preview": "accepted"\' "$CLOUD_DIR/board-preflight.json"', + '>"$CLOUD_DIR/board-bundle-before-apply.sha256"', + '--install-id "$CODE_MOWER_INSTALL_ID" --yes --json', + '>"$CLOUD_DIR/board-bundle-after-apply.sha256"', + 'raise SystemExit(f"board snapshot upload is not a verified gate: {problems}")', + ), + ), + ) + + +BOARD_SNAPSHOT_COMMAND = "code-mower cloud board-snapshot" +BOARD_SNAPSHOT_BINDING_ASSERTIONS = ( + 'test "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA"', + 'test -z "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" status --porcelain --untracked-files=all)"', +) + + +def _board_snapshot_binding_problems(runbook_doc: str) -> list[str]: + """Require the checkout re-binding immediately before the Board snapshot. + + The snapshot event carries no commit or dirty-state field, so an assertion + made earlier in the runbook cannot speak for a checkout that moved since. + Both assertions must be the last commands before the snapshot runs. + """ + + start = runbook_doc.find(BOARD_SNAPSHOT_COMMAND) + if start < 0: + return [f"{BOARD_SNAPSHOT_COMMAND} is missing"] + preceding = [ + line.strip() + for line in runbook_doc[:start].splitlines() + if line.strip() and not line.strip().startswith("#") + ] + if preceding[-2:] != list(BOARD_SNAPSHOT_BINDING_ASSERTIONS): + return [ + "the release checkout is not re-bound immediately before " + f"{BOARD_SNAPSHOT_COMMAND}" + ] + return [] + + +def _post_merge_gate_order_problems(runbook_doc: str) -> list[str]: + problems: list[str] = [] + for name, sequence in _post_merge_runbook_gate_orders(): + for marker in _unordered_markers(runbook_doc, sequence): + problems.append(f"{name}: {marker}") + return problems + + def _forbidden_runbook_markers() -> tuple[str, ...]: """Commands the post-merge runbook must not publish.""" @@ -547,6 +713,67 @@ def _forbidden_runbook_markers() -> tuple[str, ...]: ) +def _forbidden_release_document_markers() -> tuple[str, ...]: + """Commands the release document must not publish anywhere. + + pip gives its primary index no priority over an extra index, so a TestPyPI + candidate rehearsal that also names production PyPI cannot show which index + supplied the package. + """ + + return ( + "--pip-extra-index-url https://pypi.org/simple/", + "--refresh-package code-mower", + ) + + +UV_ISOLATION_SITE_COUNT = 2 +UV_ISOLATION_ENVIRONMENT = ( + "-u UV_INDEX", + "-u UV_DEFAULT_INDEX", + "-u UV_INDEX_URL", + "-u UV_EXTRA_INDEX_URL", + "-u UV_FIND_LINKS", + "-u UV_NO_INDEX", + "-u UV_OFFLINE", +) +UV_ISOLATION_ARGUMENTS = ( + "uv --no-config --no-cache tool install", + "--default-index https://pypi.org/simple/", +) + + +def _uv_isolation_problems(release_doc: str) -> list[str]: + """Report documented `uv tool install` commands that could resolve elsewhere. + + An ambient `UV_INDEX`, find-links value, offline flag, or project + configuration redirects uv the same way a `pip.conf` redirects pip, and only + `--no-cache` bypasses the cache. + """ + + sites = [ + command for command in _shell_commands(release_doc) if "tool install" in command + ] + problems: list[str] = [] + if len(sites) != UV_ISOLATION_SITE_COUNT: + problems.append( + f"expected {UV_ISOLATION_SITE_COUNT} uv tool installs, found {len(sites)}" + ) + for command in sites: + label = command.split("uv ", 1)[-1].strip()[:72] + problems.extend( + f"{label} does not clear {fragment}" + for fragment in UV_ISOLATION_ENVIRONMENT + if fragment not in command + ) + problems.extend( + f"{label} does not use {fragment}" + for fragment in UV_ISOLATION_ARGUMENTS + if fragment not in command + ) + return problems + + PIP_ISOLATION_SITE_COUNT = 8 PIP_ISOLATION_ENVIRONMENT = ( "-u PIP_INDEX_URL", @@ -642,6 +869,43 @@ def _post_merge_pip_isolation_problems(runbook_doc: str) -> list[str]: return problems +FAIL_FAST_CONTRACT = "set -euo pipefail" + + +def _post_merge_fail_fast_problems(runbook_doc: str) -> list[str]: + """Report ordered runbook Bash blocks that keep running after a failure. + + A block without the fail-fast contract reports only its last command's exit + status, so a failed assertion in the middle of a block can be masked by a + later command that happens to succeed. + """ + + problems: list[str] = [] + in_block = False + is_bash = False + block: list[str] = [] + index = 0 + for line in runbook_doc.splitlines(): + stripped = line.strip() + if stripped.startswith("```"): + if not in_block: + in_block = True + is_bash = stripped == "```bash" + block = [] + continue + in_block = False + if is_bash: + index += 1 + if block and block[0] != FAIL_FAST_CONTRACT: + problems.append(f"bash block {index} does not {FAIL_FAST_CONTRACT}") + continue + if in_block and is_bash and stripped: + block.append(stripped) + if not index: + problems.append("the post-merge runbook publishes no bash block") + return problems + + def _document_section(text: str, heading: str) -> str: """Return one Markdown section, so a gate reads the runbook and nothing else.""" @@ -785,12 +1049,25 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: if runbook_assertions else ["unknown release version"] ) + release_doc = docs.get("docs/pypi-release.md", "") forbidden_runbook_markers = [ marker for marker in _forbidden_runbook_markers() if marker in runbook_doc + ] + [ + marker + for marker in _forbidden_release_document_markers() + if marker in release_doc ] + uv_isolation_problems = _uv_isolation_problems(release_doc) if release_doc else [] pip_isolation_problems = ( _post_merge_pip_isolation_problems(runbook_doc) if runbook_doc else [] ) + gate_order_problems = ( + _post_merge_gate_order_problems(runbook_doc) + + _board_snapshot_binding_problems(runbook_doc) + + _post_merge_fail_fast_problems(runbook_doc) + if runbook_doc + else ["unknown release version"] + ) public_hygiene_blobs = { relative_path: text.lower() for relative_path, text in public_hygiene_docs.items() @@ -1078,6 +1355,8 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: not missing_runbook_assertions and not forbidden_runbook_markers and not pip_isolation_problems + and not uv_isolation_problems + and not gate_order_problems ) else "fail" ), @@ -1088,6 +1367,8 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: "missing_assertions": missing_runbook_assertions, "forbidden_commands": forbidden_runbook_markers, "pip_isolation_problems": pip_isolation_problems[:20], + "uv_isolation_problems": uv_isolation_problems[:20], + "gate_order_problems": gate_order_problems[:20], }, ), _release_check( diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py index 9622afe1..e5680cdc 100644 --- a/tests/test_release_hygiene.py +++ b/tests/test_release_hygiene.py @@ -2,6 +2,7 @@ import json import copy +import hashlib import importlib.util import os import re @@ -49,6 +50,7 @@ from code_mower import init as code_mower_init from code_mower import migration as code_mower_migration from code_mower import migration_install as code_mower_migration_install +from code_mower import migration_readiness as code_mower_migration_readiness from code_mower import next_steps from code_mower import package as code_mower_package from code_mower import package_content as code_mower_package_content @@ -63,6 +65,8 @@ from code_mower.provider_registry import REFERENCE_PROVIDERS from scripts import guard_package_workflows, privacy_scan +CLOUD_ENDPOINT = "https://cloud.example.invalid" + class ReleaseHygieneTests(unittest.TestCase): def test_version_is_current_supervised_pilot_release(self) -> None: @@ -7314,6 +7318,7 @@ def test_first_user_readiness_scorecard_passes_for_complete_artifacts(self) -> N toy_repo=toy_repo, outputs=outputs, version="code-mower 0.6.0b3", + distribution_version="0.6.0b3", steps=[ { "command": ["code-mower", "doctor", "--easy", "--json"], @@ -7393,6 +7398,7 @@ def test_first_user_readiness_scorecard_fails_open_on_privacy_regression(self) - toy_repo=toy_repo, outputs=outputs, version="code-mower 0.6.0b3", + distribution_version="0.6.0b3", steps=[ { "command": ["code-mower", "doctor", "--easy", "--json"], @@ -7455,6 +7461,7 @@ def test_first_user_readiness_scorecard_fails_open_on_malformed_cloud_export( toy_repo=toy_repo, outputs=outputs, version="code-mower 0.6.0b3", + distribution_version="0.6.0b3", steps=[ { "command": ["code-mower", "doctor", "--easy", "--json"], @@ -7467,6 +7474,146 @@ def test_first_user_readiness_scorecard_fails_open_on_malformed_cloud_export( failed = {check["id"] for check in scorecard["checks"] if check["status"] == "fail"} self.assertEqual(failed, {"cloud-export-metadata-bundle"}) + def test_installed_version_binding_rejects_mismatched_and_missing_versions( + self, + ) -> None: + problems = code_mower_migration_readiness.installed_version_problems + + self.assertEqual( + problems(version="code-mower 1.4.0", distribution_version="1.4.0"), + [], + ) + self.assertEqual( + problems( + version="code-mower 1.4.0", + distribution_version="1.4.0", + requested_version="1.4.0", + ), + [], + ) + self.assertTrue( + problems(version="code-mower 1.3.0", distribution_version="1.4.0") + ) + self.assertTrue( + problems( + version="code-mower 1.4.0", + distribution_version="1.4.0", + requested_version="1.4.1", + ) + ) + self.assertTrue(problems(version="code-mower", distribution_version="1.4.0")) + self.assertTrue( + problems(version="code-mower 1.4.0", distribution_version="") + ) + self.assertTrue(problems(version="", distribution_version="")) + + def test_requested_candidate_version_binds_specs_and_wheels(self) -> None: + requested = code_mower_migration_install.requested_candidate_version + + self.assertEqual(requested("code-mower==1.4.0"), "1.4.0") + self.assertEqual( + requested("/tmp/dist/code_mower-1.4.0-py3-none-any.whl"), + "1.4.0", + ) + self.assertEqual(requested("/tmp/dist/code_mower-1.4.0.tar.gz"), "1.4.0") + self.assertEqual(requested("code-mower"), "") + self.assertEqual(requested("code-mower>=1.4.0"), "") + self.assertEqual(requested("."), "") + self.assertEqual( + requested("/tmp/dist/other_package-1.4.0-py3-none-any.whl"), + "", + ) + + def test_first_user_readiness_scorecard_fails_on_version_mismatch(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + toy_repo = root / "toy-repo" + outputs = root / "outputs" + generated = toy_repo / ".code-mower.generated" + (generated / "tools").mkdir(parents=True) + outputs.mkdir() + for path in ( + generated / "code-mower-init-plan.json", + generated / "smoke-tests.sh", + generated / "tools" / "code_mower", + ): + path.write_text("ok\n", encoding="utf-8") + artifacts = code_mower_migration._first_user_artifacts(toy_repo) + for key in ( + "draft_calibration_corpus", + "draft_reviewer_value_report", + "reviewer_value_report", + ): + Path(artifacts[key]).parent.mkdir(parents=True, exist_ok=True) + Path(artifacts[key]).write_text("ok\n", encoding="utf-8") + Path(artifacts["cloud_export"]).write_text( + json.dumps( + { + "mode": "cloud-export", + "included_reports": [ + {"kind": "reviewer-metrics"}, + {"kind": "lane-policy"}, + {"kind": "value-report"}, + ], + "upload_ready": True, + } + ), + encoding="utf-8", + ) + dry_run_upload = { + "mode": "cloud-upload-dry-run", + "privacy_mode": "metadata_and_reports", + "requires_yes": True, + "would_upload": False, + "excluded_content": sorted( + code_mower_migration.PRIVACY_EXCLUDED_CONTENT + ), + } + Path(artifacts["cloud_upload_dry_run"]).write_text( + json.dumps(dry_run_upload), + encoding="utf-8", + ) + Path(artifacts["cloud_dogfood_dry_run"]).write_text( + json.dumps({"status": "dry_run", "upload": dry_run_upload}), + encoding="utf-8", + ) + steps = [ + { + "command": ["code-mower", "doctor", "--easy", "--json"], + "returncode": 0, + } + ] + + stale_cli = code_mower_migration._first_user_readiness_scorecard( + toy_repo=toy_repo, + outputs=outputs, + version="code-mower 1.3.0", + distribution_version="1.4.0", + steps=steps, + ) + wrong_candidate = code_mower_migration._first_user_readiness_scorecard( + toy_repo=toy_repo, + outputs=outputs, + version="code-mower 1.4.0", + distribution_version="1.4.0", + requested_version="1.4.1", + steps=steps, + ) + missing_metadata = code_mower_migration._first_user_readiness_scorecard( + toy_repo=toy_repo, + outputs=outputs, + version="code-mower 1.4.0", + distribution_version="", + steps=steps, + ) + + for scorecard in (stale_cli, wrong_candidate, missing_metadata): + self.assertEqual(scorecard["status"], "fail") + failed = { + check["id"] for check in scorecard["checks"] if check["status"] == "fail" + } + self.assertEqual(failed, {"package-installed"}) + def test_rehearsal_step_to_file_writes_stdout_and_creates_parent(self) -> None: with tempfile.TemporaryDirectory() as tmp: output = Path(tmp) / "nested" / "stdout.json" @@ -8254,10 +8401,10 @@ def test_release_readiness_fails_when_job_posture_assertions_are_deleted(self) - def test_release_readiness_fails_when_a_run_assertion_is_dropped(self) -> None: for removed in ( - '"$NO_PUBLISH_RUN_ID" workflow_dispatch "$RELEASE_SHA" skipped skipped', - '"$TESTPYPI_RUN_ID" workflow_dispatch "$RELEASE_SHA" success skipped', - '"$PYPI_RUN_ID" workflow_dispatch "$RELEASE_SHA" skipped success', - '"$RELEASE_EVENT_RUN_ID" release "$RELEASE_SHA" skipped skipped', + '"$NO_PUBLISH_RUN_ID" workflow_dispatch "$RELEASE_SHA" v1.4.0 skipped skipped', + '"$TESTPYPI_RUN_ID" workflow_dispatch "$RELEASE_SHA" v1.4.0 success skipped', + '"$PYPI_RUN_ID" workflow_dispatch "$RELEASE_SHA" v1.4.0 skipped success', + '"$RELEASE_EVENT_RUN_ID" release "$RELEASE_SHA" v1.4.0 skipped skipped', ): with self.subTest(removed=removed): check = self._asserted_runbook_check( @@ -8304,6 +8451,7 @@ def test_release_readiness_fails_when_a_workflow_identity_assertion_is_deleted( 'if run.get("workflowName") != EXPECTED_WORKFLOW:', 'if run.get("event") != event:', 'if run.get("headSha") != head_sha:', + 'if run.get("headBranch") != head_branch:', 'if run.get("status") != "completed" or run.get("conclusion") != "success":', 'problems.append(f"{job_name} is {actual}, expected success")', ): @@ -8527,7 +8675,11 @@ def test_release_readiness_requires_board_snapshot_schema_assertions(self) -> No 'if event.get("repo_slug") != EXPECTED_REPO_SLUG:', 'if preview.get("event_count") != len(events)' ' or preview.get("event_count") != 1:', - 'if preview.get("event_types") != EXPECTED_EVENT_TYPES:', + # Generic `cloud upload --dry-run` emits no event-type map, so the + # exact event types come from the digest-bound manifest. + "if event_type_counts != EXPECTED_EVENT_TYPES:", + "current_digest = hashlib.sha256(manifest_path.read_bytes()).hexdigest()", + "if before_digest != after_digest or current_digest != before_digest:", 'if event.get("schema") != EVENT_SCHEMA' ' or not str(event.get("event_id") or ""):', 'if dimensions.get("snapshot_schema") != SNAPSHOT_SCHEMA:', @@ -8616,11 +8768,12 @@ def test_runbook_requires_hosted_devin_readiness_checks(self) -> None: self.assertIn("--set-transport devin=devin_api_v3", runbook) self.assertIn( - 'blocked = [name for name in required if checks.get(name) != "pass"]', + 'problems.append(f"Devin check {name!r} is {statuses.get(name)!r}")', runbook, ) self.assertIn( - 'raise SystemExit(f"hosted Devin readiness is blocked: {blocked}")', runbook + 'raise SystemExit(f"hosted Devin readiness is blocked: {problems}")', + runbook, ) for check_id in sorted(required): with self.subTest(check_id=check_id): @@ -8673,9 +8826,10 @@ def test_release_readiness_requires_duplicate_identity_rejection(self) -> None: def test_release_readiness_requires_the_cloud_doctor_probe(self) -> None: for assertion in ( - 'code-mower cloud doctor --install-id "$CODE_MOWER_INSTALL_ID"', + 'code-mower cloud doctor "$CLOUD_DOCTOR_BUNDLE_DIR"', '--probe-service --json >"$CLOUD_DIR/doctor.json"', - 'REQUIRED_CLOUD_CHECKS = ("endpoint", "service", "token")', + 'PASSING_CLOUD_CHECKS = ("endpoint", "service", "token")', + 'EXPECTED_CLOUD_CHECKS = frozenset(PASSING_CLOUD_CHECKS) | {"bundle"}', 'if report.get("mode") != "cloud-doctor":', 'if report.get("failures") != 0:', 'problems.append(f"cloud doctor {name} check is {statuses.get(name)!r}")', @@ -8778,6 +8932,154 @@ def test_release_readiness_fails_when_a_pip_site_disappears(self) -> None: check["detail"]["pip_isolation_problems"], ) + def test_ordered_runbook_bash_blocks_declare_the_fail_fast_contract(self) -> None: + blocks = re.findall(r"```bash\n(.*?)```", self._runbook_section(), flags=re.DOTALL) + + self.assertTrue(blocks) + for index, block in enumerate(blocks, start=1): + with self.subTest(block=index): + self.assertEqual(block.splitlines()[0], "set -euo pipefail") + + def test_the_fail_fast_contract_stops_a_masked_assertion(self) -> None: + script = 'test "a" = "b"\necho reached\n' + masked = subprocess.run(["bash", "-c", script], capture_output=True, text=True) + guarded = subprocess.run( + ["bash", "-c", f"set -euo pipefail\n{script}"], + capture_output=True, + text=True, + ) + + self.assertEqual(masked.returncode, 0) + self.assertNotEqual(guarded.returncode, 0) + self.assertNotIn("reached", guarded.stdout) + + def test_release_readiness_fails_when_a_bash_block_loses_fail_fast(self) -> None: + check = self._asserted_runbook_check( + lambda doc: doc.replace( + "set -euo pipefail\nRELEASE_EVENT_RUN_ID=", + "RELEASE_EVENT_RUN_ID=", + ) + ) + + self.assertEqual(check["status"], "fail") + self.assertTrue( + any( + "does not set -euo pipefail" in problem + for problem in check["detail"]["gate_order_problems"] + ), + check["detail"]["gate_order_problems"], + ) + + def test_release_readiness_fails_when_release_notes_are_not_checkout_bound( + self, + ) -> None: + for old, new in ( + ( + '--notes-file "$CODE_MOWER_RELEASE_CHECKOUT/docs/v140-release-notes.md"', + "--notes-file docs/v140-release-notes.md", + ), + ( + 'problems.append("release body does not match the exact checkout release notes")', + "pass", + ), + ( + 'problems.append("release title is not the expected v1.4.0 title")', + "pass", + ), + ): + with self.subTest(old=old): + check = self._asserted_runbook_check( + lambda doc, old=old, new=new: doc.replace(old, new) + ) + + self.assertEqual(check["status"], "fail") + self.assertIn(old, check["detail"]["missing_assertions"]) + + def test_release_readiness_fails_when_cloud_identity_binding_is_deleted( + self, + ) -> None: + for assertion in ( + ': "${CODE_MOWER_CLOUD_TEAM_ID:?private cloud team id is required}"', + ': "${CODE_MOWER_INSTALL_ID:?private cloud install id is required}"', + 'case "$CODE_MOWER_CLOUD_TEAM_ID" in REPLACE_WITH_*) exit 1 ;; esac', + 'case "$CODE_MOWER_INSTALL_ID" in REPLACE_WITH_*) exit 1 ;; esac', + 'problems.append("the selected install profile stores a different install identity")', + 'problems.append("the selected install profile stores a different team identity")', + 'grep -q \'"cloud_identity": "bound"\' "$CLOUD_DIR/identity.json"', + ): + with self.subTest(assertion=assertion): + check = self._asserted_runbook_check( + lambda doc, assertion=assertion: doc.replace(assertion, "true") + ) + + self.assertEqual(check["status"], "fail") + self.assertIn(assertion, check["detail"]["missing_assertions"]) + + def _run_release_run_gate(self, run: dict, head_branch: str) -> subprocess.CompletedProcess: + snippet = self._runbook_python_snippet("BUILD_JOBS") + with tempfile.TemporaryDirectory() as tmp: + scratch = Path(tmp) + stub = scratch / "gh" + stub.write_text( + "#!/bin/sh\n" + f"cat {shlex.quote(str(scratch / 'run.json'))}\n", + encoding="utf-8", + ) + stub.chmod(0o755) + (scratch / "run.json").write_text(json.dumps(run), encoding="utf-8") + script = scratch / "assert_release_run.py" + script.write_text(snippet, encoding="utf-8") + return subprocess.run( + [ + sys.executable, + str(script), + "codemower-ai/code-mower", + "4242", + "workflow_dispatch", + "c0ffee", + head_branch, + "success", + "skipped", + ], + capture_output=True, + text=True, + env={**os.environ, "PATH": f"{scratch}{os.pathsep}{os.environ['PATH']}"}, + ) + + def _release_run_payload(self, head_branch: str) -> dict: + return { + "databaseId": 4242, + "workflowName": "Code Mower Release", + "headSha": "c0ffee", + "headBranch": head_branch, + "event": "workflow_dispatch", + "status": "completed", + "conclusion": "success", + "url": "https://example.invalid/run/4242", + "jobs": [ + {"name": "build-distributions", "conclusion": "success"}, + {"name": "verify-distributions", "conclusion": "success"}, + {"name": "publish-testpypi", "conclusion": "success"}, + {"name": "publish-pypi", "conclusion": "skipped"}, + ], + } + + def test_release_run_gate_accepts_the_expected_tag_branch(self) -> None: + completed = self._run_release_run_gate( + self._release_run_payload("v1.4.0"), "v1.4.0" + ) + + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertEqual(json.loads(completed.stdout)["head_branch"], "v1.4.0") + + def test_release_run_gate_rejects_another_tag_on_the_same_commit(self) -> None: + completed = self._run_release_run_gate( + self._release_run_payload("v1.4.0rc1"), "v1.4.0" + ) + + self.assertNotEqual(completed.returncode, 0) + self.assertIn("head branch is v1.4.0rc1, not v1.4.0", completed.stderr) + def _runbook_python_snippet(self, marker: str) -> str: snippets = [ snippet @@ -9097,7 +9399,7 @@ def _cloud_doctor_report(self, checks: list[dict] | None = None, **overrides) -> return report def _run_cloud_doctor_gate(self, report: dict) -> subprocess.CompletedProcess: - snippet = self._runbook_python_snippet("REQUIRED_CLOUD_CHECKS") + snippet = self._runbook_python_snippet("PASSING_CLOUD_CHECKS") with tempfile.TemporaryDirectory() as tmp: cloud_dir = Path(tmp) (cloud_dir / "doctor.json").write_text( @@ -9152,6 +9454,18 @@ def _board_snapshot_fixtures(self) -> tuple[dict, dict, dict, dict]: "repo_slug": "codemower-ai/code-mower", "event_count": 1, "export": {"event_types": {"board_snapshot": 1}, "included_reports": 0}, + "upload": {"endpoint": CLOUD_ENDPOINT}, + "doctor": { + "mode": "cloud-doctor", + "status": "pass", + "failures": 0, + "checks": [ + {"name": "endpoint", "status": "pass"}, + {"name": "service", "status": "skip"}, + {"name": "token", "status": "pass"}, + {"name": "bundle", "status": "pass"}, + ], + }, } manifest = { "schema": "code_mower.cloudBenchmarkBundle.v1", @@ -9176,20 +9490,39 @@ def _board_snapshot_fixtures(self) -> tuple[dict, dict, dict, dict]: "upload_mode": "metadata_only", "report_count": 0, "event_count": 1, - "event_types": {"board_snapshot": 1}, + "endpoint": CLOUD_ENDPOINT, + } + applied = { + "mode": "cloud-upload", + "status": 200, + "endpoint": CLOUD_ENDPOINT, } - applied = {"mode": "cloud-upload", "status": 200} return snapshot, manifest, preview, applied def _run_board_snapshot_gate( - self, snapshot: dict, manifest: dict, preview: dict, applied: dict + self, + snapshot: dict, + manifest: dict, + preview: dict, + applied: dict, + *, + phase: str = "preflight", + applied_manifest: dict | None = None, ) -> subprocess.CompletedProcess: - snippet = self._runbook_python_snippet("board snapshot upload is not a verified") + marker = ( + "board snapshot preview is not an acceptable payload" + if phase == "preflight" + else "board snapshot upload is not a verified" + ) + snippet = self._runbook_python_snippet(marker) with tempfile.TemporaryDirectory() as tmp: cloud_dir = Path(tmp) / "cloud" bundle_dir = Path(tmp) / "bundle" cloud_dir.mkdir() bundle_dir.mkdir() + (cloud_dir / "doctor.json").write_text( + json.dumps({"endpoint": CLOUD_ENDPOINT}), encoding="utf-8" + ) (cloud_dir / "board-snapshot.json").write_text( json.dumps(snapshot), encoding="utf-8" ) @@ -9199,8 +9532,19 @@ def _run_board_snapshot_gate( (cloud_dir / "board-applied.json").write_text( json.dumps(applied), encoding="utf-8" ) - (bundle_dir / "code-mower-cloud-bundle.json").write_text( - json.dumps(manifest), encoding="utf-8" + manifest_path = bundle_dir / "code-mower-cloud-bundle.json" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + digest = hashlib.sha256(manifest_path.read_bytes()).hexdigest() + (cloud_dir / "board-bundle-before-apply.sha256").write_text( + f"{digest} {manifest_path}\n", encoding="utf-8" + ) + if applied_manifest is not None: + manifest_path.write_text( + json.dumps(applied_manifest), encoding="utf-8" + ) + after = hashlib.sha256(manifest_path.read_bytes()).hexdigest() + (cloud_dir / "board-bundle-after-apply.sha256").write_text( + f"{after} {manifest_path}\n", encoding="utf-8" ) return self._run_runbook_snippet( snippet, @@ -9211,10 +9555,48 @@ def _run_board_snapshot_gate( ) def test_runbook_board_snapshot_gate_accepts_the_release_bundle(self) -> None: - result = self._run_board_snapshot_gate(*self._board_snapshot_fixtures()) + fixtures = self._board_snapshot_fixtures() - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(json.loads(result.stdout)["board_upload"], "accepted") + preflight = self._run_board_snapshot_gate(*fixtures) + applied = self._run_board_snapshot_gate(*fixtures, phase="applied") + + self.assertEqual(preflight.returncode, 0, preflight.stderr) + self.assertEqual(json.loads(preflight.stdout)["board_preview"], "accepted") + self.assertEqual(applied.returncode, 0, applied.stderr) + self.assertEqual(json.loads(applied.stdout)["board_upload"], "accepted") + + def test_runbook_board_snapshot_gate_rejects_a_swapped_bundle(self) -> None: + snapshot, manifest, preview, applied = self._board_snapshot_fixtures() + swapped = copy.deepcopy(manifest) + swapped["events"][0]["event_id"] = "evt-board-2" + + result = self._run_board_snapshot_gate( + snapshot, manifest, preview, applied, phase="applied", + applied_manifest=swapped, + ) + + self.assertEqual(result.returncode, 1) + self.assertIn("board bundle changed", result.stderr) + + def test_runbook_board_snapshot_gate_rejects_a_nested_doctor_failure(self) -> None: + snapshot, manifest, preview, applied = self._board_snapshot_fixtures() + degraded = copy.deepcopy(snapshot) + degraded["doctor"]["checks"][1]["status"] = "fail" + + result = self._run_board_snapshot_gate(degraded, manifest, preview, applied) + + self.assertEqual(result.returncode, 1) + self.assertIn("board snapshot doctor check", result.stderr) + + def test_runbook_board_snapshot_gate_rejects_an_extra_event(self) -> None: + snapshot, manifest, preview, applied = self._board_snapshot_fixtures() + padded = copy.deepcopy(manifest) + padded["events"].append("board_snapshot") + + result = self._run_board_snapshot_gate(snapshot, padded, preview, applied) + + self.assertEqual(result.returncode, 1) + self.assertIn("exactly one structured event", result.stderr) def test_runbook_board_snapshot_gate_rejects_another_repository(self) -> None: snapshot, manifest, preview, applied = self._board_snapshot_fixtures() @@ -9241,7 +9623,6 @@ def test_runbook_board_snapshot_gate_rejects_another_repository(self) -> None: def test_runbook_board_snapshot_gate_rejects_uncorrelated_previews(self) -> None: snapshot, manifest, preview, applied = self._board_snapshot_fixtures() cases = { - "event_types": dict(preview, event_types={"adoption_run": 1}), "event_count": dict(preview, event_count=2), } for label, candidate in cases.items(): @@ -9525,7 +9906,10 @@ def test_current_release_docs_record_package_index_procedure(self) -> None: pypi_release, ) for text in (first_user, pypi_release): - self.assertIn("--reinstall --refresh-package code-mower", text) + # `--refresh-package` refreshes metadata only, so the cache-bypass + # contract is the isolated, uncached, explicitly indexed install. + self.assertNotIn("--reinstall --refresh-package code-mower", text) + self.assertIn("uv --no-config --no-cache tool install", text) self.assertIn("dist/code_mower-*.whl", text) self.assertIn("no matching distribution", text) self.assertIn( From 56c373ae027afd4fea21e30d31ff0a97eff29138 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 06:17:28 +0000 Subject: [PATCH 09/20] Bind the applied Board upload to the pre/post-preview manifest digest and make the nested snapshot doctor contract exact Co-Authored-By: bot_apk --- docs/pypi-release.md | 67 ++++++++++++++--- src/code_mower/release_readiness.py | 25 +++++- tests/test_release_hygiene.py | 113 ++++++++++++++++++++++------ 3 files changed, 168 insertions(+), 37 deletions(-) diff --git a/docs/pypi-release.md b/docs/pypi-release.md index 23bb3838..a0ff45af 100644 --- a/docs/pypi-release.md +++ b/docs/pypi-release.md @@ -1501,11 +1501,16 @@ code-mower cloud board-snapshot \ --output-dir "$BOARD_SNAPSHOT_DIR" \ --install-id "$CODE_MOWER_INSTALL_ID" --team-id "$CODE_MOWER_CLOUD_TEAM_ID" --json \ >"$CLOUD_DIR/board-snapshot.json" +sha256sum "$BOARD_SNAPSHOT_DIR/code-mower-cloud-bundle.json" \ + >"$CLOUD_DIR/board-bundle-before-preview.sha256" code-mower cloud upload "$BOARD_SNAPSHOT_DIR" \ --install-id "$CODE_MOWER_INSTALL_ID" --dry-run --json \ >"$CLOUD_DIR/board-preview.json" +sha256sum "$BOARD_SNAPSHOT_DIR/code-mower-cloud-bundle.json" \ + >"$CLOUD_DIR/board-bundle-after-preview.sha256" CLOUD_DIR="$CLOUD_DIR" BOARD_SNAPSHOT_DIR="$BOARD_SNAPSHOT_DIR" "$RELEASE_PYTHON" - \ >"$CLOUD_DIR/board-preflight.json" <<'PY' +import hashlib import json import os from pathlib import Path @@ -1518,7 +1523,15 @@ def load(path: Path) -> dict: return json.loads(path.read_text(encoding="utf-8")) +def digest_of(path: Path) -> str: + return path.read_text(encoding="utf-8").split()[0] + + BUNDLE_SCHEMA = "code_mower.cloudBenchmarkBundle.v1" +# The nested snapshot doctor runs without --probe-service, so its inventory is +# exactly these rows with that one expected skip. +SNAPSHOT_DOCTOR_PASSING = ("endpoint", "token", "bundle", "model-provenance") +SNAPSHOT_DOCTOR_CHECKS = frozenset(SNAPSHOT_DOCTOR_PASSING) | {"service"} EVENT_SCHEMA = "code_mower.benchmarkEvent.v1" SNAPSHOT_SCHEMA = "code_mower.cloudBoardSnapshot.v1" EXPECTED_REPO_SLUG = "codemower-ai/code-mower" @@ -1588,12 +1601,12 @@ if snapshot_doctor.get("failures") != 0: problems.append( f"board snapshot doctor reports {snapshot_doctor.get('failures')!r} failures" ) +if snapshot_doctor.get("endpoint") != probed_endpoint: + problems.append("board snapshot doctor does not target the probed service") doctor_rows = snapshot_doctor.get("checks") if not isinstance(doctor_rows, list) or not doctor_rows: problems.append("board snapshot doctor check list is not a nonempty list") doctor_rows = [] -# The nested report is produced without --probe-service, so the skipped service -# probe stays acceptable while any failing or unrecognized status does not. doctor_statuses = {} doctor_failures = 0 for row in doctor_rows: @@ -1605,17 +1618,28 @@ for row in doctor_rows: if not isinstance(name, str) or not isinstance(status, str): problems.append("board snapshot doctor check identity is malformed") continue - if status not in {"pass", "warn", "skip"}: + if status == "fail": doctor_failures += 1 - problems.append(f"board snapshot doctor check {name!r} is {status!r}") if name in doctor_statuses: problems.append(f"board snapshot doctor check {name!r} appears more than once") continue doctor_statuses[name] = status -missing_doctor = sorted({"endpoint", "service", "token", "bundle"} - set(doctor_statuses)) +unexpected_doctor = sorted(set(doctor_statuses) - SNAPSHOT_DOCTOR_CHECKS) +missing_doctor = sorted(SNAPSHOT_DOCTOR_CHECKS - set(doctor_statuses)) +if unexpected_doctor: + problems.append(f"board snapshot doctor reported unexpected checks {unexpected_doctor}") if missing_doctor: problems.append(f"board snapshot doctor is missing checks {missing_doctor}") -if doctor_failures != snapshot_doctor.get("failures"): +for name in SNAPSHOT_DOCTOR_PASSING: + if doctor_statuses.get(name) != "pass": + problems.append( + f"board snapshot doctor {name} check is {doctor_statuses.get(name)!r}" + ) +if doctor_statuses.get("service") != "skip": + problems.append( + f"board snapshot doctor service check is {doctor_statuses.get('service')!r}" + ) +if doctor_failures or doctor_failures != snapshot_doctor.get("failures"): problems.append(f"board snapshot doctor rows report {doctor_failures} failures") if preview.get("mode") != "cloud-upload-dry-run" or preview.get("would_upload") is not False: problems.append(f"board preview mode is {preview.get('mode')!r}") @@ -1638,11 +1662,19 @@ if snapshot_preview.get("endpoint") != probed_endpoint: problems.append("board snapshot preview does not target the probed service") if preview.get("endpoint") != probed_endpoint: problems.append("board upload preview does not target the probed service") +# The manifest is hashed on both sides of the preview command, so a bundle that +# is replaced while the preview runs cannot become the previewed identity. +before_preview = digest_of(cloud_dir / "board-bundle-before-preview.sha256") +after_preview = digest_of(cloud_dir / "board-bundle-after-preview.sha256") +previewed_digest = hashlib.sha256(manifest_path.read_bytes()).hexdigest() +if before_preview != after_preview or previewed_digest != before_preview: + problems.append("board bundle changed while the preview was generated") if problems: raise SystemExit(f"board snapshot preview is not an acceptable payload: {problems}") print(json.dumps({ "board_preview": "accepted", "event_types": event_types, + "previewed_digest": previewed_digest, "reports": 0, })) PY @@ -1699,10 +1731,17 @@ if not probed_endpoint or applied.get("endpoint") != probed_endpoint: # Generic cloud upload returns no event identifiers, so the applied upload is # bound to the inspected bundle by digest instead: the manifest may not change # between the accepted preview and the applied upload. +previewed_digest = str(load(cloud_dir / "board-preflight.json").get("previewed_digest") or "") before_digest = digest_of(cloud_dir / "board-bundle-before-apply.sha256") after_digest = digest_of(cloud_dir / "board-bundle-after-apply.sha256") current_digest = hashlib.sha256(manifest_path.read_bytes()).hexdigest() -if before_digest != after_digest or current_digest != before_digest: +if not previewed_digest: + problems.append("the previewed board bundle identity was not retained") +if ( + before_digest != after_digest + or current_digest != before_digest + or current_digest != previewed_digest +): problems.append("board bundle changed between the preview and the applied upload") if problems: raise SystemExit(f"board snapshot upload is not a verified gate: {problems}") @@ -1721,11 +1760,15 @@ repository, not only the top-level summary, so a truthful summary cannot cover evidence gathered from another repository. The health of both `cloud doctor` reports -- the standalone probe and the one nested in the Board snapshot -- is derived from their raw check rows, so a falsified `status` or `failures` cannot -hide a degraded check; the nested report is produced without `--probe-service`, -so its skipped service probe stays acceptable. Generic `cloud upload` returns -no event identifiers, so the applied upload is bound to the previewed bundle by -the SHA-256 digest taken immediately after the preview and recomputed -immediately after the upload. +hide a degraded check. The nested report's inventory is exact: `endpoint`, +`token`, `bundle`, and `model-provenance` must pass, `service` must be the skip +this producer causes by not requesting `--probe-service`, and its endpoint must +equal the privately probed one. Generic `cloud upload` returns no event +identifiers, so the applied upload is bound to the previewed bundle by SHA-256: +the manifest is hashed immediately before and immediately after the dry-run +preview, those two values must match before the preview can be accepted, that +matching value is the previewed identity, and the digest recomputed after the +upload must equal it. ## Cache Bypass And Propagation Triage diff --git a/src/code_mower/release_readiness.py b/src/code_mower/release_readiness.py index 3cc8c7cf..06fe43f7 100644 --- a/src/code_mower/release_readiness.py +++ b/src/code_mower/release_readiness.py @@ -604,18 +604,33 @@ def _post_merge_runbook_assertions(version: str, release_tag: str) -> tuple[str, 'snapshot_doctor = snapshot.get("doctor")', 'if snapshot_doctor.get("mode") != "cloud-doctor":', 'if snapshot_doctor.get("status") != "pass":', - 'if status not in {"pass", "warn", "skip"}:', + 'SNAPSHOT_DOCTOR_PASSING = ("endpoint", "token", "bundle", "model-provenance")', + 'SNAPSHOT_DOCTOR_CHECKS = frozenset(SNAPSHOT_DOCTOR_PASSING) | {"service"}', + 'problems.append("board snapshot doctor does not target the probed service")', 'problems.append(f"board snapshot doctor check {name!r} appears more than once")', + 'problems.append(f"board snapshot doctor reported unexpected checks {unexpected_doctor}")', 'problems.append(f"board snapshot doctor is missing checks {missing_doctor}")', - 'if doctor_failures != snapshot_doctor.get("failures"):', + 'f"board snapshot doctor {name} check is {doctor_statuses.get(name)!r}"', + 'if doctor_statuses.get("service") != "skip":', + 'if doctor_failures or doctor_failures != snapshot_doctor.get("failures"):', # Generic cloud upload returns no event identifiers, so the applied - # upload is bound to the previewed bundle by digest. + # upload is bound to the previewed bundle by digest, and the bundle is + # hashed on both sides of the preview command too. + '>"$CLOUD_DIR/board-bundle-before-preview.sha256"', + '>"$CLOUD_DIR/board-bundle-after-preview.sha256"', + 'before_preview = digest_of(cloud_dir / "board-bundle-before-preview.sha256")', + 'after_preview = digest_of(cloud_dir / "board-bundle-after-preview.sha256")', + 'previewed_digest = hashlib.sha256(manifest_path.read_bytes()).hexdigest()', + 'if before_preview != after_preview or previewed_digest != before_preview:', + 'problems.append("board bundle changed while the preview was generated")', + '"previewed_digest": previewed_digest,', '>"$CLOUD_DIR/board-bundle-before-apply.sha256"', '>"$CLOUD_DIR/board-bundle-after-apply.sha256"', 'before_digest = digest_of(cloud_dir / "board-bundle-before-apply.sha256")', 'after_digest = digest_of(cloud_dir / "board-bundle-after-apply.sha256")', 'current_digest = hashlib.sha256(manifest_path.read_bytes()).hexdigest()', - 'if before_digest != after_digest or current_digest != before_digest:', + 'problems.append("the previewed board bundle identity was not retained")', + "or current_digest != previewed_digest", 'problems.append("board bundle changed between the preview and the applied upload")', 'raise SystemExit(f"board snapshot upload is not a verified gate: {problems}")', ) @@ -647,7 +662,9 @@ def _post_merge_runbook_gate_orders() -> tuple[tuple[str, tuple[str, ...]], ...] ( 'test -z "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" status --porcelain --untracked-files=all)"', "code-mower cloud board-snapshot", + '>"$CLOUD_DIR/board-bundle-before-preview.sha256"', '--dry-run --json \\\n >"$CLOUD_DIR/board-preview.json"', + '>"$CLOUD_DIR/board-bundle-after-preview.sha256"', 'raise SystemExit(f"board snapshot preview is not an acceptable payload: {problems}")', 'grep -q \'"board_preview": "accepted"\' "$CLOUD_DIR/board-preflight.json"', '>"$CLOUD_DIR/board-bundle-before-apply.sha256"', diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py index e5680cdc..44ed1c8b 100644 --- a/tests/test_release_hygiene.py +++ b/tests/test_release_hygiene.py @@ -8679,7 +8679,11 @@ def test_release_readiness_requires_board_snapshot_schema_assertions(self) -> No # exact event types come from the digest-bound manifest. "if event_type_counts != EXPECTED_EVENT_TYPES:", "current_digest = hashlib.sha256(manifest_path.read_bytes()).hexdigest()", - "if before_digest != after_digest or current_digest != before_digest:", + "or current_digest != previewed_digest", + 'before_preview = digest_of(cloud_dir / "board-bundle-before-preview.sha256")', + "if before_preview != after_preview or previewed_digest != before_preview:", + 'SNAPSHOT_DOCTOR_PASSING = ("endpoint", "token", "bundle", "model-provenance")', + 'if doctor_statuses.get("service") != "skip":', 'if event.get("schema") != EVENT_SCHEMA' ' or not str(event.get("event_id") or ""):', 'if dimensions.get("snapshot_schema") != SNAPSHOT_SCHEMA:', @@ -9459,11 +9463,13 @@ def _board_snapshot_fixtures(self) -> tuple[dict, dict, dict, dict]: "mode": "cloud-doctor", "status": "pass", "failures": 0, + "endpoint": CLOUD_ENDPOINT, "checks": [ {"name": "endpoint", "status": "pass"}, {"name": "service", "status": "skip"}, {"name": "token", "status": "pass"}, {"name": "bundle", "status": "pass"}, + {"name": "model-provenance", "status": "pass"}, ], }, } @@ -9507,7 +9513,9 @@ def _run_board_snapshot_gate( applied: dict, *, phase: str = "preflight", - applied_manifest: dict | None = None, + preview_swap: dict | None = None, + pre_apply_swap: dict | None = None, + during_apply_swap: dict | None = None, ) -> subprocess.CompletedProcess: marker = ( "board snapshot preview is not an acceptable payload" @@ -9533,19 +9541,33 @@ def _run_board_snapshot_gate( json.dumps(applied), encoding="utf-8" ) manifest_path = bundle_dir / "code-mower-cloud-bundle.json" + + def write_digest(name: str) -> str: + digest = hashlib.sha256(manifest_path.read_bytes()).hexdigest() + (cloud_dir / name).write_text( + f"{digest} {manifest_path}\n", encoding="utf-8" + ) + return digest + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") - digest = hashlib.sha256(manifest_path.read_bytes()).hexdigest() - (cloud_dir / "board-bundle-before-apply.sha256").write_text( - f"{digest} {manifest_path}\n", encoding="utf-8" + write_digest("board-bundle-before-preview.sha256") + if preview_swap is not None: + manifest_path.write_text(json.dumps(preview_swap), encoding="utf-8") + previewed = write_digest("board-bundle-after-preview.sha256") + (cloud_dir / "board-preflight.json").write_text( + json.dumps( + {"board_preview": "accepted", "previewed_digest": previewed} + ), + encoding="utf-8", ) - if applied_manifest is not None: + if pre_apply_swap is not None: + manifest_path.write_text(json.dumps(pre_apply_swap), encoding="utf-8") + write_digest("board-bundle-before-apply.sha256") + if during_apply_swap is not None: manifest_path.write_text( - json.dumps(applied_manifest), encoding="utf-8" + json.dumps(during_apply_swap), encoding="utf-8" ) - after = hashlib.sha256(manifest_path.read_bytes()).hexdigest() - (cloud_dir / "board-bundle-after-apply.sha256").write_text( - f"{after} {manifest_path}\n", encoding="utf-8" - ) + write_digest("board-bundle-after-apply.sha256") return self._run_runbook_snippet( snippet, { @@ -9570,23 +9592,72 @@ def test_runbook_board_snapshot_gate_rejects_a_swapped_bundle(self) -> None: swapped = copy.deepcopy(manifest) swapped["events"][0]["event_id"] = "evt-board-2" - result = self._run_board_snapshot_gate( - snapshot, manifest, preview, applied, phase="applied", - applied_manifest=swapped, + during_preview = self._run_board_snapshot_gate( + snapshot, manifest, preview, applied, preview_swap=swapped + ) + before_apply = self._run_board_snapshot_gate( + snapshot, + manifest, + preview, + applied, + phase="applied", + pre_apply_swap=swapped, + ) + during_apply = self._run_board_snapshot_gate( + snapshot, + manifest, + preview, + applied, + phase="applied", + during_apply_swap=swapped, ) - self.assertEqual(result.returncode, 1) - self.assertIn("board bundle changed", result.stderr) + self.assertEqual(during_preview.returncode, 1) + self.assertIn( + "board bundle changed while the preview was generated", + during_preview.stderr, + ) + for result in (before_apply, during_apply): + self.assertEqual(result.returncode, 1) + self.assertIn( + "board bundle changed between the preview and the applied upload", + result.stderr, + ) def test_runbook_board_snapshot_gate_rejects_a_nested_doctor_failure(self) -> None: snapshot, manifest, preview, applied = self._board_snapshot_fixtures() - degraded = copy.deepcopy(snapshot) - degraded["doctor"]["checks"][1]["status"] = "fail" + cases = {} + for label, status in (("failed", "fail"), ("warned", "warn")): + degraded = copy.deepcopy(snapshot) + degraded["doctor"]["checks"][4]["status"] = status + cases[label] = degraded + probing = copy.deepcopy(snapshot) + probing["doctor"]["checks"][1]["status"] = "pass" + cases["service_not_skipped"] = probing + missing = copy.deepcopy(snapshot) + missing["doctor"]["checks"] = missing["doctor"]["checks"][:4] + cases["missing"] = missing + unknown = copy.deepcopy(snapshot) + unknown["doctor"]["checks"].append({"name": "owner.mood", "status": "pass"}) + cases["unknown"] = unknown + duplicate = copy.deepcopy(snapshot) + duplicate["doctor"]["checks"].insert(0, {"name": "token", "status": "fail"}) + cases["duplicate"] = duplicate + malformed = copy.deepcopy(snapshot) + malformed["doctor"]["checks"].append("token=pass") + cases["malformed"] = malformed + elsewhere = copy.deepcopy(snapshot) + elsewhere["doctor"]["endpoint"] = "https://other.example.invalid" + cases["endpoint"] = elsewhere - result = self._run_board_snapshot_gate(degraded, manifest, preview, applied) + for label, candidate in cases.items(): + with self.subTest(doctor=label): + result = self._run_board_snapshot_gate( + candidate, manifest, preview, applied + ) - self.assertEqual(result.returncode, 1) - self.assertIn("board snapshot doctor check", result.stderr) + self.assertEqual(result.returncode, 1) + self.assertIn("board snapshot doctor", result.stderr) def test_runbook_board_snapshot_gate_rejects_an_extra_event(self) -> None: snapshot, manifest, preview, applied = self._board_snapshot_fixtures() From 4fa2df0182a54f2fcb838d4671c709ca40c78c5e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 06:29:29 +0000 Subject: [PATCH 10/20] Require source-exclusive TestPyPI candidate qualification in readiness and docs Co-Authored-By: bot_apk --- docs/first-user-install-rehearsal.md | 44 ++++++++-------- src/code_mower/release_readiness.py | 44 +++++++++++----- tests/test_release_hygiene.py | 78 ++++++++++++++++++++++++++-- 3 files changed, 128 insertions(+), 38 deletions(-) diff --git a/docs/first-user-install-rehearsal.md b/docs/first-user-install-rehearsal.md index 787852bd..557837d3 100644 --- a/docs/first-user-install-rehearsal.md +++ b/docs/first-user-install-rehearsal.md @@ -79,17 +79,20 @@ code-mower migration package-install-rehearsal \ --json ``` -For a TestPyPI candidate, use the exact version published to TestPyPI: +For a TestPyPI candidate, qualify it from TestPyPI alone. pip gives its primary +index no priority over an extra index, so a single install naming both TestPyPI +and production PyPI cannot show which index supplied the candidate; +`--package-source testpypi` downloads the candidate with TestPyPI as its only +index, verifies the artifact identity and version, and resolves dependencies +separately from canonical PyPI: ```bash -code-mower migration package-install-rehearsal \ +code-mower release qualify \ + --release-tag v \ --package-spec code-mower== \ - --allow-package-index \ - --upgrade-pip \ - --pip-index-url https://test.pypi.org/simple/ \ - --pip-extra-index-url https://pypi.org/simple/ \ - --python "$(command -v python3.12)" \ - --json + --output /tmp/code-mower-testpypi-qualification.json \ + --package-source testpypi \ + --execute ``` Use `--pip-install-attempts` and `--pip-retry-delay` only when you need to tune @@ -322,25 +325,22 @@ gh workflow run release.yml \ -f publish_pypi=false ``` -After that workflow run finishes, record its workflow run link and rehearse the -candidate from TestPyPI. This command only shows that the candidate installs -alongside canonical PyPI: pip does not prefer `--index-url` over -`--extra-index-url`, so use the source-exclusive TestPyPI fetch in the -[v1.4.0 post-merge release runbook](pypi-release.md#6-publish-testpypi-only-then-rehearse-the-exact-candidate-from-testpypi) -when TestPyPI must be proven as the artifact source: +After that workflow run finishes, record its workflow run link and qualify the +candidate from TestPyPI alone, so the accepted artifact is proven to come from +TestPyPI rather than from an identically versioned package on another index: ```bash -code-mower migration package-install-rehearsal \ +code-mower release qualify \ + --release-tag v1.4.0 \ --package-spec code-mower==1.4.0 \ - --allow-package-index \ - --upgrade-pip \ - --pip-index-url https://test.pypi.org/simple/ \ - --pip-extra-index-url https://pypi.org/simple/ \ - --python "$(command -v python3.12)" \ - --work-dir /tmp/code-mower-v140-testpypi-rehearsal \ - --json + --output /tmp/code-mower-v140-testpypi-qualification.json \ + --package-source testpypi \ + --execute ``` +The equivalent no-deps TestPyPI download plus local-artifact rehearsal is in the +[v1.4.0 post-merge release runbook](pypi-release.md#6-publish-testpypi-only-then-rehearse-the-exact-candidate-from-testpypi). + Then run `release.yml` for production PyPI only: ```bash diff --git a/src/code_mower/release_readiness.py b/src/code_mower/release_readiness.py index 06fe43f7..47b388ed 100644 --- a/src/code_mower/release_readiness.py +++ b/src/code_mower/release_readiness.py @@ -29,6 +29,17 @@ "docs/first-user-install-rehearsal.md", "docs/public-release-checklist.md", ) +# Current public release and install guidance that must never present a +# TestPyPI candidate install that also names production PyPI as an extra index. +CURRENT_PACKAGE_INDEX_GUIDANCE_DOC_PATHS = ( + "README.md", + "docs/quickstart.md", + "docs/try-in-10-minutes.md", + "docs/first-user-install-rehearsal.md", + "docs/pypi-release.md", + "docs/public-release-checklist.md", +) +UNSAFE_MULTI_INDEX_MARKER = "--pip-extra-index-url https://pypi.org/simple/" PUBLIC_HYGIENE_DOC_PATHS = ( "CODE_OF_CONDUCT.md", "CONTRIBUTING.md", @@ -1110,6 +1121,13 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: for relative_path, text in docs.items() if package_index_spec and package_index_spec in text ] + # pip gives its primary index no priority, so a candidate rehearsal that + # also names production PyPI cannot show which index supplied the package. + unsafe_package_index_docs = [ + relative_path + for relative_path in CURRENT_PACKAGE_INDEX_GUIDANCE_DOC_PATHS + if UNSAFE_MULTI_INDEX_MARKER in docs.get(relative_path, "") + ] missing_public_hygiene_docs = [ relative_path for relative_path, text in public_hygiene_docs.items() @@ -1305,21 +1323,24 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: ), _release_check( check_id="package-index-rehearsal-docs", - title="Package-index rehearsal is documented", + title="Package-index rehearsal is documented with source-exclusive TestPyPI", status=( "pass" if ( package_index_spec and package_index_spec in doc_blob and "--allow-package-index" in doc_blob - and "--pip-index-url https://test.pypi.org/simple/" in doc_blob - and "--pip-extra-index-url https://pypi.org/simple/" in doc_blob + and "--package-source testpypi" in doc_blob and "package-install-rehearsal" in doc_blob + and not unsafe_package_index_docs ) else "fail" ), evidence=package_index_spec or "missing version", - detail={"docs": package_index_docs}, + detail={ + "docs": package_index_docs, + "unsafe_multi_index_docs": unsafe_package_index_docs, + }, ), _release_check( check_id="ci-package-install-rehearsal", @@ -1444,16 +1465,15 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: "url": PACKAGE_INDEX_SETUP_URLS["release_workflow"], }, { - "id": "testpypi-install-rehearsal", - "title": "Install from TestPyPI in a fresh toy repo", + "id": "testpypi-source-exclusive-qualification", + "title": "Qualify the TestPyPI candidate from TestPyPI alone", "command": ( - "code-mower migration package-install-rehearsal " + "code-mower release qualify " + f"--release-tag {release_workflow_ref} " f"--package-spec {package_index_spec} " - "--allow-package-index " - "--upgrade-pip " - "--pip-index-url https://test.pypi.org/simple/ " - "--pip-extra-index-url https://pypi.org/simple/ " - "--json" + "--output result.json " + "--package-source testpypi " + "--execute" ), "url": PACKAGE_INDEX_SETUP_URLS["testpypi_project"], }, diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py index 44ed1c8b..fe6ee428 100644 --- a/tests/test_release_hygiene.py +++ b/tests/test_release_hygiene.py @@ -7949,9 +7949,24 @@ def test_release_readiness_reports_package_index_promotion_gate(self) -> None: self.assertNotIn("--ref main", commands["publish-testpypi-candidate"]) self.assertIn("publish_testpypi=true", commands["publish-testpypi-candidate"]) self.assertIn("publish_pypi=false", commands["publish-testpypi-candidate"]) - self.assertIn("--allow-package-index", commands["testpypi-install-rehearsal"]) - self.assertIn("--upgrade-pip", commands["testpypi-install-rehearsal"]) - self.assertIn("--pip-index-url https://test.pypi.org/simple/", commands["testpypi-install-rehearsal"]) + qualification = commands["testpypi-source-exclusive-qualification"] + self.assertNotIn("testpypi-install-rehearsal", commands) + self.assertIn("code-mower release qualify", qualification) + self.assertIn("--release-tag v1.4.0", qualification) + self.assertIn("--package-spec code-mower==1.4.0", qualification) + self.assertIn("--package-source testpypi", qualification) + self.assertIn("--execute", qualification) + self.assertNotIn("--pip-extra-index-url", qualification) + self.assertNotIn("--pip-index-url", qualification) + self.assertEqual( + check_ids["package-index-rehearsal-docs"]["status"], "pass" + ) + self.assertEqual( + check_ids["package-index-rehearsal-docs"]["detail"][ + "unsafe_multi_index_docs" + ], + [], + ) self.assertEqual( payload["setup_urls"]["github_environments"], "https://github.com/codemower-ai/code-mower/settings/environments", @@ -7965,10 +7980,65 @@ def test_release_readiness_reports_package_index_promotion_gate(self) -> None: "https://github.com/codemower-ai/code-mower/actions/workflows/release.yml", ) self.assertEqual( - urls["testpypi-install-rehearsal"], + urls["testpypi-source-exclusive-qualification"], "https://test.pypi.org/project/code-mower/", ) + def _package_index_docs_check( + self, mutate: Callable[[dict[str, str]], None] + ) -> dict: + docs = release_readiness._release_docs(ROOT) + mutate(docs) + with mock.patch.object(release_readiness, "_release_docs", return_value=docs): + payload = release_readiness.render_release_readiness(ROOT) + checks = {check["id"]: check for check in payload["checks"]} + return checks["package-index-rehearsal-docs"] + + def test_package_index_rehearsal_docs_rejects_unsafe_multi_index_pairing( + self, + ) -> None: + def add_unsafe_pairing(docs: dict[str, str]) -> None: + docs["docs/first-user-install-rehearsal.md"] += ( + "\n```bash\ncode-mower migration package-install-rehearsal " + "--package-spec code-mower==1.4.0 --allow-package-index " + "--pip-index-url https://test.pypi.org/simple/ " + "--pip-extra-index-url https://pypi.org/simple/ --json\n```\n" + ) + + check = self._package_index_docs_check(add_unsafe_pairing) + + self.assertEqual(check["status"], "fail") + self.assertEqual( + check["detail"]["unsafe_multi_index_docs"], + ["docs/first-user-install-rehearsal.md"], + ) + + def test_package_index_rehearsal_docs_requires_source_exclusive_contract( + self, + ) -> None: + def drop_safe_marker(docs: dict[str, str]) -> None: + for relative_path, text in docs.items(): + docs[relative_path] = text.replace("--package-source testpypi", "") + + check = self._package_index_docs_check(drop_safe_marker) + + self.assertEqual(check["status"], "fail") + + def test_public_first_user_docs_omit_unsafe_testpypi_candidate_command( + self, + ) -> None: + doc = (ROOT / "docs" / "first-user-install-rehearsal.md").read_text( + encoding="utf-8" + ) + + self.assertNotIn(release_readiness.UNSAFE_MULTI_INDEX_MARKER, doc) + self.assertNotIn("--pip-index-url https://test.pypi.org/simple/", doc) + self.assertIn("code-mower release qualify", doc) + self.assertIn("--package-source testpypi", doc) + # Ordinary production-PyPI rehearsal guidance stays intact. + self.assertIn("code-mower migration package-install-rehearsal", doc) + self.assertIn("--allow-package-index", doc) + def test_release_readiness_fails_on_materialized_package_version_drift( self, ) -> None: From d6b9a60a6d1bb97b1c92556ee2833021770e82f6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 07:07:39 +0000 Subject: [PATCH 11/20] Bind v1.4.0 release evidence to producer-owned identity and the exact release commit - use one defined RELEASE_CHECKOUT in the ordered runbook and test variable data flow - document source-exclusive TestPyPI qualification and scan that doc for unsafe indexes - resolve the stored install profile with ambient cloud token/endpoint excluded - return manifest digest, event ids and type counts from board-snapshot and cloud upload - record and enforce source git provenance while the board snapshot is collected - gate workflow_dispatch builds and publishes on an expected_sha identity job - treat the PyPI-verified artifact map as immutable release evidence - bind board repository paths to their slugs before the boards restart Co-Authored-By: bot_apk --- .github/workflows/release.yml | 24 +- docs/pypi-release.md | 326 ++++++++++++++---- docs/release-qualification.md | 22 +- src/code_mower/cloud.py | 26 +- src/code_mower/cloud_client/__init__.py | 12 + src/code_mower/cloud_client/events.py | 11 + src/code_mower/cloud_client/git_metadata.py | 83 +++++ src/code_mower/cloud_client/manifest.py | 57 +++- src/code_mower/cloud_client/operations.py | 47 ++- src/code_mower/cloud_client/upload.py | 30 +- src/code_mower/release_qualify.py | 7 +- src/code_mower/release_readiness.py | 201 ++++++++++- tests/test_cloud_client.py | 318 ++++++++++++++++++ tests/test_release_hygiene.py | 355 +++++++++++++++++++- 14 files changed, 1408 insertions(+), 111 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bca8a731..85923f21 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,12 +15,32 @@ on: required: false default: false type: boolean + expected_sha: + description: Exact 40-character release commit this dispatch must build. + required: true + type: string permissions: contents: read jobs: + release-identity: + runs-on: ubuntu-latest + steps: + - name: Assert the dispatched ref and commit are the expected release + if: ${{ github.event_name == 'workflow_dispatch' }} + env: + EXPECTED_SHA: ${{ inputs.expected_sha }} + ACTUAL_SHA: ${{ github.sha }} + ACTUAL_REF: ${{ github.ref }} + run: | + set -euo pipefail + printf '%s\n' "$EXPECTED_SHA" | grep -Eq '^[0-9a-f]{40}$' + test "$ACTUAL_REF" = "refs/tags/v1.4.0" + test "$ACTUAL_SHA" = "$EXPECTED_SHA" + build-distributions: + needs: release-identity runs-on: ubuntu-latest steps: - name: Check out @@ -66,7 +86,7 @@ jobs: python -m twine check dist/* publish-testpypi: - needs: verify-distributions + needs: [release-identity, verify-distributions] runs-on: ubuntu-latest if: ${{ (github.event_name == 'workflow_dispatch' && inputs.publish_testpypi == true) || (github.event_name == 'release' && vars.CODE_MOWER_TESTPYPI_PUBLISH == 'true') }} permissions: @@ -86,7 +106,7 @@ jobs: repository-url: https://test.pypi.org/legacy/ publish-pypi: - needs: verify-distributions + needs: [release-identity, verify-distributions] runs-on: ubuntu-latest if: ${{ (github.event_name == 'workflow_dispatch' && inputs.publish_pypi == true) || (github.event_name == 'release' && vars.CODE_MOWER_PYPI_PUBLISH == 'true') }} permissions: diff --git a/docs/pypi-release.md b/docs/pypi-release.md index a0ff45af..e18cb413 100644 --- a/docs/pypi-release.md +++ b/docs/pypi-release.md @@ -228,9 +228,10 @@ test "$(git ls-remote origin 'refs/tags/v1.4.0^{}' | awk '{print $1}')" = "$RELE ### 4. Install the workflow-run assertion helper Every workflow run below is asserted with this helper: workflow identity, -triggering event, exact head SHA, `success` conclusion, successful -`build-distributions` and `verify-distributions` jobs, and the exact posture of -both publish jobs. A run whose only reported jobs are skipped publish jobs fails. +triggering event, exact head SHA, `success` conclusion, a successful +`release-identity` gate job, successful `build-distributions` and +`verify-distributions` jobs, and the exact posture of both publish jobs. A run +whose only reported jobs are skipped publish jobs fails. A job that is expected to skip must be reported skipped or be absent from the run; a job that is expected to publish must report `success`. @@ -245,7 +246,10 @@ import subprocess import sys EXPECTED_WORKFLOW = "Code Mower Release" -BUILD_JOBS = ("build-distributions", "verify-distributions") +# release-identity is the workflow's fail-fast gate: it proves the dispatched +# ref is the v1.4.0 tag and github.sha equals the expected_sha input, and both +# build and publish jobs depend on it. +BUILD_JOBS = ("release-identity", "build-distributions", "verify-distributions") SKIPPED = {"skipped", "absent"} @@ -307,6 +311,7 @@ def main() -> None: "event": event, "head_sha": head_sha, "head_branch": head_branch, + "release_identity": job_posture(run, "release-identity"), "build_distributions": job_posture(run, "build-distributions"), "verify_distributions": job_posture(run, "verify-distributions"), "publish_testpypi": job_posture(run, "publish-testpypi"), @@ -321,12 +326,18 @@ PY ### 5. Run `release.yml` with no publishing first -Both publish jobs must skip on this run. +Both publish jobs must skip on this run. Every dispatch below passes +`-f expected_sha="$RELEASE_SHA"`, and the workflow's first job, +`release-identity`, fails fast unless the dispatch ref is `refs/tags/v1.4.0` and +`github.sha` equals that exact 40-character commit. `build-distributions`, +`publish-testpypi`, and `publish-pypi` all depend on that job, so a missing, +malformed, or mismatched expected SHA cannot build or publish anything. ```bash set -euo pipefail gh workflow run release.yml --repo "$REPO" --ref v1.4.0 \ - -f publish_testpypi=false -f publish_pypi=false + -f publish_testpypi=false -f publish_pypi=false \ + -f expected_sha="$RELEASE_SHA" NO_PUBLISH_RUN_ID="REPLACE_WITH_EXACT_RUN_ID" gh run watch "$NO_PUBLISH_RUN_ID" --repo "$REPO" --exit-status "$RELEASE_PYTHON" "$RELEASE_ENV/assert_release_run.py" "$REPO" \ @@ -345,7 +356,8 @@ canonical PyPI. ```bash set -euo pipefail gh workflow run release.yml --repo "$REPO" --ref v1.4.0 \ - -f publish_testpypi=true -f publish_pypi=false + -f publish_testpypi=true -f publish_pypi=false \ + -f expected_sha="$RELEASE_SHA" TESTPYPI_RUN_ID="REPLACE_WITH_EXACT_RUN_ID" gh run watch "$TESTPYPI_RUN_ID" --repo "$REPO" --exit-status "$RELEASE_PYTHON" "$RELEASE_ENV/assert_release_run.py" "$REPO" \ @@ -404,7 +416,8 @@ canonical `https://pypi.org/simple/` explicitly with no cache, so no ambient ```bash set -euo pipefail gh workflow run release.yml --repo "$REPO" --ref v1.4.0 \ - -f publish_testpypi=false -f publish_pypi=true + -f publish_testpypi=false -f publish_pypi=true \ + -f expected_sha="$RELEASE_SHA" PYPI_RUN_ID="REPLACE_WITH_EXACT_RUN_ID" gh run watch "$PYPI_RUN_ID" --repo "$REPO" --exit-status "$RELEASE_PYTHON" "$RELEASE_ENV/assert_release_run.py" "$REPO" \ @@ -443,8 +456,10 @@ env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL -u PIP_FIND_LINKS -u PIP_NO_INDEX \ PIP_CONFIG_FILE=/dev/null python3.12 -m pip --isolated download code-mower==1.4.0 \ --no-cache-dir --no-deps --only-binary :all: \ --index-url https://pypi.org/simple/ --dest "$PYPI_DOWNLOAD_DIR" +PYPI_VERIFIED_MAP="$RELEASE_ENV/pypi-verified-artifacts.json" +test ! -e "$PYPI_VERIFIED_MAP" PROD_DIST_DIR="$PROD_DIST_DIR" PYPI_DOWNLOAD_DIR="$PYPI_DOWNLOAD_DIR" \ - "$RELEASE_PYTHON" - <<'PY' + PYPI_VERIFIED_MAP="$PYPI_VERIFIED_MAP" "$RELEASE_PYTHON" - <<'PY' import hashlib import json import os @@ -466,12 +481,22 @@ if set(workflow) != expected or set(published) != expected: raise SystemExit("workflow and PyPI artifact sets differ") if any(workflow[name] != published[name] for name in workflow): raise SystemExit("workflow and PyPI SHA-256 values differ") +# This map is the immutable release artifact evidence: every later local and +# GitHub Release asset check compares against it, never against a freshly +# recomputed map of the mutable download directory. +Path(os.environ["PYPI_VERIFIED_MAP"]).write_text( + json.dumps(workflow, indent=2, sort_keys=True) + "\n", encoding="utf-8" +) print(json.dumps({"artifact_count": len(workflow), "sha256_match": True})) PY +test -s "$PYPI_VERIFIED_MAP" ``` Only continue when the artifact set and every digest match. A mismatch is a -release blocker: do not attach unverified files. +release blocker: do not attach unverified files. The saved +`pypi-verified-artifacts.json` map is written once, at the moment the workflow +artifacts are proven identical to canonical PyPI, and is treated as immutable +release evidence from then on. ### 10. Assert the publish variables are off before creating the Release @@ -531,10 +556,13 @@ PY ### 11. Create the GitHub Release with those exact assets and verify them An existing `v1.4.0` release is never clobbered: inspect it first and stop -unless its tag and its exact asset set and digests already match -`PROD_DIST_DIR`. Install the asset assertion first. It downloads the Release's -own assets and requires the exact filename set and every SHA-256 value to equal -`PROD_DIST_DIR`, with exactly one wheel and one sdist: +unless its tag and its exact asset set and digests already match the saved +PyPI-verified map. Install the asset assertion first. It compares the local +files and the Release's own downloaded assets against +`$PYPI_VERIFIED_MAP` -- not against a freshly recomputed `PROD_DIST_DIR` map -- +and re-resolves the remote peeled `v1.4.0` tag to `$RELEASE_SHA` on every +invocation, including the `pre-create` invocation that runs immediately before +`gh release create`: ```bash set -euo pipefail @@ -562,21 +590,70 @@ def digests(directory: Path) -> dict[str, str]: } +def gh_json(args: list[str]) -> dict: + return json.loads(subprocess.run( + ["gh", *args], check=True, capture_output=True, text=True, + ).stdout) + + +def remote_peeled_tag_sha(repo: str) -> str: + """Resolve the remote v1.4.0 tag to the commit it currently peels to.""" + ref = gh_json(["api", f"repos/{repo}/git/ref/tags/v1.4.0"]) + target = ref.get("object") if isinstance(ref.get("object"), dict) else {} + sha = str(target.get("sha") or "") + if target.get("type") == "tag" and sha: + annotated = gh_json(["api", f"repos/{repo}/git/tags/{sha}"]) + peeled = annotated.get("object") if isinstance(annotated.get("object"), dict) else {} + sha = str(peeled.get("sha") or "") + return sha + + def main() -> None: mode = sys.argv[1] repo = os.environ["REPO"] release_sha = os.environ["RELEASE_SHA"] + # The immutable map saved when the workflow artifacts were proven identical + # to canonical PyPI. A file replaced in PROD_DIST_DIR afterwards cannot + # become release evidence. + verified = json.loads( + Path(os.environ["PYPI_VERIFIED_MAP"]).read_text(encoding="utf-8") + ) local = digests(Path(os.environ["PROD_DIST_DIR"])) + problems = [] + if not isinstance(verified, dict) or set(verified) != EXPECTED: + raise SystemExit(f"{mode}: the PyPI-verified artifact map is not the release set") + if any(not isinstance(value, str) or len(value) != 64 for value in verified.values()): + raise SystemExit(f"{mode}: the PyPI-verified artifact map is malformed") + if local != verified: + problems.append("local artifacts differ from the PyPI-verified map") + # Re-resolved on every invocation, so a tag moved after the earlier local + # check cannot reach release creation or acceptance. + if remote_peeled_tag_sha(repo) != release_sha: + problems.append("remote v1.4.0 tag does not peel to the exact release commit") + tag_target = subprocess.run( + ["git", "rev-list", "-n", "1", "v1.4.0"], + check=True, capture_output=True, text=True, + ).stdout.strip() + if tag_target != release_sha: + problems.append("release tag does not target the exact release commit") + if mode == "pre-create": + if problems: + raise SystemExit(f"{mode} release assets are not acceptable: {problems}") + print(json.dumps({ + "mode": mode, + "assets": sorted(verified), + "sha256_match": True, + "remote_tag_match": True, + }, sort_keys=True)) + return # The notes are read from the clean checkout of the exact release commit, so # an ambient working copy cannot describe the published release. - notes_path = Path(os.environ["CODE_MOWER_RELEASE_CHECKOUT"]) / RELEASE_NOTES_RELPATH + notes_path = Path(os.environ["RELEASE_CHECKOUT"]) / RELEASE_NOTES_RELPATH expected_notes = notes_path.read_text(encoding="utf-8").strip() - view = json.loads(subprocess.run( - ["gh", "release", "view", "v1.4.0", "--repo", repo, "--json", - "tagName,isDraft,isPrerelease,assets,body,name"], - check=True, capture_output=True, text=True, - ).stdout) - problems = [] + view = gh_json([ + "release", "view", "v1.4.0", "--repo", repo, "--json", + "tagName,isDraft,isPrerelease,assets,body,name", + ]) if view.get("tagName") != "v1.4.0": problems.append("release tag is not v1.4.0") if not expected_notes: @@ -587,16 +664,8 @@ def main() -> None: problems.append("release title is not the expected v1.4.0 title") if view.get("isDraft") or view.get("isPrerelease"): problems.append("release is a draft or prerelease") - tag_target = subprocess.run( - ["git", "rev-list", "-n", "1", "v1.4.0"], - check=True, capture_output=True, text=True, - ).stdout.strip() - if tag_target != release_sha: - problems.append("release tag does not target the exact release commit") - if set(local) != EXPECTED: - problems.append(f"local artifact set is unexpected: {sorted(local)}") asset_names = {asset["name"] for asset in view.get("assets") or []} - if asset_names != set(local): + if asset_names != set(verified): problems.append(f"release asset set differs: {sorted(asset_names)}") with tempfile.TemporaryDirectory() as scratch: target = Path(scratch) @@ -606,14 +675,15 @@ def main() -> None: check=True, capture_output=True, text=True, ) downloaded = digests(target) - if downloaded != local: - problems.append("release asset SHA-256 values differ from PROD_DIST_DIR") + if downloaded != verified: + problems.append("release asset SHA-256 values differ from the PyPI-verified map") if problems: raise SystemExit(f"{mode} release assets are not acceptable: {problems}") print(json.dumps({ "mode": mode, - "assets": sorted(local), + "assets": sorted(verified), "sha256_match": True, + "remote_tag_match": True, "notes_match": True, "title_match": True, }, sort_keys=True)) @@ -629,19 +699,25 @@ runbook for inspection. ```bash set -euo pipefail -test -f "$CODE_MOWER_RELEASE_CHECKOUT/docs/v140-release-notes.md" +test -f "$RELEASE_CHECKOUT/docs/v140-release-notes.md" +test -s "$PYPI_VERIFIED_MAP" if gh release view v1.4.0 --repo "$REPO" >/dev/null 2>&1; then REPO="$REPO" PROD_DIST_DIR="$PROD_DIST_DIR" RELEASE_SHA="$RELEASE_SHA" \ - CODE_MOWER_RELEASE_CHECKOUT="$CODE_MOWER_RELEASE_CHECKOUT" \ + PYPI_VERIFIED_MAP="$PYPI_VERIFIED_MAP" RELEASE_CHECKOUT="$RELEASE_CHECKOUT" \ "$RELEASE_PYTHON" "$RELEASE_ENV/assert_release_assets.py" existing else - gh release create v1.4.0 "$PROD_DIST_DIR"/* --repo "$REPO" \ + REPO="$REPO" PROD_DIST_DIR="$PROD_DIST_DIR" RELEASE_SHA="$RELEASE_SHA" \ + PYPI_VERIFIED_MAP="$PYPI_VERIFIED_MAP" RELEASE_CHECKOUT="$RELEASE_CHECKOUT" \ + "$RELEASE_PYTHON" "$RELEASE_ENV/assert_release_assets.py" pre-create + gh release create v1.4.0 \ + "$PROD_DIST_DIR/code_mower-1.4.0-py3-none-any.whl" \ + "$PROD_DIST_DIR/code_mower-1.4.0.tar.gz" --repo "$REPO" \ --verify-tag --title "Code Mower v1.4.0" \ - --notes-file "$CODE_MOWER_RELEASE_CHECKOUT/docs/v140-release-notes.md" \ + --notes-file "$RELEASE_CHECKOUT/docs/v140-release-notes.md" \ --latest --fail-on-no-commits fi REPO="$REPO" PROD_DIST_DIR="$PROD_DIST_DIR" RELEASE_SHA="$RELEASE_SHA" \ - CODE_MOWER_RELEASE_CHECKOUT="$CODE_MOWER_RELEASE_CHECKOUT" \ + PYPI_VERIFIED_MAP="$PYPI_VERIFIED_MAP" RELEASE_CHECKOUT="$RELEASE_CHECKOUT" \ "$RELEASE_PYTHON" "$RELEASE_ENV/assert_release_assets.py" created gh release view v1.4.0 --repo "$REPO" \ --json tagName,targetCommitish,isDraft,isPrerelease,publishedAt,url,assets @@ -968,13 +1044,61 @@ port. ```bash set -euo pipefail -CODE_MOWER_RELEASE_CHECKOUT="REPLACE_WITH_EXACT_V140_CHECKOUT" BOARD_5342_REPO="REUSE_PRIVATE_INVENTORIED_SLUG" BOARD_5342_REPO_PATH="REUSE_PRIVATE_INVENTORIED_PATH" BOARD_5344_REPO="REUSE_PRIVATE_INVENTORIED_SLUG" BOARD_5344_REPO_PATH="REUSE_PRIVATE_INVENTORIED_PATH" -test "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA" -test "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" rev-list -n 1 v1.4.0)" = "$RELEASE_SHA" +test "$(git -C "$RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA" +test "$(git -C "$RELEASE_CHECKOUT" rev-list -n 1 v1.4.0)" = "$RELEASE_SHA" + +cat >"$RELEASE_ENV/assert_board_repo_paths.py" <<'PY' +"""Require every Board repository path to be the checkout of its paired slug. + +A path paired with another repository's slug would serve that repository's +history under the wrong name, so each path's git origin is normalized and +compared. Only the port count is printed; slugs and paths stay in arguments. +""" + +import json +import re +import subprocess +import sys + +ORIGIN_PATTERN = re.compile(r"^(?:git@[^:]+:|(?:https?|ssh|git)://[^/]+/)(?P.+?)(?:\.git)?$") + + +def origin_slug(path: str) -> str: + completed = subprocess.run( + ["git", "-C", path, "config", "--get", "remote.origin.url"], + check=True, capture_output=True, text=True, + ) + match = ORIGIN_PATTERN.match(completed.stdout.strip()) + if match is None: + raise SystemExit("a Board repository path has no recognizable git origin") + return match.group("slug").strip().lower() + + +def main() -> None: + pairs = [] + for value in sys.argv[1:]: + slug, separator, path = value.partition("=") + if not separator or not slug.strip() or not path.strip(): + raise SystemExit("each argument must be SLUG=PATH") + pairs.append((slug.strip().lower(), path.strip())) + if not pairs: + raise SystemExit("no Board repository pairs supplied") + for slug, path in pairs: + if origin_slug(path) != slug: + raise SystemExit("a Board repository path does not match its paired slug") + print(json.dumps({"board_repo_paths": "slug_bound", "pair_count": len(pairs)})) + + +main() +PY +"$RELEASE_PYTHON" "$RELEASE_ENV/assert_board_repo_paths.py" \ + "codemower-ai/code-mower=$RELEASE_CHECKOUT" \ + "$BOARD_5342_REPO=$BOARD_5342_REPO_PATH" \ + "$BOARD_5344_REPO=$BOARD_5344_REPO_PATH" cat >"$RELEASE_ENV/board_wait.py" <<'PY' """Bounded waits on the Board inventory: gone after a stop, serving after a start. @@ -1057,8 +1181,10 @@ for BOARD_PORT in 5332 5342 5344; do "$RELEASE_PYTHON" "$RELEASE_ENV/board_wait.py" gone "$BOARD_PORT" done +test "$(git -C "$RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA" +test -z "$(git -C "$RELEASE_CHECKOUT" status --porcelain --untracked-files=all)" nohup code-mower board serve --repo codemower-ai/code-mower \ - --repo-path "$CODE_MOWER_RELEASE_CHECKOUT" --host 127.0.0.1 \ + --repo-path "$RELEASE_CHECKOUT" --host 127.0.0.1 \ --port 5332 --record-events >/tmp/code-mower-board-5332.log 2>&1 & nohup code-mower board serve --repo "$BOARD_5342_REPO" \ --repo-path "$BOARD_5342_REPO_PATH" --host 127.0.0.1 \ @@ -1071,7 +1197,7 @@ nohup code-mower board serve --repo "$BOARD_5344_REPO" \ BOARD_DOCTOR_DIR="$(mktemp -d /tmp/code-mower-v140-board-doctor.XXXXXX)" code-mower board doctor --repo codemower-ai/code-mower \ - --repo-path "$CODE_MOWER_RELEASE_CHECKOUT" --json >"$BOARD_DOCTOR_DIR/5332.json" + --repo-path "$RELEASE_CHECKOUT" --json >"$BOARD_DOCTOR_DIR/5332.json" code-mower board doctor --repo "$BOARD_5342_REPO" \ --repo-path "$BOARD_5342_REPO_PATH" --json >"$BOARD_DOCTOR_DIR/5342.json" code-mower board doctor --repo "$BOARD_5344_REPO" \ @@ -1213,7 +1339,8 @@ test -n "$CODE_MOWER_CLOUD_TEAM_ID" test -n "$CODE_MOWER_INSTALL_ID" case "$CODE_MOWER_CLOUD_TEAM_ID" in REPLACE_WITH_*) exit 1 ;; esac case "$CODE_MOWER_INSTALL_ID" in REPLACE_WITH_*) exit 1 ;; esac -CODE_MOWER_CLOUD_TEAM_ID="$CODE_MOWER_CLOUD_TEAM_ID" \ +env -u CODE_MOWER_CLOUD_TOKEN -u CODE_MOWER_CLOUD_ENDPOINT \ + CODE_MOWER_CLOUD_TEAM_ID="$CODE_MOWER_CLOUD_TEAM_ID" \ CODE_MOWER_INSTALL_ID="$CODE_MOWER_INSTALL_ID" "$RELEASE_PYTHON" - \ >"$CLOUD_DIR/identity.json" <<'PY' import json @@ -1223,8 +1350,10 @@ from code_mower.cloud_client import DEFAULT_TOKEN_ENV, resolve_cloud_token # The stored profile is resolved privately and only compared: the supplied # identifiers must be exactly the ones the selected install profile holds, so -# an upload cannot silently target another install or team. Only the verdict is -# printed. +# an upload cannot silently target another install or team. The ambient cloud +# token and endpoint are excluded from this process, so the resolution has to +# come from the selected stored install profile rather than reflecting the very +# values being asserted. Only the verdict is printed. install_id = os.environ["CODE_MOWER_INSTALL_ID"].strip() team_id = os.environ["CODE_MOWER_CLOUD_TEAM_ID"].strip() resolution = resolve_cloud_token(token_env=DEFAULT_TOKEN_ENV, install_id=install_id) @@ -1235,6 +1364,8 @@ if not team_id or team_id.startswith("REPLACE_WITH_"): problems.append("the private team identifier is empty or a placeholder") if not resolution.has_token: problems.append("the selected install profile has no usable cloud token") +if resolution.source != "install_id": + problems.append("the cloud token was not resolved from the selected install profile") if not resolution.install_id or resolution.install_id.strip() != install_id: problems.append("the selected install profile stores a different install identity") if not resolution.team_id or resolution.team_id.strip() != team_id: @@ -1244,8 +1375,10 @@ if problems: print(json.dumps({"cloud_identity": "bound", "source": resolution.source})) PY grep -q '"cloud_identity": "bound"' "$CLOUD_DIR/identity.json" +grep -q '"source": "install_id"' "$CLOUD_DIR/identity.json" CLOUD_DOCTOR_BUNDLE_DIR="$(mktemp -d /tmp/code-mower-v140-cloud-doctor.XXXXXX)" -code-mower cloud doctor "$CLOUD_DOCTOR_BUNDLE_DIR" \ +env -u CODE_MOWER_CLOUD_TOKEN -u CODE_MOWER_CLOUD_ENDPOINT \ + code-mower cloud doctor "$CLOUD_DOCTOR_BUNDLE_DIR" \ --install-id "$CODE_MOWER_INSTALL_ID" \ --probe-service --json >"$CLOUD_DIR/doctor.json" CLOUD_DIR="$CLOUD_DIR" "$RELEASE_PYTHON" - <<'PY' @@ -1310,7 +1443,8 @@ if problems: print(json.dumps({"cloud_doctor": "pass", "checks": sorted(PASSING_CLOUD_CHECKS)})) PY -code-mower release campaign upload --release-tag v1.4.0 \ +env -u CODE_MOWER_CLOUD_TOKEN -u CODE_MOWER_CLOUD_ENDPOINT \ + code-mower release campaign upload --release-tag v1.4.0 \ --install-id "$CODE_MOWER_INSTALL_ID" --team-id "$CODE_MOWER_CLOUD_TEAM_ID" --json \ >"$CLOUD_DIR/campaign-preview.json" CLOUD_DIR="$CLOUD_DIR" "$RELEASE_PYTHON" - \ @@ -1403,7 +1537,8 @@ print(json.dumps({ PY grep -q '"campaign_preview": "accepted"' "$CLOUD_DIR/campaign-preflight.json" -code-mower release campaign upload --release-tag v1.4.0 \ +env -u CODE_MOWER_CLOUD_TOKEN -u CODE_MOWER_CLOUD_ENDPOINT \ + code-mower release campaign upload --release-tag v1.4.0 \ --install-id "$CODE_MOWER_INSTALL_ID" --team-id "$CODE_MOWER_CLOUD_TEAM_ID" --yes --json \ >"$CLOUD_DIR/campaign-applied.json" CLOUD_DIR="$CLOUD_DIR" "$RELEASE_PYTHON" - <<'PY' @@ -1490,25 +1625,30 @@ print(json.dumps({ PY BOARD_SNAPSHOT_DIR="$(mktemp -d /tmp/code-mower-v140-board-snapshot.XXXXXX)" -# The Board snapshot event carries no commit or dirty-state field, so the -# checkout it reads is re-bound to the released commit immediately before the -# snapshot runs: an earlier assertion cannot speak for a checkout that moved. -test "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA" -test -z "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" status --porcelain --untracked-files=all)" -code-mower cloud board-snapshot \ - --repo-path "$CODE_MOWER_RELEASE_CHECKOUT" \ +# The checkout is re-bound to the released commit immediately before the +# snapshot runs, and the producer is also told to require that exact commit and +# a clean worktree while it collects, so the emitted evidence names the source +# it actually read instead of relying on an earlier assertion. +test "$(git -C "$RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA" +test -z "$(git -C "$RELEASE_CHECKOUT" status --porcelain --untracked-files=all)" +env -u CODE_MOWER_CLOUD_TOKEN -u CODE_MOWER_CLOUD_ENDPOINT \ + code-mower cloud board-snapshot \ + --repo-path "$RELEASE_CHECKOUT" \ --repo-slug codemower-ai/code-mower \ --output-dir "$BOARD_SNAPSHOT_DIR" \ + --require-head-sha "$RELEASE_SHA" --require-clean \ --install-id "$CODE_MOWER_INSTALL_ID" --team-id "$CODE_MOWER_CLOUD_TEAM_ID" --json \ >"$CLOUD_DIR/board-snapshot.json" sha256sum "$BOARD_SNAPSHOT_DIR/code-mower-cloud-bundle.json" \ >"$CLOUD_DIR/board-bundle-before-preview.sha256" -code-mower cloud upload "$BOARD_SNAPSHOT_DIR" \ +env -u CODE_MOWER_CLOUD_TOKEN -u CODE_MOWER_CLOUD_ENDPOINT \ + code-mower cloud upload "$BOARD_SNAPSHOT_DIR" \ --install-id "$CODE_MOWER_INSTALL_ID" --dry-run --json \ >"$CLOUD_DIR/board-preview.json" sha256sum "$BOARD_SNAPSHOT_DIR/code-mower-cloud-bundle.json" \ >"$CLOUD_DIR/board-bundle-after-preview.sha256" -CLOUD_DIR="$CLOUD_DIR" BOARD_SNAPSHOT_DIR="$BOARD_SNAPSHOT_DIR" "$RELEASE_PYTHON" - \ +CLOUD_DIR="$CLOUD_DIR" BOARD_SNAPSHOT_DIR="$BOARD_SNAPSHOT_DIR" \ + RELEASE_SHA="$RELEASE_SHA" "$RELEASE_PYTHON" - \ >"$CLOUD_DIR/board-preflight.json" <<'PY' import hashlib import json @@ -1534,6 +1674,8 @@ SNAPSHOT_DOCTOR_PASSING = ("endpoint", "token", "bundle", "model-provenance") SNAPSHOT_DOCTOR_CHECKS = frozenset(SNAPSHOT_DOCTOR_PASSING) | {"service"} EVENT_SCHEMA = "code_mower.benchmarkEvent.v1" SNAPSHOT_SCHEMA = "code_mower.cloudBoardSnapshot.v1" +UPLOAD_IDENTITY_SCHEMA = "code_mower.cloudUploadIdentity.v1" +release_sha = os.environ["RELEASE_SHA"].strip() EXPECTED_REPO_SLUG = "codemower-ai/code-mower" EXPECTED_EVENT_TYPES = {"board_snapshot": 1} snapshot = load(cloud_dir / "board-snapshot.json") @@ -1669,12 +1811,43 @@ after_preview = digest_of(cloud_dir / "board-bundle-after-preview.sha256") previewed_digest = hashlib.sha256(manifest_path.read_bytes()).hexdigest() if before_preview != after_preview or previewed_digest != before_preview: problems.append("board bundle changed while the preview was generated") +# External hashes alone cannot see an A-B-A substitution inside a producer, so +# both producers also report the exact manifest bytes and events they acted on, +# and those reports must agree with each other and with the inspected event. +expected_identity = { + "schema": UPLOAD_IDENTITY_SCHEMA, + "manifest_sha256": previewed_digest, + "event_count": 1, + "event_ids": [str(event.get("event_id") or "")], + "event_type_counts": dict(EXPECTED_EVENT_TYPES), +} +snapshot_identity = snapshot.get("manifest") +preview_identity = preview.get("manifest") +if snapshot_identity != expected_identity: + problems.append("board snapshot does not report the inspected manifest identity") +if preview_identity != expected_identity: + problems.append("board upload preview does not report the inspected manifest identity") +# The snapshot also names the checkout it read, so accepted Board evidence is +# owned by the producer rather than inferred from a separate assertion. +snapshot_git = snapshot.get("git") +snapshot_git = snapshot_git if isinstance(snapshot_git, dict) else {} +expected_git = { + "available": True, + "head_sha": release_sha, + "clean": True, + "dirty_entry_count": 0, +} +if snapshot_git != expected_git: + problems.append("board snapshot was not collected from the exact clean release checkout") +if dimensions.get("source_git") != expected_git: + problems.append("board event does not carry the release checkout provenance") if problems: raise SystemExit(f"board snapshot preview is not an acceptable payload: {problems}") print(json.dumps({ "board_preview": "accepted", "event_types": event_types, "previewed_digest": previewed_digest, + "previewed_identity": expected_identity, "reports": 0, })) PY @@ -1682,7 +1855,8 @@ grep -q '"board_preview": "accepted"' "$CLOUD_DIR/board-preflight.json" sha256sum "$BOARD_SNAPSHOT_DIR/code-mower-cloud-bundle.json" \ >"$CLOUD_DIR/board-bundle-before-apply.sha256" -code-mower cloud upload "$BOARD_SNAPSHOT_DIR" \ +env -u CODE_MOWER_CLOUD_TOKEN -u CODE_MOWER_CLOUD_ENDPOINT \ + code-mower cloud upload "$BOARD_SNAPSHOT_DIR" \ --install-id "$CODE_MOWER_INSTALL_ID" --yes --json \ >"$CLOUD_DIR/board-applied.json" sha256sum "$BOARD_SNAPSHOT_DIR/code-mower-cloud-bundle.json" \ @@ -1728,10 +1902,19 @@ if not 200 <= int(applied.get("status") or 0) < 300: problems.append(f"board upload was not accepted: {applied.get('status')!r}") if not probed_endpoint or applied.get("endpoint") != probed_endpoint: problems.append("board applied upload does not target the probed service") -# Generic cloud upload returns no event identifiers, so the applied upload is -# bound to the inspected bundle by digest instead: the manifest may not change -# between the accepted preview and the applied upload. -previewed_digest = str(load(cloud_dir / "board-preflight.json").get("previewed_digest") or "") +# The applied upload is also bound to the inspected bundle by external digests, +# so the manifest may not change between the accepted preview and the applied +# upload. +preflight = load(cloud_dir / "board-preflight.json") +previewed_digest = str(preflight.get("previewed_digest") or "") +previewed_identity = preflight.get("previewed_identity") +# The applied upload reports the exact manifest bytes and event identifiers it +# submitted, so a same-shape manifest swapped in before the mutation is caught +# by the producer itself rather than only by external hashes. +if not isinstance(previewed_identity, dict) or not previewed_identity: + problems.append("the previewed board manifest identity was not retained") +elif applied.get("manifest") != previewed_identity: + problems.append("board applied upload does not report the previewed manifest identity") before_digest = digest_of(cloud_dir / "board-bundle-before-apply.sha256") after_digest = digest_of(cloud_dir / "board-bundle-after-apply.sha256") current_digest = hashlib.sha256(manifest_path.read_bytes()).hexdigest() @@ -1763,12 +1946,17 @@ derived from their raw check rows, so a falsified `status` or `failures` cannot hide a degraded check. The nested report's inventory is exact: `endpoint`, `token`, `bundle`, and `model-provenance` must pass, `service` must be the skip this producer causes by not requesting `--probe-service`, and its endpoint must -equal the privately probed one. Generic `cloud upload` returns no event -identifiers, so the applied upload is bound to the previewed bundle by SHA-256: -the manifest is hashed immediately before and immediately after the dry-run -preview, those two values must match before the preview can be accepted, that -matching value is the previewed identity, and the digest recomputed after the -upload must equal it. +equal the privately probed one. The Board snapshot and both generic upload +phases each report the exact manifest bytes and event identifiers they acted +on, and those producer-owned reports must be identical to one another, to the +inspected single `board_snapshot` event, and to the manifest digest, so a +same-shape manifest substituted around a phase cannot pass as the previewed +payload. The manifest is also hashed immediately before and immediately after +the dry-run preview and again around the applied upload, and every one of those +values must match the retained previewed identity. The snapshot additionally +carries the exact commit and clean state of the checkout it read, which must be +the release commit, and the command itself is required to fail unless that +checkout stays exactly that commit and clean while the snapshot is collected. ## Cache Bypass And Propagation Triage diff --git a/docs/release-qualification.md b/docs/release-qualification.md index e2026da1..aa284920 100644 --- a/docs/release-qualification.md +++ b/docs/release-qualification.md @@ -225,15 +225,19 @@ arbitrary index URL or credential. It is bound into the campaign the same way - it is part of every provider's idempotency key, so a dispatch/result pair for one source can never satisfy a same-tag campaign from a different source; -- local adapters receive it (as `{package_source}`) and construct their - install commands against the canonical TestPyPI simple index - (`https://test.pypi.org/simple/`) with production PyPI - (`https://pypi.org/simple/`) as a dependency-only extra index; -- a hosted dispatch comment states the source (and, for `testpypi`, both - canonical index URLs) in its machine-readable marker and human-facing - instructions, so a remote provider never has to guess which index to - install from; a reply's own declared source must match, or it is ignored - like any other unbound comment; +- local adapters receive it (as `{package_source}`) and construct the same + source-exclusive two-stage install the qualification command runs: download + the exact candidate artifact from the canonical TestPyPI simple index + (`https://test.pypi.org/simple/`) as the only configured index with + `--no-deps`, verify its package identity and version, then install that + verified local artifact file while resolving its dependencies from + production PyPI (`https://pypi.org/simple/`); the indexes are never + combined in one command; +- a hosted dispatch comment states the source (and, for `testpypi`, the + candidate index, the dependency index, and that two-stage requirement) in + its machine-readable marker and human-facing instructions, so a remote + provider never has to guess where to install from; a reply's own declared + source must match, or it is ignored like any other unbound comment; - resuming or dispatching an existing campaign with a different `--package-source` is rejected as an identity conflict, exactly like a conflicting `--qualification-context`. diff --git a/src/code_mower/cloud.py b/src/code_mower/cloud.py index b21333a6..37e3b9d3 100644 --- a/src/code_mower/cloud.py +++ b/src/code_mower/cloud.py @@ -31,6 +31,7 @@ catch_up_upload as _catch_up_upload, default_setup_path as _default_setup_path, build_upload_payload, + build_upload_payload_with_identity as _build_upload_payload_with_identity, default_dogfood_reports as _default_dogfood_reports, detect_repo_slug as _detect_repo_slug, dogfood_upload as _dogfood_upload, @@ -87,6 +88,7 @@ catch_up_upload as _catch_up_upload, default_setup_path as _default_setup_path, build_upload_payload, + build_upload_payload_with_identity as _build_upload_payload_with_identity, default_dogfood_reports as _default_dogfood_reports, detect_repo_slug as _detect_repo_slug, dogfood_upload as _dogfood_upload, @@ -164,6 +166,7 @@ "write_setup_env_file", "main", "_board_snapshot_upload", + "_build_upload_payload_with_identity", "_catch_up_upload", "_default_dogfood_reports", "_default_setup_path", @@ -569,6 +572,19 @@ def main(argv: list[str] | None = None) -> int: default=None, help="directory with Code Mower Cloud token profiles", ) + board_snapshot.add_argument( + "--require-head-sha", + default="", + help=( + "fail unless the snapshot source checkout is exactly this " + "40-character commit, rechecked around collection" + ), + ) + board_snapshot.add_argument( + "--require-clean", + action="store_true", + help="fail unless the snapshot source checkout has no local changes", + ) board_snapshot.add_argument( "--yes", action="store_true", @@ -786,7 +802,9 @@ def main(argv: list[str] | None = None) -> int: print("Upload: local export only") return 0 if args.command == "upload": - payload = build_upload_payload( + # Payload and manifest identity come from one read, so the digest + # and event identity name exactly the bytes being sent. + payload, manifest_identity = _build_upload_payload_with_identity( bundle_dir=args.bundle_dir, include_reports=args.include_reports, ) @@ -809,6 +827,7 @@ def main(argv: list[str] | None = None) -> int: "event_count": len(payload["events"]), "privacy_mode": payload["privacy_mode"], "excluded_content": payload["excluded_content"], + "manifest": manifest_identity, } if args.json: print(json.dumps(preview, indent=2, sort_keys=True)) @@ -831,6 +850,7 @@ def main(argv: list[str] | None = None) -> int: token=token, timeout=args.timeout, ) + result["manifest"] = manifest_identity if args.json: print(json.dumps(result, indent=2, sort_keys=True)) else: @@ -954,6 +974,8 @@ def main(argv: list[str] | None = None) -> int: workflow_limit=args.workflow_limit, stale_minutes=args.stale_minutes, event_limit=args.event_limit, + require_head_sha=args.require_head_sha, + require_clean=args.require_clean, yes=args.yes, timeout=args.timeout, ) @@ -965,6 +987,8 @@ def main(argv: list[str] | None = None) -> int: print(f"Repository: {result['repo_slug']}") print(f"Events: {result['event_count']}") print(f"Bundle: {result['export']['output_dir']}") + print(f"Source commit: {result['git']['head_sha']}") + print(f"Source clean: {result['git']['clean']}") if result["status"] == "uploaded": print(f"Upload status: {result['upload']['status']}") elif result["status"] == "dry_run": diff --git a/src/code_mower/cloud_client/__init__.py b/src/code_mower/cloud_client/__init__.py index da5f8982..51c84958 100644 --- a/src/code_mower/cloud_client/__init__.py +++ b/src/code_mower/cloud_client/__init__.py @@ -83,12 +83,17 @@ validate_cloud_event, ) from .git_metadata import ( + checkout_provenance, detect_repo_slug, repo_slug_from_remote, + require_checkout_provenance, run_git, ) from .manifest import ( + UPLOAD_IDENTITY_SCHEMA, + bundle_manifest_identity, load_bundle_manifest, + read_bundle_manifest, report_path_from_manifest, ) from .reports import included_report_payloads @@ -183,6 +188,7 @@ build_event_upload_payload, build_upload_payload, build_upload_payload_from_manifest, + build_upload_payload_with_identity, post_upload_payload, ) from .work_types import ( @@ -284,6 +290,12 @@ "build_event_upload_payload", "build_upload_payload", "build_upload_payload_from_manifest", + "build_upload_payload_with_identity", + "bundle_manifest_identity", + "checkout_provenance", + "require_checkout_provenance", + "read_bundle_manifest", + "UPLOAD_IDENTITY_SCHEMA", "build_board_snapshot_event", "build_provenance_summary", "board_snapshot_upload", diff --git a/src/code_mower/cloud_client/events.py b/src/code_mower/cloud_client/events.py index 847eab5a..017982e5 100644 --- a/src/code_mower/cloud_client/events.py +++ b/src/code_mower/cloud_client/events.py @@ -697,6 +697,7 @@ def build_board_snapshot_event( install_id: str, source: str, snapshot: Mapping[str, Any], + git_provenance: Mapping[str, Any] | None = None, ) -> dict[str, Any]: remote = _as_mapping(snapshot.get("remote")) board_meta = _as_mapping(snapshot.get("board")) @@ -772,6 +773,16 @@ def build_board_snapshot_event( "privacy_excluded_content": list(EXCLUDED_CONTENT), }, } + provenance = _as_mapping(git_provenance) + if provenance: + # The commit and cleanliness of the checkout the snapshot was read + # from, so the evidence itself names the source it describes. + event["dimensions"]["source_git"] = { + "available": bool(provenance.get("available")), + "head_sha": _safe_dimension_text(provenance.get("head_sha"), max_length=40), + "clean": bool(provenance.get("clean")), + "dirty_entry_count": _int(provenance.get("dirty_entry_count")) or 0, + } return validate_cloud_event(event) diff --git a/src/code_mower/cloud_client/git_metadata.py b/src/code_mower/cloud_client/git_metadata.py index ae84bae4..7ec11d73 100644 --- a/src/code_mower/cloud_client/git_metadata.py +++ b/src/code_mower/cloud_client/git_metadata.py @@ -2,8 +2,15 @@ from __future__ import annotations +import re import subprocess from pathlib import Path +from typing import Any + +from .errors import CloudBundleError + + +COMMIT_SHA_PATTERN = re.compile(r"^[0-9a-f]{40}$") def run_git(repo_path: Path, args: list[str]) -> str: @@ -44,3 +51,79 @@ def repo_slug_from_remote(remote_url: str) -> str: def detect_repo_slug(repo_path: Path) -> str: return repo_slug_from_remote(run_git(repo_path, ["config", "--get", "remote.origin.url"])) + + +def _required_git_output(repo_path: Path, args: list[str]) -> str: + """Return stdout for a git command that must succeed.""" + try: + completed = subprocess.run( + ["git", *args], + cwd=repo_path, + text=True, + capture_output=True, + check=False, + ) + except OSError as exc: + raise CloudBundleError(f"unable to run git {args[0]} in {repo_path}: {exc}") from exc + if completed.returncode != 0: + raise CloudBundleError(f"git {args[0]} failed in {repo_path}") + return completed.stdout + + +UNAVAILABLE_PROVENANCE: dict[str, Any] = { + "available": False, + "head_sha": "", + "clean": False, + "dirty_entry_count": 0, +} + + +def checkout_provenance(repo_path: Path, *, required: bool = False) -> dict[str, Any]: + """Report the exact commit and worktree cleanliness of a source checkout. + + Unlike ``run_git``, a failed command never reads as a clean checkout: a + directory that is not a usable Git checkout is reported unavailable, and is + an error when the caller requires provenance. + """ + + try: + head_sha = _required_git_output(repo_path, ["rev-parse", "HEAD"]).strip() + if not COMMIT_SHA_PATTERN.match(head_sha): + raise CloudBundleError(f"unable to resolve an exact HEAD commit in {repo_path}") + status = _required_git_output( + repo_path, + ["status", "--porcelain", "--untracked-files=all"], + ) + except CloudBundleError: + if required: + raise + return dict(UNAVAILABLE_PROVENANCE) + dirty_entries = [line for line in status.splitlines() if line.strip()] + return { + "available": True, + "head_sha": head_sha, + "clean": not dirty_entries, + "dirty_entry_count": len(dirty_entries), + } + + +def require_checkout_provenance( + provenance: dict[str, Any], + *, + expected_head_sha: str = "", + require_clean: bool = False, +) -> None: + """Fail closed unless a checkout is the expected commit and clean enough.""" + + expected = expected_head_sha.strip().lower() + if (expected or require_clean) and not provenance.get("available"): + raise CloudBundleError("source checkout provenance is unavailable") + if expected: + if not COMMIT_SHA_PATTERN.match(expected): + raise CloudBundleError( + "expected head sha must be an exact 40-character commit" + ) + if provenance.get("head_sha") != expected: + raise CloudBundleError("source checkout is not the expected commit") + if require_clean and not provenance.get("clean"): + raise CloudBundleError("source checkout has uncommitted or untracked changes") diff --git a/src/code_mower/cloud_client/manifest.py b/src/code_mower/cloud_client/manifest.py index fda96a79..968f9763 100644 --- a/src/code_mower/cloud_client/manifest.py +++ b/src/code_mower/cloud_client/manifest.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import json from pathlib import Path from typing import Any @@ -10,17 +11,67 @@ from .errors import CloudBundleError -def load_bundle_manifest(bundle_dir: Path) -> dict[str, Any]: +UPLOAD_IDENTITY_SCHEMA = "code_mower.cloudUploadIdentity.v1" + + +def read_bundle_manifest(bundle_dir: Path) -> tuple[dict[str, Any], bytes]: + """Return one bundle manifest and the exact bytes it was parsed from. + + Callers that have to prove which bytes they previewed or submitted need the + manifest and its digest to come from a single read: a second read can see a + replaced file of the same shape. + """ + manifest_path = bundle_dir / BUNDLE_MANIFEST_FILENAME if not manifest_path.is_file(): raise CloudBundleError(f"bundle manifest not found: {manifest_path}") try: - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + raw = manifest_path.read_bytes() + manifest = json.loads(raw.decode("utf-8")) except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: raise CloudBundleError(f"unable to read bundle manifest {manifest_path}: {exc}") from exc if not is_bundle_manifest(manifest): raise CloudBundleError(f"unsupported bundle manifest schema in {manifest_path}") - return manifest + return manifest, raw + + +def load_bundle_manifest(bundle_dir: Path) -> dict[str, Any]: + return read_bundle_manifest(bundle_dir)[0] + + +def bundle_manifest_identity( + manifest: dict[str, Any], + manifest_bytes: bytes, +) -> dict[str, Any]: + """Describe the exact manifest bytes and events a producer is acting on. + + Malformed or repeated event rows fail closed rather than being filtered or + collapsed, so this identity always names every event the payload carries. + """ + + events = manifest.get("events") + if not isinstance(events, list): + raise CloudBundleError("bundle manifest events must be a list") + event_ids: list[str] = [] + event_type_counts: dict[str, int] = {} + for row in events: + if not isinstance(row, dict): + raise CloudBundleError("bundle manifest event rows must be objects") + event_id = str(row.get("event_id") or "").strip() + event_type = str(row.get("event_type") or "").strip() + if not event_id or not event_type: + raise CloudBundleError("bundle manifest event identity is missing") + if event_id in event_ids: + raise CloudBundleError(f"bundle manifest repeats event {event_id}") + event_ids.append(event_id) + event_type_counts[event_type] = event_type_counts.get(event_type, 0) + 1 + return { + "schema": UPLOAD_IDENTITY_SCHEMA, + "manifest_sha256": hashlib.sha256(manifest_bytes).hexdigest(), + "event_count": len(event_ids), + "event_ids": event_ids, + "event_type_counts": dict(sorted(event_type_counts.items())), + } def report_path_from_manifest(bundle_dir: Path, target: str) -> Path: diff --git a/src/code_mower/cloud_client/operations.py b/src/code_mower/cloud_client/operations.py index 509871c4..dc51521d 100644 --- a/src/code_mower/cloud_client/operations.py +++ b/src/code_mower/cloud_client/operations.py @@ -39,7 +39,11 @@ ) from .export import build_cloud_bundle from .events import safe_event_type -from .git_metadata import detect_repo_slug +from .git_metadata import ( + checkout_provenance, + detect_repo_slug, + require_checkout_provenance, +) from .productivity_windows import load_productivity_window_events from .tokens import ( CloudTokenResolution, @@ -48,7 +52,11 @@ resolve_cloud_identity, resolve_cloud_token, ) -from .upload import build_upload_payload, post_upload_payload +from .upload import ( + build_upload_payload, + build_upload_payload_with_identity, + post_upload_payload, +) CATCH_UP_TRUST_GUIDANCE = { @@ -307,6 +315,8 @@ def board_snapshot_upload( workflow_limit: int = 20, stale_minutes: int = 30, event_limit: int = 20, + require_head_sha: str = "", + require_clean: bool = False, yes: bool, timeout: float, ) -> dict[str, Any]: @@ -339,14 +349,35 @@ def board_snapshot_upload( stale_minutes=stale_minutes, event_limit=event_limit, ) + # The checkout is read before and after collection and both readings must + # agree, so a checkout that moves or is modified while the snapshot is + # gathered cannot produce evidence attributed to the expected commit. + provenance_required = bool(require_head_sha.strip() or require_clean) + provenance = checkout_provenance(repo_path, required=provenance_required) + require_checkout_provenance( + provenance, + expected_head_sha=require_head_sha, + require_clean=require_clean, + ) snapshot = board.status_payload(config) snapshot["timelines"] = board.timelines_payload(config) + collected_provenance = checkout_provenance(repo_path, required=provenance_required) + if collected_provenance != provenance: + raise CloudBundleError( + "source checkout changed while the Board snapshot was collected" + ) + require_checkout_provenance( + collected_provenance, + expected_head_sha=require_head_sha, + require_clean=require_clean, + ) event = build_board_snapshot_event( repo_slug=detected_repo_slug, team_id=resolved_team_id, install_id=resolved_install_id, source=source, snapshot=snapshot, + git_provenance=provenance, ) export_result = build_cloud_bundle( reports=[], @@ -373,9 +404,15 @@ def board_snapshot_upload( "repo_slug": detected_repo_slug, "event_count": 1, "export": export_result, + "git": provenance, "doctor": doctor_result, } - payload = build_upload_payload(bundle_dir=output_dir, include_reports=False) + # The manifest identity is derived from the same bytes the payload was + # built from, so the snapshot names the exact manifest it just wrote. + payload, manifest_identity = build_upload_payload_with_identity( + bundle_dir=output_dir, + include_reports=False, + ) if not yes: return { "mode": "cloud-board-snapshot", @@ -383,6 +420,8 @@ def board_snapshot_upload( "repo_slug": detected_repo_slug, "event_count": 1, "export": export_result, + "git": provenance, + "manifest": manifest_identity, "doctor": doctor_result, "upload": build_dogfood_dry_run_preview( endpoint=resolved_endpoint, @@ -400,6 +439,8 @@ def board_snapshot_upload( "repo_slug": detected_repo_slug, "event_count": 1, "export": export_result, + "git": provenance, + "manifest": manifest_identity, "doctor": doctor_result, "upload": post_upload_payload( payload=payload, diff --git a/src/code_mower/cloud_client/upload.py b/src/code_mower/cloud_client/upload.py index f78248c4..2d215b75 100644 --- a/src/code_mower/cloud_client/upload.py +++ b/src/code_mower/cloud_client/upload.py @@ -18,7 +18,7 @@ from .errors import CloudBundleError from .events import normalize_event, validate_cloud_event from .export import build_provenance_summary -from .manifest import load_bundle_manifest +from .manifest import bundle_manifest_identity, load_bundle_manifest, read_bundle_manifest from .reports import included_report_payloads @@ -86,6 +86,34 @@ def build_upload_payload( ) +def build_upload_payload_with_identity( + *, + bundle_dir: Path, + include_reports: bool = False, +) -> tuple[dict[str, Any], dict[str, Any]]: + """Return an upload payload plus the identity of the bytes it was built from. + + The payload and its manifest digest/event identity come from a single read, + so a same-shape manifest substituted after the read cannot be reported as + the thing that was previewed or submitted. + """ + + bundle_dir = bundle_dir.expanduser() + if not bundle_dir.is_dir(): + raise CloudBundleError(f"bundle directory does not exist: {bundle_dir}") + manifest, manifest_bytes = read_bundle_manifest(bundle_dir) + payload = build_upload_payload_from_manifest( + manifest, + reports=included_report_payloads( + manifest, + bundle_dir, + include_reports=include_reports, + ), + include_reports=include_reports, + ) + return payload, bundle_manifest_identity(manifest, manifest_bytes) + + def build_event_upload_payload( *, events: list[dict[str, Any]], diff --git a/src/code_mower/release_qualify.py b/src/code_mower/release_qualify.py index 1a7747ef..b613edc5 100644 --- a/src/code_mower/release_qualify.py +++ b/src/code_mower/release_qualify.py @@ -55,9 +55,10 @@ VALID_PACKAGE_SOURCES = {"pypi", "testpypi"} DEFAULT_PACKAGE_SOURCE = "pypi" # The one canonical TestPyPI simple index, and the one canonical production -# PyPI simple index used only as a dependency-only extra index for a TestPyPI -# candidate (TestPyPI does not mirror every dependency). Fixed constants, not -# configurable: no credentials, and no other index is ever accepted. +# PyPI simple index used to resolve dependencies in a separate second stage for +# a TestPyPI candidate (TestPyPI does not mirror every dependency). The two are +# never configured in one command. Fixed constants, not configurable: no +# credentials, and no other index is ever accepted. TESTPYPI_INDEX_URL = "https://test.pypi.org/simple/" PRODUCTION_PYPI_INDEX_URL = "https://pypi.org/simple/" VALID_STEP_STATUSES = {"pass", "fail", "warn", "unavailable", "planned"} diff --git a/src/code_mower/release_readiness.py b/src/code_mower/release_readiness.py index 47b388ed..34b8c9cc 100644 --- a/src/code_mower/release_readiness.py +++ b/src/code_mower/release_readiness.py @@ -21,6 +21,7 @@ "docs/first-user-install-rehearsal.md", "docs/pypi-release.md", "docs/public-release-checklist.md", + "docs/release-qualification.md", ) REQUIRED_PUBLIC_PACKAGE_SPEC_DOC_PATHS = ( "README.md", @@ -38,8 +39,16 @@ "docs/first-user-install-rehearsal.md", "docs/pypi-release.md", "docs/public-release-checklist.md", + "docs/release-qualification.md", ) UNSAFE_MULTI_INDEX_MARKER = "--pip-extra-index-url https://pypi.org/simple/" +# Combined-index guidance regresses as a command flag or as prose promising +# production PyPI as an extra index alongside the TestPyPI candidate index. +UNSAFE_MULTI_INDEX_MARKERS = ( + UNSAFE_MULTI_INDEX_MARKER, + "--extra-index-url https://pypi.org/simple/", + "dependency-only extra index", +) PUBLIC_HYGIENE_DOC_PATHS = ( "CODE_OF_CONDUCT.md", "CONTRIBUTING.md", @@ -384,8 +393,11 @@ def _post_merge_runbook_assertions(version: str, release_tag: str) -> tuple[str, f'test "$(git rev-list -n 1 {release_tag})" = "$RELEASE_SHA"', f"test \"$(git ls-remote origin 'refs/tags/{release_tag}^{{}}' | awk '{{print $1}}')\"" ' = "$RELEASE_SHA"', + # Every dispatch names the exact commit it may build, and the + # workflow's own identity job fails fast unless it matches. + '-f expected_sha="$RELEASE_SHA"', # Every workflow run is asserted, including both publish-job postures. - 'BUILD_JOBS = ("build-distributions", "verify-distributions")', + 'BUILD_JOBS = ("release-identity", "build-distributions", "verify-distributions")', 'if str(run.get("databaseId")) != run_id:', 'if run.get("workflowName") != EXPECTED_WORKFLOW:', 'if run.get("event") != event:', @@ -414,17 +426,30 @@ def _post_merge_runbook_assertions(version: str, release_tag: str) -> tuple[str, '["gh", "api", f"repos/{repo}/actions/variables/{name}"]', 'if value is None or value.strip().lower() != "false"', '"repository-scope publish variables must exist and equal false: "', + # The production artifact map proven against canonical PyPI is saved + # once and then treated as immutable release evidence, so a local file + # replaced afterwards cannot become a Release asset. + 'PYPI_VERIFIED_MAP="$RELEASE_ENV/pypi-verified-artifacts.json"', + 'test ! -e "$PYPI_VERIFIED_MAP"', + 'Path(os.environ["PYPI_VERIFIED_MAP"]).write_text(', + 'test -s "$PYPI_VERIFIED_MAP"', + 'problems.append("local artifacts differ from the PyPI-verified map")', + 'problems.append("release asset SHA-256 values differ from the PyPI-verified map")', + # The remote peeled tag is re-resolved on every invocation, including + # the one immediately before the irreversible release creation. + 'if remote_peeled_tag_sha(repo) != release_sha:', + 'problems.append("remote v1.4.0 tag does not peel to the exact release commit")', + 'assert_release_assets.py" pre-create', # The Release's own assets are downloaded and compared digest by digest. 'raise SystemExit(f"{mode} release assets are not acceptable: {problems}")', - 'problems.append("release asset SHA-256 values differ from PROD_DIST_DIR")', 'assert_release_assets.py" existing', # The post-create verification is unconditional: it runs for a release # this runbook created as well as one it found already present. 'assert_release_assets.py" created', # Notes come from the clean checkout of the exact release commit, and # the published body and title are compared with that file. - '--notes-file "$CODE_MOWER_RELEASE_CHECKOUT/docs/v140-release-notes.md"', - 'notes_path = Path(os.environ["CODE_MOWER_RELEASE_CHECKOUT"]) / RELEASE_NOTES_RELPATH', + '--notes-file "$RELEASE_CHECKOUT/docs/v140-release-notes.md"', + 'notes_path = Path(os.environ["RELEASE_CHECKOUT"]) / RELEASE_NOTES_RELPATH', 'problems.append("release notes in the exact checkout are empty")', 'problems.append("release body does not match the exact checkout release notes")', 'problems.append("release title is not the expected v1.4.0 title")', @@ -452,7 +477,7 @@ def _post_merge_runbook_assertions(version: str, release_tag: str) -> tuple[str, "-u PIP_NO_INDEX", "--pip-args='--isolated --no-cache-dir'", # Boards stop, are waited for, and only then restart from the release. - 'test "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA"', + 'test "$(git -C "$RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA"', 'board_wait.py" gone "$BOARD_PORT"', # Serving is only satisfied by the expected repository on each port. 'and row.get("repo") == expected_repo', @@ -520,6 +545,13 @@ def _post_merge_runbook_assertions(version: str, release_tag: str) -> tuple[str, 'case "$CODE_MOWER_CLOUD_TEAM_ID" in REPLACE_WITH_*) exit 1 ;; esac', 'case "$CODE_MOWER_INSTALL_ID" in REPLACE_WITH_*) exit 1 ;; esac', 'resolution = resolve_cloud_token(token_env=DEFAULT_TOKEN_ENV, install_id=install_id)', + # The ambient cloud token and endpoint are excluded, so the gate and + # every later command resolve the selected stored install profile + # instead of reflecting the values being asserted. + "env -u CODE_MOWER_CLOUD_TOKEN -u CODE_MOWER_CLOUD_ENDPOINT", + 'if resolution.source != "install_id":', + 'problems.append("the cloud token was not resolved from the selected install profile")', + 'grep -q \'"source": "install_id"\' "$CLOUD_DIR/identity.json"', 'problems.append("the selected install profile stores a different install identity")', 'problems.append("the selected install profile stores a different team identity")', 'raise SystemExit(f"cloud identity is not bound to the selected profile: {problems}")', @@ -609,7 +641,7 @@ def _post_merge_runbook_assertions(version: str, release_tag: str) -> tuple[str, 'if not 200 <= int(applied.get("status") or 0) < 300:', # The Board snapshot reads a checkout, and its event carries no commit # or dirty-state field, so the checkout is re-bound immediately before. - 'test -z "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" status --porcelain --untracked-files=all)"', + 'test -z "$(git -C "$RELEASE_CHECKOUT" status --porcelain --untracked-files=all)"', # The Board snapshot's own nested cloud doctor must be healthy on its # raw rows; only the producer's skipped service probe is tolerated. 'snapshot_doctor = snapshot.get("doctor")', @@ -643,7 +675,26 @@ def _post_merge_runbook_assertions(version: str, release_tag: str) -> tuple[str, 'problems.append("the previewed board bundle identity was not retained")', "or current_digest != previewed_digest", 'problems.append("board bundle changed between the preview and the applied upload")', + # External hashes cannot see an A-B-A substitution inside a producer, so + # the snapshot and both upload phases report the exact manifest bytes + # and events they acted on, and those reports must be identical. + 'UPLOAD_IDENTITY_SCHEMA = "code_mower.cloudUploadIdentity.v1"', + '"manifest_sha256": previewed_digest,', + '"event_ids": [str(event.get("event_id") or "")],', + 'problems.append("board snapshot does not report the inspected manifest identity")', + 'problems.append("board upload preview does not report the inspected manifest identity")', + 'problems.append("board applied upload does not report the previewed manifest identity")', + '"previewed_identity": expected_identity,', + # The snapshot producer is required to collect from the exact clean + # release checkout and to report the commit it read. + '--require-head-sha "$RELEASE_SHA" --require-clean', + 'problems.append("board snapshot was not collected from the exact clean release checkout")', + 'problems.append("board event does not carry the release checkout provenance")', 'raise SystemExit(f"board snapshot upload is not a verified gate: {problems}")', + # Every Board repository path is the checkout of its paired slug, so a + # path cannot serve another repository's history under the wrong name. + 'raise SystemExit("a Board repository path does not match its paired slug")', + 'assert_board_repo_paths.py" \\', ) @@ -671,7 +722,7 @@ def _post_merge_runbook_gate_orders() -> tuple[tuple[str, tuple[str, ...]], ...] ( "board-preflight-before-apply", ( - 'test -z "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" status --porcelain --untracked-files=all)"', + 'test -z "$(git -C "$RELEASE_CHECKOUT" status --porcelain --untracked-files=all)"', "code-mower cloud board-snapshot", '>"$CLOUD_DIR/board-bundle-before-preview.sha256"', '--dry-run --json \\\n >"$CLOUD_DIR/board-preview.json"', @@ -689,8 +740,13 @@ def _post_merge_runbook_gate_orders() -> tuple[tuple[str, tuple[str, ...]], ...] BOARD_SNAPSHOT_COMMAND = "code-mower cloud board-snapshot" BOARD_SNAPSHOT_BINDING_ASSERTIONS = ( - 'test "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA"', - 'test -z "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" status --porcelain --untracked-files=all)"', + 'test "$(git -C "$RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA"', + 'test -z "$(git -C "$RELEASE_CHECKOUT" status --porcelain --untracked-files=all)"', +) +# The snapshot command is invoked with the ambient cloud token and endpoint +# excluded; that prefix is part of the same command, not an intervening step. +CLOUD_ENV_ISOLATION_PREFIX = ( + "env -u CODE_MOWER_CLOUD_TOKEN -u CODE_MOWER_CLOUD_ENDPOINT \\" ) @@ -708,7 +764,9 @@ def _board_snapshot_binding_problems(runbook_doc: str) -> list[str]: preceding = [ line.strip() for line in runbook_doc[:start].splitlines() - if line.strip() and not line.strip().startswith("#") + if line.strip() + and not line.strip().startswith("#") + and line.strip() != CLOUD_ENV_ISOLATION_PREFIX ] if preceding[-2:] != list(BOARD_SNAPSHOT_BINDING_ASSERTIONS): return [ @@ -897,6 +955,63 @@ def _post_merge_pip_isolation_problems(runbook_doc: str) -> list[str]: return problems +# Variables the operator or the shell supplies, which the ordered runbook is +# not expected to assign. The private cloud identifiers are deliberately never +# written into the document; every other name must be established by an earlier +# runbook command before it is dereferenced. +RUNBOOK_EXTERNAL_VARIABLES = frozenset( + { + "CODE_MOWER_CLOUD_TEAM_ID", + "CODE_MOWER_INSTALL_ID", + "CODE_MOWER_CLOUD_TOKEN", + "CODE_MOWER_CLOUD_ENDPOINT", + "HOME", + "PATH", + "PWD", + "TMPDIR", + "VIRTUAL_ENV", + } +) +_VARIABLE_ASSIGNMENT = re.compile(r"^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=") +_VARIABLE_USE = re.compile(r"\$\{?([A-Za-z_][A-Za-z0-9_]*)") +_LOOP_VARIABLE = re.compile(r"^for\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s") + + +def _post_merge_variable_flow_problems(runbook_doc: str) -> list[str]: + """Report ordered runbook variables dereferenced before they are assigned. + + Every ordered block runs under ``set -u``, so a name that no earlier block + assigned either aborts the step or -- worse, when an unrelated ambient value + happens to exist -- silently binds the release to something the runbook + never established. + """ + + problems: list[str] = [] + assigned: set[str] = set(RUNBOOK_EXTERNAL_VARIABLES) + reported: set[str] = set() + for command in _shell_commands(runbook_doc): + loop = _LOOP_VARIABLE.match(command.strip()) + if loop: + assigned.add(loop.group(1)) + for name in _VARIABLE_USE.findall(command): + if name in assigned or name in reported: + continue + reported.add(name) + problems.append( + f"${name} is used before the ordered runbook assigns it: " + f"{command[:72]}" + ) + for line in command.split(";"): + match = _VARIABLE_ASSIGNMENT.match(line.strip()) + if match: + assigned.add(match.group(1)) + for marker in (": \"${", "read -r "): + if marker in command: + for name in _VARIABLE_USE.findall(command): + assigned.add(name) + return problems + + FAIL_FAST_CONTRACT = "set -euo pipefail" @@ -980,6 +1095,56 @@ def _workflow_jobs(workflow: str) -> dict[str, Any]: return jobs if isinstance(jobs, dict) else {} +def _workflow_dispatch_inputs(workflow: str) -> dict[str, Any]: + try: + parsed = yaml.safe_load(workflow) if workflow.strip() else {} + except yaml.YAMLError: + return {} + if not isinstance(parsed, dict): + return {} + # ``on`` is parsed as the boolean ``True`` by YAML 1.1 loaders. + triggers = parsed.get("on", parsed.get(True)) + if not isinstance(triggers, dict): + return {} + dispatch = triggers.get("workflow_dispatch") + if not isinstance(dispatch, dict): + return {} + inputs = dispatch.get("inputs") + return inputs if isinstance(inputs, dict) else {} + + +def _dispatch_sha_gate_holds(workflow: str, workflow_jobs: dict[str, Any]) -> bool: + """Require every dispatched build and publish to name the exact commit. + + A dispatch that only names a ref can build whatever that ref points at when + the job starts, so the expected commit is a required input and the first + job refuses to let anything else run. + """ + + expected = _workflow_dispatch_inputs(workflow).get("expected_sha") + if not isinstance(expected, dict): + return False + if expected.get("required") is not True or expected.get("type") != "string": + return False + # ``safe_dump`` doubles the single quotes inside the step's shell script. + identity_text = _job_text(workflow_jobs.get("release-identity")).replace("''", "'") + if not identity_text: + return False + required_identity_fragments = ( + "inputs.expected_sha", + "github.sha", + "grep -Eq '^[0-9a-f]{40}$'", + 'test "$ACTUAL_REF" = "refs/tags/v1.4.0"', + 'test "$ACTUAL_SHA" = "$EXPECTED_SHA"', + ) + if any(fragment not in identity_text for fragment in required_identity_fragments): + return False + return all( + _needs_job(workflow_jobs.get(job_name), "release-identity") + for job_name in ("build-distributions", "publish-testpypi", "publish-pypi") + ) + + def _needs_job(job: Any, required: str) -> bool: if not isinstance(job, dict): return False @@ -1093,6 +1258,7 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: _post_merge_gate_order_problems(runbook_doc) + _board_snapshot_binding_problems(runbook_doc) + _post_merge_fail_fast_problems(runbook_doc) + + _post_merge_variable_flow_problems(runbook_doc) if runbook_doc else ["unknown release version"] ) @@ -1126,7 +1292,10 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: unsafe_package_index_docs = [ relative_path for relative_path in CURRENT_PACKAGE_INDEX_GUIDANCE_DOC_PATHS - if UNSAFE_MULTI_INDEX_MARKER in docs.get(relative_path, "") + if any( + marker in docs.get(relative_path, "") + for marker in UNSAFE_MULTI_INDEX_MARKERS + ) ] missing_public_hygiene_docs = [ relative_path @@ -1255,6 +1424,16 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: ), evidence=str(workflow_path), ), + _release_check( + check_id="release-dispatch-sha-gate", + title="Dispatched release builds are bound to the expected commit", + status=( + "pass" + if _dispatch_sha_gate_holds(workflow, workflow_jobs) + else "fail" + ), + evidence=str(workflow_path), + ), _release_check( check_id="manual-dispatch-gates", title="Manual workflow dispatch has separate TestPyPI and PyPI inputs", diff --git a/tests/test_cloud_client.py b/tests/test_cloud_client.py index 86280cb6..4a5ec195 100644 --- a/tests/test_cloud_client.py +++ b/tests/test_cloud_client.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json import subprocess import tempfile @@ -7,6 +8,8 @@ from io import StringIO from pathlib import Path +import pytest + import code_mower.cloud_client.operations as cloud_operations from code_mower.cloud_client import ( BUNDLE_MANIFEST_FILENAME, @@ -14,16 +17,19 @@ CloudBundleError, DEFAULT_SETUP_INSTALL_ID, EVENT_SCHEMA, + UPLOAD_IDENTITY_SCHEMA, build_board_snapshot_event, build_provenance_summary, build_provider_catalog_snapshot_events, build_cloud_bundle, build_upload_payload, + bundle_manifest_identity, default_setup_path, dogfood_upload, normalize_event, parse_event_args, parse_repo_sync_spec, + read_bundle_manifest, repo_slug_from_remote, repo_sync_output_name, render_cloud_doctor_text, @@ -343,6 +349,55 @@ def test_cloud_token_resolver_uses_install_id_after_restart(monkeypatch, tmp_pat assert resolution.install_id == "codex-code-mower" +def test_conflicting_ambient_cloud_variables_cannot_satisfy_an_install_gate( + monkeypatch, tmp_path +) -> None: + token_env = "CODE_MOWER_TEST_AMBIENT_TOKEN" + token_dir = tmp_path / "tokens" + token_dir.mkdir() + (token_dir / "codex-code-mower.env").write_text( + "\n".join( + [ + f"export {token_env}='cmw_live_install_secret'", + "export CODE_MOWER_CLOUD_TEAM_ID='stored-team'", + "export CODE_MOWER_INSTALL_ID='codex-code-mower'", + "export CODE_MOWER_CLOUD_ENDPOINT='https://codemower.com/api/ingest'", + ] + ) + + "\n", + encoding="utf-8", + ) + monkeypatch.setenv(token_env, "cmw_live_ambient_secret") + monkeypatch.setenv("CODE_MOWER_CLOUD_TEAM_ID", "stored-team") + monkeypatch.setenv("CODE_MOWER_INSTALL_ID", "codex-code-mower") + monkeypatch.setenv("CODE_MOWER_CLOUD_ENDPOINT", "https://attacker.example/api") + + ambient = resolve_cloud_token( + token_env=token_env, + token_dir=token_dir, + install_id="codex-code-mower", + ) + + # The ambient values mirror the asserted identities, so only the source + # discriminates a reflected environment from the stored install profile. + assert ambient.source == "env" + assert ambient.team_id == "stored-team" + assert ambient.install_id == "codex-code-mower" + assert ambient.endpoint == "https://attacker.example/api" + + monkeypatch.delenv(token_env) + monkeypatch.delenv("CODE_MOWER_CLOUD_ENDPOINT") + selected = resolve_cloud_token( + token_env=token_env, + token_dir=token_dir, + install_id="codex-code-mower", + ) + + assert selected.source == "install_id" + assert selected.token == "cmw_live_install_secret" + assert selected.endpoint == "https://codemower.com/api/ingest" + + def test_cloud_token_resolver_refuses_ambiguous_profiles(monkeypatch, tmp_path) -> None: token_env = "CODE_MOWER_TEST_AMBIGUOUS_TOKEN" token_dir = tmp_path / "tokens" @@ -624,6 +679,177 @@ def fake_post_upload_payload(**kwargs): assert "cmw_live_board_secret" not in serialized +def _init_git_checkout(path: Path) -> str: + path.mkdir(parents=True, exist_ok=True) + subprocess.run(["git", "init", "--quiet"], cwd=path, check=True) + subprocess.run(["git", "config", "user.email", "dev@example.com"], cwd=path, check=True) + subprocess.run(["git", "config", "user.name", "Dev"], cwd=path, check=True) + (path / "tracked.txt").write_text("one\n", encoding="utf-8") + subprocess.run(["git", "add", "tracked.txt"], cwd=path, check=True) + subprocess.run(["git", "commit", "--quiet", "-m", "first"], cwd=path, check=True) + return subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=path, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def _board_snapshot_dry_run(monkeypatch, repo_path: Path, output_dir: Path, **kwargs): + monkeypatch.setattr( + cloud_operations.board, + "status_payload", + kwargs.pop("status_payload", lambda _config: _board_snapshot_fixture()), + ) + monkeypatch.setattr(cloud_operations.board, "timelines_payload", lambda _config: {}) + return cloud_operations.board_snapshot_upload( + repo_path=repo_path, + output_dir=output_dir, + repo_slug="owner/repo", + team_id="team", + install_id="install", + source="unit-test", + endpoint="http://localhost:3000/api/ingest", + token_env="CODE_MOWER_TEST_BOARD_TOKEN", + yes=False, + timeout=0.1, + **kwargs, + ) + + +def test_board_snapshot_reports_producer_owned_manifest_identity(monkeypatch, tmp_path) -> None: + output_dir = tmp_path / "board-snapshot" + result = _board_snapshot_dry_run(monkeypatch, tmp_path, output_dir) + + manifest_bytes = (output_dir / BUNDLE_MANIFEST_FILENAME).read_bytes() + manifest = json.loads(manifest_bytes.decode("utf-8")) + identity = result["manifest"] + assert identity["schema"] == UPLOAD_IDENTITY_SCHEMA + assert identity["manifest_sha256"] == hashlib.sha256(manifest_bytes).hexdigest() + assert identity["event_count"] == 1 + assert identity["event_type_counts"] == {"board_snapshot": 1} + assert identity["event_ids"] == [manifest["events"][0]["event_id"]] + assert result["upload"]["event_types"] == {"board_snapshot": 1} + + +def test_manifest_identity_detects_a_same_shape_substitution(monkeypatch, tmp_path) -> None: + first_dir = tmp_path / "first" + second_dir = tmp_path / "second" + first = _board_snapshot_dry_run(monkeypatch, tmp_path, first_dir) + second = _board_snapshot_dry_run(monkeypatch, tmp_path, second_dir) + + assert first["manifest"]["event_type_counts"] == second["manifest"]["event_type_counts"] + assert first["manifest"]["event_count"] == second["manifest"]["event_count"] + # A different bundle of the very same shape is still a different manifest: + # a validator comparing producer-owned identity rejects the substitution. + assert first["manifest"]["event_ids"] != second["manifest"]["event_ids"] + assert first["manifest"]["manifest_sha256"] != second["manifest"]["manifest_sha256"] + + swapped = read_bundle_manifest(second_dir) + identity = bundle_manifest_identity(*swapped) + assert identity == second["manifest"] + assert identity != first["manifest"] + + +def test_manifest_identity_rejects_malformed_and_repeated_event_rows() -> None: + base = {"schema": "code_mower.cloudBundle.v1", "events": []} + row = {"event_id": "evt-1", "event_type": "board_snapshot"} + + with pytest.raises(CloudBundleError): + bundle_manifest_identity({**base, "events": {}}, b"{}") + with pytest.raises(CloudBundleError): + bundle_manifest_identity({**base, "events": [row, "board_snapshot"]}, b"{}") + with pytest.raises(CloudBundleError): + bundle_manifest_identity({**base, "events": [row, dict(row)]}, b"{}") + with pytest.raises(CloudBundleError): + bundle_manifest_identity( + {**base, "events": [{"event_id": "", "event_type": "board_snapshot"}]}, + b"{}", + ) + + +def test_board_snapshot_records_and_enforces_source_git_provenance(monkeypatch, tmp_path) -> None: + repo_path = tmp_path / "checkout" + head_sha = _init_git_checkout(repo_path) + + result = _board_snapshot_dry_run( + monkeypatch, + repo_path, + tmp_path / "clean", + require_head_sha=head_sha, + require_clean=True, + ) + assert result["git"] == { + "available": True, + "head_sha": head_sha, + "clean": True, + "dirty_entry_count": 0, + } + manifest = json.loads( + (tmp_path / "clean" / BUNDLE_MANIFEST_FILENAME).read_text(encoding="utf-8") + ) + event = manifest["events"][0] + assert event["dimensions"]["source_git"] == { + "available": True, + "head_sha": head_sha, + "clean": True, + "dirty_entry_count": 0, + } + + with pytest.raises(CloudBundleError): + _board_snapshot_dry_run( + monkeypatch, + repo_path, + tmp_path / "wrong-head", + require_head_sha="0" * 40, + require_clean=True, + ) + + (repo_path / "tracked.txt").write_text("two\n", encoding="utf-8") + with pytest.raises(CloudBundleError): + _board_snapshot_dry_run( + monkeypatch, + repo_path, + tmp_path / "dirty", + require_head_sha=head_sha, + require_clean=True, + ) + subprocess.run(["git", "checkout", "--", "tracked.txt"], cwd=repo_path, check=True) + + (repo_path / "untracked.txt").write_text("new\n", encoding="utf-8") + with pytest.raises(CloudBundleError): + _board_snapshot_dry_run( + monkeypatch, + repo_path, + tmp_path / "untracked", + require_head_sha=head_sha, + require_clean=True, + ) + (repo_path / "untracked.txt").unlink() + + +def test_board_snapshot_rejects_a_checkout_that_moves_during_collection(monkeypatch, tmp_path) -> None: + repo_path = tmp_path / "checkout" + head_sha = _init_git_checkout(repo_path) + + def moving_status(_config): + (repo_path / "tracked.txt").write_text("changed\n", encoding="utf-8") + subprocess.run(["git", "add", "tracked.txt"], cwd=repo_path, check=True) + subprocess.run(["git", "commit", "--quiet", "-m", "second"], cwd=repo_path, check=True) + return _board_snapshot_fixture() + + with pytest.raises(CloudBundleError): + _board_snapshot_dry_run( + monkeypatch, + repo_path, + tmp_path / "moved", + status_payload=moving_status, + require_head_sha=head_sha, + require_clean=True, + ) + + def test_cloud_repo_slug_from_remote_supports_common_github_forms() -> None: assert repo_slug_from_remote("git@github.com:codemower-ai/code-mower.git") == "codemower-ai/code-mower" assert repo_slug_from_remote("https://github.com/codemower-ai/code-mower.git") == "codemower-ai/code-mower" @@ -1290,6 +1516,98 @@ def fake_post_upload_payload(**kwargs): assert token not in out.getvalue() +def test_cloud_upload_reports_the_identity_of_the_bytes_it_sends( + monkeypatch, tmp_path +) -> None: + token_env = "CODE_MOWER_TEST_UPLOAD_IDENTITY_TOKEN" + monkeypatch.setenv(token_env, "cmw_live_identity_secret") + bundle_dir = tmp_path / "bundle" + build_cloud_bundle( + reports=[], + events=[ + build_board_snapshot_event( + repo_slug="owner/repo", + team_id="team", + install_id="install", + source="unit-test", + snapshot=_board_snapshot_fixture(), + ) + ], + output_dir=bundle_dir, + repo_slug="owner/repo", + team_id="team", + install_id="install", + anonymous=False, + ) + manifest, manifest_bytes = read_bundle_manifest(bundle_dir) + expected = bundle_manifest_identity(manifest, manifest_bytes) + + def run(*extra: str) -> dict: + out = StringIO() + with redirect_stdout(out): + status = cloud_cli.main( + [ + "upload", + str(bundle_dir), + "--endpoint", + "https://codemower.com/api/ingest", + "--token-env", + token_env, + "--json", + *extra, + ] + ) + assert status == 0 + return json.loads(out.getvalue()) + + monkeypatch.setattr( + cloud_cli, + "post_upload_payload", + lambda **kwargs: { + "mode": "cloud-upload", + "endpoint": kwargs["endpoint"], + "status": 200, + "response": {"ok": True}, + }, + ) + preview = run("--dry-run") + applied = run("--yes") + + assert preview["manifest"] == expected + assert applied["manifest"] == expected + assert expected["schema"] == UPLOAD_IDENTITY_SCHEMA + assert expected["event_type_counts"] == {"board_snapshot": 1} + + # A same-shape manifest swapped in afterwards reports a different identity. + substitute = tmp_path / "substitute" + build_cloud_bundle( + reports=[], + events=[ + build_board_snapshot_event( + repo_slug="owner/repo", + team_id="team", + install_id="install", + source="unit-test", + snapshot=_board_snapshot_fixture(), + ) + ], + output_dir=substitute, + repo_slug="owner/repo", + team_id="team", + install_id="install", + anonymous=False, + ) + (bundle_dir / "code-mower-cloud-bundle.json").write_bytes( + (substitute / "code-mower-cloud-bundle.json").read_bytes() + ) + swapped = run("--dry-run") + + assert swapped["manifest"]["event_count"] == expected["event_count"] + assert swapped["manifest"]["event_type_counts"] == expected["event_type_counts"] + assert swapped["manifest"]["manifest_sha256"] != expected["manifest_sha256"] + assert swapped["manifest"]["event_ids"] != expected["event_ids"] + + def test_cloud_doctor_warns_when_model_provenance_is_missing() -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py index fe6ee428..40ca3bc2 100644 --- a/tests/test_release_hygiene.py +++ b/tests/test_release_hygiene.py @@ -66,6 +66,35 @@ from scripts import guard_package_workflows, privacy_scan CLOUD_ENDPOINT = "https://cloud.example.invalid" +BOARD_GATE_PROVENANCE = { + "available": True, + "head_sha": "b" * 40, + "clean": True, + "dirty_entry_count": 0, +} + + +def _reported_manifest_identity(manifest_bytes: bytes) -> dict: + """Mirror the producer-owned manifest identity for runbook gate fixtures.""" + + manifest = json.loads(manifest_bytes.decode("utf-8")) + rows = manifest.get("events") + rows = rows if isinstance(rows, list) else [] + event_ids: list[str] = [] + event_type_counts: dict[str, int] = {} + for row in rows: + if not isinstance(row, dict): + continue + event_ids.append(str(row.get("event_id") or "")) + event_type = str(row.get("event_type") or "") + event_type_counts[event_type] = event_type_counts.get(event_type, 0) + 1 + return { + "schema": "code_mower.cloudUploadIdentity.v1", + "manifest_sha256": hashlib.sha256(manifest_bytes).hexdigest(), + "event_count": len(event_ids), + "event_ids": event_ids, + "event_type_counts": dict(sorted(event_type_counts.items())), + } class ReleaseHygieneTests(unittest.TestCase): @@ -122,7 +151,9 @@ def test_release_workflow_verifies_downloaded_distributions_before_publish(self) ) self.assertIn(" python -m twine check dist/*\n", workflow) publish_job = workflow.split(" publish-pypi:\n", 1)[1] - self.assertIn(" needs: verify-distributions\n", publish_job) + self.assertIn( + " needs: [release-identity, verify-distributions]\n", publish_job + ) def test_test_extra_matches_contributor_setup_docs(self) -> None: pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) @@ -246,6 +277,97 @@ def test_ruff_static_rule_stage_is_intentional(self) -> None: self.assertIn("E501", lint["ignore"]) self.assertIn("B019", lint["ignore"]) + def _dispatch_sha_gate_check(self, mutate: Callable[[str], str]) -> dict: + workflow_path = ROOT / ".github" / "workflows" / "release.yml" + original = release_readiness._read_text_if_exists + + def read(path: Path) -> str: + text = original(path) + return mutate(text) if path == workflow_path else text + + with mock.patch.object(release_readiness, "_read_text_if_exists", read): + payload = release_readiness.render_release_readiness(ROOT) + checks = {check["id"]: check for check in payload["checks"]} + return checks["release-dispatch-sha-gate"] + + def test_release_workflow_binds_every_dispatch_to_the_expected_commit( + self, + ) -> None: + workflow = (ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8") + parsed = yaml.safe_load(workflow) + triggers = parsed.get("on", parsed.get(True)) + inputs = triggers["workflow_dispatch"]["inputs"] + jobs = parsed["jobs"] + + self.assertTrue(inputs["expected_sha"]["required"]) + self.assertEqual(inputs["expected_sha"]["type"], "string") + self.assertEqual(next(iter(jobs)), "release-identity") + self.assertEqual(jobs["build-distributions"]["needs"], "release-identity") + for job_name in ("publish-testpypi", "publish-pypi"): + self.assertIn("release-identity", jobs[job_name]["needs"]) + self.assertEqual(self._dispatch_sha_gate_check(lambda text: text)["status"], "pass") + + def test_release_readiness_fails_when_the_dispatch_sha_gate_regresses(self) -> None: + for old, new in ( + (" required: true\n type: string\n", " type: string\n"), + (" release-identity:\n", " release-identity-disabled:\n"), + (" needs: release-identity\n", " needs: verify-distributions\n"), + ("[release-identity, verify-distributions]", "[verify-distributions]"), + ("grep -Eq '^[0-9a-f]{40}$'", "true"), + ('test "$ACTUAL_SHA" = "$EXPECTED_SHA"', "true"), + ('test "$ACTUAL_REF" = "refs/tags/v1.4.0"', "true"), + ): + with self.subTest(old=old): + check = self._dispatch_sha_gate_check( + lambda text, old=old, new=new: text.replace(old, new) + ) + + self.assertEqual(check["status"], "fail") + + def test_release_identity_guard_rejects_wrong_expected_commits(self) -> None: + workflow = yaml.safe_load( + (ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8") + ) + step = workflow["jobs"]["release-identity"]["steps"][0] + release_sha = "a" * 40 + cases = ( + ("", "refs/tags/v1.4.0", release_sha), + ("not-a-sha", "refs/tags/v1.4.0", release_sha), + ("b" * 40, "refs/tags/v1.4.0", release_sha), + (release_sha, "refs/heads/main", release_sha), + ) + with tempfile.TemporaryDirectory() as tmp: + script = Path(tmp) / "identity.sh" + script.write_text(step["run"], encoding="utf-8") + for expected, ref, actual in cases: + with self.subTest(expected=expected, ref=ref): + result = subprocess.run( + ["bash", str(script)], + capture_output=True, + text=True, + env={ + **os.environ, + "EXPECTED_SHA": expected, + "ACTUAL_REF": ref, + "ACTUAL_SHA": actual, + }, + ) + + self.assertNotEqual(result.returncode, 0) + ok = subprocess.run( + ["bash", str(script)], + capture_output=True, + text=True, + env={ + **os.environ, + "EXPECTED_SHA": release_sha, + "ACTUAL_REF": "refs/tags/v1.4.0", + "ACTUAL_SHA": release_sha, + }, + ) + + self.assertEqual(ok.returncode, 0, ok.stderr) + def test_release_workflow_has_separate_testpypi_publish_gate(self) -> None: workflow = (ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8") self.assertIn(" publish_testpypi:\n", workflow) @@ -254,7 +376,9 @@ def test_release_workflow_has_separate_testpypi_publish_gate(self) -> None: " publish-pypi:\n", 1, )[0] - self.assertIn(" needs: verify-distributions\n", testpypi_job) + self.assertIn( + " needs: [release-identity, verify-distributions]\n", testpypi_job + ) self.assertIn("inputs.publish_testpypi", testpypi_job) self.assertIn("inputs.publish_testpypi == true", testpypi_job) self.assertIn("github.event_name == 'workflow_dispatch'", testpypi_job) @@ -8024,6 +8148,39 @@ def drop_safe_marker(docs: dict[str, str]) -> None: self.assertEqual(check["status"], "fail") + def test_release_qualification_doc_documents_the_source_exclusive_flow( + self, + ) -> None: + doc = (ROOT / "docs" / "release-qualification.md").read_text(encoding="utf-8") + + self.assertIn("docs/release-qualification.md", release_readiness.RELEASE_DOC_PATHS) + self.assertIn( + "docs/release-qualification.md", + release_readiness.CURRENT_PACKAGE_INDEX_GUIDANCE_DOC_PATHS, + ) + for marker in release_readiness.UNSAFE_MULTI_INDEX_MARKERS: + self.assertNotIn(marker, doc) + self.assertIn("--no-deps", doc) + self.assertIn("the only configured index", doc) + self.assertIn("never\n combined in one command", doc) + + def test_package_index_rehearsal_docs_reject_qualification_doc_regression( + self, + ) -> None: + for marker in release_readiness.UNSAFE_MULTI_INDEX_MARKERS: + with self.subTest(marker=marker): + + def add_marker(docs: dict[str, str], marker: str = marker) -> None: + docs["docs/release-qualification.md"] += f"\n{marker}\n" + + check = self._package_index_docs_check(add_marker) + + self.assertEqual(check["status"], "fail") + self.assertIn( + "docs/release-qualification.md", + check["detail"]["unsafe_multi_index_docs"], + ) + def test_public_first_user_docs_omit_unsafe_testpypi_candidate_command( self, ) -> None: @@ -8487,14 +8644,16 @@ def test_release_readiness_fails_when_a_run_assertion_is_dropped(self) -> None: def test_release_readiness_fails_when_release_asset_gate_is_deleted(self) -> None: check = self._asserted_runbook_check( lambda doc: doc.replace( - 'problems.append("release asset SHA-256 values differ from PROD_DIST_DIR")', + 'problems.append("release asset SHA-256 values differ from the ' + 'PyPI-verified map")', "pass", ) ) self.assertEqual(check["status"], "fail") self.assertIn( - 'problems.append("release asset SHA-256 values differ from PROD_DIST_DIR")', + 'problems.append("release asset SHA-256 values differ from the ' + 'PyPI-verified map")', check["detail"]["missing_assertions"], ) @@ -8516,7 +8675,8 @@ def test_release_readiness_fails_when_a_workflow_identity_assertion_is_deleted( self, ) -> None: for assertion in ( - 'BUILD_JOBS = ("build-distributions", "verify-distributions")', + 'BUILD_JOBS = ("release-identity", "build-distributions", ' + '"verify-distributions")', 'if str(run.get("databaseId")) != run_id:', 'if run.get("workflowName") != EXPECTED_WORKFLOW:', 'if run.get("event") != event:', @@ -8860,7 +9020,7 @@ def test_runbook_board_restart_waits_and_polls_every_port(self) -> None: boards = runbook.partition("### 15. Restart the three Boards")[2] self.assertIn( - 'test "$(git -C "$CODE_MOWER_RELEASE_CHECKOUT" rev-parse HEAD)"' + 'test "$(git -C "$RELEASE_CHECKOUT" rev-parse HEAD)"' ' = "$RELEASE_SHA"', boards, ) @@ -9049,7 +9209,7 @@ def test_release_readiness_fails_when_release_notes_are_not_checkout_bound( ) -> None: for old, new in ( ( - '--notes-file "$CODE_MOWER_RELEASE_CHECKOUT/docs/v140-release-notes.md"', + '--notes-file "$RELEASE_CHECKOUT/docs/v140-release-notes.md"', "--notes-file docs/v140-release-notes.md", ), ( @@ -9089,6 +9249,110 @@ def test_release_readiness_fails_when_cloud_identity_binding_is_deleted( self.assertEqual(check["status"], "fail") self.assertIn(assertion, check["detail"]["missing_assertions"]) + def test_runbook_defines_every_variable_before_it_is_used(self) -> None: + runbook = self._runbook_section() + + self.assertEqual( + release_readiness._post_merge_variable_flow_problems(runbook), [] + ) + self.assertNotIn("CODE_MOWER_RELEASE_CHECKOUT", runbook) + + def test_release_readiness_fails_on_a_variable_used_before_assignment( + self, + ) -> None: + check = self._asserted_runbook_check( + lambda doc: doc.replace( + '"$RELEASE_CHECKOUT/docs/v140-release-notes.md"', + '"$CODE_MOWER_RELEASE_CHECKOUT/docs/v140-release-notes.md"', + ) + ) + + self.assertEqual(check["status"], "fail") + self.assertTrue( + any( + "CODE_MOWER_RELEASE_CHECKOUT is used before" in problem + for problem in check["detail"]["gate_order_problems"] + ), + check["detail"]["gate_order_problems"], + ) + + def test_release_readiness_fails_when_the_verified_artifact_map_is_bypassed( + self, + ) -> None: + for assertion in ( + 'PYPI_VERIFIED_MAP="$RELEASE_ENV/pypi-verified-artifacts.json"', + 'test ! -e "$PYPI_VERIFIED_MAP"', + 'problems.append("local artifacts differ from the PyPI-verified map")', + 'problems.append("release asset SHA-256 values differ from the PyPI-verified map")', + "if remote_peeled_tag_sha(repo) != release_sha:", + 'problems.append("remote v1.4.0 tag does not peel to the exact release commit")', + 'assert_release_assets.py" pre-create', + ): + with self.subTest(assertion=assertion): + check = self._asserted_runbook_check( + lambda doc, assertion=assertion: doc.replace(assertion, "true") + ) + + self.assertEqual(check["status"], "fail") + self.assertIn(assertion, check["detail"]["missing_assertions"]) + + def test_release_readiness_fails_when_producer_owned_board_evidence_is_deleted( + self, + ) -> None: + for assertion in ( + 'problems.append("board snapshot does not report the inspected manifest identity")', + 'problems.append("board upload preview does not report the inspected manifest identity")', + 'problems.append("board applied upload does not report the previewed manifest identity")', + '--require-head-sha "$RELEASE_SHA" --require-clean', + 'problems.append("board snapshot was not collected from the exact clean release checkout")', + 'problems.append("board event does not carry the release checkout provenance")', + 'raise SystemExit("a Board repository path does not match its paired slug")', + ): + with self.subTest(assertion=assertion): + check = self._asserted_runbook_check( + lambda doc, assertion=assertion: doc.replace(assertion, "true") + ) + + self.assertEqual(check["status"], "fail") + self.assertIn(assertion, check["detail"]["missing_assertions"]) + + def test_release_readiness_fails_when_ambient_cloud_overrides_are_kept( + self, + ) -> None: + for assertion in ( + "env -u CODE_MOWER_CLOUD_TOKEN -u CODE_MOWER_CLOUD_ENDPOINT", + 'if resolution.source != "install_id":', + 'grep -q \'"source": "install_id"\' "$CLOUD_DIR/identity.json"', + ): + with self.subTest(assertion=assertion): + check = self._asserted_runbook_check( + lambda doc, assertion=assertion: doc.replace(assertion, "true") + ) + + self.assertEqual(check["status"], "fail") + self.assertIn(assertion, check["detail"]["missing_assertions"]) + + def test_runbook_dispatches_name_the_expected_release_commit(self) -> None: + runbook = self._runbook_section() + + self.assertEqual( + runbook.count('\n -f expected_sha="$RELEASE_SHA"\n'), + 3, + "every workflow_dispatch must name the exact release commit", + ) + + def test_release_readiness_fails_when_a_dispatch_drops_the_expected_sha( + self, + ) -> None: + check = self._asserted_runbook_check( + lambda doc: doc.replace('-f expected_sha="$RELEASE_SHA"', "") + ) + + self.assertEqual(check["status"], "fail") + self.assertIn( + '-f expected_sha="$RELEASE_SHA"', check["detail"]["missing_assertions"] + ) + def _run_release_run_gate(self, run: dict, head_branch: str) -> subprocess.CompletedProcess: snippet = self._runbook_python_snippet("BUILD_JOBS") with tempfile.TemporaryDirectory() as tmp: @@ -9131,6 +9395,7 @@ def _release_run_payload(self, head_branch: str) -> dict: "conclusion": "success", "url": "https://example.invalid/run/4242", "jobs": [ + {"name": "release-identity", "conclusion": "success"}, {"name": "build-distributions", "conclusion": "success"}, {"name": "verify-distributions", "conclusion": "success"}, {"name": "publish-testpypi", "conclusion": "success"}, @@ -9528,6 +9793,7 @@ def _board_snapshot_fixtures(self) -> tuple[dict, dict, dict, dict]: "repo_slug": "codemower-ai/code-mower", "event_count": 1, "export": {"event_types": {"board_snapshot": 1}, "included_reports": 0}, + "git": dict(BOARD_GATE_PROVENANCE), "upload": {"endpoint": CLOUD_ENDPOINT}, "doctor": { "mode": "cloud-doctor", @@ -9554,7 +9820,8 @@ def _board_snapshot_fixtures(self) -> tuple[dict, dict, dict, dict]: "event_type": "board_snapshot", "repo_slug": "codemower-ai/code-mower", "dimensions": { - "snapshot_schema": "code_mower.cloudBoardSnapshot.v1" + "snapshot_schema": "code_mower.cloudBoardSnapshot.v1", + "source_git": dict(BOARD_GATE_PROVENANCE), }, } ], @@ -9583,6 +9850,8 @@ def _run_board_snapshot_gate( applied: dict, *, phase: str = "preflight", + snapshot_identity: dict | None = None, + preview_identity: dict | None = None, preview_swap: dict | None = None, pre_apply_swap: dict | None = None, during_apply_swap: dict | None = None, @@ -9620,16 +9889,33 @@ def write_digest(name: str) -> str: return digest manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + reported = _reported_manifest_identity(manifest_path.read_bytes()) + snapshot = {**snapshot, "manifest": snapshot_identity or reported} + preview = {**preview, "manifest": preview_identity or reported} + (cloud_dir / "board-snapshot.json").write_text( + json.dumps(snapshot), encoding="utf-8" + ) + (cloud_dir / "board-preview.json").write_text( + json.dumps(preview), encoding="utf-8" + ) write_digest("board-bundle-before-preview.sha256") if preview_swap is not None: manifest_path.write_text(json.dumps(preview_swap), encoding="utf-8") previewed = write_digest("board-bundle-after-preview.sha256") (cloud_dir / "board-preflight.json").write_text( json.dumps( - {"board_preview": "accepted", "previewed_digest": previewed} + { + "board_preview": "accepted", + "previewed_digest": previewed, + "previewed_identity": preview_identity or reported, + } ), encoding="utf-8", ) + (cloud_dir / "board-applied.json").write_text( + json.dumps({**applied, "manifest": preview_identity or reported}), + encoding="utf-8", + ) if pre_apply_swap is not None: manifest_path.write_text(json.dumps(pre_apply_swap), encoding="utf-8") write_digest("board-bundle-before-apply.sha256") @@ -9643,6 +9929,7 @@ def write_digest(name: str) -> str: { "CLOUD_DIR": str(cloud_dir), "BOARD_SNAPSHOT_DIR": str(bundle_dir), + "RELEASE_SHA": BOARD_GATE_PROVENANCE["head_sha"], }, ) @@ -9657,6 +9944,56 @@ def test_runbook_board_snapshot_gate_accepts_the_release_bundle(self) -> None: self.assertEqual(applied.returncode, 0, applied.stderr) self.assertEqual(json.loads(applied.stdout)["board_upload"], "accepted") + def test_runbook_board_snapshot_gate_rejects_foreign_checkout_provenance( + self, + ) -> None: + snapshot, manifest, preview, applied = self._board_snapshot_fixtures() + wrong_head = copy.deepcopy(snapshot) + wrong_head["git"]["head_sha"] = "c" * 40 + dirty = copy.deepcopy(snapshot) + dirty["git"].update({"clean": False, "dirty_entry_count": 2}) + + for mutated in (wrong_head, dirty): + with self.subTest(head=mutated["git"]["head_sha"]): + result = self._run_board_snapshot_gate( + mutated, manifest, preview, applied + ) + + self.assertEqual(result.returncode, 1) + self.assertIn( + "board snapshot was not collected from the exact clean " + "release checkout", + result.stderr, + ) + + def test_runbook_board_snapshot_gate_rejects_producer_identity_disagreement( + self, + ) -> None: + snapshot, manifest, preview, applied = self._board_snapshot_fixtures() + other = copy.deepcopy(manifest) + other["events"][0]["event_id"] = "evt-board-2" + substituted = _reported_manifest_identity( + json.dumps(other).encode("utf-8") + ) + + snapshot_mismatch = self._run_board_snapshot_gate( + snapshot, manifest, preview, applied, snapshot_identity=substituted + ) + preview_mismatch = self._run_board_snapshot_gate( + snapshot, manifest, preview, applied, preview_identity=substituted + ) + + self.assertEqual(snapshot_mismatch.returncode, 1) + self.assertIn( + "board snapshot does not report the inspected manifest identity", + snapshot_mismatch.stderr, + ) + self.assertEqual(preview_mismatch.returncode, 1) + self.assertIn( + "board upload preview does not report the inspected manifest identity", + preview_mismatch.stderr, + ) + def test_runbook_board_snapshot_gate_rejects_a_swapped_bundle(self) -> None: snapshot, manifest, preview, applied = self._board_snapshot_fixtures() swapped = copy.deepcopy(manifest) From ff341e014b7bc64ad482e3e5657bee4ce19b72db Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 07:11:32 +0000 Subject: [PATCH 12/20] Keep the cloud client tests importable in the unittest package lane Co-Authored-By: bot_apk --- tests/test_cloud_client.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/tests/test_cloud_client.py b/tests/test_cloud_client.py index 4a5ec195..d51acb3b 100644 --- a/tests/test_cloud_client.py +++ b/tests/test_cloud_client.py @@ -4,12 +4,11 @@ import json import subprocess import tempfile +import unittest from contextlib import redirect_stdout from io import StringIO from pathlib import Path -import pytest - import code_mower.cloud_client.operations as cloud_operations from code_mower.cloud_client import ( BUNDLE_MANIFEST_FILENAME, @@ -42,6 +41,10 @@ ) from code_mower import cloud as cloud_cli +# The package lane loads this module with plain unittest, which has no pytest +# available, so exception expectations come from unittest itself. +assert_raises = unittest.TestCase().assertRaises + def _board_snapshot_fixture() -> dict[str, object]: return { @@ -756,13 +759,13 @@ def test_manifest_identity_rejects_malformed_and_repeated_event_rows() -> None: base = {"schema": "code_mower.cloudBundle.v1", "events": []} row = {"event_id": "evt-1", "event_type": "board_snapshot"} - with pytest.raises(CloudBundleError): + with assert_raises(CloudBundleError): bundle_manifest_identity({**base, "events": {}}, b"{}") - with pytest.raises(CloudBundleError): + with assert_raises(CloudBundleError): bundle_manifest_identity({**base, "events": [row, "board_snapshot"]}, b"{}") - with pytest.raises(CloudBundleError): + with assert_raises(CloudBundleError): bundle_manifest_identity({**base, "events": [row, dict(row)]}, b"{}") - with pytest.raises(CloudBundleError): + with assert_raises(CloudBundleError): bundle_manifest_identity( {**base, "events": [{"event_id": "", "event_type": "board_snapshot"}]}, b"{}", @@ -797,7 +800,7 @@ def test_board_snapshot_records_and_enforces_source_git_provenance(monkeypatch, "dirty_entry_count": 0, } - with pytest.raises(CloudBundleError): + with assert_raises(CloudBundleError): _board_snapshot_dry_run( monkeypatch, repo_path, @@ -807,7 +810,7 @@ def test_board_snapshot_records_and_enforces_source_git_provenance(monkeypatch, ) (repo_path / "tracked.txt").write_text("two\n", encoding="utf-8") - with pytest.raises(CloudBundleError): + with assert_raises(CloudBundleError): _board_snapshot_dry_run( monkeypatch, repo_path, @@ -818,7 +821,7 @@ def test_board_snapshot_records_and_enforces_source_git_provenance(monkeypatch, subprocess.run(["git", "checkout", "--", "tracked.txt"], cwd=repo_path, check=True) (repo_path / "untracked.txt").write_text("new\n", encoding="utf-8") - with pytest.raises(CloudBundleError): + with assert_raises(CloudBundleError): _board_snapshot_dry_run( monkeypatch, repo_path, @@ -839,7 +842,7 @@ def moving_status(_config): subprocess.run(["git", "commit", "--quiet", "-m", "second"], cwd=repo_path, check=True) return _board_snapshot_fixture() - with pytest.raises(CloudBundleError): + with assert_raises(CloudBundleError): _board_snapshot_dry_run( monkeypatch, repo_path, From 36b2c1577224e41ac9979dce3a1efe58fad9f959 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 07:42:21 +0000 Subject: [PATCH 13/20] Bind release evidence to exact bytes, checkouts, and profiles Co-Authored-By: bot_apk --- docs/pypi-release.md | 34 ++- src/code_mower/cloud_client/export.py | 12 +- src/code_mower/cloud_client/git_metadata.py | 34 +++ src/code_mower/cloud_client/operations.py | 16 +- src/code_mower/cloud_client/tokens.py | 23 ++- src/code_mower/release_readiness.py | 101 ++++++++- tests/test_cloud_client.py | 176 +++++++++++++++- tests/test_release_hygiene.py | 217 ++++++++++++++++++++ 8 files changed, 588 insertions(+), 25 deletions(-) diff --git a/docs/pypi-release.md b/docs/pypi-release.md index e18cb413..e15dc518 100644 --- a/docs/pypi-release.md +++ b/docs/pypi-release.md @@ -636,6 +636,28 @@ def main() -> None: ).stdout.strip() if tag_target != release_sha: problems.append("release tag does not target the exact release commit") + # The notes are read from the clean checkout of the exact release commit on + # every invocation, so neither an ambient working copy nor notes edited + # after the earlier checks can describe the published release. + checkout = Path(os.environ["RELEASE_CHECKOUT"]) + checkout_head = subprocess.run( + ["git", "-C", str(checkout), "rev-parse", "HEAD"], + check=True, capture_output=True, text=True, + ).stdout.strip() + if checkout_head != release_sha: + problems.append("release checkout is not the exact release commit") + checkout_status = subprocess.run( + ["git", "-C", str(checkout), "status", "--porcelain", "--untracked-files=all"], + check=True, capture_output=True, text=True, + ).stdout.strip() + if checkout_status: + problems.append("release checkout has uncommitted or untracked changes") + notes_path = checkout / RELEASE_NOTES_RELPATH + expected_notes = ( + notes_path.read_text(encoding="utf-8").strip() if notes_path.is_file() else "" + ) + if not expected_notes: + problems.append("release notes in the exact checkout are empty") if mode == "pre-create": if problems: raise SystemExit(f"{mode} release assets are not acceptable: {problems}") @@ -644,20 +666,16 @@ def main() -> None: "assets": sorted(verified), "sha256_match": True, "remote_tag_match": True, + "checkout_match": True, + "notes_present": True, }, sort_keys=True)) return - # The notes are read from the clean checkout of the exact release commit, so - # an ambient working copy cannot describe the published release. - notes_path = Path(os.environ["RELEASE_CHECKOUT"]) / RELEASE_NOTES_RELPATH - expected_notes = notes_path.read_text(encoding="utf-8").strip() view = gh_json([ "release", "view", "v1.4.0", "--repo", repo, "--json", "tagName,isDraft,isPrerelease,assets,body,name", ]) if view.get("tagName") != "v1.4.0": problems.append("release tag is not v1.4.0") - if not expected_notes: - problems.append("release notes in the exact checkout are empty") if str(view.get("body") or "").replace("\r\n", "\n").strip() != expected_notes: problems.append("release body does not match the exact checkout release notes") if view.get("name") != EXPECTED_TITLE: @@ -699,7 +717,9 @@ runbook for inspection. ```bash set -euo pipefail -test -f "$RELEASE_CHECKOUT/docs/v140-release-notes.md" +test "$(git -C "$RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA" +test -z "$(git -C "$RELEASE_CHECKOUT" status --porcelain --untracked-files=all)" +test -s "$RELEASE_CHECKOUT/docs/v140-release-notes.md" test -s "$PYPI_VERIFIED_MAP" if gh release view v1.4.0 --repo "$REPO" >/dev/null 2>&1; then REPO="$REPO" PROD_DIST_DIR="$PROD_DIST_DIR" RELEASE_SHA="$RELEASE_SHA" \ diff --git a/src/code_mower/cloud_client/export.py b/src/code_mower/cloud_client/export.py index 2d242309..9bb253a7 100644 --- a/src/code_mower/cloud_client/export.py +++ b/src/code_mower/cloud_client/export.py @@ -19,6 +19,7 @@ from .adoption_runs import ADOPTION_RUN_EVENT_TYPE from .errors import CloudBundleError from .events import normalize_event, safe_kind, validate_cloud_event +from .manifest import bundle_manifest_identity INVENTORY_ONLY_PROVENANCE_EVENT_TYPES = {"provider_catalog_snapshot"} @@ -239,11 +240,13 @@ def build_cloud_bundle( readme = output_dir / "README.md" manifest_tmp = output_dir / f".{BUNDLE_MANIFEST_FILENAME}.tmp" readme_tmp = output_dir / ".README.md.tmp" + # The identity comes from the exact bytes written here, so a later reader + # that sees a replaced manifest of the same shape cannot claim this export + # produced it. + manifest_bytes = (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode("utf-8") + manifest_identity = bundle_manifest_identity(manifest, manifest_bytes) try: - manifest_tmp.write_text( - json.dumps(manifest, indent=2, sort_keys=True) + "\n", - encoding="utf-8", - ) + manifest_tmp.write_bytes(manifest_bytes) except OSError as exc: shutil.rmtree(stage_dir, ignore_errors=True) raise CloudBundleError(f"unable to write bundle manifest {manifest_tmp}: {exc}") from exc @@ -265,6 +268,7 @@ def build_cloud_bundle( "manifest": str(manifest_path), "readme": str(readme), "included_reports": included_reports, + "manifest_identity": manifest_identity, "event_count": len(manifest["events"]), "event_types": event_type_counts(manifest["events"]), "productivity_window_event_count": productivity_window_event_count, diff --git a/src/code_mower/cloud_client/git_metadata.py b/src/code_mower/cloud_client/git_metadata.py index 7ec11d73..ad7edb16 100644 --- a/src/code_mower/cloud_client/git_metadata.py +++ b/src/code_mower/cloud_client/git_metadata.py @@ -2,8 +2,11 @@ from __future__ import annotations +import os import re import subprocess +from collections.abc import Iterator +from contextlib import contextmanager from pathlib import Path from typing import Any @@ -107,6 +110,37 @@ def checkout_provenance(repo_path: Path, *, required: bool = False) -> dict[str, } +@contextmanager +def index_mutation_guard(repo_path: Path) -> Iterator[Path]: + """Hold the worktree's Git index lock for the duration of the block. + + Sampling the checkout before and after a read cannot detect a checkout that + moves away and back again in between. Holding the lock Git itself takes for + any index mutation makes checkouts, merges, and resets fail while the + measured data is read, so accepted evidence is attributable to one commit. + """ + + lock_path = Path(_required_git_output(repo_path, ["rev-parse", "--git-path", "index.lock"]).strip()) + if not lock_path.is_absolute(): + lock_path = repo_path / lock_path + try: + descriptor = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) + except OSError as exc: + raise CloudBundleError( + f"unable to hold the git index lock for {repo_path}: {exc}" + ) from exc + try: + os.close(descriptor) + yield lock_path + finally: + try: + lock_path.unlink(missing_ok=True) + except OSError as exc: + raise CloudBundleError( + f"unable to release the git index lock for {repo_path}: {exc}" + ) from exc + + def require_checkout_provenance( provenance: dict[str, Any], *, diff --git a/src/code_mower/cloud_client/operations.py b/src/code_mower/cloud_client/operations.py index dc51521d..98528920 100644 --- a/src/code_mower/cloud_client/operations.py +++ b/src/code_mower/cloud_client/operations.py @@ -42,6 +42,7 @@ from .git_metadata import ( checkout_provenance, detect_repo_slug, + index_mutation_guard, require_checkout_provenance, ) from .productivity_windows import load_productivity_window_events @@ -359,8 +360,15 @@ def board_snapshot_upload( expected_head_sha=require_head_sha, require_clean=require_clean, ) - snapshot = board.status_payload(config) - snapshot["timelines"] = board.timelines_payload(config) + if provenance_required: + # The index lock is held across both Board reads so a checkout cannot + # move to another commit and back between the two provenance samples. + with index_mutation_guard(repo_path): + snapshot = board.status_payload(config) + snapshot["timelines"] = board.timelines_payload(config) + else: + snapshot = board.status_payload(config) + snapshot["timelines"] = board.timelines_payload(config) collected_provenance = checkout_provenance(repo_path, required=provenance_required) if collected_provenance != provenance: raise CloudBundleError( @@ -413,6 +421,10 @@ def board_snapshot_upload( bundle_dir=output_dir, include_reports=False, ) + if manifest_identity != export_result["manifest_identity"]: + raise CloudBundleError( + "the exported Board snapshot manifest was replaced before upload" + ) if not yes: return { "mode": "cloud-board-snapshot", diff --git a/src/code_mower/cloud_client/tokens.py b/src/code_mower/cloud_client/tokens.py index f0763f2b..84094ad8 100644 --- a/src/code_mower/cloud_client/tokens.py +++ b/src/code_mower/cloud_client/tokens.py @@ -382,9 +382,28 @@ def resolve_cloud_identity( install_id: str, resolution: CloudTokenResolution, ) -> tuple[str, str]: + """Return the team and install identity every producer must report. + + A requested identity never silently overrides the resolved install profile: + a profile that carries a different nonempty identity means the caller is + describing one install while the token and endpoint come from another. + """ + + requested_team = team_id or os.environ.get(DEFAULT_TEAM_ID_ENV, "") + requested_install = install_id or os.environ.get(DEFAULT_INSTALL_ID_ENV, "") + for label, requested, stored in ( + ("team", requested_team, resolution.team_id), + ("install", requested_install, resolution.install_id), + ): + if requested and stored and requested != stored: + raise CloudBundleError( + f"the resolved cloud install profile reports a different {label} " + "identity than the one requested; re-select the profile or drop " + "the explicit value" + ) return ( - team_id or os.environ.get(DEFAULT_TEAM_ID_ENV, "") or resolution.team_id, - install_id or os.environ.get(DEFAULT_INSTALL_ID_ENV, "") or resolution.install_id, + requested_team or resolution.team_id, + requested_install or resolution.install_id, ) diff --git a/src/code_mower/release_readiness.py b/src/code_mower/release_readiness.py index 34b8c9cc..eb5dcf3e 100644 --- a/src/code_mower/release_readiness.py +++ b/src/code_mower/release_readiness.py @@ -449,8 +449,15 @@ def _post_merge_runbook_assertions(version: str, release_tag: str) -> tuple[str, # Notes come from the clean checkout of the exact release commit, and # the published body and title are compared with that file. '--notes-file "$RELEASE_CHECKOUT/docs/v140-release-notes.md"', - 'notes_path = Path(os.environ["RELEASE_CHECKOUT"]) / RELEASE_NOTES_RELPATH', + "notes_path = checkout / RELEASE_NOTES_RELPATH", 'problems.append("release notes in the exact checkout are empty")', + # The late gate binds the accepted or created release to the exact + # clean checkout immediately before the existing/pre-create branch. + 'test "$(git -C "$RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA"', + 'test -z "$(git -C "$RELEASE_CHECKOUT" status --porcelain --untracked-files=all)"', + 'test -s "$RELEASE_CHECKOUT/docs/v140-release-notes.md"', + 'problems.append("release checkout is not the exact release commit")', + 'problems.append("release checkout has uncommitted or untracked changes")', 'problems.append("release body does not match the exact checkout release notes")', 'problems.append("release title is not the expected v1.4.0 title")', # Hosted Devin readiness is required, not reported. @@ -776,6 +783,39 @@ def _board_snapshot_binding_problems(runbook_doc: str) -> list[str]: return [] +RELEASE_CREATE_BRANCH = 'if gh release view v1.4.0 --repo "$REPO" >/dev/null 2>&1; then' +RELEASE_CREATE_BINDING_ASSERTIONS = ( + 'test "$(git -C "$RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA"', + 'test -z "$(git -C "$RELEASE_CHECKOUT" status --porcelain --untracked-files=all)"', + 'test -s "$RELEASE_CHECKOUT/docs/v140-release-notes.md"', + 'test -s "$PYPI_VERIFIED_MAP"', +) + + +def _release_create_binding_problems(runbook_doc: str) -> list[str]: + """Require the exact clean checkout immediately before the Release branch. + + Accepting an existing release or creating one publishes notes read from the + checkout, so a checkout that moved or became dirty after the earlier + assertions must stop the runbook before either branch runs. + """ + + start = runbook_doc.find(RELEASE_CREATE_BRANCH) + if start < 0: + return ["the GitHub Release creation branch is missing"] + preceding = [ + line.strip() + for line in runbook_doc[:start].splitlines() + if line.strip() and not line.strip().startswith("#") + ] + if preceding[-4:] != list(RELEASE_CREATE_BINDING_ASSERTIONS): + return [ + "the exact clean release checkout is not re-bound immediately " + "before the GitHub Release branch" + ] + return [] + + def _post_merge_gate_order_problems(runbook_doc: str) -> list[str]: problems: list[str] = [] for name, sequence in _post_merge_runbook_gate_orders(): @@ -1145,6 +1185,37 @@ def _dispatch_sha_gate_holds(workflow: str, workflow_jobs: dict[str, Any]) -> bo ) +RELEASE_WORKFLOW_DISPATCH_COMMAND = "gh workflow run release.yml" + + +def _incomplete_dispatch_actions( + workflow: str, next_actions: list[dict[str, Any]] +) -> list[str]: + """Report advertised workflow dispatches missing a required input. + + A readiness report that passes while every advertised dispatch is rejected + at submission is worse than no next action at all, so each generated + command must supply every input the workflow marks required. + """ + + required_inputs = sorted( + name + for name, spec in _workflow_dispatch_inputs(workflow).items() + if isinstance(spec, dict) and spec.get("required") is True + ) + problems: list[str] = [] + for action in next_actions: + command = str(action.get("command") or "") + if RELEASE_WORKFLOW_DISPATCH_COMMAND not in command: + continue + problems.extend( + f"{action.get('id')} omits -f {name}=" + for name in required_inputs + if f"-f {name}=" not in command + ) + return problems + + def _needs_job(job: Any, required: str) -> bool: if not isinstance(job, dict): return False @@ -1257,6 +1328,7 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: gate_order_problems = ( _post_merge_gate_order_problems(runbook_doc) + _board_snapshot_binding_problems(runbook_doc) + + _release_create_binding_problems(runbook_doc) + _post_merge_fail_fast_problems(runbook_doc) + _post_merge_variable_flow_problems(runbook_doc) if runbook_doc @@ -1617,10 +1689,6 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: detail={"missing_terms_by_doc": missing_redaction_terms}, ), ] - failed = sum(1 for check in checks if check["status"] == "fail") - warnings = sum(1 for check in checks if check["status"] == "warn") - passed = sum(1 for check in checks if check["status"] == "pass") - status = "pass" if failed == 0 else "fail" release_workflow_ref = release_tag or "main" next_actions = [ { @@ -1629,7 +1697,8 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: "command": ( "gh workflow run release.yml --repo codemower-ai/code-mower " f"--ref {release_workflow_ref} " - "-f publish_testpypi=false -f publish_pypi=false" + "-f publish_testpypi=false -f publish_pypi=false " + '-f expected_sha="$RELEASE_SHA"' ), "url": PACKAGE_INDEX_SETUP_URLS["release_workflow"], }, @@ -1639,7 +1708,8 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: "command": ( "gh workflow run release.yml --repo codemower-ai/code-mower " f"--ref {release_workflow_ref} " - "-f publish_testpypi=true -f publish_pypi=false" + "-f publish_testpypi=true -f publish_pypi=false " + '-f expected_sha="$RELEASE_SHA"' ), "url": PACKAGE_INDEX_SETUP_URLS["release_workflow"], }, @@ -1662,7 +1732,8 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: "command": ( "gh workflow run release.yml --repo codemower-ai/code-mower " f"--ref {release_workflow_ref} " - "-f publish_testpypi=false -f publish_pypi=true" + "-f publish_testpypi=false -f publish_pypi=true " + '-f expected_sha="$RELEASE_SHA"' ), "url": PACKAGE_INDEX_SETUP_URLS["release_workflow"], }, @@ -1699,6 +1770,20 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: "url": PACKAGE_INDEX_SETUP_URLS["release_workflow"], }, ] + incomplete_dispatch_actions = _incomplete_dispatch_actions(workflow, next_actions) + checks.append( + _release_check( + check_id="release-workflow-next-actions-dispatchable", + title="Advertised release workflow dispatches supply every required input", + status="pass" if not incomplete_dispatch_actions else "fail", + evidence="release-readiness next actions, .github/workflows/release.yml", + detail={"incomplete_dispatch_actions": incomplete_dispatch_actions}, + ) + ) + failed = sum(1 for check in checks if check["status"] == "fail") + warnings = sum(1 for check in checks if check["status"] == "warn") + passed = sum(1 for check in checks if check["status"] == "pass") + status = "pass" if failed == 0 else "fail" return { "mode": "code-mower-release-readiness", "status": status, diff --git a/tests/test_cloud_client.py b/tests/test_cloud_client.py index d51acb3b..2bfbba57 100644 --- a/tests/test_cloud_client.py +++ b/tests/test_cloud_client.py @@ -14,6 +14,7 @@ BUNDLE_MANIFEST_FILENAME, CURRENT_PROFILE_FILENAME, CloudBundleError, + CloudTokenResolution, DEFAULT_SETUP_INSTALL_ID, EVENT_SCHEMA, UPLOAD_IDENTITY_SCHEMA, @@ -32,6 +33,7 @@ repo_slug_from_remote, repo_sync_output_name, render_cloud_doctor_text, + resolve_cloud_identity, resolve_cloud_token, run_cloud_doctor, run_cloud_setup, @@ -835,11 +837,17 @@ def test_board_snapshot_records_and_enforces_source_git_provenance(monkeypatch, def test_board_snapshot_rejects_a_checkout_that_moves_during_collection(monkeypatch, tmp_path) -> None: repo_path = tmp_path / "checkout" head_sha = _init_git_checkout(repo_path) + index_mutations: list[int] = [] def moving_status(_config): (repo_path / "tracked.txt").write_text("changed\n", encoding="utf-8") - subprocess.run(["git", "add", "tracked.txt"], cwd=repo_path, check=True) - subprocess.run(["git", "commit", "--quiet", "-m", "second"], cwd=repo_path, check=True) + index_mutations.append( + subprocess.run( + ["git", "add", "tracked.txt"], + cwd=repo_path, + capture_output=True, + ).returncode + ) return _board_snapshot_fixture() with assert_raises(CloudBundleError): @@ -851,6 +859,170 @@ def moving_status(_config): require_head_sha=head_sha, require_clean=True, ) + assert index_mutations and all(code != 0 for code in index_mutations) + + +def test_board_snapshot_blocks_an_a_b_a_checkout_during_collection(monkeypatch, tmp_path) -> None: + repo_path = tmp_path / "checkout" + first_sha = _init_git_checkout(repo_path) + (repo_path / "tracked.txt").write_text("two\n", encoding="utf-8") + subprocess.run(["git", "add", "tracked.txt"], cwd=repo_path, check=True) + subprocess.run(["git", "commit", "--quiet", "-m", "second"], cwd=repo_path, check=True) + second_sha = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repo_path, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + subprocess.run(["git", "checkout", "--quiet", first_sha], cwd=repo_path, check=True) + + switches: list[int] = [] + + def switching_status(_config): + for target in (second_sha, first_sha): + switches.append( + subprocess.run( + ["git", "checkout", "--quiet", target], + cwd=repo_path, + capture_output=True, + ).returncode + ) + return _board_snapshot_fixture() + + result = _board_snapshot_dry_run( + monkeypatch, + repo_path, + tmp_path / "aba", + status_payload=switching_status, + require_head_sha=first_sha, + require_clean=True, + ) + # Neither switch can run while the snapshot is read, so the accepted + # evidence cannot describe data collected from the second commit. + assert switches and all(code != 0 for code in switches) + assert result["git"]["head_sha"] == first_sha + assert ( + subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repo_path, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + == first_sha + ) + + +def test_board_snapshot_rejects_a_manifest_replaced_after_export(monkeypatch, tmp_path) -> None: + substitute_dir = tmp_path / "substitute" + substitute = _board_snapshot_dry_run(monkeypatch, tmp_path, substitute_dir) + replacement = (substitute_dir / BUNDLE_MANIFEST_FILENAME).read_bytes() + + output_dir = tmp_path / "board-snapshot" + real_doctor = cloud_operations.run_cloud_doctor + + def replacing_doctor(**kwargs): + (output_dir / BUNDLE_MANIFEST_FILENAME).write_bytes(replacement) + return real_doctor(**kwargs) + + monkeypatch.setattr(cloud_operations, "run_cloud_doctor", replacing_doctor) + with assert_raises(CloudBundleError): + _board_snapshot_dry_run(monkeypatch, tmp_path, output_dir) + assert substitute["manifest"]["manifest_sha256"] == hashlib.sha256(replacement).hexdigest() + + +def test_cloud_export_owns_the_identity_of_the_manifest_bytes_it_writes(tmp_path) -> None: + output_dir = tmp_path / "bundle" + export = build_cloud_bundle( + reports=[], + events=[ + { + "event_type": "dogfood_upload", + "repo_slug": "owner/repo", + "dimensions": {"lane": "unit-test"}, + } + ], + output_dir=output_dir, + repo_slug="owner/repo", + ) + manifest_bytes = (output_dir / BUNDLE_MANIFEST_FILENAME).read_bytes() + assert export["manifest_identity"] == bundle_manifest_identity( + json.loads(manifest_bytes.decode("utf-8")), + manifest_bytes, + ) + + +def test_resolve_cloud_identity_rejects_a_profile_that_disagrees_with_explicit_values( + monkeypatch, +) -> None: + monkeypatch.delenv("CODE_MOWER_CLOUD_TEAM_ID", raising=False) + monkeypatch.delenv("CODE_MOWER_INSTALL_ID", raising=False) + resolution = CloudTokenResolution( + status="ok", + token_env="CODE_MOWER_CLOUD_TOKEN", + source="install_id", + token="cmw_live_secret", + team_id="stored-team", + install_id="stored-install", + ) + + assert resolve_cloud_identity( + team_id="stored-team", + install_id="stored-install", + resolution=resolution, + ) == ("stored-team", "stored-install") + + with assert_raises(CloudBundleError): + resolve_cloud_identity( + team_id="other-team", + install_id="stored-install", + resolution=resolution, + ) + with assert_raises(CloudBundleError): + resolve_cloud_identity( + team_id="stored-team", + install_id="other-install", + resolution=resolution, + ) + + # A profile that omits an identity leaves the explicit value in force. + partial = CloudTokenResolution( + status="ok", + token_env="CODE_MOWER_CLOUD_TOKEN", + source="install_id", + token="cmw_live_secret", + install_id="stored-install", + ) + assert resolve_cloud_identity( + team_id="explicit-team", + install_id="stored-install", + resolution=partial, + ) == ("explicit-team", "stored-install") + + +def test_board_snapshot_refuses_to_preview_a_conflicting_profile_identity( + monkeypatch, tmp_path +) -> None: + conflicting = CloudTokenResolution( + status="ok", + token_env="CODE_MOWER_TEST_BOARD_TOKEN", + source="install_id", + token="cmw_live_board_secret", + endpoint="http://localhost:3000/api/ingest", + team_id="stored-team", + install_id="stored-install", + ) + monkeypatch.setattr( + cloud_operations, + "_resolve_upload_profile", + lambda **_kwargs: (conflicting, "http://localhost:3000/api/ingest"), + ) + output_dir = tmp_path / "conflicting" + + with assert_raises(CloudBundleError): + _board_snapshot_dry_run(monkeypatch, tmp_path, output_dir) + assert not output_dir.exists() def test_cloud_repo_slug_from_remote_supports_common_github_forms() -> None: diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py index 40ca3bc2..c97f2d21 100644 --- a/tests/test_release_hygiene.py +++ b/tests/test_release_hygiene.py @@ -9419,6 +9419,223 @@ def test_release_run_gate_rejects_another_tag_on_the_same_commit(self) -> None: self.assertNotEqual(completed.returncode, 0) self.assertIn("head branch is v1.4.0rc1, not v1.4.0", completed.stderr) + def test_release_readiness_next_actions_supply_the_required_dispatch_sha( + self, + ) -> None: + payload = release_readiness.render_release_readiness(ROOT) + checks = {check["id"]: check for check in payload["checks"]} + commands = { + action["id"]: action["command"] for action in payload["next_actions"] + } + dispatches = [ + action + for action in payload["next_actions"] + if "gh workflow run release.yml" in action["command"] + ] + + self.assertEqual( + checks["release-workflow-next-actions-dispatchable"]["status"], "pass" + ) + self.assertEqual(len(dispatches), 3) + for action_id in ( + "dry-run-release-workflow", + "publish-testpypi-candidate", + "publish-pypi-release", + ): + with self.subTest(action=action_id): + self.assertIn('-f expected_sha="$RELEASE_SHA"', commands[action_id]) + + def test_release_readiness_rejects_a_dispatch_action_without_the_expected_sha( + self, + ) -> None: + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text( + encoding="utf-8" + ) + payload = release_readiness.render_release_readiness(ROOT) + + for action in payload["next_actions"]: + if "gh workflow run release.yml" not in action["command"]: + continue + with self.subTest(action=action["id"]): + stripped = [ + { + **action, + "command": action["command"].replace( + ' -f expected_sha="$RELEASE_SHA"', "" + ), + } + ] + + self.assertEqual( + release_readiness._incomplete_dispatch_actions(workflow, stripped), + [f"{action['id']} omits -f expected_sha="], + ) + + def _run_release_assets_gate( + self, mutate: Callable[[Path], None] | None = None + ) -> subprocess.CompletedProcess: + snippet = self._runbook_python_snippet('EXPECTED_TITLE = "Code Mower v1.4.0"') + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + checkout = root / "checkout" + (checkout / "docs").mkdir(parents=True) + (checkout / "docs" / "v140-release-notes.md").write_text( + "Code Mower v1.4.0 release notes\n", encoding="utf-8" + ) + + def git(*args: str) -> str: + return subprocess.run( + ["git", "-C", str(checkout), *args], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + git("init") + git("config", "user.email", "release@example.invalid") + git("config", "user.name", "Release Bot") + git("add", "docs/v140-release-notes.md") + git("commit", "-m", "notes") + git("tag", "v1.4.0") + release_sha = git("rev-parse", "HEAD") + dist = root / "dist" + dist.mkdir() + verified_map = {} + for name in ( + "code_mower-1.4.0-py3-none-any.whl", + "code_mower-1.4.0.tar.gz", + ): + artifact = dist / name + artifact.write_bytes(name.encode("utf-8")) + verified_map[name] = hashlib.sha256( + artifact.read_bytes() + ).hexdigest() + verified_path = root / "pypi-verified-artifacts.json" + verified_path.write_text(json.dumps(verified_map), encoding="utf-8") + stub_dir = root / "bin" + stub_dir.mkdir() + stub = stub_dir / "gh" + stub.write_text( + "#!/usr/bin/env bash\n" + f'printf \'{{"object": {{"type": "commit", "sha": "{release_sha}"}}}}\'\n', + encoding="utf-8", + ) + stub.chmod(0o755) + if mutate is not None: + mutate(checkout) + script = root / "assert_release_assets.py" + script.write_text(snippet, encoding="utf-8") + return subprocess.run( + [sys.executable, str(script), "pre-create"], + cwd=checkout, + capture_output=True, + text=True, + env={ + **os.environ, + "PATH": f"{stub_dir}{os.pathsep}{os.environ['PATH']}", + "REPO": "codemower-ai/code-mower", + "RELEASE_SHA": release_sha, + "PYPI_VERIFIED_MAP": str(verified_path), + "PROD_DIST_DIR": str(dist), + "RELEASE_CHECKOUT": str(checkout), + }, + ) + + def test_pre_create_gate_accepts_the_exact_clean_release_checkout(self) -> None: + completed = self._run_release_assets_gate() + + self.assertEqual(completed.returncode, 0, completed.stderr) + payload = json.loads(completed.stdout) + self.assertTrue(payload["checkout_match"]) + self.assertTrue(payload["notes_present"]) + + def test_pre_create_gate_rejects_a_late_release_checkout_mutation(self) -> None: + cases = { + "modified notes": ( + lambda checkout: (checkout / "docs" / "v140-release-notes.md").write_text( + "rewritten notes\n", encoding="utf-8" + ), + "release checkout has uncommitted or untracked changes", + ), + "untracked file": ( + lambda checkout: (checkout / "docs" / "extra.md").write_text( + "untracked\n", encoding="utf-8" + ), + "release checkout has uncommitted or untracked changes", + ), + "moved head": ( + lambda checkout: self._advance_checkout_head(checkout), + "release checkout is not the exact release commit", + ), + } + for label, (mutate, expected) in cases.items(): + with self.subTest(case=label): + completed = self._run_release_assets_gate(mutate) + + self.assertNotEqual(completed.returncode, 0, completed.stdout) + self.assertIn(expected, completed.stderr) + self.assertNotIn("sha256_match", completed.stdout) + + def _advance_checkout_head(self, checkout: Path) -> None: + (checkout / "docs" / "later.md").write_text("later\n", encoding="utf-8") + for args in ( + ("add", "docs/later.md"), + ("commit", "-m", "later"), + ): + subprocess.run( + ["git", "-C", str(checkout), *args], + check=True, + capture_output=True, + text=True, + ) + + def test_release_readiness_fails_when_the_late_release_checkout_gate_is_deleted( + self, + ) -> None: + for assertion in ( + 'test -s "$RELEASE_CHECKOUT/docs/v140-release-notes.md"', + 'problems.append("release checkout is not the exact release commit")', + 'problems.append("release checkout has uncommitted or untracked changes")', + 'problems.append("release notes in the exact checkout are empty")', + "notes_path = checkout / RELEASE_NOTES_RELPATH", + ): + with self.subTest(assertion=assertion): + check = self._asserted_runbook_check( + lambda doc, assertion=assertion: doc.replace(assertion, "true") + ) + + self.assertEqual(check["status"], "fail") + self.assertIn(assertion, check["detail"]["missing_assertions"]) + + def test_release_readiness_fails_when_the_late_gate_moves_behind_the_release( + self, + ) -> None: + runbook = self._runbook_section() + self.assertEqual( + release_readiness._release_create_binding_problems(runbook), [] + ) + + check = self._asserted_runbook_check( + lambda doc: doc.replace( + 'test -s "$RELEASE_CHECKOUT/docs/v140-release-notes.md"\n' + 'test -s "$PYPI_VERIFIED_MAP"\n' + 'if gh release view v1.4.0 --repo "$REPO" >/dev/null 2>&1; then', + 'test -s "$RELEASE_CHECKOUT/docs/v140-release-notes.md"\n' + 'test -s "$PYPI_VERIFIED_MAP"\n' + 'echo "about to release"\n' + 'if gh release view v1.4.0 --repo "$REPO" >/dev/null 2>&1; then', + ) + ) + + self.assertEqual(check["status"], "fail") + self.assertTrue( + any( + "not re-bound immediately" in problem + for problem in check["detail"]["gate_order_problems"] + ), + check["detail"]["gate_order_problems"], + ) + def _runbook_python_snippet(self, marker: str) -> str: snippets = [ snippet From 0ce1bb1b2c9fbeb1c38952351f4ee13aed753fb6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 07:48:57 +0000 Subject: [PATCH 14/20] Fetch the release tag into the exact release checkout Co-Authored-By: bot_apk --- docs/pypi-release.md | 3 +++ src/code_mower/release_readiness.py | 4 ++++ tests/test_release_hygiene.py | 22 ++++++++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/docs/pypi-release.md b/docs/pypi-release.md index e15dc518..b8358774 100644 --- a/docs/pypi-release.md +++ b/docs/pypi-release.md @@ -1069,6 +1069,9 @@ BOARD_5342_REPO_PATH="REUSE_PRIVATE_INVENTORIED_PATH" BOARD_5344_REPO="REUSE_PRIVATE_INVENTORIED_SLUG" BOARD_5344_REPO_PATH="REUSE_PRIVATE_INVENTORIED_PATH" test "$(git -C "$RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA" +# The fresh clone predates the tag, so the published tag is fetched into it +# before its target is asserted against the release commit. +git -C "$RELEASE_CHECKOUT" fetch --no-tags origin "+refs/tags/v1.4.0:refs/tags/v1.4.0" test "$(git -C "$RELEASE_CHECKOUT" rev-list -n 1 v1.4.0)" = "$RELEASE_SHA" cat >"$RELEASE_ENV/assert_board_repo_paths.py" <<'PY' diff --git a/src/code_mower/release_readiness.py b/src/code_mower/release_readiness.py index eb5dcf3e..7c152d92 100644 --- a/src/code_mower/release_readiness.py +++ b/src/code_mower/release_readiness.py @@ -485,6 +485,10 @@ def _post_merge_runbook_assertions(version: str, release_tag: str) -> tuple[str, "--pip-args='--isolated --no-cache-dir'", # Boards stop, are waited for, and only then restart from the release. 'test "$(git -C "$RELEASE_CHECKOUT" rev-parse HEAD)" = "$RELEASE_SHA"', + # The release checkout is cloned before the tag exists, so the tag is + # fetched into it before its target is compared with the release commit. + 'git -C "$RELEASE_CHECKOUT" fetch --no-tags origin "+refs/tags/v1.4.0:refs/tags/v1.4.0"', + 'test "$(git -C "$RELEASE_CHECKOUT" rev-list -n 1 v1.4.0)" = "$RELEASE_SHA"', 'board_wait.py" gone "$BOARD_PORT"', # Serving is only satisfied by the expected repository on each port. 'and row.get("repo") == expected_repo', diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py index c97f2d21..bdf664b1 100644 --- a/tests/test_release_hygiene.py +++ b/tests/test_release_hygiene.py @@ -8597,6 +8597,28 @@ def test_release_readiness_requires_asserted_release_gates(self) -> None: self.assertIn('if run.get("headSha") != head_sha:', required) self.assertIn("--set-transport devin=devin_api_v3", required) + def test_runbook_fetches_the_release_tag_into_the_release_checkout(self) -> None: + doc = (ROOT / "docs" / "pypi-release.md").read_text(encoding="utf-8") + fetch = ( + 'git -C "$RELEASE_CHECKOUT" fetch --no-tags origin ' + '"+refs/tags/v1.4.0:refs/tags/v1.4.0"' + ) + assertion = 'test "$(git -C "$RELEASE_CHECKOUT" rev-list -n 1 v1.4.0)" = "$RELEASE_SHA"' + + self.assertIn(fetch, doc) + self.assertLess(doc.index(fetch), doc.index(assertion)) + + def test_release_readiness_rejects_a_missing_release_tag_fetch(self) -> None: + check = self._asserted_runbook_check( + lambda doc: doc.replace( + 'git -C "$RELEASE_CHECKOUT" fetch --no-tags origin ' + '"+refs/tags/v1.4.0:refs/tags/v1.4.0"\n', + "", + ) + ) + + self.assertEqual(check["status"], "fail") + def test_release_readiness_rejects_mutable_main_release_binding(self) -> None: check = self._asserted_runbook_check( lambda doc: doc.replace( From 0ed457732b740984bb9d075c06176337b1cd91e3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:19:19 +0000 Subject: [PATCH 15/20] Bind public release dispatches, stored-profile identity, and exact-commit Board collection Co-Authored-By: bot_apk --- docs/first-user-install-rehearsal.md | 19 +- src/code_mower/board.py | 16 + src/code_mower/cloud.py | 11 + src/code_mower/cloud_client/__init__.py | 4 + src/code_mower/cloud_client/git_metadata.py | 75 +++-- src/code_mower/cloud_client/operations.py | 51 ++- src/code_mower/cloud_client/tokens.py | 56 +++- src/code_mower/release_campaigns.py | 23 +- src/code_mower/release_readiness.py | 72 +++- tests/test_cloud_client.py | 343 +++++++++++++++++--- tests/test_release_campaigns.py | 89 +++++ tests/test_release_hygiene.py | 81 +++++ 12 files changed, 737 insertions(+), 103 deletions(-) diff --git a/docs/first-user-install-rehearsal.md b/docs/first-user-install-rehearsal.md index 557837d3..5ee27e77 100644 --- a/docs/first-user-install-rehearsal.md +++ b/docs/first-user-install-rehearsal.md @@ -315,6 +315,19 @@ publication runs with `--ref v1.4.0`; never substitute mutable `main`, because the TestPyPI and production PyPI builds must check out identical source. +Both dispatches must name the exact release commit, because the workflow +requires an `expected_sha` input and refuses to build or publish anything else. +Bind it once from the peeled `v1.4.0` tag and assert its shape before +dispatching, in the same shell that runs both commands: + +```bash +set -euo pipefail +RELEASE_REPO=codemower-ai/code-mower +RELEASE_SHA="$(git ls-remote "https://github.com/$RELEASE_REPO.git" \ + 'refs/tags/v1.4.0^{}' | awk '{print $1}')" +printf '%s' "$RELEASE_SHA" | grep -Eq '^[0-9a-f]{40}$' +``` + First, run `release.yml` for TestPyPI only: ```bash @@ -322,7 +335,8 @@ gh workflow run release.yml \ --repo codemower-ai/code-mower \ --ref v1.4.0 \ -f publish_testpypi=true \ - -f publish_pypi=false + -f publish_pypi=false \ + -f expected_sha="$RELEASE_SHA" ``` After that workflow run finishes, record its workflow run link and qualify the @@ -348,7 +362,8 @@ gh workflow run release.yml \ --repo codemower-ai/code-mower \ --ref v1.4.0 \ -f publish_testpypi=false \ - -f publish_pypi=true + -f publish_pypi=true \ + -f expected_sha="$RELEASE_SHA" ``` After that workflow run finishes, record its workflow run link and rehearse the diff --git a/src/code_mower/board.py b/src/code_mower/board.py index 64ec111b..a9ad007d 100644 --- a/src/code_mower/board.py +++ b/src/code_mower/board.py @@ -101,6 +101,22 @@ def _campaigns_path(config: BoardConfig) -> Path: return Path(config.repo_path) / DEFAULT_CAMPAIGNS_RELATIVE_PATH +def resolved_metadata_paths(config: BoardConfig) -> dict[str, str]: + """Return the local metadata input paths one config resolves to. + + A caller that collects against a different source tree can bind these + explicitly, so the live Code Mower metadata inputs stay the ones it means + rather than following the collection source's repo path. + """ + + return { + "store_path": str(_store_path(config)), + "spend_path": str(_spend_path(config)), + "agent_adapters_path": str(_agent_adapters_path(config)), + "campaigns_path": str(_campaigns_path(config)), + } + + def _is_loopback(host: str) -> bool: return host in {"localhost", "::1"} or host.startswith("127.") diff --git a/src/code_mower/cloud.py b/src/code_mower/cloud.py index 37e3b9d3..e68e8ff2 100644 --- a/src/code_mower/cloud.py +++ b/src/code_mower/cloud.py @@ -45,6 +45,7 @@ read_token_file as _read_token_file, render_bundle_readme, render_cloud_doctor_text, + require_cloud_profile_identity, require_upload_token, resolve_cloud_endpoint, resolve_cloud_token, @@ -102,6 +103,7 @@ read_token_file as _read_token_file, render_bundle_readme, render_cloud_doctor_text, + require_cloud_profile_identity, require_upload_token, resolve_cloud_endpoint, resolve_cloud_token, @@ -155,6 +157,7 @@ "build_cloud_bundle", "build_upload_payload", "post_upload_payload", + "require_cloud_profile_identity", "require_upload_token", "render_bundle_readme", "render_setup_env", @@ -815,6 +818,14 @@ def main(argv: list[str] | None = None) -> int: install_id=args.install_id, ) resolved_endpoint = resolve_cloud_endpoint(args.endpoint, token_resolution) + # The bundle's own identity is checked against the profile that was + # just resolved, so a profile swapped after the bundle was exported + # cannot authorize this payload with a different install's token. + require_cloud_profile_identity( + team_id=str(payload.get("team_id") or ""), + install_id=str(payload.get("install_id") or ""), + resolution=token_resolution, + ) dry_run = args.dry_run or not args.yes if dry_run: preview = { diff --git a/src/code_mower/cloud_client/__init__.py b/src/code_mower/cloud_client/__init__.py index 51c84958..667cfadc 100644 --- a/src/code_mower/cloud_client/__init__.py +++ b/src/code_mower/cloud_client/__init__.py @@ -85,6 +85,7 @@ from .git_metadata import ( checkout_provenance, detect_repo_slug, + materialized_commit_source, repo_slug_from_remote, require_checkout_provenance, run_git, @@ -132,6 +133,7 @@ default_token_dir, display_token_path, read_token_profile, + require_cloud_profile_identity, require_upload_token, resolve_cloud_endpoint, resolve_cloud_identity, @@ -293,6 +295,7 @@ "build_upload_payload_with_identity", "bundle_manifest_identity", "checkout_provenance", + "materialized_commit_source", "require_checkout_provenance", "read_bundle_manifest", "UPLOAD_IDENTITY_SCHEMA", @@ -334,6 +337,7 @@ "repo_sync_output_name", "repo_sync_upload", "report_path_from_manifest", + "require_cloud_profile_identity", "require_upload_token", "resolve_cloud_endpoint", "resolve_cloud_identity", diff --git a/src/code_mower/cloud_client/git_metadata.py b/src/code_mower/cloud_client/git_metadata.py index ad7edb16..c02743fb 100644 --- a/src/code_mower/cloud_client/git_metadata.py +++ b/src/code_mower/cloud_client/git_metadata.py @@ -4,7 +4,9 @@ import os import re +import shutil import subprocess +import tempfile from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path @@ -110,35 +112,60 @@ def checkout_provenance(repo_path: Path, *, required: bool = False) -> dict[str, } -@contextmanager -def index_mutation_guard(repo_path: Path) -> Iterator[Path]: - """Hold the worktree's Git index lock for the duration of the block. +def _set_tree_permissions(root: Path, *, writable: bool) -> None: + """Remove or restore write permission for a whole private directory tree.""" + + directory_mode = 0o700 if writable else 0o500 + file_mode = 0o600 if writable else 0o400 + paths = [root, *sorted(root.rglob("*"), reverse=True)] + for path in paths: + try: + if path.is_symlink(): + continue + os.chmod(path, directory_mode if path.is_dir() else file_mode) + except OSError: + if not writable: + raise CloudBundleError( + "unable to make the private exact-commit source read-only" + ) from None + - Sampling the checkout before and after a read cannot detect a checkout that - moves away and back again in between. Holding the lock Git itself takes for - any index mutation makes checkouts, merges, and resets fail while the - measured data is read, so accepted evidence is attributable to one commit. +@contextmanager +def materialized_commit_source(repo_path: Path, commit_sha: str) -> Iterator[Path]: + """Yield a private read-only checkout materialized from an exact commit. + + Tracked and source-derived data is read from Git objects in a private + location rather than from the caller's mutable worktree, so a tracked file + that is changed and restored while the data is read cannot be observed at + all: the only state reachable during collection is the required commit. + The materialization is made non-writable for the collection interval, so an + attempt to mutate it fails, and it is removed on every exit. """ - lock_path = Path(_required_git_output(repo_path, ["rev-parse", "--git-path", "index.lock"]).strip()) - if not lock_path.is_absolute(): - lock_path = repo_path / lock_path - try: - descriptor = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) - except OSError as exc: - raise CloudBundleError( - f"unable to hold the git index lock for {repo_path}: {exc}" - ) from exc + expected = commit_sha.strip().lower() + if not COMMIT_SHA_PATTERN.match(expected): + raise CloudBundleError("expected head sha must be an exact 40-character commit") + temp_root = Path(tempfile.mkdtemp(prefix="code-mower-exact-commit-")) + source = temp_root / "source" try: - os.close(descriptor) - yield lock_path - finally: - try: - lock_path.unlink(missing_ok=True) - except OSError as exc: + # A local clone reads the original repository's objects and writes + # nothing into it, and the private clone cannot be moved to another + # commit once it is read-only. + _required_git_output( + repo_path, + ["clone", "--quiet", "--shared", "--no-checkout", str(repo_path), str(source)], + ) + _required_git_output(source, ["checkout", "--quiet", "--detach", expected]) + materialized = checkout_provenance(source, required=True) + if materialized.get("head_sha") != expected or not materialized.get("clean"): raise CloudBundleError( - f"unable to release the git index lock for {repo_path}: {exc}" - ) from exc + "unable to materialize a clean private checkout of the required commit" + ) + _set_tree_permissions(source, writable=False) + yield source + finally: + _set_tree_permissions(temp_root, writable=True) + shutil.rmtree(temp_root, ignore_errors=True) def require_checkout_provenance( diff --git a/src/code_mower/cloud_client/operations.py b/src/code_mower/cloud_client/operations.py index 98528920..a62c875f 100644 --- a/src/code_mower/cloud_client/operations.py +++ b/src/code_mower/cloud_client/operations.py @@ -5,6 +5,7 @@ import json import os import re +from dataclasses import replace from pathlib import Path from typing import Any, Mapping @@ -42,12 +43,13 @@ from .git_metadata import ( checkout_provenance, detect_repo_slug, - index_mutation_guard, + materialized_commit_source, require_checkout_provenance, ) from .productivity_windows import load_productivity_window_events from .tokens import ( CloudTokenResolution, + require_cloud_profile_identity, require_upload_token, resolve_cloud_endpoint, resolve_cloud_identity, @@ -350,9 +352,9 @@ def board_snapshot_upload( stale_minutes=stale_minutes, event_limit=event_limit, ) - # The checkout is read before and after collection and both readings must - # agree, so a checkout that moves or is modified while the snapshot is - # gathered cannot produce evidence attributed to the expected commit. + # The checkout must be clean at the required commit before collection, and + # strict collection then reads a private materialization of that exact + # commit, so no state other than the required commit can be measured. provenance_required = bool(require_head_sha.strip() or require_clean) provenance = checkout_provenance(repo_path, required=provenance_required) require_checkout_provenance( @@ -361,11 +363,20 @@ def board_snapshot_upload( require_clean=require_clean, ) if provenance_required: - # The index lock is held across both Board reads so a checkout cannot - # move to another commit and back between the two provenance samples. - with index_mutation_guard(repo_path): - snapshot = board.status_payload(config) - snapshot["timelines"] = board.timelines_payload(config) + # Tracked and source-derived data is read from a private read-only + # checkout materialized from the required commit, so a tracked file + # that changes and is restored in the original during collection cannot + # be observed. The live Code Mower metadata inputs stay bound to the + # original checkout explicitly rather than following the new repo path. + metadata_paths = board.resolved_metadata_paths(config) + with materialized_commit_source(repo_path, provenance["head_sha"]) as stable_path: + stable_config = replace( + config, + repo_path=str(stable_path), + **metadata_paths, + ) + snapshot = board.status_payload(stable_config) + snapshot["timelines"] = board.timelines_payload(stable_config) else: snapshot = board.status_payload(config) snapshot["timelines"] = board.timelines_payload(config) @@ -425,6 +436,26 @@ def board_snapshot_upload( raise CloudBundleError( "the exported Board snapshot manifest was replaced before upload" ) + # The install profile is resolved again from disk and the exported bundle's + # own identity is validated against it, so a profile replaced after this + # command started cannot preview or post this payload with another token. + upload_resolution, upload_endpoint = _resolve_upload_profile( + endpoint=endpoint, + token_env=token_env, + token_file=token_file, + token_dir=token_dir, + install_id=install_id, + ) + if upload_endpoint != resolved_endpoint: + raise CloudBundleError( + "the resolved cloud endpoint changed while the Board snapshot was " + "exported; re-select the install profile and retry" + ) + require_cloud_profile_identity( + team_id=str(payload.get("team_id") or ""), + install_id=str(payload.get("install_id") or ""), + resolution=upload_resolution, + ) if not yes: return { "mode": "cloud-board-snapshot", @@ -442,7 +473,7 @@ def board_snapshot_upload( } token = require_upload_token( endpoint=resolved_endpoint, - resolution=token_resolution, + resolution=upload_resolution, local_endpoint=is_local_http_endpoint(resolved_endpoint), ) return { diff --git a/src/code_mower/cloud_client/tokens.py b/src/code_mower/cloud_client/tokens.py index 84094ad8..62671fc8 100644 --- a/src/code_mower/cloud_client/tokens.py +++ b/src/code_mower/cloud_client/tokens.py @@ -391,20 +391,60 @@ def resolve_cloud_identity( requested_team = team_id or os.environ.get(DEFAULT_TEAM_ID_ENV, "") requested_install = install_id or os.environ.get(DEFAULT_INSTALL_ID_ENV, "") - for label, requested, stored in ( - ("team", requested_team, resolution.team_id), - ("install", requested_install, resolution.install_id), + require_cloud_profile_identity( + team_id=requested_team, + install_id=requested_install, + resolution=resolution, + ) + return ( + requested_team or resolution.team_id, + requested_install or resolution.install_id, + ) + + +def require_cloud_profile_identity( + *, + team_id: str, + install_id: str, + resolution: CloudTokenResolution, +) -> None: + """Require an asserted identity to match the selected stored profile. + + An identity that is being asserted -- by a flag, the environment, or an + already exported bundle -- is only authorized by a selected profile that + records the same identity. A profile selected by install id must carry both + identity fields, so a replacement profile that records no identity cannot + authorize an explicitly identified payload with its own token. + """ + + if not (team_id or install_id): + return + if resolution.source == "install_id": + missing = [ + label + for label, stored in ( + ("team", resolution.team_id), + ("install", resolution.install_id), + ) + if not stored + ] + if missing: + raise CloudBundleError( + "the selected cloud install profile records no " + f"{' or '.join(missing)} identity, so it cannot authorize an " + "identified upload; re-run `code-mower cloud setup` for this " + "install or re-select the profile" + ) + for label, asserted, stored in ( + ("team", team_id, resolution.team_id), + ("install", install_id, resolution.install_id), ): - if requested and stored and requested != stored: + if asserted and stored and asserted != stored: raise CloudBundleError( f"the resolved cloud install profile reports a different {label} " "identity than the one requested; re-select the profile or drop " "the explicit value" ) - return ( - requested_team or resolution.team_id, - requested_install or resolution.install_id, - ) def require_upload_token( diff --git a/src/code_mower/release_campaigns.py b/src/code_mower/release_campaigns.py index 1d559071..3a413139 100644 --- a/src/code_mower/release_campaigns.py +++ b/src/code_mower/release_campaigns.py @@ -5465,6 +5465,27 @@ def campaign_upload( team_id=resolved_team_id, install_id=resolved_install_id, ) + # The install profile is resolved again from disk and the payload's own + # identity is validated against it, so a profile replaced after the earlier + # preflight cannot preview or post this payload with another install's token. + upload_resolution = cloud.resolve_cloud_token( + token_env=resolved_token_env, + token_file=token_file, + token_dir=token_dir, + install_id=install_id, + ) + if cloud.resolve_cloud_endpoint(requested_endpoint, upload_resolution) != ( + resolved_endpoint + ): + raise cloud.CloudBundleError( + "the resolved cloud endpoint changed while the campaign upload was " + "prepared; re-select the install profile and retry" + ) + cloud.require_cloud_profile_identity( + team_id=str(payload.get("team_id") or ""), + install_id=str(payload.get("install_id") or ""), + resolution=upload_resolution, + ) if not yes: summary["upload"] = cloud.build_dogfood_dry_run_preview( endpoint=resolved_endpoint, @@ -5473,7 +5494,7 @@ def campaign_upload( return summary token = cloud.require_upload_token( endpoint=resolved_endpoint, - resolution=token_resolution, + resolution=upload_resolution, local_endpoint=cloud.is_local_http_endpoint(resolved_endpoint), ) try: diff --git a/src/code_mower/release_readiness.py b/src/code_mower/release_readiness.py index 7c152d92..f7d926c8 100644 --- a/src/code_mower/release_readiness.py +++ b/src/code_mower/release_readiness.py @@ -1202,11 +1202,7 @@ def _incomplete_dispatch_actions( command must supply every input the workflow marks required. """ - required_inputs = sorted( - name - for name, spec in _workflow_dispatch_inputs(workflow).items() - if isinstance(spec, dict) and spec.get("required") is True - ) + required_inputs = _required_dispatch_inputs(workflow) problems: list[str] = [] for action in next_actions: command = str(action.get("command") or "") @@ -1220,6 +1216,56 @@ def _incomplete_dispatch_actions( return problems +def _required_dispatch_inputs(workflow: str) -> list[str]: + return sorted( + name + for name, spec in _workflow_dispatch_inputs(workflow).items() + if isinstance(spec, dict) and spec.get("required") is True + ) + + +def _documented_dispatch_commands(doc: str) -> list[str]: + """Return every documented dispatch command, joining continuation lines.""" + + commands: list[str] = [] + lines = doc.splitlines() + index = 0 + while index < len(lines): + line = lines[index] + if RELEASE_WORKFLOW_DISPATCH_COMMAND not in line: + index += 1 + continue + command = line.strip() + while command.endswith("\\") and index + 1 < len(lines): + index += 1 + command = f"{command[:-1].strip()} {lines[index].strip()}" + commands.append(command) + index += 1 + return commands + + +def _incomplete_documented_dispatches( + workflow: str, docs: dict[str, str] +) -> list[str]: + """Report documented workflow dispatches missing a required input. + + Public release and install guidance is followed verbatim, so a documented + command that the workflow rejects at submission blocks the release exactly + like a broken generated next action. + """ + + required_inputs = _required_dispatch_inputs(workflow) + problems: list[str] = [] + for relative_path, doc in sorted(docs.items()): + for position, command in enumerate(_documented_dispatch_commands(doc), start=1): + problems.extend( + f"{relative_path} dispatch {position} omits -f {name}=" + for name in required_inputs + if f"-f {name}=" not in command + ) + return problems + + def _needs_job(job: Any, required: str) -> bool: if not isinstance(job, dict): return False @@ -1775,13 +1821,23 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]: }, ] incomplete_dispatch_actions = _incomplete_dispatch_actions(workflow, next_actions) + incomplete_documented_dispatches = _incomplete_documented_dispatches(workflow, docs) checks.append( _release_check( check_id="release-workflow-next-actions-dispatchable", title="Advertised release workflow dispatches supply every required input", - status="pass" if not incomplete_dispatch_actions else "fail", - evidence="release-readiness next actions, .github/workflows/release.yml", - detail={"incomplete_dispatch_actions": incomplete_dispatch_actions}, + status="pass" + if not incomplete_dispatch_actions and not incomplete_documented_dispatches + else "fail", + evidence=( + "release-readiness next actions, " + f"{', '.join(RELEASE_DOC_PATHS)}, .github/workflows/release.yml" + ), + detail={ + "incomplete_dispatch_actions": incomplete_dispatch_actions, + "incomplete_documented_dispatches": incomplete_documented_dispatches, + "required_dispatch_inputs": _required_dispatch_inputs(workflow), + }, ) ) failed = sum(1 for check in checks if check["status"] == "fail") diff --git a/tests/test_cloud_client.py b/tests/test_cloud_client.py index 2bfbba57..1caa3bd1 100644 --- a/tests/test_cloud_client.py +++ b/tests/test_cloud_client.py @@ -5,7 +5,7 @@ import subprocess import tempfile import unittest -from contextlib import redirect_stdout +from contextlib import redirect_stderr, redirect_stdout from io import StringIO from pathlib import Path @@ -717,7 +717,7 @@ def _board_snapshot_dry_run(monkeypatch, repo_path: Path, output_dir: Path, **kw source="unit-test", endpoint="http://localhost:3000/api/ingest", token_env="CODE_MOWER_TEST_BOARD_TOKEN", - yes=False, + yes=kwargs.pop("yes", False), timeout=0.1, **kwargs, ) @@ -837,11 +837,11 @@ def test_board_snapshot_records_and_enforces_source_git_provenance(monkeypatch, def test_board_snapshot_rejects_a_checkout_that_moves_during_collection(monkeypatch, tmp_path) -> None: repo_path = tmp_path / "checkout" head_sha = _init_git_checkout(repo_path) - index_mutations: list[int] = [] + mutations: list[int] = [] def moving_status(_config): (repo_path / "tracked.txt").write_text("changed\n", encoding="utf-8") - index_mutations.append( + mutations.append( subprocess.run( ["git", "add", "tracked.txt"], cwd=repo_path, @@ -859,59 +859,100 @@ def moving_status(_config): require_head_sha=head_sha, require_clean=True, ) - assert index_mutations and all(code != 0 for code in index_mutations) + assert mutations == [0] -def test_board_snapshot_blocks_an_a_b_a_checkout_during_collection(monkeypatch, tmp_path) -> None: +def test_board_snapshot_reads_only_the_materialized_exact_commit(monkeypatch, tmp_path) -> None: repo_path = tmp_path / "checkout" - first_sha = _init_git_checkout(repo_path) - (repo_path / "tracked.txt").write_text("two\n", encoding="utf-8") - subprocess.run(["git", "add", "tracked.txt"], cwd=repo_path, check=True) - subprocess.run(["git", "commit", "--quiet", "-m", "second"], cwd=repo_path, check=True) - second_sha = subprocess.run( - ["git", "rev-parse", "HEAD"], - cwd=repo_path, - check=True, - capture_output=True, - text=True, - ).stdout.strip() - subprocess.run(["git", "checkout", "--quiet", first_sha], cwd=repo_path, check=True) - - switches: list[int] = [] - - def switching_status(_config): - for target in (second_sha, first_sha): - switches.append( - subprocess.run( - ["git", "checkout", "--quiet", target], - cwd=repo_path, - capture_output=True, - ).returncode - ) + head_sha = _init_git_checkout(repo_path) + sources: list[Path] = [] + observed: list[str] = [] + write_failures: list[str] = [] + metadata_paths: list[dict[str, str]] = [] + + def substituting_status(config): + tracked = repo_path / "tracked.txt" + restored = tracked.read_text(encoding="utf-8") + # A tracked file is changed and restored directly in the original + # checkout, which no index lock prevents, while the data is read. + tracked.write_text("substituted\n", encoding="utf-8") + source = Path(config.repo_path) + sources.append(source) + observed.append((source / "tracked.txt").read_text(encoding="utf-8")) + metadata_paths.append(cloud_operations.board.resolved_metadata_paths(config)) + try: + (source / "tracked.txt").write_text("mutated\n", encoding="utf-8") + except OSError as exc: + write_failures.append(type(exc).__name__) + tracked.write_text(restored, encoding="utf-8") return _board_snapshot_fixture() result = _board_snapshot_dry_run( monkeypatch, repo_path, - tmp_path / "aba", - status_payload=switching_status, - require_head_sha=first_sha, + tmp_path / "materialized", + status_payload=substituting_status, + require_head_sha=head_sha, require_clean=True, ) - # Neither switch can run while the snapshot is read, so the accepted - # evidence cannot describe data collected from the second commit. - assert switches and all(code != 0 for code in switches) - assert result["git"]["head_sha"] == first_sha - assert ( - subprocess.run( - ["git", "rev-parse", "HEAD"], - cwd=repo_path, - check=True, - capture_output=True, - text=True, - ).stdout.strip() - == first_sha + + # Collection can only observe the exact materialized commit, never the + # substituted content that existed in the original at the same moment. + assert observed == ["one\n"] + assert sources and sources[0] != repo_path + # Mutating the private materialization is refused for the whole interval. + assert write_failures == ["PermissionError"] + # The live Code Mower metadata inputs stay bound to the original checkout. + assert metadata_paths and all( + str(repo_path) in path for path in metadata_paths[0].values() + ) + assert result["git"] == { + "available": True, + "head_sha": head_sha, + "clean": True, + "dirty_entry_count": 0, + } + # The private materialization is removed once collection is over. + assert not sources[0].exists() + + +def test_board_snapshot_materialization_is_cleaned_up_after_a_failure(monkeypatch, tmp_path) -> None: + repo_path = tmp_path / "checkout" + head_sha = _init_git_checkout(repo_path) + sources: list[Path] = [] + + def failing_status(config): + sources.append(Path(config.repo_path)) + raise CloudBundleError("collection failed") + + with assert_raises(CloudBundleError): + _board_snapshot_dry_run( + monkeypatch, + repo_path, + tmp_path / "failed", + status_payload=failing_status, + require_head_sha=head_sha, + require_clean=True, + ) + assert sources and not sources[0].exists() + + +def test_non_strict_board_snapshot_reads_the_original_checkout(monkeypatch, tmp_path) -> None: + repo_path = tmp_path / "checkout" + _init_git_checkout(repo_path) + sources: list[Path] = [] + + def recording_status(config): + sources.append(Path(config.repo_path)) + return _board_snapshot_fixture() + + _board_snapshot_dry_run( + monkeypatch, + repo_path, + tmp_path / "plain", + status_payload=recording_status, ) + assert sources == [repo_path.resolve()] def test_board_snapshot_rejects_a_manifest_replaced_after_export(monkeypatch, tmp_path) -> None: @@ -986,7 +1027,8 @@ def test_resolve_cloud_identity_rejects_a_profile_that_disagrees_with_explicit_v resolution=resolution, ) - # A profile that omits an identity leaves the explicit value in force. + # A selected profile that records no team identity cannot authorize an + # explicitly identified payload with its own token. partial = CloudTokenResolution( status="ok", token_env="CODE_MOWER_CLOUD_TOKEN", @@ -994,11 +1036,43 @@ def test_resolve_cloud_identity_rejects_a_profile_that_disagrees_with_explicit_v token="cmw_live_secret", install_id="stored-install", ) + with assert_raises(CloudBundleError): + resolve_cloud_identity( + team_id="explicit-team", + install_id="stored-install", + resolution=partial, + ) + identityless = CloudTokenResolution( + status="ok", + token_env="CODE_MOWER_CLOUD_TOKEN", + source="install_id", + token="cmw_live_secret", + ) + with assert_raises(CloudBundleError): + resolve_cloud_identity( + team_id="explicit-team", + install_id="explicit-install", + resolution=identityless, + ) + # An anonymous caller asserting no identity keeps working. + assert resolve_cloud_identity( + team_id="", + install_id="", + resolution=identityless, + ) == ("", "") + # A legacy environment-sourced token is not a selected profile, so it still + # leaves explicit values in force. + legacy = CloudTokenResolution( + status="ok", + token_env="CODE_MOWER_CLOUD_TOKEN", + source="env", + token="cmw_live_secret", + ) assert resolve_cloud_identity( team_id="explicit-team", - install_id="stored-install", - resolution=partial, - ) == ("explicit-team", "stored-install") + install_id="explicit-install", + resolution=legacy, + ) == ("explicit-team", "explicit-install") def test_board_snapshot_refuses_to_preview_a_conflicting_profile_identity( @@ -1025,6 +1099,175 @@ def test_board_snapshot_refuses_to_preview_a_conflicting_profile_identity( assert not output_dir.exists() +def _board_resolution(team_id: str = "", install_id: str = "") -> CloudTokenResolution: + return CloudTokenResolution( + status="ok", + token_env="CODE_MOWER_TEST_BOARD_TOKEN", + source="install_id", + token="cmw_live_board_secret", + endpoint="http://localhost:3000/api/ingest", + team_id=team_id, + install_id=install_id, + ) + + +def _board_profile_sequence(monkeypatch, *resolutions: CloudTokenResolution) -> None: + """Resolve the stored install profile differently on each producer call.""" + + remaining = list(resolutions) + + def _resolve(**_kwargs): + resolution = remaining.pop(0) if len(remaining) > 1 else remaining[0] + return resolution, "http://localhost:3000/api/ingest" + + monkeypatch.setattr(cloud_operations, "_resolve_upload_profile", _resolve) + + +def _refusing_post(*_args, **_kwargs): + raise AssertionError("must refuse before any network upload") + + +def test_board_snapshot_accepts_a_stable_matching_profile(monkeypatch, tmp_path) -> None: + matching = _board_resolution(team_id="team", install_id="install") + _board_profile_sequence(monkeypatch, matching, matching) + posted: list[dict[str, object]] = [] + monkeypatch.setattr( + cloud_operations, + "post_upload_payload", + lambda *, payload, endpoint, token, timeout: ( + posted.append(payload) or {"status": 200, "endpoint": endpoint} + ), + ) + + preview = _board_snapshot_dry_run(monkeypatch, tmp_path, tmp_path / "preview") + assert preview["status"] == "dry_run" + assert posted == [] + + applied = _board_snapshot_dry_run(monkeypatch, tmp_path, tmp_path / "applied", yes=True) + assert applied["status"] == "uploaded" + assert len(posted) == 1 + assert posted[0]["team_id"] == "team" + assert posted[0]["install_id"] == "install" + + +def test_board_snapshot_rejects_a_profile_replaced_after_preflight(monkeypatch, tmp_path) -> None: + matching = _board_resolution(team_id="team", install_id="install") + replacements = ( + _board_resolution(team_id="other-team", install_id="other-install"), + _board_resolution(install_id="install"), + _board_resolution(), + ) + monkeypatch.setattr(cloud_operations, "post_upload_payload", _refusing_post) + + for index, replacement in enumerate(replacements): + for applied in (False, True): + _board_profile_sequence(monkeypatch, matching, replacement) + output_dir = tmp_path / f"replaced-{index}-{applied}" + with assert_raises(CloudBundleError) as caught: + _board_snapshot_dry_run(monkeypatch, tmp_path, output_dir, yes=applied) + message = str(caught.exception) + assert "profile" in message + # Nothing protected reaches the error text. + for secret in ("cmw_live_board_secret", "localhost:3000", "other-team", "other-install"): + assert secret not in message + + +def _generic_upload_bundle(tmp_path: Path, *, team_id: str, install_id: str) -> Path: + output_dir = tmp_path / f"bundle-{team_id or 'anon'}-{install_id or 'anon'}" + build_cloud_bundle( + reports=[], + events=[ + { + "event_type": "dogfood_upload", + "repo_slug": "owner/repo", + "dimensions": {"lane": "unit-test"}, + } + ], + output_dir=output_dir, + repo_slug="owner/repo", + team_id=team_id, + install_id=install_id, + anonymous=False, + ) + return output_dir + + +def _run_generic_upload( + monkeypatch, bundle_dir: Path, *, resolution, applied: bool +) -> tuple[int, str]: + monkeypatch.delenv("CODE_MOWER_CLOUD_TOKEN", raising=False) + monkeypatch.delenv("CODE_MOWER_CLOUD_ENDPOINT", raising=False) + monkeypatch.setattr(cloud_cli, "resolve_cloud_token", lambda **_kwargs: resolution) + argv = [ + "upload", + str(bundle_dir), + "--endpoint", + "http://localhost:3000/api/ingest", + "--json", + ] + if applied: + argv.append("--yes") + stderr = StringIO() + with redirect_stdout(StringIO()), redirect_stderr(stderr): + return cloud_cli.main(argv), stderr.getvalue() + + +def test_generic_cloud_upload_accepts_a_matching_stored_profile(monkeypatch, tmp_path) -> None: + bundle_dir = _generic_upload_bundle(tmp_path, team_id="team", install_id="install") + posted: list[dict[str, object]] = [] + monkeypatch.setattr( + cloud_cli, + "post_upload_payload", + lambda *, payload, endpoint, token, timeout: ( + posted.append(payload) or {"status": 200, "endpoint": endpoint} + ), + ) + resolution = _board_resolution(team_id="team", install_id="install") + + assert _run_generic_upload( + monkeypatch, bundle_dir, resolution=resolution, applied=False + ) == (0, "") + assert posted == [] + assert _run_generic_upload( + monkeypatch, bundle_dir, resolution=resolution, applied=True + ) == (0, "") + assert len(posted) == 1 + + +def test_generic_cloud_upload_rejects_a_substituted_stored_profile(monkeypatch, tmp_path) -> None: + bundle_dir = _generic_upload_bundle(tmp_path, team_id="team", install_id="install") + monkeypatch.setattr(cloud_cli, "post_upload_payload", _refusing_post) + + for resolution in ( + _board_resolution(team_id="other-team", install_id="other-install"), + _board_resolution(install_id="install"), + _board_resolution(), + ): + for applied in (False, True): + code, stderr = _run_generic_upload( + monkeypatch, bundle_dir, resolution=resolution, applied=applied + ) + assert code == 1 + assert "profile" in stderr + for secret in ( + "cmw_live_board_secret", + "localhost:3000", + "other-team", + "other-install", + ): + assert secret not in stderr + + +def test_generic_cloud_upload_keeps_anonymous_bundles_working(monkeypatch, tmp_path) -> None: + bundle_dir = _generic_upload_bundle(tmp_path, team_id="", install_id="") + monkeypatch.setattr(cloud_cli, "post_upload_payload", _refusing_post) + resolution = _board_resolution() + + assert _run_generic_upload( + monkeypatch, bundle_dir, resolution=resolution, applied=False + ) == (0, "") + + def test_cloud_repo_slug_from_remote_supports_common_github_forms() -> None: assert repo_slug_from_remote("git@github.com:codemower-ai/code-mower.git") == "codemower-ai/code-mower" assert repo_slug_from_remote("https://github.com/codemower-ai/code-mower.git") == "codemower-ai/code-mower" diff --git a/tests/test_release_campaigns.py b/tests/test_release_campaigns.py index 4fd088b4..509d72c4 100644 --- a/tests/test_release_campaigns.py +++ b/tests/test_release_campaigns.py @@ -10855,6 +10855,95 @@ def test_upload_writes_no_campaign_state(self) -> None: self.assertEqual(before, self._stored_bytes()) + def _write_install_profile( + self, *, team_id: str, install_id: str, stem: str = "install-a" + ) -> Path: + """Write one stored install profile the resolver can select by install id.""" + cloud = release_campaigns._load_cloud_client() + lines = [f"{cloud.DEFAULT_TOKEN_ENV}={self.FAKE_CREDENTIAL}"] + if team_id: + lines.append(f"CODE_MOWER_CLOUD_TEAM_ID={team_id}") + if install_id: + lines.append(f"CODE_MOWER_INSTALL_ID={install_id}") + path = self.token_dir / f"{stem}.env" + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return path + + def test_matching_stored_profile_previews_and_uploads(self) -> None: + """The selected profile's own identities authorize the identified payload.""" + self._seed(complete=("claude",)) + self._write_install_profile(team_id="team-a", install_id="install-a") + post = self._capturing_post() + with self._cloud_env(), mock.patch.object( + release_campaigns._load_cloud_client(), "post_upload_payload", post + ): + code, preview, _, error = self._upload(install_id="install-a", team_id="team-a") + self.assertEqual(code, 0, error) + applied_code, applied, _, applied_error = self._upload( + install_id="install-a", team_id="team-a", yes=True + ) + + assert preview is not None and applied is not None + self.assertEqual(preview["status"], "dry_run") + self.assertEqual(applied_code, 0, applied_error) + self.assertEqual(applied["status"], "uploaded") + self.assertEqual(len(post.posted), 1) + self.assertEqual(post.posted[0]["team_id"], "team-a") + self.assertEqual(post.posted[0]["install_id"], "install-a") + + def test_profile_replaced_after_preflight_cannot_preview_or_upload(self) -> None: + """A profile swapped while the upload is prepared authorizes nothing. + + The producer resolves the selected profile again once the payload + exists, so a replacement carrying different identities -- or no + identities at all -- fails closed before any preview or network post. + """ + cloud = release_campaigns._load_cloud_client() + replacements = ( + {"team_id": "other-team", "install_id": "other-install"}, + {"team_id": "", "install_id": "install-a"}, + {"team_id": "", "install_id": ""}, + ) + for replacement in replacements: + for applied in (False, True): + with self.subTest(replacement=replacement, applied=applied): + self._seed(complete=("claude",)) + self._write_install_profile(team_id="team-a", install_id="install-a") + real_payload = cloud.build_event_upload_payload + + def _replacing_payload( + *, + _build: Any = real_payload, + _replacement: dict[str, str] = replacement, + **kwargs: Any, + ) -> dict[str, Any]: + built = _build(**kwargs) + self._write_install_profile(**_replacement) + return built + + post = self._capturing_post() + with self._cloud_env(), mock.patch.object( + cloud, "build_event_upload_payload", _replacing_payload + ), mock.patch.object(cloud, "post_upload_payload", post): + code, result, stdout, error = self._upload( + install_id="install-a", team_id="team-a", yes=applied + ) + + self.assertEqual(code, 1) + self.assertIsNone(result) + self.assertEqual(post.posted, []) + self.assertIn("install profile", error) + self.assertTrue( + "records no" in error or "different" in error, error + ) + for protected in ( + self.FAKE_CREDENTIAL, + "other-team", + "other-install", + str(self.token_dir), + ): + self.assertNotIn(protected, error + stdout) + def test_malformed_stored_result_is_a_bounded_error(self) -> None: """A completed provider with an unusable result stops the upload; nothing partial posts.""" self._seed(complete=("claude", "codex")) diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py index bdf664b1..a48b8b8e 100644 --- a/tests/test_release_hygiene.py +++ b/tests/test_release_hygiene.py @@ -9493,6 +9493,87 @@ def test_release_readiness_rejects_a_dispatch_action_without_the_expected_sha( [f"{action['id']} omits -f expected_sha="], ) + def test_public_release_docs_dispatch_every_required_workflow_input(self) -> None: + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text( + encoding="utf-8" + ) + docs = { + relative_path: (ROOT / relative_path).read_text(encoding="utf-8") + for relative_path in release_readiness.RELEASE_DOC_PATHS + } + documented = { + relative_path: release_readiness._documented_dispatch_commands(doc) + for relative_path, doc in docs.items() + } + + self.assertEqual( + release_readiness._required_dispatch_inputs(workflow), ["expected_sha"] + ) + self.assertEqual(len(documented["docs/first-user-install-rehearsal.md"]), 2) + self.assertEqual(len(documented["docs/pypi-release.md"]), 3) + self.assertEqual( + release_readiness._incomplete_documented_dispatches(workflow, docs), [] + ) + for relative_path, commands in documented.items(): + for position, command in enumerate(commands, start=1): + with self.subTest(doc=relative_path, dispatch=position): + self.assertIn('-f expected_sha="$RELEASE_SHA"', command) + + def test_install_rehearsal_binds_the_exact_release_sha_before_dispatching( + self, + ) -> None: + doc = (ROOT / "docs" / "first-user-install-rehearsal.md").read_text( + encoding="utf-8" + ) + binding = "RELEASE_SHA=\"$(git ls-remote \"https://github.com/$RELEASE_REPO.git\" \\" + assertion = "printf '%s' \"$RELEASE_SHA\" | grep -Eq '^[0-9a-f]{40}$'" + + self.assertIn(binding, doc) + self.assertIn(assertion, doc) + self.assertLess(doc.index(assertion), doc.index("gh workflow run release.yml")) + + def test_release_readiness_rejects_documented_dispatches_without_expected_sha( + self, + ) -> None: + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text( + encoding="utf-8" + ) + for relative_path in ( + "docs/first-user-install-rehearsal.md", + "docs/pypi-release.md", + ): + with self.subTest(doc=relative_path): + docs = { + path: (ROOT / path).read_text(encoding="utf-8") + for path in release_readiness.RELEASE_DOC_PATHS + } + for pattern in ( + ' \\\n -f expected_sha="$RELEASE_SHA"', + ' -f expected_sha="$RELEASE_SHA"', + ): + if pattern in docs[relative_path]: + docs[relative_path] = docs[relative_path].replace( + pattern, "", 1 + ) + break + problems = release_readiness._incomplete_documented_dispatches( + workflow, docs + ) + + self.assertEqual( + problems, [f"{relative_path} dispatch 1 omits -f expected_sha="] + ) + with mock.patch.object( + release_readiness, "_release_docs", return_value=docs + ): + payload = release_readiness.render_release_readiness(ROOT) + checks = {check["id"]: check for check in payload["checks"]} + + self.assertEqual( + checks["release-workflow-next-actions-dispatchable"]["status"], + "fail", + ) + def _run_release_assets_gate( self, mutate: Callable[[Path], None] | None = None ) -> subprocess.CompletedProcess: From 8aef767c423ac71fbf0f18eb521c4c0a222475fe Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:27:58 +0000 Subject: [PATCH 16/20] Resolve the git root before materializing the strict snapshot source Co-Authored-By: bot_apk --- src/code_mower/cloud_client/git_metadata.py | 15 ++++++++++- tests/test_cloud_client.py | 29 +++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/code_mower/cloud_client/git_metadata.py b/src/code_mower/cloud_client/git_metadata.py index c02743fb..4dabbc1d 100644 --- a/src/code_mower/cloud_client/git_metadata.py +++ b/src/code_mower/cloud_client/git_metadata.py @@ -148,12 +148,25 @@ def materialized_commit_source(repo_path: Path, commit_sha: str) -> Iterator[Pat temp_root = Path(tempfile.mkdtemp(prefix="code-mower-exact-commit-")) source = temp_root / "source" try: + # Git discovers the enclosing repository from any path inside it, but a + # clone source must name the repository itself, so the enclosing root is + # resolved before materializing. + clone_source = Path( + _required_git_output(repo_path, ["rev-parse", "--show-toplevel"]).strip() + ) # A local clone reads the original repository's objects and writes # nothing into it, and the private clone cannot be moved to another # commit once it is read-only. _required_git_output( repo_path, - ["clone", "--quiet", "--shared", "--no-checkout", str(repo_path), str(source)], + [ + "clone", + "--quiet", + "--shared", + "--no-checkout", + str(clone_source), + str(source), + ], ) _required_git_output(source, ["checkout", "--quiet", "--detach", expected]) materialized = checkout_provenance(source, required=True) diff --git a/tests/test_cloud_client.py b/tests/test_cloud_client.py index 1caa3bd1..7cf20ef7 100644 --- a/tests/test_cloud_client.py +++ b/tests/test_cloud_client.py @@ -916,6 +916,35 @@ def substituting_status(config): assert not sources[0].exists() +def test_board_snapshot_materializes_from_a_repository_subdirectory(monkeypatch, tmp_path) -> None: + repo_path = tmp_path / "checkout" + head_sha = _init_git_checkout(repo_path) + nested = repo_path / "nested" + nested.mkdir() + sources: list[Path] = [] + observed_tracked: list[str] = [] + + def recording_status(config): + source = Path(config.repo_path) + sources.append(source) + observed_tracked.append((source / "tracked.txt").read_text(encoding="utf-8")) + return _board_snapshot_fixture() + + # Git discovers the enclosing repository from a subdirectory, so strict + # collection must materialize the commit from the repository root. + result = _board_snapshot_dry_run( + monkeypatch, + nested, + tmp_path / "nested-out", + status_payload=recording_status, + require_head_sha=head_sha, + require_clean=True, + ) + assert sources and sources[0] != nested + assert observed_tracked == ["one\n"] + assert result["git"]["head_sha"] == head_sha + + def test_board_snapshot_materialization_is_cleaned_up_after_a_failure(monkeypatch, tmp_path) -> None: repo_path = tmp_path / "checkout" head_sha = _init_git_checkout(repo_path) From 345b01036709875e612c8426aac3abc996aa8b58 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:49:39 +0000 Subject: [PATCH 17/20] Normalize strict Board snapshot repository semantics and preserve executable bits Co-Authored-By: bot_apk --- src/code_mower/cloud_client/__init__.py | 2 + src/code_mower/cloud_client/git_metadata.py | 54 +++++-- src/code_mower/cloud_client/operations.py | 10 +- tests/test_cloud_client.py | 156 +++++++++++++++++++- tests/test_release_hygiene.py | 24 +++ 5 files changed, 234 insertions(+), 12 deletions(-) diff --git a/src/code_mower/cloud_client/__init__.py b/src/code_mower/cloud_client/__init__.py index 667cfadc..34c2202a 100644 --- a/src/code_mower/cloud_client/__init__.py +++ b/src/code_mower/cloud_client/__init__.py @@ -85,6 +85,7 @@ from .git_metadata import ( checkout_provenance, detect_repo_slug, + git_top_level, materialized_commit_source, repo_slug_from_remote, require_checkout_provenance, @@ -295,6 +296,7 @@ "build_upload_payload_with_identity", "bundle_manifest_identity", "checkout_provenance", + "git_top_level", "materialized_commit_source", "require_checkout_provenance", "read_bundle_manifest", diff --git a/src/code_mower/cloud_client/git_metadata.py b/src/code_mower/cloud_client/git_metadata.py index 4dabbc1d..feb37412 100644 --- a/src/code_mower/cloud_client/git_metadata.py +++ b/src/code_mower/cloud_client/git_metadata.py @@ -5,6 +5,7 @@ import os import re import shutil +import stat import subprocess import tempfile from collections.abc import Iterator @@ -58,6 +59,26 @@ def detect_repo_slug(repo_path: Path) -> str: return repo_slug_from_remote(run_git(repo_path, ["config", "--get", "remote.origin.url"])) +def git_top_level(repo_path: Path, *, required: bool = False) -> Path: + """Return the enclosing repository root for a path inside a checkout. + + Git discovers the enclosing repository from any directory inside it, so a + nested path and the repository root describe the same source. Callers that + derive repository-relative inputs resolve the canonical root first, so the + same repository and commit cannot yield different inputs. + """ + + if required: + top_level = _required_git_output(repo_path, ["rev-parse", "--show-toplevel"]).strip() + else: + top_level = run_git(repo_path, ["rev-parse", "--show-toplevel"]).strip() + if not top_level: + if required: + raise CloudBundleError(f"unable to resolve the git root of {repo_path}") + return repo_path + return Path(top_level).expanduser().resolve() + + def _required_git_output(repo_path: Path, args: list[str]) -> str: """Return stdout for a git command that must succeed.""" try: @@ -113,16 +134,25 @@ def checkout_provenance(repo_path: Path, *, required: bool = False) -> dict[str, def _set_tree_permissions(root: Path, *, writable: bool) -> None: - """Remove or restore write permission for a whole private directory tree.""" + """Remove or restore write permission for a whole private directory tree. + + Only the write bits change: each path keeps its existing read and execute + bits, so a tracked executable stays executable and the materialization does + not become dirty through mode changes, and directories stay traversable. + """ - directory_mode = 0o700 if writable else 0o500 - file_mode = 0o600 if writable else 0o400 paths = [root, *sorted(root.rglob("*"), reverse=True)] for path in paths: try: if path.is_symlink(): continue - os.chmod(path, directory_mode if path.is_dir() else file_mode) + mode = stat.S_IMODE(path.stat().st_mode) + if writable: + target = mode | (0o700 if path.is_dir() else 0o600) + else: + target = (mode & ~0o222) | (0o500 if path.is_dir() else 0o400) + if target != mode: + os.chmod(path, target) except OSError: if not writable: raise CloudBundleError( @@ -148,12 +178,9 @@ def materialized_commit_source(repo_path: Path, commit_sha: str) -> Iterator[Pat temp_root = Path(tempfile.mkdtemp(prefix="code-mower-exact-commit-")) source = temp_root / "source" try: - # Git discovers the enclosing repository from any path inside it, but a - # clone source must name the repository itself, so the enclosing root is - # resolved before materializing. - clone_source = Path( - _required_git_output(repo_path, ["rev-parse", "--show-toplevel"]).strip() - ) + # A clone source must name the repository itself, so the enclosing root + # is resolved before materializing. + clone_source = git_top_level(repo_path, required=True) # A local clone reads the original repository's objects and writes # nothing into it, and the private clone cannot be moved to another # commit once it is read-only. @@ -175,6 +202,13 @@ def materialized_commit_source(repo_path: Path, commit_sha: str) -> Iterator[Pat "unable to materialize a clean private checkout of the required commit" ) _set_tree_permissions(source, writable=False) + # Hardening must not have changed the materialized tree itself, so the + # exact commit and cleanliness are proven again before it is read. + hardened = checkout_provenance(source, required=True) + if hardened != materialized: + raise CloudBundleError( + "the private exact-commit source changed while it was made read-only" + ) yield source finally: _set_tree_permissions(temp_root, writable=True) diff --git a/src/code_mower/cloud_client/operations.py b/src/code_mower/cloud_client/operations.py index a62c875f..8d6ae625 100644 --- a/src/code_mower/cloud_client/operations.py +++ b/src/code_mower/cloud_client/operations.py @@ -43,6 +43,7 @@ from .git_metadata import ( checkout_provenance, detect_repo_slug, + git_top_level, materialized_commit_source, require_checkout_provenance, ) @@ -324,6 +325,14 @@ def board_snapshot_upload( timeout: float, ) -> dict[str, Any]: repo_path = repo_path.expanduser().resolve() + provenance_required = bool(require_head_sha.strip() or require_clean) + if provenance_required: + # A strict snapshot is attributed to one repository and commit, so the + # canonical repository root is resolved before anything repository + # relative is derived. A nested path and the root then produce the same + # default live metadata inputs rather than two snapshots that differ + # while claiming the same provenance. + repo_path = git_top_level(repo_path, required=True) detected_repo_slug = repo_slug or detect_repo_slug(repo_path) if not detected_repo_slug: raise CloudBundleError( @@ -355,7 +364,6 @@ def board_snapshot_upload( # The checkout must be clean at the required commit before collection, and # strict collection then reads a private materialization of that exact # commit, so no state other than the required commit can be measured. - provenance_required = bool(require_head_sha.strip() or require_clean) provenance = checkout_provenance(repo_path, required=provenance_required) require_checkout_provenance( provenance, diff --git a/tests/test_cloud_client.py b/tests/test_cloud_client.py index 7cf20ef7..2fdc7b22 100644 --- a/tests/test_cloud_client.py +++ b/tests/test_cloud_client.py @@ -2,6 +2,7 @@ import hashlib import json +import stat import subprocess import tempfile import unittest @@ -684,13 +685,19 @@ def fake_post_upload_payload(**kwargs): assert "cmw_live_board_secret" not in serialized -def _init_git_checkout(path: Path) -> str: +def _init_git_checkout(path: Path, *, executable: bool = False) -> str: path.mkdir(parents=True, exist_ok=True) subprocess.run(["git", "init", "--quiet"], cwd=path, check=True) subprocess.run(["git", "config", "user.email", "dev@example.com"], cwd=path, check=True) subprocess.run(["git", "config", "user.name", "Dev"], cwd=path, check=True) + subprocess.run(["git", "config", "commit.gpgSign", "false"], cwd=path, check=True) (path / "tracked.txt").write_text("one\n", encoding="utf-8") subprocess.run(["git", "add", "tracked.txt"], cwd=path, check=True) + if executable: + script = path / "tool.sh" + script.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + script.chmod(0o755) + subprocess.run(["git", "add", "tool.sh"], cwd=path, check=True) subprocess.run(["git", "commit", "--quiet", "-m", "first"], cwd=path, check=True) return subprocess.run( ["git", "rev-parse", "HEAD"], @@ -945,6 +952,153 @@ def recording_status(config): assert result["git"]["head_sha"] == head_sha +def _strict_collection_inputs(monkeypatch, repo_path: Path, output_dir: Path, **kwargs): + """Collect strictly and report what the collection source actually was.""" + + collected: dict[str, object] = {} + + def recording_status(config): + source = Path(config.repo_path) + collected["source"] = source + collected["metadata"] = cloud_operations.board.resolved_metadata_paths(config) + collected["tracked"] = (source / "tracked.txt").read_text(encoding="utf-8") + collected["modes"] = { + path.name: stat.S_IMODE(path.stat().st_mode) + for path in sorted(source.glob("*")) + } + return _board_snapshot_fixture() + + result = _board_snapshot_dry_run( + monkeypatch, + repo_path, + output_dir, + status_payload=recording_status, + **kwargs, + ) + collected["result"] = result + return collected + + +def test_strict_board_snapshot_inputs_are_identical_from_root_and_nested_paths( + monkeypatch, tmp_path +) -> None: + repo_path = tmp_path / "checkout" + head_sha = _init_git_checkout(repo_path) + nested = repo_path / "nested" + nested.mkdir() + + from_root = _strict_collection_inputs( + monkeypatch, + repo_path, + tmp_path / "root-out", + require_head_sha=head_sha, + require_clean=True, + ) + from_nested = _strict_collection_inputs( + monkeypatch, + nested, + tmp_path / "nested-out", + require_head_sha=head_sha, + require_clean=True, + ) + + # The same repository and commit must yield the same live metadata inputs + # and the same exact provenance from either invocation directory. + assert from_nested["metadata"] == from_root["metadata"] + assert all( + str(repo_path.resolve()) in path for path in from_root["metadata"].values() + ) + assert from_nested["tracked"] == from_root["tracked"] == "one\n" + assert from_root["source"] != repo_path and from_nested["source"] != nested + expected_git = { + "available": True, + "head_sha": head_sha, + "clean": True, + "dirty_entry_count": 0, + } + assert from_root["result"]["git"] == from_nested["result"]["git"] == expected_git + assert not Path(from_root["source"]).exists() + assert not Path(from_nested["source"]).exists() + + +def test_strict_board_snapshot_keeps_explicit_metadata_paths_from_a_nested_path( + monkeypatch, tmp_path +) -> None: + repo_path = tmp_path / "checkout" + head_sha = _init_git_checkout(repo_path) + nested = repo_path / "nested" + nested.mkdir() + explicit_store = tmp_path / "explicit-store.json" + + collected = _strict_collection_inputs( + monkeypatch, + nested, + tmp_path / "explicit-out", + store_path=explicit_store, + require_head_sha=head_sha, + require_clean=True, + ) + + assert collected["metadata"]["store_path"] == str(explicit_store) + + +def test_strict_board_snapshot_preserves_tracked_executable_modes( + monkeypatch, tmp_path +) -> None: + repo_path = tmp_path / "checkout" + head_sha = _init_git_checkout(repo_path, executable=True) + nested = repo_path / "nested" + nested.mkdir() + + collected = _strict_collection_inputs( + monkeypatch, + nested, + tmp_path / "modes-out", + require_head_sha=head_sha, + require_clean=True, + ) + + modes = collected["modes"] + # A tracked executable stays executable, no path stays writable, and the + # materialization is still exactly the required clean commit. + assert modes["tool.sh"] & 0o111 + assert not modes["tool.sh"] & 0o222 + assert not modes["tracked.txt"] & 0o222 + assert modes["tracked.txt"] & 0o400 + assert collected["result"]["git"]["head_sha"] == head_sha + assert collected["result"]["git"]["clean"] is True + assert not Path(collected["source"]).exists() + + +def test_strict_board_snapshot_cleans_up_a_materialization_with_executables( + monkeypatch, tmp_path +) -> None: + repo_path = tmp_path / "checkout" + head_sha = _init_git_checkout(repo_path, executable=True) + nested = repo_path / "nested" + nested.mkdir() + monkeypatch.setattr(cloud_operations, "post_upload_payload", _refusing_post) + sources: list[Path] = [] + + def failing_status(config): + sources.append(Path(config.repo_path)) + raise CloudBundleError("collection failed") + + # A failure during collection removes the read-only materialization, + # executable bits and all, and never reaches the network. + with assert_raises(CloudBundleError): + _board_snapshot_dry_run( + monkeypatch, + nested, + tmp_path / "exec-failure", + status_payload=failing_status, + require_head_sha=head_sha, + require_clean=True, + yes=True, + ) + assert sources and not sources[0].exists() + + def test_board_snapshot_materialization_is_cleaned_up_after_a_failure(monkeypatch, tmp_path) -> None: repo_path = tmp_path / "checkout" head_sha = _init_git_checkout(repo_path) diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py index a48b8b8e..1eaad459 100644 --- a/tests/test_release_hygiene.py +++ b/tests/test_release_hygiene.py @@ -9597,6 +9597,11 @@ def git(*args: str) -> str: git("init") git("config", "user.email", "release@example.invalid") git("config", "user.name", "Release Bot") + # This temporary repository must not inherit an operator's global + # signing settings, which would make tag and commit creation + # prompt or fail here without saying anything about the runbook. + git("config", "tag.gpgSign", "false") + git("config", "commit.gpgSign", "false") git("add", "docs/v140-release-notes.md") git("commit", "-m", "notes") git("tag", "v1.4.0") @@ -9652,6 +9657,25 @@ def test_pre_create_gate_accepts_the_exact_clean_release_checkout(self) -> None: self.assertTrue(payload["checkout_match"]) self.assertTrue(payload["notes_present"]) + def test_pre_create_gate_is_hermetic_under_global_tag_signing(self) -> None: + with tempfile.TemporaryDirectory() as config_home: + global_config = Path(config_home) / "gitconfig" + global_config.write_text( + "[tag]\n\tgpgSign = true\n[commit]\n\tgpgSign = true\n", + encoding="utf-8", + ) + with mock.patch.dict( + os.environ, + { + "GIT_CONFIG_GLOBAL": str(global_config), + "GIT_CONFIG_SYSTEM": os.devnull, + }, + ): + completed = self._run_release_assets_gate() + + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertTrue(json.loads(completed.stdout)["checkout_match"]) + def test_pre_create_gate_rejects_a_late_release_checkout_mutation(self) -> None: cases = { "modified notes": ( From 7fbb65f818d5585c6e11a20a15d91aad726a571b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 09:00:45 +0000 Subject: [PATCH 18/20] Fail closed when private materialization cleanup is incomplete and compare candidate versions by PEP 440 Co-Authored-By: bot_apk --- src/code_mower/cloud_client/git_metadata.py | 31 +++++- src/code_mower/migration_readiness.py | 73 +++++++++++++- tests/test_cloud_client.py | 103 ++++++++++++++++++++ tests/test_release_hygiene.py | 44 +++++++++ 4 files changed, 248 insertions(+), 3 deletions(-) diff --git a/src/code_mower/cloud_client/git_metadata.py b/src/code_mower/cloud_client/git_metadata.py index feb37412..4b607d8b 100644 --- a/src/code_mower/cloud_client/git_metadata.py +++ b/src/code_mower/cloud_client/git_metadata.py @@ -160,6 +160,22 @@ def _set_tree_permissions(root: Path, *, writable: bool) -> None: ) from None +def _private_tree_is_removed(root: Path) -> bool: + """Restore write access, remove the private tree, and confirm it is gone. + + Removal is best effort, but the outcome is not assumed: the caller learns + whether the whole temporary root is actually absent so a leftover private + checkout cannot be reported as a clean run. + """ + + try: + _set_tree_permissions(root, writable=True) + except OSError: + pass + shutil.rmtree(root, ignore_errors=True) + return not root.exists() + + @contextmanager def materialized_commit_source(repo_path: Path, commit_sha: str) -> Iterator[Path]: """Yield a private read-only checkout materialized from an exact commit. @@ -177,6 +193,7 @@ def materialized_commit_source(repo_path: Path, commit_sha: str) -> Iterator[Pat raise CloudBundleError("expected head sha must be an exact 40-character commit") temp_root = Path(tempfile.mkdtemp(prefix="code-mower-exact-commit-")) source = temp_root / "source" + collection_error: BaseException | None = None try: # A clone source must name the repository itself, so the enclosing root # is resolved before materializing. @@ -210,9 +227,19 @@ def materialized_commit_source(repo_path: Path, commit_sha: str) -> Iterator[Pat "the private exact-commit source changed while it was made read-only" ) yield source + except BaseException as error: + collection_error = error + raise finally: - _set_tree_permissions(temp_root, writable=True) - shutil.rmtree(temp_root, ignore_errors=True) + if not _private_tree_is_removed(temp_root): + # A private materialization left on disk is never reported as + # success, and the bounded message names no path or content. + cleanup_error = CloudBundleError( + "unable to remove the private exact-commit source" + ) + if collection_error is None: + raise cleanup_error + raise cleanup_error from collection_error def require_checkout_provenance( diff --git a/src/code_mower/migration_readiness.py b/src/code_mower/migration_readiness.py index ff390947..765a50c1 100644 --- a/src/code_mower/migration_readiness.py +++ b/src/code_mower/migration_readiness.py @@ -3,9 +3,25 @@ from __future__ import annotations import json +import re from pathlib import Path from typing import Any, Sequence +_PEP440_PATTERN = re.compile( + r""" + ^\s*v? + (?:(?P\d+)!)? + (?P\d+(?:\.\d+)*) + (?P
[-_.]?(?:a|b|c|rc|alpha|beta|pre|preview)[-_.]?\d*)?
+    (?P-\d+|[-_.]?(?:post|rev|r)[-_.]?\d*)?
+    (?P[-_.]?dev[-_.]?\d*)?
+    (?:\+(?P[a-z0-9]+(?:[-_.][a-z0-9]+)*))?
+    \s*$
+    """,
+    re.IGNORECASE | re.VERBOSE,
+)
+_PRE_LETTERS = {"alpha": "a", "a": "a", "beta": "b", "b": "b", "c": "rc", "pre": "rc", "preview": "rc", "rc": "rc"}
+
 FIRST_USER_ARTIFACTS = (
     ("calibration_plan", ".code-mower/calibration-plan.json"),
     ("draft_calibration_corpus", ".code-mower/draft-calibration-corpus.json"),
@@ -30,6 +46,57 @@
 )
 
 
+def normalized_release_version(value: str) -> tuple[object, ...] | None:
+    """Return a comparable PEP 440 identity, or ``None`` when unparseable.
+
+    Equivalent spellings such as ``v1.4.0``, ``1.4.0`` and ``1.4.0.0`` describe
+    one distribution version, so version agreement is decided on the normalized
+    identity rather than the raw text.
+    """
+
+    match = _PEP440_PATTERN.match(value)
+    if not match:
+        return None
+
+    def numeric_segment(raw: str | None, *, names: tuple[str, ...]) -> int | None:
+        if raw is None:
+            return None
+        digits = re.sub(r"[^0-9]", "", raw)
+        if not digits and not any(name in raw.lower() for name in names):
+            return None
+        return int(digits) if digits else 0
+
+    release = [int(part) for part in match.group("release").split(".")]
+    while len(release) > 1 and release[-1] == 0:
+        release.pop()
+
+    pre: tuple[str, int] | None = None
+    raw_pre = match.group("pre")
+    if raw_pre:
+        letters = re.sub(r"[^a-zA-Z]", "", raw_pre).lower()
+        pre = (_PRE_LETTERS.get(letters, letters), int(re.sub(r"[^0-9]", "", raw_pre) or 0))
+
+    local = match.group("local")
+    return (
+        int(match.group("epoch") or 0),
+        tuple(release),
+        pre,
+        numeric_segment(match.group("post"), names=("post", "rev", "r")),
+        numeric_segment(match.group("dev"), names=("dev",)),
+        tuple(re.split(r"[-_.]", local.lower())) if local else None,
+    )
+
+
+def release_versions_agree(left: str, right: str) -> bool:
+    """Report whether two version spellings describe the same release."""
+
+    normalized_left = normalized_release_version(left)
+    normalized_right = normalized_release_version(right)
+    if normalized_left is None or normalized_right is None:
+        return left.strip() == right.strip()
+    return normalized_left == normalized_right
+
+
 def installed_version_problems(
     *,
     version: str,
@@ -54,7 +121,11 @@ def installed_version_problems(
         problems.append(
             "CLI version output does not match the installed distribution version"
         )
-    if requested_version and installed and requested_version != installed:
+    if (
+        requested_version
+        and installed
+        and not release_versions_agree(requested_version, installed)
+    ):
         problems.append(
             "installed distribution version does not match the requested candidate"
         )
diff --git a/tests/test_cloud_client.py b/tests/test_cloud_client.py
index 2fdc7b22..a24f8329 100644
--- a/tests/test_cloud_client.py
+++ b/tests/test_cloud_client.py
@@ -2,6 +2,7 @@
 
 import hashlib
 import json
+import shutil
 import stat
 import subprocess
 import tempfile
@@ -10,6 +11,7 @@
 from io import StringIO
 from pathlib import Path
 
+import code_mower.cloud_client.git_metadata as git_metadata
 import code_mower.cloud_client.operations as cloud_operations
 from code_mower.cloud_client import (
     BUNDLE_MANIFEST_FILENAME,
@@ -1070,6 +1072,107 @@ def test_strict_board_snapshot_preserves_tracked_executable_modes(
     assert not Path(collected["source"]).exists()
 
 
+def _materialization_roots() -> list[Path]:
+    return sorted(Path(tempfile.gettempdir()).glob("code-mower-exact-commit-*"))
+
+
+def _force_cleanup(roots: list[Path]) -> None:
+    for root in roots:
+        git_metadata._set_tree_permissions(root, writable=True)
+        shutil.rmtree(root, ignore_errors=True)
+
+
+def test_strict_board_snapshot_fails_closed_when_cleanup_leaves_the_private_tree(
+    monkeypatch, tmp_path
+) -> None:
+    repo_path = tmp_path / "checkout"
+    head_sha = _init_git_checkout(repo_path, executable=True)
+    before = _materialization_roots()
+    monkeypatch.setattr(cloud_operations, "post_upload_payload", _refusing_post)
+    monkeypatch.setattr(git_metadata.shutil, "rmtree", lambda *_a, **_k: None)
+
+    # Collection itself succeeds, but a private materialization left on disk
+    # must not be reported as success or uploaded.
+    with assert_raises(CloudBundleError) as caught:
+        _board_snapshot_dry_run(
+            monkeypatch,
+            repo_path,
+            tmp_path / "leaked-out",
+            require_head_sha=head_sha,
+            require_clean=True,
+            yes=True,
+        )
+    message = str(caught.exception)
+    assert "unable to remove the private exact-commit source" == message
+    leaked = [root for root in _materialization_roots() if root not in before]
+    assert leaked
+    _force_cleanup(leaked)
+
+
+def test_materialization_cleanup_fails_closed_when_write_access_cannot_be_restored(
+    monkeypatch, tmp_path
+) -> None:
+    repo_path = tmp_path / "checkout"
+    head_sha = _init_git_checkout(repo_path, executable=True)
+    before = _materialization_roots()
+    real_chmod = git_metadata.os.chmod
+    hardened: list[bool] = []
+
+    def failing_restore(path, mode, *args, **kwargs):
+        # Hardening is allowed; restoring write access is not, so the
+        # non-writable directories cannot be removed.
+        if mode & 0o200 and hardened:
+            raise OSError("chmod refused")
+        if not mode & 0o200:
+            hardened.append(True)
+        return real_chmod(path, mode, *args, **kwargs)
+
+    monkeypatch.setattr(git_metadata.os, "chmod", failing_restore)
+
+    with assert_raises(CloudBundleError) as caught:
+        with git_metadata.materialized_commit_source(repo_path, head_sha) as source:
+            assert (source / "tracked.txt").read_text(encoding="utf-8") == "one\n"
+    assert str(caught.exception) == "unable to remove the private exact-commit source"
+
+    monkeypatch.undo()
+    leaked = [root for root in _materialization_roots() if root not in before]
+    assert leaked
+    _force_cleanup(leaked)
+    assert not [root for root in _materialization_roots() if root not in before]
+
+
+def test_materialization_cleanup_failure_chains_a_collection_failure(
+    monkeypatch, tmp_path
+) -> None:
+    repo_path = tmp_path / "checkout"
+    head_sha = _init_git_checkout(repo_path)
+    before = _materialization_roots()
+    monkeypatch.setattr(git_metadata.shutil, "rmtree", lambda *_a, **_k: None)
+    primary = CloudBundleError("collection failed")
+
+    with assert_raises(CloudBundleError) as caught:
+        with git_metadata.materialized_commit_source(repo_path, head_sha):
+            raise primary
+    # The cleanup failure is surfaced and the primary failure is preserved.
+    assert str(caught.exception) == "unable to remove the private exact-commit source"
+    assert caught.exception.__cause__ is primary
+
+    monkeypatch.undo()
+    _force_cleanup([root for root in _materialization_roots() if root not in before])
+
+
+def test_materialization_cleanup_succeeds_on_the_normal_path(tmp_path) -> None:
+    repo_path = tmp_path / "checkout"
+    head_sha = _init_git_checkout(repo_path, executable=True)
+    before = _materialization_roots()
+
+    with git_metadata.materialized_commit_source(repo_path, head_sha) as source:
+        assert (source / "tracked.txt").read_text(encoding="utf-8") == "one\n"
+
+    assert not source.exists()
+    assert not [root for root in _materialization_roots() if root not in before]
+
+
 def test_strict_board_snapshot_cleans_up_a_materialization_with_executables(
     monkeypatch, tmp_path
 ) -> None:
diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py
index 1eaad459..9ceda691 100644
--- a/tests/test_release_hygiene.py
+++ b/tests/test_release_hygiene.py
@@ -7631,6 +7631,50 @@ def test_installed_version_binding_rejects_mismatched_and_missing_versions(
         )
         self.assertTrue(problems(version="", distribution_version=""))
 
+    def test_installed_version_binding_accepts_pep440_equivalent_spellings(self) -> None:
+        problems = code_mower_migration_readiness.installed_version_problems
+
+        # pip installs normalized metadata, so an equivalent requested spelling
+        # of the same release is the requested candidate.
+        for requested in ("1.4.0", "v1.4.0", "1.4.0.0", "1.4", " 1.4.0 "):
+            with self.subTest(requested=requested):
+                self.assertEqual(
+                    problems(
+                        version="code-mower 1.4.0",
+                        distribution_version="1.4.0",
+                        requested_version=requested,
+                    ),
+                    [],
+                )
+
+        for requested in ("1.4.1", "1.4.0rc1", "1.4.0.post1", "2!1.4.0", "1.4.0+local", "not-a-version"):
+            with self.subTest(requested=requested):
+                self.assertEqual(
+                    problems(
+                        version="code-mower 1.4.0",
+                        distribution_version="1.4.0",
+                        requested_version=requested,
+                    ),
+                    ["installed distribution version does not match the requested candidate"],
+                )
+
+    def test_normalized_release_version_equivalence(self) -> None:
+        agree = code_mower_migration_readiness.release_versions_agree
+        normalized = code_mower_migration_readiness.normalized_release_version
+
+        self.assertTrue(agree("v1.4.0", "1.4.0"))
+        self.assertTrue(agree("1.4.0.0", "1.4.0"))
+        self.assertTrue(agree("1.4.0-rc.1", "1.4.0rc1"))
+        self.assertTrue(agree("1.4.0.post0", "1.4.0-0"))
+        self.assertFalse(agree("1.4.0", "1.4.0rc1"))
+        self.assertFalse(agree("1.4.0", "1.4.0.post1"))
+        self.assertFalse(agree("1.4.0", "1.5.0"))
+        self.assertIsNone(normalized("nonsense"))
+        # Unparseable values fall back to exact text, so nothing is accepted
+        # on the strength of a failed parse.
+        self.assertTrue(agree("nonsense", "nonsense"))
+        self.assertFalse(agree("nonsense", "1.4.0"))
+
     def test_requested_candidate_version_binds_specs_and_wheels(self) -> None:
         requested = code_mower_migration_install.requested_candidate_version
 

From 4b66f2077aab7a13206167576ec95d278c70c13c Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Sun, 13 Sep 2026 09:14:08 +0000
Subject: [PATCH 19/20] Use packaging for release candidate version and
 requirement binding

Co-Authored-By: bot_apk 
---
 pyproject.toml                        |   2 +-
 src/code_mower/migration_install.py   |  66 ++++++---
 src/code_mower/migration_readiness.py |  71 +++-------
 src/code_mower/migration_rehearsal.py |   6 +-
 src/code_mower/package_content.py     |   2 +-
 tests/test_migration_rehearsal.py     |  18 ++-
 tests/test_release_hygiene.py         | 184 ++++++++++++++++++++++++--
 7 files changed, 258 insertions(+), 91 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index 9475eb57..71c3d85d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,7 +19,7 @@ classifiers = [
     "Programming Language :: Python :: 3.14",
     "Topic :: Software Development :: Quality Assurance",
 ]
-dependencies = ["PyYAML>=6.0"]
+dependencies = ["PyYAML>=6.0", "packaging>=23.2"]
 
 [project.urls]
 Homepage = "https://github.com/codemower-ai/code-mower"
diff --git a/src/code_mower/migration_install.py b/src/code_mower/migration_install.py
index d0775fbe..3d40cde9 100644
--- a/src/code_mower/migration_install.py
+++ b/src/code_mower/migration_install.py
@@ -16,6 +16,10 @@
 from pathlib import Path
 from typing import Any, Sequence
 
+from packaging.requirements import InvalidRequirement, Requirement
+from packaging.utils import canonicalize_name
+from packaging.version import InvalidVersion, Version
+
 from .git_identity import scratch_git_config_commands
 
 #: Closed taxonomy for package-install failure reasons. Always one of these
@@ -31,13 +35,6 @@
     }
 )
 
-# Grammar for the one exact package-index spec shape a candidate-only download
-# accepts: ==. Anything else (ranges, extras, paths, URLs) has
-# no single artifact a downloaded file could be checked against, so it is
-# refused rather than downloaded.
-_EXACT_NAME_VERSION_SPEC_PATTERN = re.compile(
-    r"^(?P[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)==(?P[A-Za-z0-9.]+)$"
-)
 # Wheel and sdist filenames as pip/setuptools emit them: the distribution name
 # and version are escaped (runs of ``-_.`` collapsed to ``_``) ahead of a
 # fixed suffix -- wheel tags for a wheel, ``.tar.gz``/``.zip`` for an sdist.
@@ -433,39 +430,64 @@ def _pip_install_command(
 
 def _normalize_distribution_name(name: str) -> str:
     """PEP 503 normalization, so a spec and a downloaded filename agree on identity."""
-    return re.sub(r"[-_.]+", "-", name.strip()).lower()
+    return canonicalize_name(name.strip())
 
 
 def _parse_exact_name_version_spec(package_spec: str) -> tuple[str, str]:
     """Parse a candidate-only download spec into ``(identity, version)``.
 
-    Only the exact ``==`` shape has one artifact a downloaded
-    file can be checked against; anything else is refused rather than
-    downloaded, matching :func:`_package_spec_uses_package_index`'s refusal of
-    inexact specs elsewhere in this module.
+    Only one exact, non-wildcard ``==`` requirement for a single distribution,
+    with no extras, URL, marker, or additional specifier, has one artifact a
+    downloaded file can be checked against; anything else is refused rather
+    than downloaded, matching :func:`_package_spec_uses_package_index`'s
+    refusal of inexact specs elsewhere in this module. The returned version is
+    the PEP 440 normal form of the requested version.
     """
-    match = _EXACT_NAME_VERSION_SPEC_PATTERN.match(package_spec.strip())
-    if not match:
-        raise ValueError(
-            "candidate-only download requires an exact == spec, "
-            f"got: {package_spec!r}"
-        )
-    return _normalize_distribution_name(match.group("name")), match.group("version")
+    text = package_spec.strip()
+    refusal = ValueError(
+        "candidate-only download requires an exact == spec, "
+        f"got: {package_spec!r}"
+    )
+    try:
+        requirement = Requirement(text)
+    except InvalidRequirement as error:
+        raise refusal from error
+    if requirement.url or requirement.extras or requirement.marker:
+        raise refusal
+    specifiers = list(requirement.specifier)
+    if len(specifiers) != 1:
+        raise refusal
+    specifier = specifiers[0]
+    if specifier.operator != "==" or specifier.version.endswith(".*"):
+        raise refusal
+    try:
+        version = Version(specifier.version)
+    except InvalidVersion as error:
+        raise refusal from error
+    return _normalize_distribution_name(requirement.name), str(version)
 
 
 def _parse_downloaded_artifact_identity(filename: str) -> tuple[str, str]:
     """Parse a downloaded wheel/sdist filename into ``(identity, version)``.
 
     Raises ``ValueError`` for any filename that is not a recognized wheel or
-    sdist shape, so a candidate-only download can fail closed on a malformed
-    artifact instead of accepting it on faith.
+    sdist shape, or whose version is not a valid PEP 440 version, so a
+    candidate-only download can fail closed on a malformed artifact instead of
+    accepting it on faith. The returned version is the PEP 440 normal form, so
+    a filename-escaped spelling still compares equal to the requested version.
     """
     match = _WHEEL_FILENAME_PATTERN.match(filename) or _SDIST_FILENAME_PATTERN.match(filename)
     if not match:
         raise ValueError(
             f"downloaded candidate artifact has an unrecognized filename: {filename!r}"
         )
-    return _normalize_distribution_name(match.group("name")), match.group("version")
+    try:
+        version = Version(match.group("version"))
+    except InvalidVersion as error:
+        raise ValueError(
+            f"downloaded candidate artifact has an unrecognized filename: {filename!r}"
+        ) from error
+    return _normalize_distribution_name(match.group("name")), str(version)
 
 
 def requested_candidate_version(package_spec: str, *, distribution: str = "code-mower") -> str:
diff --git a/src/code_mower/migration_readiness.py b/src/code_mower/migration_readiness.py
index 765a50c1..24560df5 100644
--- a/src/code_mower/migration_readiness.py
+++ b/src/code_mower/migration_readiness.py
@@ -3,24 +3,10 @@
 from __future__ import annotations
 
 import json
-import re
 from pathlib import Path
 from typing import Any, Sequence
 
-_PEP440_PATTERN = re.compile(
-    r"""
-    ^\s*v?
-    (?:(?P\d+)!)?
-    (?P\d+(?:\.\d+)*)
-    (?P
[-_.]?(?:a|b|c|rc|alpha|beta|pre|preview)[-_.]?\d*)?
-    (?P-\d+|[-_.]?(?:post|rev|r)[-_.]?\d*)?
-    (?P[-_.]?dev[-_.]?\d*)?
-    (?:\+(?P[a-z0-9]+(?:[-_.][a-z0-9]+)*))?
-    \s*$
-    """,
-    re.IGNORECASE | re.VERBOSE,
-)
-_PRE_LETTERS = {"alpha": "a", "a": "a", "beta": "b", "b": "b", "c": "rc", "pre": "rc", "preview": "rc", "rc": "rc"}
+from packaging.version import InvalidVersion, Version
 
 FIRST_USER_ARTIFACTS = (
     ("calibration_plan", ".code-mower/calibration-plan.json"),
@@ -46,54 +32,33 @@
 )
 
 
-def normalized_release_version(value: str) -> tuple[object, ...] | None:
-    """Return a comparable PEP 440 identity, or ``None`` when unparseable.
+def normalized_release_version(value: str) -> Version | None:
+    """Return the PEP 440 version ``value`` spells, or ``None`` when invalid.
 
-    Equivalent spellings such as ``v1.4.0``, ``1.4.0`` and ``1.4.0.0`` describe
-    one distribution version, so version agreement is decided on the normalized
-    identity rather than the raw text.
+    Equivalent spellings such as ``v1.4.0``, ``1.4.0``, ``1.4.0.0`` and
+    ``1.4.0+build.01`` versus ``1.4.0+build.1`` describe one distribution
+    version, so agreement is decided on the parsed version rather than the raw
+    text.
     """
 
-    match = _PEP440_PATTERN.match(value)
-    if not match:
+    try:
+        return Version(value.strip())
+    except InvalidVersion:
         return None
 
-    def numeric_segment(raw: str | None, *, names: tuple[str, ...]) -> int | None:
-        if raw is None:
-            return None
-        digits = re.sub(r"[^0-9]", "", raw)
-        if not digits and not any(name in raw.lower() for name in names):
-            return None
-        return int(digits) if digits else 0
-
-    release = [int(part) for part in match.group("release").split(".")]
-    while len(release) > 1 and release[-1] == 0:
-        release.pop()
-
-    pre: tuple[str, int] | None = None
-    raw_pre = match.group("pre")
-    if raw_pre:
-        letters = re.sub(r"[^a-zA-Z]", "", raw_pre).lower()
-        pre = (_PRE_LETTERS.get(letters, letters), int(re.sub(r"[^0-9]", "", raw_pre) or 0))
-
-    local = match.group("local")
-    return (
-        int(match.group("epoch") or 0),
-        tuple(release),
-        pre,
-        numeric_segment(match.group("post"), names=("post", "rev", "r")),
-        numeric_segment(match.group("dev"), names=("dev",)),
-        tuple(re.split(r"[-_.]", local.lower())) if local else None,
-    )
-
 
 def release_versions_agree(left: str, right: str) -> bool:
-    """Report whether two version spellings describe the same release."""
+    """Report whether two version spellings describe the same release.
+
+    An unparseable version never agrees with anything, including an identical
+    invalid spelling, so a malformed candidate or malformed installed metadata
+    cannot satisfy the binding.
+    """
 
     normalized_left = normalized_release_version(left)
     normalized_right = normalized_release_version(right)
     if normalized_left is None or normalized_right is None:
-        return left.strip() == right.strip()
+        return False
     return normalized_left == normalized_right
 
 
@@ -121,6 +86,8 @@ def installed_version_problems(
         problems.append(
             "CLI version output does not match the installed distribution version"
         )
+    if installed and normalized_release_version(installed) is None:
+        problems.append("installed distribution version is not a valid version")
     if (
         requested_version
         and installed
diff --git a/src/code_mower/migration_rehearsal.py b/src/code_mower/migration_rehearsal.py
index bd064afb..c6715685 100644
--- a/src/code_mower/migration_rehearsal.py
+++ b/src/code_mower/migration_rehearsal.py
@@ -47,6 +47,7 @@
         PRIVACY_EXCLUDED_CONTENT as PRIVACY_EXCLUDED_CONTENT,
         first_user_artifacts as _first_user_artifacts,
         first_user_readiness_scorecard as _first_user_readiness_scorecard,
+        release_versions_agree as _release_versions_agree,
     )
 else:
     from .migration_install import (
@@ -84,6 +85,7 @@
         PRIVACY_EXCLUDED_CONTENT as PRIVACY_EXCLUDED_CONTENT,
         first_user_artifacts as _first_user_artifacts,
         first_user_readiness_scorecard as _first_user_readiness_scorecard,
+        release_versions_agree as _release_versions_agree,
     )
 
 __all__ = [
@@ -354,7 +356,9 @@ def _run_two_stage_candidate_install(
         artifact_identity, artifact_version = _parse_downloaded_artifact_identity(artifact.name)
     except ValueError as exc:
         raise RehearsalError(str(exc), steps) from exc
-    if artifact_identity != expected_identity or artifact_version != expected_version:
+    if artifact_identity != expected_identity or not _release_versions_agree(
+        artifact_version, expected_version
+    ):
         raise RehearsalError(
             f"candidate artifact {artifact.name!r} does not match the requested "
             f"{expected_identity}=={expected_version} spec",
diff --git a/src/code_mower/package_content.py b/src/code_mower/package_content.py
index 5cec137a..f848d143 100644
--- a/src/code_mower/package_content.py
+++ b/src/code_mower/package_content.py
@@ -38,7 +38,7 @@ def _pyproject_text(package_name: str, *, version: str) -> str:
             'requires-python = ">=3.12"',
             'readme = "README.md"',
             'license = {text = "Apache-2.0"}',
-            'dependencies = ["PyYAML>=6.0"]',
+            'dependencies = ["PyYAML>=6.0", "packaging>=23.2"]',
             "",
             "[project.optional-dependencies]",
             'coworker = ["mcp>=2.2.0,<2.3", "httpx2>=2.12,<3", "PyJWT[crypto]>=2.10,<3", "keyring>=25,<26"]',
diff --git a/tests/test_migration_rehearsal.py b/tests/test_migration_rehearsal.py
index e6337e08..fb30d396 100644
--- a/tests/test_migration_rehearsal.py
+++ b/tests/test_migration_rehearsal.py
@@ -359,11 +359,27 @@ def test_fails_closed_on_identity_mismatch(self) -> None:
         with tempfile.TemporaryDirectory() as tmp:
             with self.assertRaises(migration_rehearsal.RehearsalError) as ctx:
                 self._run(
-                    artifacts=["other-package-1.0.0-py3-none-any.whl"],
+                    artifacts=["other_package-1.0.0-py3-none-any.whl"],
                     work_dir=Path(tmp),
                 )
         self.assertIn("does not match the requested", str(ctx.exception))
 
+    def test_fails_closed_on_artifact_with_invalid_version(self) -> None:
+        with tempfile.TemporaryDirectory() as tmp:
+            with self.assertRaises(migration_rehearsal.RehearsalError) as ctx:
+                self._run(
+                    artifacts=["other-package-1.0.0-py3-none-any.whl"],
+                    work_dir=Path(tmp),
+                )
+        self.assertIn("unrecognized filename", str(ctx.exception))
+
+    def test_accepts_equivalent_pep440_artifact_spelling(self) -> None:
+        with tempfile.TemporaryDirectory() as tmp:
+            self._run(
+                artifacts=["code_mower-1.0.0.0-py3-none-any.whl"],
+                work_dir=Path(tmp),
+            )
+
     def test_rejects_non_exact_package_spec_before_downloading(self) -> None:
         with tempfile.TemporaryDirectory() as tmp:
             work_dir = Path(tmp)
diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py
index 9ceda691..042e695e 100644
--- a/tests/test_release_hygiene.py
+++ b/tests/test_release_hygiene.py
@@ -23,6 +23,10 @@
 from unittest import mock
 
 import yaml
+from packaging import __version__ as packaging_version
+from packaging.requirements import Requirement
+from packaging.utils import canonicalize_name
+from packaging.version import InvalidVersion, Version
 
 ROOT = Path(__file__).resolve().parents[1]
 sys.path.insert(0, str(ROOT / "src"))
@@ -7658,22 +7662,112 @@ def test_installed_version_binding_accepts_pep440_equivalent_spellings(self) ->
                     ["installed distribution version does not match the requested candidate"],
                 )
 
-    def test_normalized_release_version_equivalence(self) -> None:
+        # Exact local, epoch, and prerelease candidates stay bound to the
+        # metadata pip actually installed.
+        for requested, installed in (
+            ("1.4.0+local", "1.4.0+local"),
+            ("1.4.0+build.01", "1.4.0+build.1"),
+            ("2!1.4.0", "2!1.4.0"),
+            ("1.4.0-rc.1", "1.4.0rc1"),
+        ):
+            with self.subTest(requested=requested, installed=installed):
+                self.assertEqual(
+                    problems(
+                        version=f"code-mower {installed}",
+                        distribution_version=installed,
+                        requested_version=requested,
+                    ),
+                    [],
+                )
+
+    def test_installed_version_binding_rejects_invalid_installed_metadata(self) -> None:
+        problems = code_mower_migration_readiness.installed_version_problems
+
+        self.assertEqual(
+            problems(version="code-mower not-a-version", distribution_version="not-a-version"),
+            ["installed distribution version is not a valid version"],
+        )
+        # An identically malformed requested candidate must not pass binding
+        # merely because the two strings match.
+        self.assertEqual(
+            problems(
+                version="code-mower not-a-version",
+                distribution_version="not-a-version",
+                requested_version="not-a-version",
+            ),
+            [
+                "installed distribution version is not a valid version",
+                "installed distribution version does not match the requested candidate",
+            ],
+        )
+
+    def test_packaging_is_a_declared_runtime_dependency(self) -> None:
+        pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))
+        dependencies = pyproject["project"]["dependencies"]
+        requirements = {
+            canonicalize_name(Requirement(entry).name): Requirement(entry)
+            for entry in dependencies
+        }
+        # The PEP 440 comparisons the release version binding relies on come
+        # from `packaging`, so an installed distribution must carry it.
+        self.assertIn("packaging", requirements)
+        self.assertTrue(
+            requirements["packaging"].specifier.filter([Version(packaging_version)]),
+            f"declared packaging bound rejects the installed {packaging_version}",
+        )
+        # The generated package tree must publish the same runtime contract.
+        generated = tomllib.loads(
+            code_mower_package_content._pyproject_text("code-mower", version=__version__)
+        )
+        self.assertEqual(generated["project"]["dependencies"], dependencies)
+
+    def test_normalized_release_version_matches_packaging_semantics(self) -> None:
         agree = code_mower_migration_readiness.release_versions_agree
         normalized = code_mower_migration_readiness.normalized_release_version
 
-        self.assertTrue(agree("v1.4.0", "1.4.0"))
-        self.assertTrue(agree("1.4.0.0", "1.4.0"))
-        self.assertTrue(agree("1.4.0-rc.1", "1.4.0rc1"))
-        self.assertTrue(agree("1.4.0.post0", "1.4.0-0"))
-        self.assertFalse(agree("1.4.0", "1.4.0rc1"))
-        self.assertFalse(agree("1.4.0", "1.4.0.post1"))
-        self.assertFalse(agree("1.4.0", "1.5.0"))
-        self.assertIsNone(normalized("nonsense"))
-        # Unparseable values fall back to exact text, so nothing is accepted
-        # on the strength of a failed parse.
-        self.assertTrue(agree("nonsense", "nonsense"))
-        self.assertFalse(agree("nonsense", "1.4.0"))
+        equivalent = (
+            ("v1.4.0", "1.4.0"),
+            ("1.4.0.0", "1.4.0"),
+            (" 1.4.0 ", "1.4.0"),
+            ("1.4.0-rc.1", "1.4.0rc1"),
+            ("1.4.0.alpha2", "1.4.0a2"),
+            ("1.4.0.post0", "1.4.0-0"),
+            ("1.4.0-dev1", "1.4.0.dev1"),
+            ("0!1.4.0", "1.4.0"),
+            ("2!1.4.0", "2!1.4.0.0"),
+            ("1.0+01", "1.0+1"),
+            ("1.0+abc.01", "1.0+abc.1"),
+            ("1.4.0+BUILD-1", "1.4.0+build.1"),
+        )
+        for left, right in equivalent:
+            with self.subTest(left=left, right=right):
+                self.assertEqual(Version(left), Version(right))
+                self.assertTrue(agree(left, right))
+
+        different = (
+            ("1.4.0", "1.5.0"),
+            ("1.4.0", "1.4.0rc1"),
+            ("1.4.0", "1.4.0.post1"),
+            ("1.4.0", "1.4.0.dev1"),
+            ("1.4.0", "2!1.4.0"),
+            ("1.4.0", "1.4.0+build.1"),
+            ("1.0+abc.1", "1.0+abc.01a"),
+            ("1.0+1", "1.0+2"),
+        )
+        for left, right in different:
+            with self.subTest(left=left, right=right):
+                self.assertNotEqual(Version(left), Version(right))
+                self.assertFalse(agree(left, right))
+
+        for invalid in ("nonsense", "1.4.0.0.0.nope", "", "==1.4.0", "1.4.0+"):
+            with self.subTest(invalid=invalid):
+                with self.assertRaises(InvalidVersion):
+                    Version(invalid)
+                self.assertIsNone(normalized(invalid))
+                # An invalid version never agrees, not even with itself.
+                self.assertFalse(agree(invalid, invalid))
+                self.assertFalse(agree(invalid, "1.4.0"))
+                self.assertFalse(agree("1.4.0", invalid))
 
     def test_requested_candidate_version_binds_specs_and_wheels(self) -> None:
         requested = code_mower_migration_install.requested_candidate_version
@@ -7692,6 +7786,70 @@ def test_requested_candidate_version_binds_specs_and_wheels(self) -> None:
             "",
         )
 
+    def test_requested_candidate_version_accepts_only_exact_requirements(self) -> None:
+        requested = code_mower_migration_install.requested_candidate_version
+
+        # Every exact PEP 440 candidate derives its normalized version, so it
+        # stays bound to the metadata pip installs.
+        accepted = (
+            ("code-mower==1.4.0", "1.4.0"),
+            ("code-mower ==1.4.0", "1.4.0"),
+            ("code_mower==1.4.0", "1.4.0"),
+            ("Code.Mower==1.4.0", "1.4.0"),
+            ("code-mower==v1.4.0", "1.4.0"),
+            ("code-mower==1.4.0.0", "1.4.0.0"),
+            ("code-mower==1.4.0+local", "1.4.0+local"),
+            ("code-mower==1.4.0+build.01", "1.4.0+build.1"),
+            ("code-mower==2!1.4.0", "2!1.4.0"),
+            ("code-mower==1.4.0-rc.1", "1.4.0rc1"),
+        )
+        for spec, version in accepted:
+            with self.subTest(spec=spec):
+                self.assertEqual(requested(spec), version)
+
+        # Anything that does not name exactly one version for this
+        # distribution establishes no candidate at all.
+        rejected = (
+            "code-mower",
+            "code-mower>=1.4.0",
+            "code-mower~=1.4.0",
+            "code-mower!=1.4.0",
+            "code-mower===1.4.0",
+            "code-mower==1.4.*",
+            "code-mower==1.4.0,!=1.4.0",
+            "code-mower>=1.4.0,<1.5.0",
+            "code-mower[coworker]==1.4.0",
+            'code-mower==1.4.0; python_version >= "3.12"',
+            "code-mower==not-a-version",
+            "code-mower==",
+            "==1.4.0",
+            "other-package==1.4.0",
+            "code mower==1.4.0",
+            "code-mower@1.4.0",
+        )
+        for spec in rejected:
+            with self.subTest(spec=spec):
+                self.assertEqual(requested(spec), "")
+
+    def test_exact_name_version_spec_parsing_is_standards_complete(self) -> None:
+        parse = code_mower_migration_install._parse_exact_name_version_spec
+
+        self.assertEqual(parse("Code_Mower==1.4.0"), ("code-mower", "1.4.0"))
+        self.assertEqual(parse("code-mower==1.4.0+build_01"), ("code-mower", "1.4.0+build.1"))
+        for spec in (
+            "code-mower",
+            "code-mower==1.4.*",
+            "code-mower[coworker]==1.4.0",
+            'code-mower==1.4.0; python_version < "3.13"',
+            "code-mower @ https://example.invalid/code_mower-1.4.0-py3-none-any.whl",
+            "code-mower==1.4.0,>=1.4.0",
+            "code-mower==oops",
+            "!!!",
+        ):
+            with self.subTest(spec=spec):
+                with self.assertRaises(ValueError):
+                    parse(spec)
+
     def test_first_user_readiness_scorecard_fails_on_version_mismatch(self) -> None:
         with tempfile.TemporaryDirectory() as tmp:
             root = Path(tmp)

From a56b30949e61d5a8553b24989e6dc32ffb7187f3 Mon Sep 17 00:00:00 2001
From: Jeff Huber 
Date: Sun, 13 Sep 2026 02:41:24 -0700
Subject: [PATCH 20/20] Validate symlinks in exact-commit snapshot sources

---
 src/code_mower/cloud_client/git_metadata.py |  80 +++++++++++
 tests/test_cloud_client.py                  |  29 ++++
 tests/test_cloud_git_metadata.py            | 151 ++++++++++++++++++++
 3 files changed, 260 insertions(+)

diff --git a/src/code_mower/cloud_client/git_metadata.py b/src/code_mower/cloud_client/git_metadata.py
index 4b607d8b..dfb77f10 100644
--- a/src/code_mower/cloud_client/git_metadata.py
+++ b/src/code_mower/cloud_client/git_metadata.py
@@ -8,6 +8,7 @@
 import stat
 import subprocess
 import tempfile
+from collections import deque
 from collections.abc import Iterator
 from contextlib import contextmanager
 from pathlib import Path
@@ -133,6 +134,82 @@ def checkout_provenance(repo_path: Path, *, required: bool = False) -> dict[str,
     }
 
 
+def _require_contained_symlink(path: Path, root: Path) -> None:
+    """Resolve a link without allowing even an intermediate step outside source.
+
+    A final-path containment check alone can depend on mutable external links.
+    Resolve components in order so parent traversal uses the actual directory,
+    and reject entry into Git metadata even if a later component leaves it.
+    """
+
+    remaining = deque(path.relative_to(root).parts)
+    target = root
+    followed = 0
+    unsafe = "the private exact-commit source contains an unsafe symlink"
+    while remaining:
+        component = remaining.popleft()
+        if component == "..":
+            if target == root:
+                raise CloudBundleError(unsafe)
+            target = target.parent
+            continue
+        target = target / component
+        mode = target.lstat().st_mode
+        if stat.S_ISLNK(mode):
+            followed += 1
+            # Bound cyclic or excessive link chains independently of Python's
+            # version-specific Path.resolve cycle handling.
+            if followed > 40:
+                raise CloudBundleError(unsafe)
+            link = target.readlink()
+            if link.anchor:
+                try:
+                    link = link.relative_to(root)
+                except ValueError:
+                    raise CloudBundleError(unsafe) from None
+                target = root
+            else:
+                target = target.parent
+            remaining.extendleft(reversed(link.parts))
+        else:
+            # File identity catches alternate .git casing on case-insensitive
+            # filesystems. Intermediate non-directories cannot be traversed.
+            if target.samefile(root / ".git") or (remaining and not stat.S_ISDIR(mode)):
+                raise CloudBundleError(unsafe)
+    # Check the original spelling too: Path components discard trailing slashes
+    # and dots, which the filesystem may reject for a non-directory target.
+    path.stat()
+
+
+def _validate_materialized_symlinks(root: Path) -> None:
+    """Allow only resolvable symlinks contained in the private source tree.
+
+    Git records a symlink's target spelling, not the content it points to.
+    Validate every source link before collection, without descending through
+    directory symlinks or treating the clone's Git metadata as source data.
+    """
+
+    try:
+        root = root.resolve(strict=True)
+        git_dir = root / ".git"
+        pending = [root]
+        while pending:
+            with os.scandir(pending.pop()) as entries:
+                for entry in entries:
+                    path = Path(entry.path)
+                    if path == git_dir:
+                        continue
+                    if entry.is_symlink():
+                        _require_contained_symlink(path, root)
+                    elif entry.is_dir(follow_symlinks=False):
+                        pending.append(path)
+    except (OSError, RuntimeError):
+        # Keep traversal and resolution errors bounded, with no paths leaked.
+        raise CloudBundleError(
+            "unable to validate symlinks in the private exact-commit source"
+        ) from None
+
+
 def _set_tree_permissions(root: Path, *, writable: bool) -> None:
     """Remove or restore write permission for a whole private directory tree.
 
@@ -184,6 +261,8 @@ def materialized_commit_source(repo_path: Path, commit_sha: str) -> Iterator[Pat
     location rather than from the caller's mutable worktree, so a tracked file
     that is changed and restored while the data is read cannot be observed at
     all: the only state reachable during collection is the required commit.
+    Every step of symlink resolution must stay within that source tree, outside
+    its Git metadata; dangling links and cycles are rejected before collection.
     The materialization is made non-writable for the collection interval, so an
     attempt to mutate it fails, and it is removed on every exit.
     """
@@ -213,6 +292,7 @@ def materialized_commit_source(repo_path: Path, commit_sha: str) -> Iterator[Pat
             ],
         )
         _required_git_output(source, ["checkout", "--quiet", "--detach", expected])
+        _validate_materialized_symlinks(source)
         materialized = checkout_provenance(source, required=True)
         if materialized.get("head_sha") != expected or not materialized.get("clean"):
             raise CloudBundleError(
diff --git a/tests/test_cloud_client.py b/tests/test_cloud_client.py
index a24f8329..ecc8262a 100644
--- a/tests/test_cloud_client.py
+++ b/tests/test_cloud_client.py
@@ -954,6 +954,35 @@ def recording_status(config):
     assert result["git"]["head_sha"] == head_sha
 
 
+def test_strict_board_snapshot_rejects_unsafe_symlinks_before_collection(monkeypatch, tmp_path) -> None:
+    repo_path = tmp_path / "checkout"
+    _init_git_checkout(repo_path)
+    external = tmp_path / "external.yml"
+    external.write_text("fixture: true\n", encoding="utf-8")
+    (repo_path / "code-mower.yml").symlink_to(external)
+    subprocess.run(["git", "add", "code-mower.yml"], cwd=repo_path, check=True)
+    subprocess.run(["git", "commit", "--quiet", "-m", "symlink fixture"], cwd=repo_path, check=True)
+    head_sha = git_metadata.run_git(repo_path, ["rev-parse", "HEAD"])
+    output = tmp_path / "rejected-snapshot"
+
+    def refusing_status(_config):
+        raise AssertionError("Board must not read an unvalidated source")
+
+    monkeypatch.setattr(cloud_operations, "post_upload_payload", _refusing_post)
+    with assert_raises(CloudBundleError) as caught:
+        _board_snapshot_dry_run(
+            monkeypatch,
+            repo_path,
+            output,
+            status_payload=refusing_status,
+            require_head_sha=head_sha,
+            require_clean=True,
+            yes=True,
+        )
+    assert str(caught.exception) == "the private exact-commit source contains an unsafe symlink"
+    assert not output.exists()
+
+
 def _strict_collection_inputs(monkeypatch, repo_path: Path, output_dir: Path, **kwargs):
     """Collect strictly and report what the collection source actually was."""
 
diff --git a/tests/test_cloud_git_metadata.py b/tests/test_cloud_git_metadata.py
index 134707d4..fe75bafd 100644
--- a/tests/test_cloud_git_metadata.py
+++ b/tests/test_cloud_git_metadata.py
@@ -4,8 +4,13 @@
 import subprocess
 import tempfile
 import unittest
+from collections.abc import Iterator
+from contextlib import contextmanager
 from pathlib import Path
+from unittest.mock import patch
 
+import code_mower.cloud_client.git_metadata as git_metadata
+from code_mower.cloud_client.errors import CloudBundleError
 from code_mower.cloud_client.git_metadata import (
     detect_repo_slug,
     repo_slug_from_remote,
@@ -56,5 +61,151 @@ def test_run_git_returns_stdout_or_empty_string(self) -> None:
         self.assertEqual(run_git(repo, ["rev-parse", "--verify", "missing-ref"]), "")
 
 
+class MaterializedSymlinkTests(unittest.TestCase):
+    def setUp(self) -> None:
+        fixture = tempfile.TemporaryDirectory(prefix="code-mower-symlink-test-")
+        self.addCleanup(fixture.cleanup)
+        self.root = Path(fixture.name).resolve()
+        self.repo = self.root / "repo"
+        self.repo.mkdir()
+        self.private_root = self.root / "private"
+        subprocess.run(["git", "init", "-q"], cwd=self.repo, check=True)
+        subprocess.run(["git", "config", "core.symlinks", "true"], cwd=self.repo, check=True)
+        (self.repo / "tracked.txt").write_text("committed fixture\n", encoding="utf-8")
+
+    @contextmanager
+    def _materialize(self) -> Iterator[Path]:
+        subprocess.run(["git", "add", "--all"], cwd=self.repo, check=True)
+        subprocess.run(
+            [
+                "git", "-c", "user.name=Dev", "-c", "user.email=dev@example.com",
+                "-c", "commit.gpgSign=false", "commit", "-qm", "fixture",
+            ],
+            cwd=self.repo,
+            check=True,
+        )
+        head = run_git(self.repo, ["rev-parse", "HEAD"])
+        self.private_root.mkdir()
+        with patch.object(git_metadata.tempfile, "mkdtemp", return_value=str(self.private_root)):
+            with git_metadata.materialized_commit_source(self.repo, head) as source:
+                yield source
+
+    def _assert_rejected(self) -> None:
+        with self.assertRaises(CloudBundleError) as caught:
+            with self._materialize():
+                self.fail("an unsafe source was exposed to a consumer")
+        self.assertIn(
+            str(caught.exception),
+            (
+                "the private exact-commit source contains an unsafe symlink",
+                "unable to validate symlinks in the private exact-commit source",
+            ),
+        )
+        self.assertNotIn(str(self.root), str(caught.exception))
+        self.assertFalse(self.private_root.exists())
+
+    def test_rejects_absolute_external_symlink(self) -> None:
+        external = self.root / "external.txt"
+        external.write_text("external fixture\n", encoding="utf-8")
+        original_mode = external.stat().st_mode
+        (self.repo / "code-mower.yml").symlink_to(external)
+        self._assert_rejected()
+        self.assertEqual(external.stat().st_mode, original_mode)
+
+    def test_rejects_relative_external_symlink(self) -> None:
+        (self.root / "external.txt").write_text("external fixture\n", encoding="utf-8")
+        (self.repo / "code-mower.yml").symlink_to("../../external.txt")
+        self._assert_rejected()
+
+    def test_rejects_external_hop_even_with_internal_destination(self) -> None:
+        (self.root / "external").symlink_to(self.private_root / "source/tracked.txt")
+        (self.repo / "code-mower.yml").symlink_to("../../external")
+        self._assert_rejected()
+
+    def test_rejects_external_directory_symlink_in_hidden_subdirectory(self) -> None:
+        (self.root / "external").mkdir()
+        hidden = self.repo / ".config"
+        hidden.mkdir()
+        (hidden / "settings").symlink_to("../../../external", target_is_directory=True)
+        self._assert_rejected()
+
+    def test_rejects_git_metadata_file_symlink(self) -> None:
+        (self.repo / "code-mower.yml").symlink_to(".git/HEAD")
+        self._assert_rejected()
+
+    def test_rejects_git_metadata_directory_symlink(self) -> None:
+        (self.repo / "metadata").symlink_to(".git", target_is_directory=True)
+        self._assert_rejected()
+
+    def test_rejects_git_metadata_traversal_even_with_internal_destination(self) -> None:
+        (self.repo / "code-mower.yml").symlink_to(".git/../tracked.txt")
+        self._assert_rejected()
+
+    def test_rejects_git_metadata_case_alias_on_case_insensitive_filesystem(self) -> None:
+        if not (self.repo / ".GIT").exists():
+            self.skipTest("filesystem is case-sensitive")
+        (self.repo / "code-mower.yml").symlink_to(".GIT/HEAD")
+        self._assert_rejected()
+
+    def test_rejects_dangling_symlink(self) -> None:
+        (self.repo / "code-mower.yml").symlink_to("missing.txt")
+        self._assert_rejected()
+
+    def test_rejects_non_directory_target_with_trailing_slash(self) -> None:
+        (self.repo / "code-mower.yml").symlink_to("tracked.txt/")
+        self._assert_rejected()
+
+    def test_rejects_non_directory_target_with_dot_suffix(self) -> None:
+        (self.repo / "code-mower.yml").symlink_to("tracked.txt/.")
+        self._assert_rejected()
+
+    def test_rejects_symlink_cycle(self) -> None:
+        (self.repo / "first").symlink_to("second")
+        (self.repo / "second").symlink_to("first")
+        self._assert_rejected()
+
+    def test_rejects_chain_to_git_metadata(self) -> None:
+        (self.repo / "code-mower.yml").symlink_to("settings")
+        (self.repo / "settings").symlink_to(".git/config")
+        self._assert_rejected()
+
+    def test_preserves_safe_internal_file_directory_and_chained_symlinks(self) -> None:
+        config = self.repo / "config"
+        config.mkdir()
+        (config / "settings.yml").write_text("fixture: true\n", encoding="utf-8")
+        (config / "tracked").symlink_to("../tracked.txt")
+        (config / "nested").mkdir()
+        (config / "nested/keep.txt").write_text("tracked directory\n", encoding="utf-8")
+        (self.repo / "deep").symlink_to("config/nested", target_is_directory=True)
+        (self.repo / "parent-traversal").symlink_to("deep/../../tracked.txt")
+        (self.repo / "settings").symlink_to("config", target_is_directory=True)
+        (self.repo / "code-mower.yml").symlink_to("settings/settings.yml")
+        with self._materialize() as source:
+            self.assertTrue((source / "code-mower.yml").is_symlink())
+            self.assertTrue((source / "settings").is_symlink())
+            self.assertEqual((source / "code-mower.yml").read_text(), "fixture: true\n")
+            self.assertEqual((source / "config/tracked").read_text(), "committed fixture\n")
+            self.assertEqual((source / "parent-traversal").read_text(), "committed fixture\n")
+            self.assertEqual((source / "code-mower.yml").stat().st_mode & 0o222, 0)
+            self.assertTrue(git_metadata.checkout_provenance(source, required=True)["clean"])
+        self.assertFalse(self.private_root.exists())
+
+    def test_preserves_absolute_symlink_inside_private_source(self) -> None:
+        (self.repo / "code-mower.yml").symlink_to(self.private_root / "source/tracked.txt")
+        with self._materialize() as source:
+            self.assertTrue((source / "code-mower.yml").is_symlink())
+            self.assertEqual((source / "code-mower.yml").read_text(), "committed fixture\n")
+        self.assertFalse(self.private_root.exists())
+
+    def test_fails_closed_on_tree_traversal_error(self) -> None:
+        with patch.object(git_metadata.os, "scandir", side_effect=PermissionError("fixture path")):
+            with self.assertRaises(CloudBundleError) as caught:
+                git_metadata._validate_materialized_symlinks(self.repo)
+        self.assertEqual(
+            str(caught.exception),
+            "unable to validate symlinks in the private exact-commit source",
+        )
+
+
 if __name__ == "__main__":
     unittest.main()