diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index dfc60211..fb176614 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -89,6 +89,39 @@ jobs:
--timeout 240
--json
+ release_rehearsal:
+ name: release wheel rehearsal
+ runs-on: ubuntu-latest
+ env:
+ SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
+ steps:
+ - name: Check out the exact rehearsal source
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+ with:
+ ref: ${{ env.SOURCE_SHA }}
+ - name: Set up Python
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
+ with:
+ python-version: "3.12"
+ - name: Build a pre-merge rehearsal pair
+ # No --release-pr: these bytes are explicitly ineligible for publication.
+ run: |
+ python -m pip install build twine
+ python scripts/release_candidate.py build --source "$GITHUB_WORKSPACE" \
+ --source-sha "$SOURCE_SHA" --dist "$RUNNER_TEMP/rehearsal-dist"
+ - name: Rehearse the exact installed wheel offline
+ run: |
+ python scripts/rehearse_v150.py --dist "$RUNNER_TEMP/rehearsal-dist" \
+ --source-sha "$SOURCE_SHA" --work-dir "$RUNNER_TEMP/rehearsal"
+ - name: Upload sanitized pre-merge evidence
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
+ with:
+ name: release-rehearsal-evidence
+ path: |
+ ${{ runner.temp }}/rehearsal-dist/candidate.json
+ ${{ runner.temp }}/rehearsal/rehearsal.json
+ if-no-files-found: error
+
graph_containment:
# The local-graph boundary, against the real kernel mechanism rather than a
# stand-in for one. The unit suite skips these when a host offers no
@@ -212,10 +245,11 @@ jobs:
package:
name: package
runs-on: ubuntu-latest
- needs: [package_matrix, board_qualification]
+ needs: [package_matrix, board_qualification, release_rehearsal]
if: always()
steps:
- name: Check matrix result
run: |
test "${{ needs.package_matrix.result }}" = "success"
test "${{ needs.board_qualification.result }}" = "success"
+ test "${{ needs.release_rehearsal.result }}" = "success"
diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml
new file mode 100644
index 00000000..a31a25d0
--- /dev/null
+++ b/.github/workflows/release-candidate.yml
@@ -0,0 +1,76 @@
+name: Code Mower Immutable Candidate
+
+on:
+ workflow_dispatch:
+ inputs:
+ expected_sha:
+ description: Exact merge SHA of the release preparation PR; never a branch or tag.
+ required: true
+ type: string
+ release_pr:
+ description: Merged release preparation PR number.
+ required: true
+ type: string
+
+permissions:
+ contents: read
+ pull-requests: read
+
+concurrency:
+ group: release-candidate-${{ inputs.expected_sha }}
+ cancel-in-progress: false
+
+jobs:
+ candidate:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Validate candidate selection
+ env:
+ SOURCE_SHA: ${{ inputs.expected_sha }}
+ RELEASE_PR: ${{ inputs.release_pr }}
+ run: |
+ set -euo pipefail
+ [[ "$GITHUB_REF" == refs/heads/main ]] || exit 1
+ [[ "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]] || exit 1
+ [[ "$RELEASE_PR" =~ ^[1-9][0-9]*$ ]] || exit 1
+ [[ "$GITHUB_SHA" == "$SOURCE_SHA" ]] || exit 1
+ [[ "$GITHUB_RUN_ATTEMPT" == 1 ]] || exit 1
+
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+ with:
+ ref: ${{ inputs.expected_sha }}
+ fetch-depth: 0
+
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
+ with:
+ python-version: "3.12"
+
+ - name: Install build tools
+ run: python -m pip install build twine
+
+ - name: Build the immutable artifact pair once
+ env:
+ GH_TOKEN: ${{ github.token }}
+ SOURCE_SHA: ${{ inputs.expected_sha }}
+ RELEASE_PR: ${{ inputs.release_pr }}
+ run: |
+ set -euo pipefail
+ git merge-base --is-ancestor "$SOURCE_SHA" origin/main
+ python scripts/release_candidate.py build --source "$GITHUB_WORKSPACE" \
+ --dist "$RUNNER_TEMP/candidate" --source-sha "$SOURCE_SHA" --release-pr "$RELEASE_PR"
+
+ - name: Rehearse the exact wheel in disposable environments
+ env:
+ SOURCE_SHA: ${{ inputs.expected_sha }}
+ run: |
+ python scripts/rehearse_v150.py --dist "$RUNNER_TEMP/candidate" \
+ --source-sha "$SOURCE_SHA" --work-dir "$RUNNER_TEMP/rehearsal"
+ cp "$RUNNER_TEMP/rehearsal/rehearsal.json" "$RUNNER_TEMP/candidate/rehearsal.json"
+
+ - name: Retain candidate and sanitized evidence
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
+ with:
+ name: code-mower-candidate
+ path: ${{ runner.temp }}/candidate/*
+ if-no-files-found: error
+ retention-days: 90
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9c1225c7..44cfda37 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -19,6 +19,10 @@ on:
description: Exact 40-character release commit this dispatch must build.
required: true
type: string
+ candidate_run_id:
+ description: Successful immutable-candidate workflow run to publish without rebuilding.
+ required: false
+ type: string
permissions:
contents: read
@@ -72,6 +76,10 @@ jobs:
build-distributions:
needs: release-identity
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ actions: read
+ pull-requests: read
steps:
- name: Check out the validated release commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
@@ -83,11 +91,42 @@ jobs:
with:
python-version: "3.12"
- - name: Build distributions
+ - name: Retrieve and verify the qualified candidate without rebuilding
+ env:
+ GH_TOKEN: ${{ github.token }}
+ CANDIDATE_RUN_ID: ${{ inputs.candidate_run_id || vars.CODE_MOWER_CANDIDATE_RUN_ID }}
+ SOURCE_SHA: ${{ needs.release-identity.outputs.resolved-sha }}
run: |
- python -m pip install --upgrade pip
- python -m pip install build
- python -m build
+ set -euo pipefail
+ [[ "$CANDIDATE_RUN_ID" =~ ^[1-9][0-9]*$ ]]
+ gh api "repos/$GITHUB_REPOSITORY/actions/runs/$CANDIDATE_RUN_ID" > "$RUNNER_TEMP/candidate-run.json"
+ python - "$RUNNER_TEMP/candidate-run.json" <<'PY'
+ import json, os, sys
+ from pathlib import Path
+ run = json.loads(Path(sys.argv[1]).read_text())
+ assert run['path'] == '.github/workflows/release-candidate.yml'
+ assert run['event'] == 'workflow_dispatch' and run['head_branch'] == 'main'
+ assert run['status'] == 'completed' and run['conclusion'] == 'success'
+ assert run['repository']['full_name'] == 'codemower-ai/code-mower'
+ assert run['head_sha'] == os.environ['SOURCE_SHA']
+ assert run['run_attempt'] == 1
+ PY
+ gh run download "$CANDIDATE_RUN_ID" --repo "$GITHUB_REPOSITORY" \
+ --name code-mower-candidate --dir candidate
+ python scripts/release_candidate.py verify --dist candidate \
+ --source-sha "$SOURCE_SHA" --require-candidate
+ python - <<'PY'
+ import json, os, subprocess
+ from pathlib import Path
+ from scripts.release_candidate import verify_rehearsal
+ candidate = json.loads(Path('candidate/candidate.json').read_text())
+ pr = json.loads(subprocess.check_output(['gh', 'pr', 'view', str(candidate['release_pr']),
+ '--repo', os.environ['GITHUB_REPOSITORY'], '--json', 'state,mergeCommit']))
+ assert pr['state'] == 'MERGED' and pr['mergeCommit']['oid'] == os.environ['SOURCE_SHA']
+ verify_rehearsal(Path('candidate'), candidate)
+ PY
+ mkdir dist
+ cp candidate/*.whl candidate/*.tar.gz dist/
- name: Upload distributions
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 681a6792..316e3f98 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,24 +7,29 @@ later entries are regular releases.
## Unreleased
-Accepted on `main` and not in any published package. The published `v1.4.2`
-package on the package index contains the originally shipped optional Graphify
-integration; the entries below are intended for the next appropriate release.
-The lineage-contract and Coworker-citation entries that were previously listed
-here shipped in the published `v1.4.1` artifact and are recorded under that
-release below.
+No additional changes recorded.
+
+## 1.5.0 — release
+
+Supervised private-workspace Slack preparation and supervisor v2, Graphify
+compatibility and query-reader parity. See [release notes](docs/v150-release-notes.md)
+and the [qualification record](docs/v150-qualification.md).
### Added
+- Explicit `slack setup` hosted manifest and redacted `slack doctor` (#1024).
+ Default install remains Slack-free. Offline snapshots never prove live readiness.
+ Basic private-workspace interaction only; telemetry/Board links and rich UX
+ remain v1.5.1. Audit publication binds the current head reliably (#1025).
+
- Supervisor v2 owns checkpointed `clarify` answers and explicitly authorized
`fix` requests under the original claim, provider binding, live lease and
cumulative ACU cap (#1017). Private input resolution, fsynced pending intents,
exact-head independent review, bounded fix/review allowances and saved
duplicate outcomes prevent implicit recovery or ambiguous message replay.
The packaged v1 schema, fixtures and five-operation enum remain frozen.
- This source contract is intended for v1.5; private bridge development may use
- an exact reviewed source pin, but live canaries and deployment require the
- final v1.5 package. No hosted canary or provider qualification is claimed.
+ Live canaries and deployment require the immutable v1.5.0 package and
+ separate authorization. No hosted canary or provider qualification is claimed.
- Release campaigns can authenticate their isolated Codex home on a headless
Linux host that has no OS keyring. `CODE_MOWER_CODEX_CAMPAIGN_AUTH_MODE=file`
@@ -70,8 +75,10 @@ release below.
either TestPyPI or PyPI can run. The same release-readiness check runs in CI
before tagging and rejects unfinished publication promises. Release jobs
resolve the selected tag to its commit, validate that checkout, and pass the
- exact SHA to the distribution build. Manual dispatch additionally requires
- that tag commit to match the supplied expected SHA (#1014).
+ exact SHA to candidate verification. Manual dispatch additionally requires
+ that tag commit to match the supplied expected SHA (#1014). The immutable
+ merge-SHA wheel/sdist pair is built and qualified before tagging, then reused
+ without rebuilding for publication (#1027).
- A doctor snapshot taken while a Board is still binding its port no longer
reports that no Board is running moments before `board list` lists it. Board
@@ -131,7 +138,7 @@ release below.
ambiguous relationship, stays `available` and usable, discloses
`provider_has_more`, `unresolved_entities` or `document_limit`, and does not
mark the generation incomplete. An answer whose only omission is
- `unresolved_entities` is `partial` too. Intended for `v1.5.0` together with #1007.
+ `unresolved_entities` is `partial` too. Included in `v1.5.0` together with #1007.
## 1.4.2 — published
diff --git a/README.md b/README.md
index af4de1ed..22e34f1a 100644
--- a/README.md
+++ b/README.md
@@ -9,24 +9,21 @@ 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.4.2`, with pinned package
-install spec `code-mower==1.4.2`. Release evidence is recorded on the GitHub
-release and in the first-user install rehearsal. v1.4.2 was published from
-release commit `55339bf1acf76d33be5937e80bdaad772e0b2bf5` under the annotated
-`v1.4.2` tag; see the
-[v1.4.2 release](https://github.com/codemower-ai/code-mower/releases/tag/v1.4.2)
-and the [v1.4.2 qualification record](https://github.com/codemower-ai/code-mower/blob/main/docs/v142-qualification.md). The published
-`v1.4.0` and `v1.4.1` artifacts remain unchanged. Every install command below
-targets the published release.
-
-One qualification boundary stays open and is not claimed by v1.4.2: the bounded
-hosted Devin canary tracked by
-[#951](https://github.com/codemower-ai/code-mower/issues/951), which needs an
-explicit owner authorization before it can run.
+The current package-index release baseline is `v1.5.0`, with pinned package
+install spec `code-mower==1.5.0`. Release evidence is recorded on the GitHub
+release and in the first-user install rehearsal. See the
+[v1.5.0 release notes](https://github.com/codemower-ai/code-mower/blob/main/docs/v150-release-notes.md)
+and [qualification record](https://github.com/codemower-ai/code-mower/blob/main/docs/v150-qualification.md)
+for the exact source, artifact digests and separately observed gates.
+Historical v1.4.x artifacts and qualification records remain unchanged.
+
+The bounded hosted Devin canary tracked by
+[#951](https://github.com/codemower-ai/code-mower/issues/951) is not claimed here;
+paid work requires explicit numeric owner authorization.
Documentation on `main` follows the source on `main`. To read the guide exactly as
-v1.4.2 shipped it, use the
-[`v1.4.2` guide](https://github.com/codemower-ai/code-mower/blob/v1.4.2/docs/try-in-10-minutes.md);
+v1.5.0 ships it, use the
+[`v1.5.0` guide](https://github.com/codemower-ai/code-mower/blob/v1.5.0/docs/try-in-10-minutes.md);
the pages on `main` are the maintained current versions.
## What Code Mower Adds
@@ -55,13 +52,13 @@ 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.4.2
+pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.5.0
command -v code-mower
code-mower --version
```
`command -v code-mower` should print the path you expect and `code-mower
---version` should print `code-mower 1.4.2` before you point Code Mower at a
+--version` should print `code-mower 1.5.0` before you point Code Mower at a
repository. If you do not have pipx, install it from the
[official pipx installation guide](https://pipx.pypa.io/stable/installation/).
@@ -131,7 +128,7 @@ ID with `session lease renew --session-id SESSION_ID` or `session lease release
--dry-run` or `--no-lease` for read-only work.
Codex, Claude Code, and Cursor are qualified for the shared session, telemetry,
-lease, and Jira-authority contract in v1.4.2. Devin, Grok Bot, Antigravity,
+lease, and Jira-authority contract in v1.5.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](https://github.com/codemower-ai/code-mower/blob/main/docs/sessions.md) and the
@@ -258,7 +255,7 @@ and the [Cloud Data Contract](https://github.com/codemower-ai/code-mower/blob/ma
## Current Capabilities And Limits
-| Area | v1.4.2 posture |
+| Area | v1.5.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 |
@@ -268,18 +265,18 @@ and the [Cloud Data Contract](https://github.com/codemower-ai/code-mower/blob/ma
| Forge and merge gate | GitHub |
| Cloud | Optional metadata/report upload; no upload by default |
| 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 |
+| Slack | Explicit private-workspace setup/doctor and supervisor v2 contract; live hosted readiness and capped canaries are separate gates |
GitLab, Bitbucket, broad unattended rollout, uncalibrated merge gates, Devin
peer-orchestrator/reviewer parity, a hosted work-order CLI, a required Graphify
-dependency, and Slack worker delivery are outside v1.4.2. The current priorities
+dependency, Slack telemetry/Board links, and rich Slack UX are outside v1.5.0. The current priorities
and boundaries are recorded in
[Current State And Roadmap](https://github.com/codemower-ai/code-mower/blob/main/docs/current-state-and-roadmap.md).
## Optional Repository Context Graph
Graphify shipped in v1.4.1 as an optional local repository-graph provider and
-remains available in v1.4.2. It is separately installed into an operator-owned
+remains available in v1.5.0. It is separately installed into an operator-owned
environment, explicitly activated, and outside the base dependency set: a
default Claude + Codex install adds no Graphify dependency, no indexer, no
background service, and no watcher.
@@ -298,15 +295,16 @@ for what a build is allowed to see and where its state lives, and
[Bounded Queries And Context Packets](https://github.com/codemower-ai/code-mower/blob/main/docs/context-graph-queries.md) for the
four questions and the packet contract.
-The published v1.4.2 package contains that originally shipped integration.
-Further real-pilot compatibility fixes -- a bounded provider-manifest reader,
-`doc_ref` exclusions, and JavaScript/TypeScript test-convention recognition --
-merged to `main` after the release and are intended for the next appropriate
-release. The accepted `0.9.58` provider pin is unchanged. Upgrading never
-repairs a generation already built, so a generation those gaps left `partial` --
-most often an older partial frontend generation -- has to be rebuilt explicitly;
-one `code-mower context-graph status --json` already reports usable does not.
-See [Optional Graphify Setup](https://github.com/codemower-ai/code-mower/blob/main/docs/graphify-setup.md#published-v142-versus-current-main).
+v1.5.0 includes #1007's bounded 16 MiB provider-manifest reader, `doc_ref`
+non-code exclusions, JavaScript/TypeScript related-test conventions and import
+relationships, plus parser/runtime/single-worker guidance. #1031 makes search
+readiness agree with the installed query reader and preserves usable bounded
+partial answers. The accepted `graphifyy==0.9.58` pin is unchanged. Upgrade does
+not repair existing graphs: explicitly refresh affected/partial generations,
+such as an older partial frontend generation. Inspect
+`code-mower context-graph status --json`; a generation it already reports usable
+does not need rebuilding. See
+[Graphify upgrade guidance](https://github.com/codemower-ai/code-mower/blob/main/docs/graphify-setup.md#v150-compatibility-and-existing-generations).
## Documentation
@@ -317,7 +315,7 @@ See [Optional Graphify Setup](https://github.com/codemower-ai/code-mower/blob/ma
- [Upgrade An Existing Repository](https://github.com/codemower-ai/code-mower/blob/main/docs/upgrade-existing-repo.md)
- [Quickstart Reference](https://github.com/codemower-ai/code-mower/blob/main/docs/quickstart.md)
- [Troubleshooting](https://github.com/codemower-ai/code-mower/blob/main/docs/troubleshooting.md)
-- [First Run Transcript](https://github.com/codemower-ai/code-mower/blob/main/docs/first-run-transcript.md) (v1.4.0 illustrative shape, not the current v1.4.2 pin)
+- [First Run Transcript](https://github.com/codemower-ai/code-mower/blob/main/docs/first-run-transcript.md) (v1.4.0 illustrative shape, not the current v1.5.0 pin)
### Local Board And Repository Context
@@ -338,7 +336,7 @@ See [Optional Graphify Setup](https://github.com/codemower-ai/code-mower/blob/ma
- [Builder Experiments](https://github.com/codemower-ai/code-mower/blob/main/docs/builder-experiments.md)
- [Orchestrator Prompt Pack](https://github.com/codemower-ai/code-mower/blob/main/docs/orchestrator-prompt-pack.md)
- [Optional Devin Setup Prompt](https://github.com/codemower-ai/code-mower/blob/main/docs/devin-setup-prompt.md)
-- [Optional Private Slack Setup and Runbook](https://github.com/codemower-ai/code-mower/blob/main/docs/slack-setup.md) (v1.5.0 candidate; default setup unchanged)
+- [Optional Private Slack Setup and Runbook](https://github.com/codemower-ai/code-mower/blob/main/docs/slack-setup.md) (explicit opt-in; default setup unchanged)
- [Provider Matrix](https://github.com/codemower-ai/code-mower/blob/main/docs/provider-matrix.md)
- [Provider Calibration Scorecard](https://github.com/codemower-ai/code-mower/blob/main/docs/provider-calibration-scorecard.md)
- [Devin Peer-Support Qualification](https://github.com/codemower-ai/code-mower/blob/main/docs/devin-peer-support-qualification.md)
@@ -370,7 +368,9 @@ See [Optional Graphify Setup](https://github.com/codemower-ai/code-mower/blob/ma
- [Cloud Data Contract](https://github.com/codemower-ai/code-mower/blob/main/docs/cloud-data-contract.md)
- [Release Qualification](https://github.com/codemower-ai/code-mower/blob/main/docs/release-qualification.md)
- [Public Release Checklist](https://github.com/codemower-ai/code-mower/blob/main/docs/public-release-checklist.md)
+- [v1.5.0 Release Notes](https://github.com/codemower-ai/code-mower/blob/main/docs/v150-release-notes.md)
- [v1.4.2 Release Notes](https://github.com/codemower-ai/code-mower/blob/main/docs/v142-release-notes.md)
+- [v1.5.0 Qualification Record](https://github.com/codemower-ai/code-mower/blob/main/docs/v150-qualification.md)
- [v1.4.2 Qualification Record](https://github.com/codemower-ai/code-mower/blob/main/docs/v142-qualification.md)
- [Release History And Archived Plans](https://github.com/codemower-ai/code-mower/blob/main/docs/release-history.md)
- [Changelog](https://github.com/codemower-ai/code-mower/blob/main/CHANGELOG.md)
diff --git a/code-mower-package-manifest.json b/code-mower-package-manifest.json
index 3b239152..4f532282 100644
--- a/code-mower-package-manifest.json
+++ b/code-mower-package-manifest.json
@@ -292,6 +292,21 @@
"source": "docs/v141-release-notes.md",
"target": "docs/v141-release-notes.md"
},
+ {
+ "kind": "doc",
+ "source": "docs/v150-qualification.md",
+ "target": "docs/v150-qualification.md"
+ },
+ {
+ "kind": "doc",
+ "source": "docs/v150-release-notes.md",
+ "target": "docs/v150-release-notes.md"
+ },
+ {
+ "kind": "doc",
+ "source": "docs/v150-release-runbook.md",
+ "target": "docs/v150-release-runbook.md"
+ },
{
"kind": "package",
"source": "generated",
@@ -2275,6 +2290,6 @@
"module": "code_mower",
"name": "code-mower",
"source_layout": "src/code_mower",
- "version": "1.4.2"
+ "version": "1.5.0"
}
}
diff --git a/docs/current-state-and-roadmap.md b/docs/current-state-and-roadmap.md
index f34efd8b..a8606bd1 100644
--- a/docs/current-state-and-roadmap.md
+++ b/docs/current-state-and-roadmap.md
@@ -22,14 +22,13 @@ dry-run-first.
## Current Published Baseline
-The current package-index release baseline is `v1.4.2`, with pinned package
-install spec `code-mower==1.4.2`. Release evidence is recorded on the GitHub
-release and in the first-user install rehearsal. It was published from release
-commit `55339bf1acf76d33be5937e80bdaad772e0b2bf5` under the annotated `v1.4.2`
-tag; release [#952](https://github.com/codemower-ai/code-mower/issues/952) is
-closed. See the
-[v1.4.2 release notes](v142-release-notes.md) and the
-[v1.4.2 qualification record](v142-qualification.md).
+The current package-index release baseline is `v1.5.0`, with pinned package
+install spec `code-mower==1.5.0`. Release evidence is recorded on the GitHub
+release and in the first-user install rehearsal. See the
+[v1.5.0 release notes](https://github.com/codemower-ai/code-mower/blob/main/docs/v150-release-notes.md)
+and [qualification record](https://github.com/codemower-ai/code-mower/blob/main/docs/v150-qualification.md)
+for the exact source, artifact digests and separately observed gates.
+Historical v1.4.x artifacts and qualification records remain unchanged.
`v1.4.0`, `v1.4.1` and `v1.4.2` have all shipped, and the v1.4.0 and v1.4.1
artifacts remain unchanged. The published baselines require Python 3.12 or
@@ -126,8 +125,8 @@ future hosted-service work.
- Private Coworker delivery is limited to explicitly approved Claude, Codex, and
Devin roles.
- Graphify is a shipped optional bounded provider with no default dependency,
- and Slack is an ingress foundation only: v1.4.2 delivers no Slack worker
- results.
+ and Slack v1.5.0 adds explicit setup/doctor and supervisor v2. Private hosted
+ readiness and the two capped canaries remain independent acceptance gates.
- 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.
@@ -235,8 +234,8 @@ Real-pilot compatibility fixes have since merged to `main` in
provider-manifest reader separate from the compact generation-manifest bound,
explicit refusal of an oversized provider manifest, `doc_ref` nodes as declared
non-code exclusions, and JavaScript/TypeScript test-convention and `imports`
-recognition in `related_tests`. They are on `main` and intended for the next
-appropriate release; the published `v1.4.2` package does not contain them. The
+recognition in `related_tests`. They are included in v1.5.0 together with #1031 readiness/query parity;
+the historical `v1.4.2` package does not contain them. The
accepted `0.9.58` provider pin is unchanged. Because a published generation is
never rewritten in place, upgrading Code Mower repairs no generation already
built -- but only the generations those compatibility gaps actually affected
@@ -291,13 +290,16 @@ blocks dispatch, and selecting a provider never promotes its role. Ingress
foundations [#916](https://github.com/codemower-ai/code-mower/issues/916) and
[#917](https://github.com/codemower-ai/code-mower/issues/917) are merged and
shipped in `v1.4.0`.
-Remaining work is OAuth, the qualified-supervisor adapter
-([#977](https://github.com/codemower-ai/code-mower/issues/977)), durable
-interactions, the bridge, paired telemetry, setup
-([#922](https://github.com/codemower-ai/code-mower/issues/922)), and release
-acceptance #923. Slack consumes the durable session lifecycle and event surface
-rather than scraping terminal or Board output, and carries no raw private
-context or private reviewer findings.
+The v1.5.0 public package includes the basic setup/doctor runbook (#1024),
+qualified-supervisor v2 contract and checkpointed clarification/fix semantics.
+The private implementation and acceptance stay in their owned repositories.
+#1027 prepares the immutable merge-SHA package; #918 consumes those bytes for
+private administration/readiness; #920 consumes them for one completion and
+one confirmed cancellation only after explicit numeric authorization; #923
+then tags and publishes the unchanged source SHA and independently reinstalls it.
+Slack telemetry/Board/cloud links and rich UX remain v1.5.1. Slack consumes the
+durable lifecycle instead of scraping terminal or Board output and carries no
+raw private context or private reviewer findings.
The preceding phases are complete, so this runtime work is no longer deferred.
Board readiness gates only Slack's end-to-end canary and final acceptance in
@@ -319,9 +321,8 @@ Elapsed time, implementation difficulty, or an open draft PR never changes this
release order. Merged fixes count as on main until a later published package is
verified to contain them; #935/#973 and the phase-3 Board PRs are now verified
in the published `v1.4.2` artifact, while the merged Graphify compatibility
-fixes in [PR #1007](https://github.com/codemower-ai/code-mower/pull/1007) are on
-main awaiting the next appropriate release. They are intended for `v1.5.0`
-together with the #1029 search-readiness check. That check makes `status` and
+fixes in [PR #1007](https://github.com/codemower-ai/code-mower/pull/1007) are included in `v1.5.0`
+together with the #1029 search-readiness check from merged PR #1031. That check makes `status` and
`connection-status` report `search` from the installed query reader, so a
current generation the reader cannot consume is reported as a reader mismatch
with an upgrade action rather than as searchable.
diff --git a/docs/early-adopter-invite-runbook.md b/docs/early-adopter-invite-runbook.md
index ce23a597..1fd2cd12 100644
--- a/docs/early-adopter-invite-runbook.md
+++ b/docs/early-adopter-invite-runbook.md
@@ -1,7 +1,7 @@
# Early Adopter Invite Runbook
-Current release: v1.4.2, published and qualified. Release invitations and
-pinned index installs target `code-mower==1.4.2`.
+Current release: v1.5.0, supervised-pilot baseline. Release invitations and
+pinned index installs target `code-mower==1.5.0`.
Use this runbook for the first 5-10 friendly users before widening Code Mower
to 20-50 early OSS users.
@@ -41,7 +41,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.4.2/docs/try-in-10-minutes.md
+https://github.com/codemower-ai/code-mower/blob/v1.5.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.
@@ -56,7 +56,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.4.2
+ pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.5.0
code-mower --version
```
diff --git a/docs/early-adopter-v05.md b/docs/early-adopter-v05.md
index 8d784dc2..c5eb8b62 100644
--- a/docs/early-adopter-v05.md
+++ b/docs/early-adopter-v05.md
@@ -1,10 +1,10 @@
# Code Mower Early Adopter Guide
-Current release: v1.4.2, published and qualified. Release invitations and
-pinned index installs target `code-mower==1.4.2`.
+Current release: v1.5.0, supervised-pilot baseline. Release invitations and
+pinned index installs target `code-mower==1.5.0`.
This document records the historical v0.5 early-adopter product plan. The
-current public install path is the v1.4.2 supervised-pilot release; use
+current public install path is the v1.5.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-user-install-rehearsal.md b/docs/first-user-install-rehearsal.md
index 99c73b13..af174499 100644
--- a/docs/first-user-install-rehearsal.md
+++ b/docs/first-user-install-rehearsal.md
@@ -1,10 +1,11 @@
# First-User Install Rehearsal
-v1.4.2 is published. The pinned index commands below install the current
-release; verify the exact command path and version after installing. Release
-evidence is on the
-[v1.4.2 release](https://github.com/codemower-ai/code-mower/releases/tag/v1.4.2)
-and in the [v1.4.2 qualification record](v142-qualification.md).
+v1.5.0 uses the exact install pin `code-mower==1.5.0`. Verify the command path
+and version after installing. Use the [v1.5.0 qualification record](v150-qualification.md)
+for observed results and the [candidate runbook](v150-release-runbook.md) for
+prepublication local-wheel rehearsals. Index commands select the release after
+publication; offline preparation does not establish live Slack readiness.
+
This is the release-gate rehearsal for Code Mower's early-adopter path. It
installs Code Mower into a clean virtual environment, creates a fresh toy Git
@@ -54,7 +55,7 @@ Use the current public tag or release candidate:
```bash
code-mower migration package-install-rehearsal \
- --package-spec code-mower==1.4.2 \
+ --package-spec code-mower==1.5.0 \
--allow-package-index \
--python "$(command -v python3.12)" \
--json
@@ -78,7 +79,7 @@ For a fixed output directory:
```bash
code-mower migration package-install-rehearsal \
- --package-spec code-mower==1.4.2 \
+ --package-spec code-mower==1.5.0 \
--allow-package-index \
--python "$(command -v python3.12)" \
--work-dir /tmp/code-mower-first-user-rehearsal \
@@ -110,7 +111,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.4.2" \
+ --package-spec "git+https://github.com/codemower-ai/code-mower.git@v1.5.0" \
--python "$(command -v python3.12)" \
--json
```
@@ -122,7 +123,7 @@ 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.4.2
+PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" code-mower==1.5.0
code-mower --version
```
@@ -132,7 +133,7 @@ For uv:
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.2
+ --default-index https://pypi.org/simple/ code-mower==1.5.0
code-mower --version
```
@@ -163,7 +164,7 @@ repository after the package install succeeds:
```bash
code-mower migration package-install-rehearsal \
- --package-spec code-mower==1.4.2 \
+ --package-spec code-mower==1.5.0 \
--allow-package-index \
--repo-path /path/to/external-repo \
--python "$(command -v python3.12)" \
@@ -255,7 +256,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.4.2 \
+ --package-spec code-mower==1.5.0 \
--allow-package-index \
--repo-path /path/to/product-repo \
--python "$(command -v python3.12)" \
@@ -315,6 +316,11 @@ If this fails, fix the first-user path before cutting or promoting a release.
## Stable Package-Index Release Procedure
+The following v1.4.2 publication commands are historical evidence, not the
+v1.5.0 sequence. For v1.5.0 build the merge-SHA candidate first, qualify it in
+#918 and explicitly authorized #920, then tag/publish the unchanged source SHA
+and the same artifacts through #923. Follow [the current runbook](v150-release-runbook.md).
+
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
diff --git a/docs/friendly-user-rollout-v05.md b/docs/friendly-user-rollout-v05.md
index 812752d6..a98fca94 100644
--- a/docs/friendly-user-rollout-v05.md
+++ b/docs/friendly-user-rollout-v05.md
@@ -1,13 +1,13 @@
# Friendly-User Rollout Plan
-Current release: v1.4.2, published and qualified. Release invitations and
-pinned index installs target `code-mower==1.4.2`.
+Current release: v1.5.0, supervised-pilot baseline. Release invitations and
+pinned index installs target `code-mower==1.5.0`.
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.4.2 supervised-pilot release.
+this document is the current v1.5.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.
@@ -31,13 +31,13 @@ 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.4.2
+pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.5.0
```
-The current package-index release baseline is `v1.4.2`, with pinned package
-install spec `code-mower==1.4.2`. Release evidence is recorded on the GitHub
+The current package-index release baseline is `v1.5.0`, with pinned package
+install spec `code-mower==1.5.0`. Release evidence is recorded on the GitHub
release and in the first-user install rehearsal. See the
-[v1.4.2 release](https://github.com/codemower-ai/code-mower/releases/tag/v1.4.2).
+[v1.5.0 release](https://github.com/codemower-ai/code-mower/releases/tag/v1.5.0).
## Invite Criteria
diff --git a/docs/graphify-setup.md b/docs/graphify-setup.md
index 1f3c45f6..1446e5a0 100644
--- a/docs/graphify-setup.md
+++ b/docs/graphify-setup.md
@@ -1,6 +1,6 @@
# Optional Graphify setup
-Graphify shipped in v1.4.1 and is available in the published v1.4.2 release as
+Graphify shipped in v1.4.1 and is available in v1.5.0 as
an **optional** local repository-graph provider. It is separately installed into
an operator-owned environment, explicitly activated, and outside the base
dependency set: a default Claude + Codex installation adds no Graphify
@@ -86,10 +86,10 @@ configuration files with `PIP_CONFIG_FILE=/dev/null`. `--isolated` alone still
permits global/site configuration and a file selected by `PIP_CONFIG_FILE`;
those sources must not add an alternate index or local dependency source.
-The next two paragraphs are **post-`v1.4.2`**: they describe current `main` and
+The next two paragraphs are included in v1.5.0 and
arrived with [PR #1007](https://github.com/codemower-ai/code-mower/pull/1007),
so they are not part of the published `v1.4.2` package. See
-[Published `v1.4.2` versus current `main`](#published-v142-versus-current-main).
+[v1.5.0 compatibility](#v150-compatibility-and-existing-generations).
Install any required language extras into this same separate environment before
the contained build. For SQL inputs, select `[sql]` on the verified local wheel
@@ -168,7 +168,7 @@ it. A provider run that admitted an incomplete census publishes a generation
`status` calls `partial` and refuses, rather than describing it as `current`.
The rest of this step, up to step 4, is **post-`v1.4.2`** and describes
-current `main` (#1029, intended for `v1.5.0`). `status` also reports `search`:
+v1.5.0 (#1029 / #1031). `status` also reports `search`:
whether the installed query reader can consume the generation. A `current`
generation with `search: unavailable` exits non-zero. Read
`query_reader.next_action`:
@@ -245,16 +245,15 @@ provider environment from
[Separate acquisition environment](#separate-acquisition-environment) is yours
to keep or delete separately; Code Mower never touches it.
-## Published `v1.4.2` versus current `main`
+
-The published `v1.4.2` package on the package index contains the optional
-Graphify integration exactly as it originally shipped. The base setup and
-ramp-up above -- acquisition, the separate contained offline build, and steps 1
-through 7 -- describe that published package. The paragraphs above that are
-explicitly marked post-`v1.4.2` describe current `main` instead: the
-language-extras and runtime-ownership paragraphs under
-[Separate acquisition environment](#separate-acquisition-environment) and the
-search-readiness text in step 3 are the only ones so marked today.
+## v1.5.0 compatibility and existing generations
+
+The historical `v1.4.2` package contains the originally shipped optional
+Graphify integration. v1.5.0 includes the compatibility and readiness additions
+throughout this guide, including the language-extras/runtime-ownership guidance
+and reader-based search-readiness checks. None of these additions changes the
+accepted provider pin or enables Graphify by default.
[PR #1007](https://github.com/codemower-ai/code-mower/pull/1007) has since
merged to `main` with further real-pilot compatibility fixes: a bounded 16 MiB
@@ -265,21 +264,22 @@ recognition of JavaScript/TypeScript `.test`/`.spec` and `__tests__`
conventions together with `imports` relationships. The language-extras and
runtime-ownership paragraphs under
[Separate acquisition environment](#separate-acquisition-environment) arrived
-with the same change. All of it is on `main` and intended for the next
-appropriate release; none of it is in the published `v1.4.2` package.
+with the same change. All of it is included in v1.5.0; none of it is in the historical `v1.4.2` package.
+PR #1031 adds reader-based readiness, actionable compatibility diagnostics, and
+separate generation/query completeness. A bounded partial answer remains usable;
+it does not itself require rebuilding an otherwise complete generation.
The accepted provider pin is unchanged. This is a Code Mower compatibility fix,
not a Graphify upgrade: `graphifyy` `0.9.58` and the recorded wheel digest above
stay exactly as they are.
-Because a published generation is never rewritten in place, installing that
-later release does not repair a generation you already built. That matters only
+Because a published generation is never rewritten in place, installing v1.5.0 does not repair a generation you already built. That matters only
for a generation one of #1007's compatibility gaps actually affected -- most
often an older frontend generation left **partial**: one whose oversized
provider manifest was refused, or one whose inputs a missing language parser
could not process. Those are the generations to rebuild.
-This is not a blanket rebuild of everything built before that future release.
+This is not a blanket rebuild of everything built before v1.5.0.
Ask `code-mower context-graph status --json` first: a generation it already
reports usable is unaffected and needs no rebuild. If it reports `partial`,
rebuild that generation explicitly with `code-mower context-graph refresh`
diff --git a/docs/install.md b/docs/install.md
index 934e2eae..878c85db 100644
--- a/docs/install.md
+++ b/docs/install.md
@@ -1,10 +1,11 @@
# Install And Bootstrap
-v1.4.2 is published. The pinned index commands below install the current
-release; verify the exact command path and version after installing. Release
-evidence is on the
-[v1.4.2 release](https://github.com/codemower-ai/code-mower/releases/tag/v1.4.2)
-and in the [v1.4.2 qualification record](v142-qualification.md).
+v1.5.0 uses the exact install pin `code-mower==1.5.0`. Verify the command path
+and version after installing. Use the [v1.5.0 qualification record](v150-qualification.md)
+for observed results and the [candidate runbook](v150-release-runbook.md) for
+prepublication local-wheel rehearsals. Index commands select the release after
+publication; offline preparation does not establish live Slack readiness.
+
Code Mower requires Python 3.12 or newer. Use one install path per machine or
agent, then verify the installed command before touching a repository.
@@ -56,7 +57,7 @@ code-mower --version
```
`command -v code-mower` must print the path belonging to the installer you
-chose, and `code-mower --version` must print `code-mower 1.4.2`. A version that
+chose, and `code-mower --version` must print `code-mower 1.5.0`. A version that
does not match, or a path from a different installer, means an older command is
still winning on `PATH`; resolve that before running anything against a
repository.
@@ -116,7 +117,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.4.2
+pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.5.0
code-mower --version
```
@@ -137,7 +138,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.4.2
+PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" code-mower==1.5.0
code-mower --version
```
@@ -150,7 +151,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.4.2
+PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" code-mower==1.5.0
"$PIPX_BIN_DIR/code-mower" --version
```
@@ -161,7 +162,7 @@ interactive shell profile:
```bash
uv python install 3.12
-uv tool install --python 3.12 code-mower==1.4.2
+uv tool install --python 3.12 code-mower==1.5.0
code-mower --version
```
@@ -171,7 +172,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.4.2
+uv tool install --python 3.12 --reinstall --refresh-package code-mower code-mower==1.5.0
code-mower --version
```
@@ -186,7 +187,7 @@ With pipx:
```bash
PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" \
- 'code-mower[coworker]==1.4.2'
+ 'code-mower[coworker]==1.5.0'
code-mower context --help
```
@@ -194,7 +195,7 @@ With uv:
```bash
uv tool install --python 3.12 --reinstall --refresh-package code-mower \
- 'code-mower[coworker]==1.4.2'
+ 'code-mower[coworker]==1.5.0'
code-mower context --help
```
@@ -222,7 +223,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.4.2
+uv tool install --python 3.12 --reinstall --refresh-package code-mower code-mower==1.5.0
hash -r
command -v code-mower
code-mower --version
@@ -242,7 +243,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.4.2
+PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON" code-mower==1.5.0
code-mower --version
```
@@ -250,7 +251,7 @@ For uv:
```bash
uv python install 3.12
-uv tool install --python 3.12 --reinstall --refresh-package code-mower code-mower==1.4.2
+uv tool install --python 3.12 --reinstall --refresh-package code-mower code-mower==1.5.0
code-mower --version
```
diff --git a/docs/oss-v1-checklist.md b/docs/oss-v1-checklist.md
index c501f6d0..5f24fe76 100644
--- a/docs/oss-v1-checklist.md
+++ b/docs/oss-v1-checklist.md
@@ -46,8 +46,10 @@ history opens the repository. They should be able to confirm:
## Current v1.0 Baseline
-The public-release baseline is the published `v1.4.2` of the standalone
-package. Before widening the release, record:
+The historical public-release baseline below is the published `v1.4.2`.
+For v1.5.0 follow [the immutable candidate-first runbook](v150-release-runbook.md);
+these publication and dogfood steps record the previous release procedure.
+Before widening that release, record:
- non-editable package-install rehearsal in a clean venv;
- fresh toy-repo easy-mode rehearsal from the installed package;
diff --git a/docs/public-release-checklist.md b/docs/public-release-checklist.md
index e5867fe7..c185b663 100644
--- a/docs/public-release-checklist.md
+++ b/docs/public-release-checklist.md
@@ -1,10 +1,11 @@
# Code Mower Public Release Checklist
-v1.4.2 is published. The pinned index commands below install the current
-release; verify the exact command path and version after installing. Release
-evidence is on the
-[v1.4.2 release](https://github.com/codemower-ai/code-mower/releases/tag/v1.4.2)
-and in the [v1.4.2 qualification record](v142-qualification.md).
+v1.5.0 uses the exact install pin `code-mower==1.5.0`. Verify the command path
+and version after installing. Use the [v1.5.0 qualification record](v150-qualification.md)
+for observed results and the [candidate runbook](v150-release-runbook.md) for
+prepublication local-wheel rehearsals. Index commands select the release after
+publication; offline preparation does not establish live Slack readiness.
+
Use this checklist for public OSS readiness and 1.x hardening. The standalone
`code-mower` repository is public; the remaining work is to make the first
@@ -17,8 +18,8 @@ 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 published package-index release entrypoint is
- `code-mower==1.4.2` (GitHub tag `v1.4.2`), with
+- The current package-index release entrypoint is
+ `code-mower==1.5.0` (GitHub tag `v1.5.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. v1.4.2 superseded `code-mower==1.4.1` (GitHub tag
diff --git a/docs/pypi-release.md b/docs/pypi-release.md
index 81bcefeb..acdcbc08 100644
--- a/docs/pypi-release.md
+++ b/docs/pypi-release.md
@@ -1,16 +1,19 @@
# PyPI Release Runbook
-Code Mower users install from PyPI. The release workflow builds source and
-wheel distributions, verifies them with `twine check`, and can publish to
-TestPyPI or production PyPI through trusted publishing.
+Code Mower users install from PyPI. For v1.5.0, build the immutable merge-SHA
+candidate first, qualify those bytes through #918 and explicitly authorized
+#920, then tag and publish the unchanged SHA through #923. The release workflow
+retrieves the retained candidate and verifies it without rebuilding. Follow the
+[v1.5.0 runbook](v150-release-runbook.md) and [qualification record](v150-qualification.md).
+The v1.4.2 post-merge section below is preserved historical evidence.
```bash
CODE_MOWER_PYTHON="$(command -v python3.12)"
-pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.4.2
+pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.5.0
```
-v1.4.2 is published; the steps below are the executed record of that release
-and the shape the next release follows. All mutating steps require the
+v1.4.2 is published; its steps below are the executed record of that release.
+They are not the v1.5.0 candidate-first sequence. All mutating steps require the
supervisor and the recorded owner release decision.
@@ -52,18 +55,19 @@ package. Never rewrite a published tag to correct the wording.
5. Obtain independent review on the exact preparation PR head, green CI, and
the authoritative Code Mower gate before merge. After the recorded owner
- release decision, bind the merged release commit and create the tag using
- the existing release procedure. Re-run the identity check from that exact
- checkout before dispatching with `--ref v1.5.0` and `-f expected_sha=...`.
+ merge process, bind the actual merge SHA and build the candidate once.
+ Complete #918 and explicitly capped #920 on that wheel before the #923 owner
+ release decision, tag or publication. Re-run identity on that exact checkout;
+ publish the retained pair with the same SHA and candidate workflow run ID.
Ordinary release-readiness CI invokes the same checker using the source
version's intended tag, so contradictions are reviewable before tagging. The
release workflow checks out the exact dispatched tag or published release tag
with full tag history, resolves lightweight and annotated tags to their commit,
and proves that checked-out HEAD equals that commit before checking the public
-text. It exports the validated 40-character SHA to the distribution build,
-which checks out that SHA; event `github.sha` is not used as source identity.
-Both TestPyPI and PyPI consume only the resulting verified distributions.
+text. It exports the validated 40-character SHA to candidate retrieval,
+which checks out that SHA and verifies the retained pair; event `github.sha`
+is not used as source identity. Both TestPyPI and PyPI consume only that pair.
Manual dispatch additionally requires the resolved tag commit to equal the
supplied `expected_sha`; a branch dispatch or mismatched tag ref fails closed.
@@ -76,12 +80,13 @@ release-candidate baseline and may describe a candidate. A final `vX.Y.Z` tag
always requires final-state text, including TestPyPI rehearsals and GitHub
releases marked prerelease. Neither the index nor that flag bypasses the gate.
-The executed v1.4.2 commands below remain a historical record; substitute the
-chosen release identity in future release work rather than reusing that tag.
+The executed v1.4.2 commands below remain a historical record. Do not mechanically
+substitute v1.5.0: its candidate-before-tag procedure is in the current runbook.
## Current Status
-- GitHub Release workflow builds distributions on every published release.
+- GitHub Release workflow retrieves and verifies the qualified candidate on
+ every published release; it does not rebuild the distributions.
- The release workflow downloads the uploaded distributions and runs
`twine check` before any optional PyPI publish job can start.
- TestPyPI publishing is gated behind the `testpypi` GitHub environment.
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 596e63e0..7b392d0d 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -11,7 +11,7 @@ To see the value loop before you touch a product repository, open the
[Demo Calibration Example](../examples/demo-calibration/README.md), the
[Board Demo Rehearsal](../examples/board-demo/README.md), and the
[First-User Demo Transcript](first-user-demo-transcript.md) (a v1.4.0
-illustrative shape, not the current v1.4.2 pin).
+illustrative shape, not the current v1.5.0 pin).
## 1. Install
@@ -24,7 +24,7 @@ is the first-class isolated path:
```bash
uv python install 3.12
-uv tool install --python 3.12 code-mower==1.4.2
+uv tool install --python 3.12 code-mower==1.5.0
code-mower --version
```
@@ -33,12 +33,13 @@ 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.4.2
+pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.5.0
code-mower --version
```
-`1.4.2` is the published supervised-pilot release. These pinned install
-commands install it today. If you want a future prerelease instead
+`1.5.0` is the supervised-pilot release baseline. These pinned install
+commands select it after publication; prepublication qualification uses the
+exact wheel from [the candidate runbook](v150-release-runbook.md). If you want a future prerelease instead
of this exact release target, use:
```bash
@@ -241,7 +242,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.4.2'
+ 'code-mower[coworker]==1.5.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
@@ -500,7 +501,7 @@ export bundle, upload dry run, and CodeMower.com dogfood dry run.
```bash
code-mower migration package-install-rehearsal \
- --package-spec code-mower==1.4.2 \
+ --package-spec code-mower==1.5.0 \
--allow-package-index \
--python "$(command -v python3.12)" \
--json
diff --git a/docs/release-history.md b/docs/release-history.md
index 78f7a4b7..42cf5f44 100644
--- a/docs/release-history.md
+++ b/docs/release-history.md
@@ -11,7 +11,10 @@ guidance; use [Install And Bootstrap](install.md) instead.
## Current Release Line
-- [v1.4.2 release notes](v142-release-notes.md) (published; current baseline)
+- [v1.5.0 release notes](v150-release-notes.md) (current baseline)
+- [v1.5.0 qualification record](v150-qualification.md)
+- [v1.5.0 candidate and publication runbook](v150-release-runbook.md)
+- [v1.4.2 release notes](v142-release-notes.md) (published; historical)
- [v1.4.2 qualification record](v142-qualification.md)
- [v1.4.1 release notes](v141-release-notes.md) (published)
- [v1.4.1 qualification record](v141-qualification.md)
diff --git a/docs/slack-setup.md b/docs/slack-setup.md
index cd4effc3..dd69c69f 100644
--- a/docs/slack-setup.md
+++ b/docs/slack-setup.md
@@ -6,13 +6,12 @@ Claude + Codex: no Slack prompt, dependency, login or service. Slack conveys req
the qualified supervisor owns execution. Hosted Devin is a bounded builder,
never an orchestrator qualification.
-This guide describes the source candidate for v1.5.0. The published v1.4.2
-package does not contain these commands. Use a reviewed candidate for offline
+These commands are included in v1.5.0. Use its reviewed wheel for offline
preparation; live operation requires the final immutable v1.5.0 package and
separately qualified hosted deployment. The private bridge verifies its
implementation lock and rejects editable/VCS installs for live operation.
Version alone is insufficient. Live completion/cancellation qualification
-belongs to #923; this guide authorizes neither spend nor deployment. Telemetry
+belongs to #920 under #923; this guide authorizes neither spend nor deployment. Telemetry
readiness, Board/cloud links, a general integrations picker, Slack Connect,
public channels and rich Slack UX are deferred to v1.5.1.
@@ -91,7 +90,7 @@ public channels and rich Slack UX are deferred to v1.5.1.
and bridge flags only after their owner-controlled deployment/logging gates
pass. Explicitly enable host composition too; web configuration starts no
worker. Missing supervision leaves work waiting/denied and prevents dispatch.
- Qualify the two capped #923 canaries before treating the candidate as a
+ Qualify the two explicitly capped #920 canaries before treating the candidate as a
supported live installation.
## Readiness and redaction
diff --git a/docs/try-in-10-minutes.md b/docs/try-in-10-minutes.md
index f05cc7d4..6da7f3a8 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.4.2` |
-| Hosted agent, CI box, or minimal Linux VM | `uv tool install --python 3.12 code-mower==1.4.2` |
+| Laptop/workstation | `pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.5.0` |
+| Hosted agent, CI box, or minimal Linux VM | `uv tool install --python 3.12 code-mower==1.5.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.4.2
+pipx install --python "$CODE_MOWER_PYTHON" code-mower==1.5.0
command -v code-mower
code-mower --version
```
@@ -48,8 +48,9 @@ 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.4.2` is the published supervised-pilot release. These pinned install
-commands install it today. To follow a future prerelease line
+`1.5.0` is the supervised-pilot release baseline. These pinned install
+commands select it after publication; prepublication qualification uses the
+exact wheel from [the candidate runbook](v150-release-runbook.md). To follow a future prerelease line
instead of pinning this exact build:
```bash
diff --git a/docs/v150-qualification.md b/docs/v150-qualification.md
new file mode 100644
index 00000000..8780aa9a
--- /dev/null
+++ b/docs/v150-qualification.md
@@ -0,0 +1,46 @@
+# v1.5.0 qualification and evidence matrix
+
+This is the immutable evidence contract for release preparation issue #1027,
+under #903 / #923. It is not a claim that publication or private acceptance has
+already occurred. Append observed results to the release PR/issue and GitHub
+Release; do not change qualified source to insert its own SHA or later results.
+Historical v1.4.x qualification records remain unchanged.
+
+## Identity and evidence locations
+
+| Identity | Authoritative record |
+| --- | --- |
+| Release preparation PR and reviewed head | The single PR closing #1027; independent exact-head audit and CI/gate checks |
+| Final source | That PR's actual `mergeCommit.oid`, never its earlier head or mutable main |
+| Candidate workflow | Successful first attempt of `Code Mower Immutable Candidate` on main, with both run `head_sha` and `expected_sha` equal to the merge SHA; reruns are refused |
+| Wheel and sdist | `code-mower-candidate` artifact: `code_mower-1.5.0-py3-none-any.whl` and `code_mower-1.5.0.tar.gz` |
+| Digests/inventory | `candidate.json`: source SHA, merged PR, SHA-256 of each artifact, complete inspected member lists and default dependencies |
+| Disposable rehearsals | `rehearsal.json`, bound to that source SHA and exact wheel digest |
+| Private qualification | Sanitized pass/fail and immutable identifiers on #918 and #920, never raw private observations |
+| Publication | #923 owner decision, unchanged `v1.5.0` tag SHA, publication run, canonical index digests and independent reinstall |
+
+## Required observations
+
+| Boundary | Required evidence | Status in this source record |
+| --- | --- | --- |
+| Source | One Codex writer; #1007, #1024, #1025 and #1031 included; identity/readiness, package guards, privacy, lint, full tests, independent current-head review, normal CI and authoritative gate | Record actual head and check results on the PR |
+| Candidate | Build once from a clean merge-SHA checkout; twine and both inventories pass; retain artifacts and digests | Requires merged release PR; pre-merge builds are rehearsals only |
+| Default install | Installed-wheel provenance; only base dependencies; init preview without Slack; no Slack network, login or service | Disposable rehearsal script; not live administration |
+| Slack opt-in | Installed setup creates mode-0600 hosted manifest and refuses overwrite; default and all-green offline doctor deny readiness | Disposable rehearsal script; no private probe is supplied |
+| Graphify compatibility | Installed wheel accepts/excludes `doc_ref`, reports reader/search available, preserves ambiguity-only partial usability with complete generation, and returns bounded `reader_incompatible` for a same-version wrong-distribution unknown type without content/type/path leakage | Three named checks in `rehearsal.json`, required by publication; synthetic public fixtures only |
+| Upgrade | Published 1.4.2 wheel verified against historical digest, then exact candidate wheel; synthetic config/receipt/reservation bytes preserved | Disposable rehearsal script |
+| Disable/removal | Disabled offline observation denies; local manifest removed; package uninstall preserves synthetic state | Offline only; live disable/uninstall belongs to #918 |
+| Rollback | Disposable package restores exact digest-verified 1.4.2 and preserves synthetic state | Does not authorize downgrading live v2 claims or schema |
+| Private administration | Install/bind/readiness, rotation, disable/uninstall and retained state observed against this candidate | #918, requires owner-controlled private interfaces |
+| Two canaries | Exactly one completion and one confirmed cancellation, writer/reviewer exit observed, independent review/gate and uncertainty preserved | #920, only after explicit numeric authorization; not run by the prep PR |
+| Publish/reinstall | Same source SHA and same bytes after #918/#920 pass, owner decision, independent canonical PyPI reinstall | #923; not run by the prep PR |
+
+An offline synthetic green observation always exits nonzero and reports
+`ready=false`, `dispatch_authorized=false`. A live probe needs the trusted
+private host and fresh observations; this package does not invent one. Synthetic
+state preservation proves installer behavior, not hosted migration safety.
+
+Follow the [v1.5.0 runbook](v150-release-runbook.md). Keep raw local logs private;
+publish only counts, check outcomes, public source/run identities and artifact
+digests. A failed or expired candidate is a stop: do not silently rebuild,
+relabel a different SHA, replay prior receipts or substitute source modules.
diff --git a/docs/v150-release-notes.md b/docs/v150-release-notes.md
new file mode 100644
index 00000000..9b9680c5
--- /dev/null
+++ b/docs/v150-release-notes.md
@@ -0,0 +1,78 @@
+# Code Mower v1.5.0 Release Notes
+
+v1.5.0 adds the basic supervised private-workspace Slack boundary and the
+Graphify compatibility changes below. The install identity is
+`code-mower==1.5.0`. Claude + Codex remain the default. Default dependencies are
+only PyYAML and packaging: no Slack SDK, login, network setup, service, Graphify
+installation or provider dispatch is added by installing Code Mower.
+
+## Included behavior
+
+- Explicit `code-mower slack setup --manifest slack-app.json --yes` creates the
+ hosted manifest privately and exclusively. `slack doctor` and `doctor --slack`
+ provide bounded, redacted diagnostics. An offline snapshot cannot establish
+ live readiness or authorize dispatch (#1024).
+- Supervisor v2 retains the original claim, lease, provider binding and
+ cumulative caps across checkpointed clarification/fix requests (#1017).
+ The v1 contract remains frozen. Public code is the contract/setup surface;
+ hosted administration, credentials and deployment remain separately owned.
+- Exact-head audit publication is reliable across repository dispatch, with
+ bounded refusal diagnostics (#1025 / #1026 / #1030).
+- Explicit headless Codex campaign file authentication, clearer setup-drift
+ operands, concise adoption doctor guidance, bounded Board startup observation
+ and canonical local-lane writer identities carry forward the accepted fixes
+ listed in the 1.5.0 CHANGELOG.
+
+## Graphify compatibility and upgrade
+
+PR #1007 contributes four behaviors:
+
+1. A separate bounded **16 MiB provider inventory** reader, retaining the
+ **256 KiB generation-manifest** bound and existing hash/coverage checks.
+ Oversized provider inventories refuse publication explicitly.
+2. `doc_ref` nodes are declared non-code exclusions; their incident edges cannot
+ become code query results or citations. Unknown types still fail validation.
+3. Related-test queries recognize JavaScript/TypeScript `.test`, `.spec` and
+ `__tests__` conventions and follow import relationships. Import evidence
+ does not establish execution coverage.
+4. Acquisition guidance covers language parser extras, supported runtime
+ ownership and the single-worker option without weakening containment.
+
+Merged PR #1031 (#1029) additionally makes build/refresh/status/connection-status
+readiness agree with the installed query reader. Compatibility diagnostics name
+the upgrade/rebuild action without disclosing graph content or local paths.
+Generation completeness and query completeness are separate: a bounded partial
+answer remains available and discloses its limits; it does not make a complete
+generation incomplete.
+
+The separate provider pin remains `graphifyy==0.9.58`. Upgrading Code Mower does
+not rewrite existing graphs. Inspect `code-mower context-graph status --json`
+and explicitly `code-mower context-graph refresh` affected/partial generations
+(for example, a refused oversized inventory or inputs skipped for missing
+parsers). A usable unaffected generation needs no rebuild merely because its
+query reached a traversal limit. See [Graphify setup](graphify-setup.md).
+
+## Qualification and boundaries
+
+The [qualification record](v150-qualification.md) separates pre-merge rehearsal,
+the immutable merge-SHA candidate, private acceptance (#918), one completion and
+one confirmed-cancellation canary (#920), and publication/reinstall (#923).
+Only explicitly observed outcomes count. These notes claim no paid or live
+hosted result. Canaries require numeric task and aggregate authorization.
+
+Slack scope is one private workspace, private unshared channels, authorized
+repository aliases, private replies, start/status/answer/confirmed cancellation,
+qualified Codex supervision and bounded hosted Devin execution. Registration is
+not qualification; acknowledgement is not provider exit or settled billing.
+Slack telemetry/Board/cloud links and richer UX remain v1.5.1. No Slack Connect,
+public channel, unrestricted execution or peer-orchestrator Devin is implied.
+
+Disposable package rollback to exact 1.4.2 is an installation rehearsal only.
+**Never downgrade live durable v2 state.** Disable admission, reconcile original
+work and confirmed exits, preserve claims/receipts/reservations, and restore only
+a reviewed compatible deployment through its owner-controlled rollback. Follow
+[Slack upgrade, disable, rollback and uninstall](slack-setup.md#troubleshooting-upgrade-and-removal).
+
+No private graph, query, source, task prose, credentials, mappings, provider
+output or adoption evidence belongs in public artifacts. Historical v1.4.x
+release notes, qualification records and published artifacts remain unchanged.
diff --git a/docs/v150-release-runbook.md b/docs/v150-release-runbook.md
new file mode 100644
index 00000000..05d0faa2
--- /dev/null
+++ b/docs/v150-release-runbook.md
@@ -0,0 +1,181 @@
+# v1.5.0 immutable candidate and publication runbook
+
+The release preparation PR performs no paid work, tag, package publication,
+hosted deployment or live Slack mutation. Index install commands select
+`code-mower==1.5.0` after publication; qualification before that uses the exact
+local wheel. Never treat a pre-merge wheel as the final candidate.
+
+## 1. Review and merge the preparation PR
+
+Require one writer, independent exact-head audit with no P0/P1/P2 findings,
+normal CI, the authoritative gate, full tests, lint, privacy scan, package guards,
+release identity and `migration release-readiness --json`. Dependencies #1007,
+#1024, #1025 and #1031 must be ancestors. The owner-controlled merge process
+supplies the final source SHA. Do not merge or bypass a gate as part of rehearsal.
+
+In one operator shell, set the actual PR number, then bind it once:
+
+```bash
+set -euo pipefail
+REPO=codemower-ai/code-mower
+RELEASE_PR=REPLACE_WITH_PREPARATION_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')"
+[[ "$RELEASE_SHA" =~ ^[0-9a-f]{40}$ ]]
+```
+
+## 2. Build and retain the merge-SHA candidate once
+
+Dispatch while `main` still points at `RELEASE_SHA`: the workflow requires its
+own `GITHUB_SHA` to equal that source SHA before checkout or build. It also
+requires `GITHUB_RUN_ATTEMPT=1`; GitHub reruns are refused before any build.
+If main has advanced, stop and resolve the release source through a newly
+reviewed preparation PR; do not use an ancestor as a substitute workflow head.
+
+```bash
+gh workflow run release-candidate.yml --repo "$REPO" --ref main \
+ -f expected_sha="$RELEASE_SHA" -f release_pr="$RELEASE_PR"
+```
+
+Bind `CANDIDATE_RUN_ID` to this exact dispatch after inspecting its inputs and
+successful conclusion. Do not select the latest run by name alone. The workflow
+checks the merged PR, exact clean source, version text, builds wheel/sdist once,
+checks both inventories and runs disposable rehearsals against that wheel.
+It uploads only artifacts, `candidate.json` and `rehearsal.json`, never raw logs
+or private state. No tag is required. Retention is 90 days; retain the approved
+pair securely for #918/#920/#923. Expiration is a stop, not permission to rebuild.
+
+```bash
+CANDIDATE_RUN_ID=REPLACE_WITH_VERIFIED_RUN_ID
+CANDIDATE_DIR="$PWD/v150-candidate-$CANDIDATE_RUN_ID"
+gh run download "$CANDIDATE_RUN_ID" --repo "$REPO" \
+ --name code-mower-candidate --dir "$CANDIDATE_DIR"
+python scripts/release_candidate.py verify --dist "$CANDIDATE_DIR" \
+ --source-sha "$RELEASE_SHA" --require-candidate
+```
+
+Record PR/merge SHA, workflow run, both SHA-256 digests, inventory outcome and
+the sanitized rehearsal result on #1027. `candidate.json` must name that merged
+PR and SHA. Subsequent qualification/publication must consume the retained pair;
+never rerun the candidate build or dispatch a second build for that SHA. If a
+code fix is needed, invalidate this candidate explicitly and repeat all gates
+for a newly reviewed source. Do not tag or publish the invalidated bytes.
+
+For **pre-merge local rehearsal only**, clone the reviewed head into a new clean
+directory, install build/twine in a separate tools venv and use:
+
+```bash
+python scripts/release_candidate.py build --source "$EXACT_CHECKOUT" \
+ --source-sha "$REHEARSAL_SHA" --dist "$REHEARSAL_DIST"
+python scripts/rehearse_v150.py --dist "$REHEARSAL_DIST" \
+ --source-sha "$REHEARSAL_SHA" --work-dir "$NEW_DISPOSABLE_DIR"
+```
+
+No `--release-pr` means `kind=rehearsal`; publication rejects it. Output/work
+directories must be new, and build output must be outside the exact checkout.
+Use Python 3.12+ from the selected runner PATH. The script installs only package
+dependencies from canonical PyPI. Product smoke runs use installed modules with
+network denial and no inherited provider credentials. Only read-only,
+transport-disabled Git commands against the synthetic fixture are allowed as
+product subprocesses. Default install,
+explicit Slack setup, offline doctor, 1.4.2 upgrade, disposable rollback and
+uninstall are checked. Disabled snapshots and local manifest removal are offline
+evidence only, not live hosted disable/uninstall.
+
+The same installed wheel publishes synthetic complete Graphify generations and
+checks the real reader, status, connection and query paths: `doc_ref` is excluded
+as non-code while search remains available; ambiguity alone yields a usable
+partial answer from a complete generation; and an unknown type from a different
+distribution at the reviewed version yields bounded `reader_incompatible`
+diagnostics with no type, path or content leakage. These three named checks are
+required in `rehearsal.json` before publication. No private graph/adoption data
+or live extractor is used.
+
+The normal CI `release wheel rehearsal` job builds the exact PR head as
+`kind=rehearsal` and runs these checks before merge. Its sanitized evidence
+artifact is not a release candidate and cannot be selected for publication.
+
+## 3. Private acceptance consumes these exact bytes (#918)
+
+Bind private installation/administration evidence to `RELEASE_SHA` and the wheel
+digest, validate the host implementation lock, use fresh trusted-host probes,
+and complete private disable/uninstall/state-preservation checks. Do not upload
+private snapshots, bindings, logs, graphs or adoption evidence. Stop if the
+installed package identity differs. Offline snapshots never prove live readiness.
+
+## 4. Explicitly authorize and run only two canaries (#920)
+
+After #918 passes, obtain the recorded numeric task and aggregate campaign ACU
+caps, task count/expiry, runtime and review spend/round limits, clarification/fix
+allowances and zero recovery creates. Run one completion and one confirmed
+cancellation only. Observe actual builder/reviewer exit, independent exact-head
+review and gate evidence. Preserve unsettled/unknown outcomes and full original
+reservations. No elapsed time, credits or prior release authorization substitutes
+for this decision. A failure blocks publication.
+
+## 5. Owner decision, unchanged tag and publication (#923)
+
+Only after #918 and #920 pass against the retained candidate does the owner
+record the release decision on #923. Recheck candidate digests, the preparation
+PR's merge SHA, independent review, CI and authoritative gate before tagging.
+Do not modify release source to append qualification evidence.
+
+```bash
+git fetch origin "$RELEASE_SHA"
+test "$(gh pr view "$RELEASE_PR" --repo "$REPO" --json mergeCommit --jq '.mergeCommit.oid')" = "$RELEASE_SHA"
+git tag -a v1.5.0 "$RELEASE_SHA" -m 'Code Mower v1.5.0'
+git push origin refs/tags/v1.5.0
+test "$(git rev-list -n 1 v1.5.0)" = "$RELEASE_SHA"
+test "$(git ls-remote origin 'refs/tags/v1.5.0^{}' | awk '{print $1}')" = "$RELEASE_SHA"
+gh workflow run release.yml --repo "$REPO" --ref v1.5.0 \
+ -f expected_sha="$RELEASE_SHA" -f candidate_run_id="$CANDIDATE_RUN_ID" \
+ -f publish_testpypi=false -f publish_pypi=false
+```
+
+Verify the exact no-publish run's ref/SHA, identity/retrieval/verification success
+and skipped publication jobs. The release workflow validates the candidate run
+origin, exact run head SHA, first attempt, merged PR, source SHA, digest pair,
+inventories and required rehearsal checks bound to the single verified wheel.
+It **downloads the retained candidate; it does not rebuild**. Publication never
+accepts a pre-merge rehearsal. If TestPyPI is required by the owner, dispatch the
+same tag/SHA/run with only `publish_testpypi=true` and independently verify that
+index's exact package and digests; never combine TestPyPI and PyPI indexes.
+
+After inspecting the no-publish result and the owner decision:
+
+```bash
+gh workflow run release.yml --repo "$REPO" --ref v1.5.0 \
+ -f expected_sha="$RELEASE_SHA" -f candidate_run_id="$CANDIDATE_RUN_ID" \
+ -f publish_testpypi=false -f publish_pypi=true
+```
+
+Keep `CODE_MOWER_PYPI_PUBLISH` and `CODE_MOWER_TESTPYPI_PUBLISH` off so creating
+the GitHub Release does not publish twice. For release-event verification, set
+`CODE_MOWER_CANDIDATE_RUN_ID` to this same verified run before creating the release.
+Keep the trusted `pypi`/`testpypi` environments and explicit owner release decision;
+a passing offline rehearsal is not publication approval.
+
+## 6. Independent canonical reinstall and release evidence
+
+Download exact 1.5.0 from production PyPI without dependencies/config/cache into
+a new directory and compare both canonical artifact digests with `candidate.json`
+before installing. Do not rebuild from the sdist or substitute checkout modules.
+Use a fresh venv or isolated uv/pipx home and record command/version/provenance,
+`pip check`, fresh install, 1.4.2 upgrade and preserved state. Repeat the offline
+Slack checks using the published wheel, and independently verify the private
+host's installed implementation lock through #923. Inspect every existing Board
+binding privately and restart only through its owned managed/transient lifecycle.
+
+Attach the exact verified wheel/sdist to the GitHub Release with `--verify-tag`,
+record the candidate and publication runs and digests, then append sanitized
+outcomes to #1027/#918/#920/#923. Do not claim a package is independently
+reinstalled from its build log alone. No cloud upload is implicit.
+
+## Live rollback boundary
+
+The disposable 1.4.2 downgrade is **not** an operational rollback of durable v2
+state. Disable admission, reconcile original work and confirmed provider exits,
+retain claims/receipts/reservations, and restore only a reviewed compatible
+runtime through its owner-controlled rollback. Never downgrade live v2 claims,
+delete uncertainty or refund allowances. Re-enable only after fresh qualification
+and an explicit owner decision. See [the Slack runbook](slack-setup.md).
diff --git a/pyproject.toml b/pyproject.toml
index 169398b4..9519e0a0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "code-mower"
-version = "1.4.2"
+version = "1.5.0"
description = "Multi-reviewer AI code audit orchestration"
requires-python = ">=3.12"
readme = "README.md"
@@ -43,6 +43,15 @@ code-mower = "code_mower.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
+[tool.setuptools.data-files]
+"share/code-mower/docs" = [
+ "docs/v150-release-notes.md",
+ "docs/v150-qualification.md",
+ "docs/v150-release-runbook.md",
+ "docs/slack-setup.md",
+ "docs/graphify-setup.md",
+]
+
[tool.setuptools.package-data]
code_mower = [
"*.json",
diff --git a/scripts/rehearse_v150.py b/scripts/rehearse_v150.py
new file mode 100644
index 00000000..6f309641
--- /dev/null
+++ b/scripts/rehearse_v150.py
@@ -0,0 +1,339 @@
+"""Disposable wheel-only offline lifecycle rehearsal; never live Slack evidence."""
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+import re
+import subprocess
+import sys
+
+from release_candidate import GRAPHIFY_CHECKS, NAMES, REHEARSAL_SCHEMA, verify, verify_rehearsal
+
+
+# This program runs under the fresh venv's -I interpreter. It imports only the
+# retained wheel and the standard library, never checkout/test fixture modules.
+GRAPHIFY_SMOKE = """
+import io, json, tarfile
+from contextlib import redirect_stdout
+from pathlib import Path
+import code_mower
+from code_mower import context_graph_command as command
+from code_mower import context_graph_connection as connection
+from code_mower import context_graph_lifecycle as lifecycle
+from code_mower import context_graph_query as query
+from code_mower.context_store import ContextStore
+
+for module in (code_mower, command, connection, lifecycle, query):
+ assert Path(module.__file__).resolve().is_relative_to(Path(sys.prefix).resolve())
+repository, private = (Path(arg) for arg in sys.argv[1:])
+private.mkdir(mode=0o700)
+
+class NoCredentials:
+ def refuse(self, *args):
+ raise AssertionError('synthetic graph must not access credentials')
+ get = put = delete = refuse
+
+store = ContextStore(private, vault=NoCredentials())
+connection.connect(store, 'synthetic-graph', {
+ 'repository_root': str(repository), 'repositories': ['public/example'],
+ 'recipients': ['codex:builder'],
+})
+policy = {'schema': 'code_mower.contextPolicy.v1', 'connection': 'synthetic-graph',
+ 'policy_version': 'v1', 'required': True}
+
+def node(identifier, label, line, **extra):
+ return {'id': identifier, 'label': label, 'file_type': 'code',
+ 'source_file': 'example.py', 'source_location': f'L{line}', **extra}
+
+def edge(source, target, relation='calls', confidence='EXTRACTED'):
+ return {'source': source, 'target': target, 'relation': relation,
+ 'confidence': confidence, 'source_file': 'example.py', 'source_location': 'L1'}
+
+def document():
+ return {'nodes': [node('n-target', 'synthetic_target', 1),
+ node('n-caller', 'synthetic_caller', 2)],
+ 'edges': [edge('n-caller', 'n-target')], 'hyperedges': [],
+ 'input_tokens': 0, 'output_tokens': 0, 'extracted_sources': ['example.py']}
+
+def publish(value, distribution='graphifyy'):
+ def indexer(request):
+ raw = json.dumps(value).encode()
+ with tarfile.open(request.output_path, 'w') as archive:
+ member = tarfile.TarInfo('graph.json')
+ member.size = len(raw)
+ archive.addfile(member, io.BytesIO(raw))
+ return lifecycle.IndexResult(completeness=lifecycle.COMPLETE, indexed_files=1)
+ manifest = lifecycle.build_graph(repository, root=private, indexer=indexer,
+ pin=lifecycle.GraphifyPin(distribution=distribution, version='0.9.58', wheel_sha256='a'*64))
+ assert manifest.completeness == lifecycle.COMPLETE
+
+def observe():
+ state = lifecycle.GraphStateRoot(repository, root=private)
+ status = lifecycle.graph_status(repository, root=private)
+ assert status.usable and status.manifest.completeness == lifecycle.COMPLETE
+ readiness = query.search_readiness(state, status)
+ stream = io.StringIO()
+ with redirect_stdout(stream):
+ code = command.main(['status', '--repo-path', str(repository),
+ '--state-dir', str(private), '--json'])
+ report = json.loads(stream.getvalue())
+ connected = connection.status(store, 'synthetic-graph', root=private)
+ with store.locked('synthetic-graph') as locked:
+ envelope = connection.authorize_locked(locked, 'synthetic-graph', root=private)
+ context = query.graph_context(repository, root=private, question='impact',
+ target='synthetic_target', envelope=envelope, policy=policy,
+ context_repository='public/example', work_item='SYNTHETIC-1')
+ return state, status, readiness, code, report, connected, context
+
+checks = []
+value = document()
+value['nodes'].append(node('n-doc', 'synthetic_doc_content', 1, file_type='doc_ref'))
+value['edges'] += [edge('n-target', 'n-doc', 'references'), edge('n-doc', 'n-caller', 'references')]
+publish(value)
+state, status, readiness, code, report, connected, context = observe()
+graph = query.read_graph(state, status)
+assert 'n-doc' not in graph.nodes and not graph.incomplete
+assert all('n-doc' not in (item.source, item.target) for item in graph.edges)
+assert code == 0 and report['search'] == connected['authorization'] == 'available'
+assert readiness['search'] == 'available' and readiness['reader'] == 'compatible'
+assert readiness['installed_code_mower'] == code_mower.__version__
+assert context.status == query.AVAILABLE and context.packet['documents']
+assert context.summary['generation_completeness'] == lifecycle.COMPLETE
+assert context.summary['query_completeness'] == lifecycle.COMPLETE
+assert 'synthetic_doc_content' not in json.dumps(context.packet)
+checks.append('graphify_doc_ref_excluded_reader_available')
+
+value = document()
+value['edges'][0]['confidence'] = 'AMBIGUOUS'
+publish(value)
+_, _, readiness, code, report, connected, context = observe()
+assert code == 0 and readiness['search'] == report['search'] == connected['authorization'] == 'available'
+assert context.status == query.AVAILABLE and context.dependent_work == 'usable'
+assert context.summary['generation_completeness'] == lifecycle.COMPLETE
+assert context.summary['query_completeness'] == context.summary['completeness'] == 'partial'
+assert context.packet['completeness'] == 'partial' and not context.packet['truncated']
+assert context.packet['omissions'] == context.summary['omissions'] == ['unresolved_entities']
+assert context.packet['documents']
+checks.append('graphify_ambiguity_only_partial_usable_complete_generation')
+
+value = document()
+value['nodes'].append(node('n-unknown', 'synthetic_unknown_content', 1,
+ file_type='synthetic_unknown_type', source_file='synthetic_unknown_path.py'))
+publish(value, distribution='other-provider')
+_, _, readiness, code, report, connected, context = observe()
+assert code == 1 and report['state'] == 'current' and report['usable']
+assert report['search'] == connected['search'] == connected['authorization'] == 'unavailable'
+assert context.status == query.REQUIRED_UNAVAILABLE and context.dependent_work == 'paused'
+assert context.packet is None and context.summary['reason'] == 'reader_incompatible'
+for verdict in (readiness, report['query_reader'], connected['query_reader']):
+ assert verdict['search'] == 'unavailable' and verdict['reader'] == 'incompatible'
+ assert verdict['reason'] == 'reader_incompatible'
+ assert verdict['remediation'] == context.summary['remediation']
+ assert verdict['remediation']['generation_provider'] == 'other-provider==0.9.58'
+ assert verdict['remediation']['reader_providers'] == ['graphifyy==0.9.58']
+ assert 'node_type' not in verdict['remediation']
+ assert 'required_code_mower' not in verdict['remediation']
+ assert verdict['next_action'] == context.summary['next_action']
+public = json.dumps([readiness, report, connected, context.summary])
+assert len(public) < 12000
+for forbidden in ('synthetic_unknown_type', 'synthetic_unknown_path', 'synthetic_unknown_content',
+ 'n-unknown', 'synthetic_target', 'example.py', str(repository), str(private)):
+ assert forbidden not in public
+checks.append('graphify_wrong_distribution_reader_incompatible_no_leakage')
+print(json.dumps(checks))
+"""
+
+
+def installed_code(code, git_repository=None):
+ """Deny network/children, except transport-disabled Git reads of the fixture."""
+ return "git_repository = " + repr(str(git_repository) if git_repository else None) + "\n" + """
+import sys
+def guard(event, args):
+ if event == 'subprocess.Popen' and git_repository is not None:
+ executable, argv, cwd, environment = args
+ prefix = ['git', '-C', git_repository, '--no-optional-locks']
+ if executable == 'git' and isinstance(argv, list) and argv[:4] == prefix:
+ tail = argv[4:]
+ safety = ['-c', 'protocol.allow=never', '-c', 'core.fsmonitor=false',
+ '-c', 'fetch.recurseSubmodules=no', '-c', 'uploadpack.allowFilter=false']
+ if tail[:len(safety)] == safety:
+ tail = tail[len(safety):]
+ if (tail and tail[0] in ('rev-parse', 'ls-tree', 'cat-file', 'config')
+ and (tail[0] != 'config' or tail[1:4] == ['--local', '--name-only', '--get-regexp'])
+ and environment.get('GIT_ALLOW_PROTOCOL') == ''
+ and environment.get('GIT_NO_LAZY_FETCH') == '1'
+ and environment.get('GIT_CONFIG_GLOBAL') == __import__('os').devnull
+ and environment.get('GIT_CONFIG_NOSYSTEM') == '1'):
+ return
+ if event.startswith('socket.') or event in ('subprocess.Popen', 'os.system', 'os.posix_spawn'):
+ raise RuntimeError('offline rehearsal forbids network and child processes')
+sys.addaudithook(guard)
+""" + code
+
+
+def rehearse(dist, sha, work):
+ manifest = verify(dist, sha)
+ work.mkdir(parents=True, exist_ok=False)
+ # Only infrastructure variables enter the disposable product processes.
+ env = {key: os.environ[key] for key in ("PATH", "SYSTEMROOT", "TMPDIR") if key in os.environ}
+ env.update(HOME=str(work / "home"), PIP_CONFIG_FILE=os.devnull, PYTHONNOUSERSITE="1",
+ GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_SYSTEM=os.devnull,
+ GIT_ALLOW_PROTOCOL="", GIT_NO_LAZY_FETCH="1")
+ (work / "home").mkdir()
+ # Installer self-check/cache files are infrastructure state, and must not
+ # contaminate the empty home used to prove product startup has no effects.
+ (work / "installer-home").mkdir()
+ installer_env = {**env, "HOME": str(work / "installer-home")}
+
+ def command(*args, expected=0, environment=None):
+ result = subprocess.run([str(a) for a in args], cwd=work, env=environment or env,
+ text=True, capture_output=True, timeout=240)
+ if result.returncode != expected:
+ # Report only isolated-program line numbers, never captured output
+ # that might contain graph content or product state.
+ lines = re.findall(r'File "", line (\d+)', result.stderr)
+ raise RuntimeError(f"rehearsal command failed (expected {expected}, got {result.returncode}): {args[0]}; isolated lines={lines}")
+ return result.stdout
+
+ def pip(python, *args):
+ return command(python, "-m", "pip", "--isolated", *args, environment=installer_env)
+
+ def installed(python, code, *args, expected=0, git_repository=None):
+ return command(python, "-I", "-c", installed_code(code, git_repository), *args, expected=expected)
+
+ def cli(python, *args, expected=0):
+ return installed(python, "from code_mower.cli import main\nraise SystemExit(main(sys.argv[1:]))", *args, expected=expected)
+
+ wheel = dist / NAMES[0]
+ checks = []
+ fresh = work / "fresh"
+ command(sys.executable, "-m", "venv", fresh, environment=installer_env)
+ py = fresh / "bin/python"
+ pip(py, "install", "--no-cache-dir", "--index-url", "https://pypi.org/simple/", wheel)
+ pip(py, "check")
+ assert cli(py, "--version").strip() == "code-mower 1.5.0"
+ installed(py, """
+import importlib.util
+import code_mower
+from pathlib import Path
+assert Path(code_mower.__file__).resolve().is_relative_to(Path(sys.prefix).resolve())
+assert all(importlib.util.find_spec(n) is None for n in ('slack_sdk', 'slack_bolt', 'mcp', 'keyring', 'graphify'))
+""")
+ preview = cli(py, "init", "--easy")
+ assert "slack" not in preview.lower()
+ assert list((work / "home").iterdir()) == []
+ checks.append("fresh_default_slack_free_no_network_or_service")
+ output = work / "slack-app.json"
+ cli(py, "slack", "setup", "--manifest", output, "--yes")
+ assert output.stat().st_mode & 0o777 == 0o600
+ hosted_manifest = json.loads(output.read_text())
+ assert hosted_manifest["oauth_config"]["scopes"]["bot"] == ["commands"]
+ cli(py, "slack", "setup", "--manifest", output, "--yes", expected=1)
+ checks.append("explicit_slack_setup_exclusive_private_manifest")
+ report = json.loads(cli(py, "slack", "doctor", "--json", expected=1))
+ assert report["ready"] is False and report["dispatch_authorized"] is False
+ # Construct a synthetic all-green observation using the installed contract.
+ snapshot = work / "offline.json"
+ installed(py, """
+import json, time
+from pathlib import Path
+from code_mower import slack_readiness as r
+now = time.time()
+value = {'schema': r.PROBE_SCHEMA, 'nonce': '0'*64, 'observed_at': now, 'expires_at': now+100,
+ 'components': {k:v[0] for k,v in r.COMPONENTS.items()},
+ 'supervisor_product': 'codex', 'supervisor_contract': r.SUPERVISOR_SCHEMA,
+ 'caps': {'task_acu':1,'campaign_acu':2,'reserved_acu':0,'task_limit':2,'reserved_tasks':0,
+ 'runtime_calls':4,'runtime_seconds':120,'review_rounds':1,'review_budget_usd':2,
+ 'clarification_answers':1,'fix_requests':0,'recovery_creates':0}}
+Path(sys.argv[1]).write_text(json.dumps(value))
+""", snapshot)
+ report = json.loads(cli(py, "slack", "doctor", "--snapshot", snapshot, "--json", expected=1))
+ assert report["basis"] == "offline" and not report["ready"] and not report["dispatch_authorized"]
+ checks.append("all_green_offline_snapshot_cannot_claim_live_readiness")
+ # Simulate disabled control-plane observations only; no live admin mutation.
+ observation = json.loads(snapshot.read_text())
+ observation["components"].update(ingress="disabled", bridge="disabled", installation="disabled")
+ snapshot.write_text(json.dumps(observation))
+ report = json.loads(cli(py, "slack", "doctor", "--snapshot", snapshot, "--json", expected=1))
+ assert not report["ready"] and not report["dispatch_authorized"]
+ output.unlink() # The only local opt-in artifact; there is no Slack service.
+ checks.append("offline_disabled_snapshot_and_local_manifest_removal")
+
+ # This fixture is public synthetic source, with no remote, hooks, provider
+ # executable or Graphify install. The wheel's real lifecycle seals it; only
+ # the extractor is replaced by a deterministic synthetic document writer.
+ repository = work / "synthetic-repository"
+ command("git", "init", "--template=", "-q", "-b", "main", repository)
+ (repository / "example.py").write_text("def synthetic_target(): pass\ndef synthetic_caller(): synthetic_target()\n")
+ command("git", "-C", repository, "add", "example.py")
+ command("git", "-C", repository, "-c", "core.hooksPath=" + os.devnull,
+ "-c", "user.name=Rehearsal", "-c", "user.email=rehearsal@example.invalid",
+ "commit", "--no-gpg-sign", "-q", "-m", "Synthetic public graph fixture")
+ graph_checks = json.loads(installed(py, GRAPHIFY_SMOKE, repository, work / "graph-state",
+ git_repository=repository))
+ assert graph_checks == list(GRAPHIFY_CHECKS)
+ checks.extend(graph_checks)
+
+ # Preserve synthetic operator evidence outside site-packages, byte for byte.
+ state = work / "operator-state"
+ state.mkdir()
+ for name, content in {"config.json": '{"enabled":false}\n',
+ "reservation.json": '{"reserved_acu":2,"refunded":false}\n',
+ "receipt.json": '{"synthetic":true,"reconciled":true}\n'}.items():
+ (state / name).write_text(content)
+
+ def state_hashes():
+ return {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in state.iterdir()}
+
+ before = state_hashes()
+ upgrade = work / "upgrade"
+ command(sys.executable, "-m", "venv", upgrade, environment=installer_env)
+ py = upgrade / "bin/python"
+ old = work / "rollback-artifact"
+ old.mkdir()
+ pip(py, "download", "--no-cache-dir", "--index-url", "https://pypi.org/simple/",
+ "--only-binary=:all:", "--no-deps", "--dest", old, "code-mower==1.4.2")
+ old_wheel, = old.glob("code_mower-1.4.2-*.whl")
+ old_digest = hashlib.sha256(old_wheel.read_bytes()).hexdigest()
+ assert old_digest == "f8bf24dd8a982ed5ab28302e837cd5d2aeece6d984ed1c44fcb4688c3fb7a522"
+ pip(py, "install", "--no-cache-dir", "--index-url", "https://pypi.org/simple/", old_wheel)
+ assert cli(py, "--version").strip() == "code-mower 1.4.2"
+ pip(py, "install", "--no-index", "--no-deps", "--upgrade", wheel)
+ assert cli(py, "--version").strip() == "code-mower 1.5.0"
+ assert before == state_hashes()
+ checks.append("upgrade_1_4_2_to_exact_wheel_preserves_synthetic_state")
+ pip(py, "install", "--no-index", "--no-deps", "--force-reinstall", old_wheel)
+ assert cli(py, "--version").strip() == "code-mower 1.4.2"
+ assert before == state_hashes()
+ checks.append("disposable_rollback_to_digest_verified_1_4_2_preserves_state")
+ pip(py, "uninstall", "--yes", "code-mower")
+ installed(py, "import importlib.util\nassert importlib.util.find_spec('code_mower') is None")
+ assert before == state_hashes()
+ pip(fresh / "bin/python", "uninstall", "--yes", "code-mower")
+ installed(fresh / "bin/python", "import importlib.util\nassert importlib.util.find_spec('code_mower') is None")
+ assert before == state_hashes()
+ checks.append("uninstall_preserves_synthetic_state")
+ result = {"schema": REHEARSAL_SCHEMA, "source_sha": sha,
+ "artifact_sha256": manifest["artifacts"][NAMES[0]], "checks": checks,
+ "rollback_wheel_sha256": old_digest, "synthetic_state_preserved": True,
+ "live_slack_readiness": "not_run", "live_disable_uninstall": "not_run",
+ "paid_canaries": "not_run", "status": "pass"}
+ (work / "rehearsal.json").write_text(json.dumps(result, indent=2) + "\n")
+ verify_rehearsal(work, manifest)
+ return result
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--dist", type=Path, required=True)
+ parser.add_argument("--source-sha", required=True)
+ parser.add_argument("--work-dir", type=Path, required=True)
+ args = parser.parse_args()
+ print(json.dumps(rehearse(args.dist.resolve(), args.source_sha, args.work_dir.resolve()), sort_keys=True))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/release_candidate.py b/scripts/release_candidate.py
new file mode 100644
index 00000000..9c542a75
--- /dev/null
+++ b/scripts/release_candidate.py
@@ -0,0 +1,208 @@
+"""Build once, inspect, and verify an exact-source v1.5.0 artifact pair.
+
+This script never tags, publishes, contacts Slack or invokes a provider. The
+candidate workflow supplies the merged PR identity; local builds are rehearsals.
+"""
+from __future__ import annotations
+
+import argparse
+from email.parser import BytesParser
+import hashlib
+import json
+import os
+from pathlib import Path
+import re
+import subprocess
+import sys
+import tarfile
+import zipfile
+
+VERSION = "1.5.0"
+SCHEMA = "code_mower.release_candidate.v1"
+NAMES = (f"code_mower-{VERSION}-py3-none-any.whl", f"code_mower-{VERSION}.tar.gz")
+MODULES = (
+ "context_graph_lifecycle.py", "context_graph_query.py", "context_graph_command.py",
+ "context_graph_connection.py",
+ "slack_setup.py", "slack_readiness.py", "supervisor_contract_v2.py",
+ "templates/slack/hosted-app-manifest.json",
+)
+DOCS = ("v150-release-notes.md", "v150-qualification.md", "v150-release-runbook.md",
+ "slack-setup.md", "graphify-setup.md")
+REHEARSAL_SCHEMA = "code_mower.v150_rehearsal.v1"
+GRAPHIFY_CHECKS = (
+ "graphify_doc_ref_excluded_reader_available",
+ "graphify_ambiguity_only_partial_usable_complete_generation",
+ "graphify_wrong_distribution_reader_incompatible_no_leakage",
+)
+REHEARSAL_CHECKS = (
+ "fresh_default_slack_free_no_network_or_service",
+ "explicit_slack_setup_exclusive_private_manifest",
+ "all_green_offline_snapshot_cannot_claim_live_readiness",
+ "offline_disabled_snapshot_and_local_manifest_removal",
+ *GRAPHIFY_CHECKS,
+ "upgrade_1_4_2_to_exact_wheel_preserves_synthetic_state",
+ "disposable_rollback_to_digest_verified_1_4_2_preserves_state",
+ "uninstall_preserves_synthetic_state",
+)
+
+
+def run(*args, cwd=None):
+ return subprocess.check_output(args, cwd=cwd, text=True).strip()
+
+
+def digest(path):
+ return hashlib.sha256(path.read_bytes()).hexdigest()
+
+
+def require(condition, message):
+ if not condition:
+ raise ValueError(message)
+
+
+def inspect(dist: Path):
+ """Inventory both containers without extraction or product execution."""
+ wheel, sdist = (dist / name for name in NAMES)
+ with zipfile.ZipFile(wheel) as archive:
+ wheel_names = archive.namelist()
+ metadata = BytesParser().parsebytes(archive.read(f"code_mower-{VERSION}.dist-info/METADATA"))
+ require(metadata["Name"] == "code-mower" and metadata["Version"] == VERSION,
+ "wheel identity mismatch")
+ deps = metadata.get_all("Requires-Dist", [])
+ # Only a solely extra-gated dependency may be excluded from the base
+ # inventory. A mixed marker (Python/platform OR extra) can install by
+ # default and must not accidentally hide an added Slack dependency.
+ base_deps = [dep for dep in deps if not re.fullmatch(
+ r"extra\s*==\s*['\"](?:coworker|test)['\"]", dep.partition(";")[2].strip())]
+ require(sorted(dep.lower().split(">=")[0] for dep in base_deps) == ["packaging", "pyyaml"],
+ "unexpected base dependencies")
+ for module in MODULES:
+ require("code_mower/" + module in wheel_names, "missing required wheel module")
+ for doc in DOCS:
+ require(f"code_mower-{VERSION}.data/data/share/code-mower/docs/{doc}" in wheel_names,
+ "missing required wheel documentation")
+ with tarfile.open(sdist) as archive:
+ members = archive.getmembers()
+ sdist_names = [member.name for member in members]
+ require(all(member.isfile() or member.isdir() for member in members),
+ "sdist contains links or special files")
+ for module in MODULES:
+ require(f"code_mower-{VERSION}/src/code_mower/{module}" in sdist_names,
+ "missing required sdist module")
+ for doc in DOCS:
+ require(f"code_mower-{VERSION}/docs/{doc}" in sdist_names,
+ "missing required sdist documentation")
+ for names in (wheel_names, sdist_names):
+ require(len(names) == len(set(names)), "duplicate archive paths")
+ require(all(not name.startswith("/") and not
+ ({"..", ".git", ".code-mower", ".graph", ".graphify", "graphify-out",
+ "__pycache__", ".env"} & set(name.split("/")))
+ and not name.endswith((".pyc", ".pyo")) for name in names),
+ "unsafe or private archive inventory")
+ return {"wheel_files": sorted(wheel_names), "sdist_files": sorted(sdist_names),
+ "default_dependencies": sorted(base_deps), "required_modules": list(MODULES),
+ "required_docs": list(DOCS)}
+
+
+def verify(dist: Path, sha: str, *, candidate=False):
+ require(sorted(p.name for p in dist.iterdir() if p.name.endswith((".whl", ".tar.gz"))) == sorted(NAMES),
+ "unexpected distribution files")
+ require(all((dist / name).is_file() and not (dist / name).is_symlink() for name in NAMES),
+ "artifact files must be regular files")
+ manifest = json.loads((dist / "candidate.json").read_text())
+ require(manifest.get("schema") == SCHEMA and manifest.get("version") == VERSION,
+ "invalid candidate identity")
+ require(manifest.get("source_sha") == sha and re.fullmatch(r"[0-9a-f]{40}", sha),
+ "candidate source SHA mismatch")
+ if candidate:
+ require(manifest.get("kind") == "candidate" and
+ type(manifest.get("release_pr")) is int and manifest["release_pr"] > 0,
+ "a pre-merge rehearsal is not a release candidate")
+ require(set(manifest.get("artifacts", {})) == set(NAMES), "invalid artifact pair")
+ for name in NAMES:
+ require(manifest["artifacts"][name] == digest(dist / name), "artifact digest mismatch")
+ require(manifest.get("inventory") == inspect(dist), "artifact inventory mismatch")
+ return manifest
+
+
+def verify_rehearsal(dist: Path, manifest: dict):
+ """Require wheel-bound lifecycle/Graphify evidence from a verified manifest."""
+ wheels = [name for name in manifest["artifacts"] if name.endswith(".whl")]
+ require(len(wheels) == 1, "candidate identity requires exactly one wheel")
+ evidence = json.loads((dist / "rehearsal.json").read_text())
+ require(evidence.get("schema") == REHEARSAL_SCHEMA and evidence.get("status") == "pass",
+ "invalid rehearsal identity or status")
+ require(evidence.get("source_sha") == manifest["source_sha"], "rehearsal source SHA mismatch")
+ require(evidence.get("artifact_sha256") == manifest["artifacts"][wheels[0]],
+ "rehearsal wheel digest mismatch")
+ checks = evidence.get("checks")
+ require(isinstance(checks, list) and all(isinstance(check, str) for check in checks)
+ and set(REHEARSAL_CHECKS) <= set(checks), "missing required rehearsal checks")
+ return evidence
+
+
+def build(source: Path, dist: Path, sha: str, release_pr: int | None):
+ require(re.fullmatch(r"[0-9a-f]{40}", sha), "a full source SHA is required")
+ require(run("git", "rev-parse", "HEAD", cwd=source) == sha, "checkout is not the requested SHA")
+ require(not run("git", "status", "--porcelain", "--untracked-files=all", cwd=source),
+ "candidate source must be clean")
+ require(not dist.exists(), "output already exists; never overwrite an artifact pair")
+ require(not dist.is_relative_to(source), "build output must be outside the source checkout")
+ run(sys.executable, str(source / "src/code_mower/release_identity.py"),
+ "--repo", str(source), "--tag", "v" + VERSION)
+ dist.mkdir(parents=True)
+ # Stable archive timestamps; dependency downloads are package build tools only.
+ env = dict(os.environ, SOURCE_DATE_EPOCH=run("git", "show", "-s", "--format=%ct", sha, cwd=source))
+ subprocess.run([sys.executable, "-m", "build", "--outdir", str(dist), str(source)],
+ env=env, check=True)
+ require(sorted(p.name for p in dist.iterdir()) == sorted(NAMES), "unexpected build outputs")
+ subprocess.run([sys.executable, "-m", "twine", "check", *(str(dist / n) for n in NAMES)], check=True)
+ with zipfile.ZipFile(dist / NAMES[0]) as wheel, tarfile.open(dist / NAMES[1]) as sdist:
+ for module in MODULES:
+ original = (source / "src/code_mower" / module).read_bytes()
+ require(wheel.read("code_mower/" + module) == original, "wheel/source inclusion mismatch")
+ require(sdist.extractfile(f"code_mower-{VERSION}/src/code_mower/{module}").read() == original,
+ "sdist/source inclusion mismatch")
+ for doc in DOCS:
+ original = (source / "docs" / doc).read_bytes()
+ require(wheel.read(f"code_mower-{VERSION}.data/data/share/code-mower/docs/{doc}") == original,
+ "wheel/source documentation mismatch")
+ require(sdist.extractfile(f"code_mower-{VERSION}/docs/{doc}").read() == original,
+ "sdist/source documentation mismatch")
+ manifest = {"schema": SCHEMA, "version": VERSION, "source_sha": sha,
+ "kind": "candidate" if release_pr else "rehearsal", "release_pr": release_pr,
+ "artifacts": {n: digest(dist / n) for n in NAMES}, "inventory": inspect(dist)}
+ (dist / "candidate.json").write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
+ verify(dist, sha, candidate=bool(release_pr))
+ return manifest
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("action", choices=("build", "verify"))
+ parser.add_argument("--source", type=Path, default=Path.cwd())
+ parser.add_argument("--dist", type=Path, required=True)
+ parser.add_argument("--source-sha", required=True)
+ parser.add_argument("--release-pr", type=int)
+ parser.add_argument("--require-candidate", action="store_true")
+ args = parser.parse_args(argv)
+ try:
+ if args.action == "build":
+ if args.release_pr is not None:
+ # Do not let a local build assert that an unmerged PR is qualified.
+ require(args.release_pr > 0, "invalid release PR")
+ pr = json.loads(run("gh", "pr", "view", str(args.release_pr), "--repo",
+ "codemower-ai/code-mower", "--json", "state,mergeCommit"))
+ require(pr["state"] == "MERGED" and pr["mergeCommit"]["oid"] == args.source_sha,
+ "candidate must bind the release PR's actual merge SHA")
+ result = build(args.source.resolve(), args.dist.resolve(), args.source_sha, args.release_pr)
+ else:
+ result = verify(args.dist.resolve(), args.source_sha, candidate=args.require_candidate)
+ except (ValueError, OSError, KeyError, subprocess.CalledProcessError) as exc:
+ parser.exit(1, f"Candidate refused: {exc}\n")
+ print(json.dumps({key: result[key] for key in
+ ("schema", "version", "source_sha", "kind", "release_pr", "artifacts")}, sort_keys=True))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/code_mower/__init__.py b/src/code_mower/__init__.py
index 6e7397a4..9fb85aaa 100644
--- a/src/code_mower/__init__.py
+++ b/src/code_mower/__init__.py
@@ -1,3 +1,3 @@
"""Code Mower package."""
-__version__ = "1.4.2"
+__version__ = "1.5.0"
diff --git a/src/code_mower/package_manifest.py b/src/code_mower/package_manifest.py
index 2fcbb5bd..b9fa0720 100644
--- a/src/code_mower/package_manifest.py
+++ b/src/code_mower/package_manifest.py
@@ -643,6 +643,9 @@
),
("docs/v141-release-notes.md", "docs/v141-release-notes.md", "doc"),
("docs/v141-qualification.md", "docs/v141-qualification.md", "doc"),
+ ("docs/v150-release-notes.md", "docs/v150-release-notes.md", "doc"),
+ ("docs/v150-qualification.md", "docs/v150-qualification.md", "doc"),
+ ("docs/v150-release-runbook.md", "docs/v150-release-runbook.md", "doc"),
("docs/graphify-setup.md", "docs/graphify-setup.md", "doc"),
("docs/v140-release-runbook.md", "docs/v140-release-runbook.md", "doc"),
("docs/v140-release-notes.md", "docs/v140-release-notes.md", "doc"),
diff --git a/src/code_mower/release_readiness.py b/src/code_mower/release_readiness.py
index 0ca3f28e..9c5366d9 100644
--- a/src/code_mower/release_readiness.py
+++ b/src/code_mower/release_readiness.py
@@ -25,6 +25,7 @@
"docs/pypi-release.md",
"docs/public-release-checklist.md",
"docs/release-qualification.md",
+ "docs/v150-release-runbook.md",
)
REQUIRED_PUBLIC_PACKAGE_SPEC_DOC_PATHS = (
"README.md",
@@ -1490,12 +1491,47 @@ def _job_text(job: Any) -> str:
return yaml.safe_dump(job, sort_keys=True) if isinstance(job, dict) else ""
+def _candidate_runbook_checks(repo_path: Path) -> tuple[list[str], list[str]]:
+ """The v1.5 sequence qualifies the merge-SHA artifacts before tagging.
+
+ The v1.4 post-publication campaign runbook stays historical. Checking its
+ ordering against a new version would require tagging before qualification.
+ These are static documentation checks, not private acceptance evidence.
+ """
+ text = _read_text_if_exists(repo_path / "docs/v150-release-runbook.md")
+ order = (
+ "## 1. Review and merge", "## 2. Build and retain",
+ "gh workflow run release-candidate.yml", "## 3. Private acceptance",
+ "## 4. Explicitly authorize", "## 5. Owner decision",
+ 'git tag -a v1.5.0 "$RELEASE_SHA"',
+ "-f publish_testpypi=false -f publish_pypi=false",
+ "-f publish_testpypi=false -f publish_pypi=true",
+ "## 6. Independent canonical reinstall",
+ )
+ assertions = (
+ "--json state --jq '.state')\" = MERGED",
+ "--json mergeCommit --jq '.mergeCommit.oid'",
+ "--require-candidate", "candidate.json", "rehearsal.json",
+ '-f candidate_run_id="$CANDIDATE_RUN_ID"',
+ 'test "$(git rev-list -n 1 v1.5.0)" = "$RELEASE_SHA"',
+ "one completion and one confirmed", "aggregate campaign ACU",
+ "does not rebuild", "Never downgrade live v2 claims",
+ "independent exact-head audit", "authoritative gate",
+ )
+ return _unordered_markers(text, order), [item for item in assertions if item not in text]
+
+
def render_release_readiness(repo_path: Path) -> dict[str, Any]:
"""Inspect whether the standalone package is ready for package-index promotion."""
repo_path = repo_path.expanduser().resolve()
workflow_path = repo_path / ".github" / "workflows" / "release.yml"
workflow = _read_text_if_exists(workflow_path)
+ candidate_workflow = _read_text_if_exists(repo_path / ".github/workflows/release-candidate.yml")
+ candidate_workflow_used = (
+ (repo_path / ".github/workflows/release-candidate.yml").exists()
+ or "scripts/release_candidate.py" in workflow
+ )
ci_workflow_path = repo_path / ".github" / "workflows" / "ci.yml"
ci_workflow = _read_text_if_exists(ci_workflow_path)
workflow_jobs = _workflow_jobs(workflow)
@@ -1561,6 +1597,15 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]:
if runbook_doc
else ["unknown release version"]
)
+ if candidate_workflow_used:
+ missing_runbook_markers, missing_runbook_assertions = _candidate_runbook_checks(repo_path)
+ runbook_markers = ("docs/v150-release-runbook.md: candidate, private acceptance, canaries, tag, publish",)
+ runbook_assertions = ("merge SHA and retained artifact binding; explicit owner gates",)
+ # Legacy checks above describe the preserved v1.4 publication procedure.
+ # The new procedure has its own ordered gates and artifact assertions.
+ forbidden_runbook_markers = []
+ pip_isolation_problems = []
+ gate_order_problems = []
public_hygiene_blobs = {
relative_path: text.lower()
for relative_path, text in public_hygiene_docs.items()
@@ -1716,14 +1761,25 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]:
),
_release_check(
check_id="distribution-build-and-verify",
- title="Release workflow builds and verifies distributions before publish",
+ title="Release workflows build once and verify distributions before publish",
status=(
"pass"
if (
" build-distributions:\n" in workflow
and " verify-distributions:\n" in workflow
and " needs: build-distributions\n" in workflow
- and "python -m build" in workflow
+ and ("python -m build" in workflow if not candidate_workflow_used else (
+ "python scripts/release_candidate.py verify" in workflow
+ and "--require-candidate" in workflow
+ and "--name code-mower-candidate" in workflow
+ and "python -m build" not in workflow
+ and "python scripts/release_candidate.py build" in candidate_workflow
+ and '[[ "$GITHUB_SHA" == "$SOURCE_SHA" ]]' in candidate_workflow
+ and '[[ "$GITHUB_RUN_ATTEMPT" == 1 ]]' in candidate_workflow
+ and "assert run['head_sha'] == os.environ['SOURCE_SHA']" in workflow
+ and "assert run['run_attempt'] == 1" in workflow
+ and "verify_rehearsal(Path('candidate'), candidate)" in workflow
+ ))
and "python -m twine check dist/*" in workflow
)
else "fail"
@@ -2013,6 +2069,17 @@ def render_release_readiness(repo_path: Path) -> dict[str, Any]:
"url": PACKAGE_INDEX_SETUP_URLS["release_workflow"],
},
]
+ if candidate_workflow_used:
+ for action in next_actions:
+ if "gh workflow run release.yml" in action["command"]:
+ action["command"] += ' -f candidate_run_id="$CANDIDATE_RUN_ID"'
+ next_actions.insert(0, {
+ "id": "immutable-candidate-first",
+ "title": "After merge: build once, then #918 and explicitly authorized #920 before tagging/publication",
+ "command": 'gh workflow run release-candidate.yml --repo codemower-ai/code-mower --ref main '
+ '-f expected_sha="$RELEASE_SHA" -f release_pr="$RELEASE_PR"',
+ "url": "https://github.com/codemower-ai/code-mower/blob/main/docs/v150-release-runbook.md",
+ })
incomplete_dispatch_actions = _incomplete_dispatch_actions(workflow, next_actions)
incomplete_documented_dispatches = _incomplete_documented_dispatches(workflow, docs)
checks.append(
diff --git a/tests/fixtures/release_identity/v14-evidence-sha256.json b/tests/fixtures/release_identity/v14-evidence-sha256.json
new file mode 100644
index 00000000..c7bdfbca
--- /dev/null
+++ b/tests/fixtures/release_identity/v14-evidence-sha256.json
@@ -0,0 +1,8 @@
+{
+ "docs/v140-release-notes.md": "2fa3e6b234bcf1935682b2768a871947e677ab648ec03768644a0c88b5cafa6d",
+ "docs/v140-release-runbook.md": "9ee5ef169243492dbd2e3eb6feeb0a4ab680f05bf10f40f4e2e6f5cad2b5d8cb",
+ "docs/v141-qualification.md": "049c9e4562c0cfe99469a2f2902cdf8963c83d07e363e794e9aec9748e8a9d37",
+ "docs/v141-release-notes.md": "5c83b0b8390184118e28a0e2d69a8fe5c357d638c21539ca10205a9091f1e1b1",
+ "docs/v142-qualification.md": "0c8d5e82e692da0464c7aa7920b1ce87f606bedc5601cb01e50c7c429c8f9e7c",
+ "docs/v142-release-notes.md": "a7439c3f285c79067494c2b256a27f5c60885efe25aa21edd7f4badff0c865cb"
+}
diff --git a/tests/test_lineage_producer_artifacts.py b/tests/test_lineage_producer_artifacts.py
index f38773c0..55826dc4 100644
--- a/tests/test_lineage_producer_artifacts.py
+++ b/tests/test_lineage_producer_artifacts.py
@@ -296,11 +296,22 @@ def test_default_init_emits_activation_with_standalone_pure_helper(self):
'.github/workflows/codex-audit-labeler.yml',
'.github/workflows/local-cli-audit.yml',
}
- self.assertCountEqual(paths, set(baseline) | {'.github/workflows/local-audit-publication.yml', '.github/workflows/local-audit-request.yml'},
- 'Actual init/runner/workflow inventory differs from accepted baseline and #1022')
+ # #1027 adds the immutable candidate and installed-wheel CI rehearsal,
+ # and makes publication consume the retained pair. test_release_v150.py
+ # owns this successor contract; keep the historical baseline untouched.
+ release_workflows = {
+ '.github/workflows/release-candidate.yml',
+ '.github/workflows/release.yml',
+ '.github/workflows/ci.yml',
+ }
+ self.assertCountEqual(paths, set(baseline) | {
+ '.github/workflows/local-audit-publication.yml',
+ '.github/workflows/local-audit-request.yml',
+ '.github/workflows/release-candidate.yml',
+ }, 'Actual init/runner/workflow inventory differs from accepted baseline, #1022 and #1027')
activated = {'src/code_mower/init.py', 'tools/lanes/run_mac_lane.sh',
'templates/lanes/run_mac_lane.sh', 'src/code_mower/templates/lanes/run_mac_lane.sh',
- '.github/workflows/code-mower-gate.yml'} | publication_workflows
+ '.github/workflows/code-mower-gate.yml'} | publication_workflows | release_workflows
for path in paths:
if path not in activated:
content = (ROOT/path).read_bytes()
diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py
index da952998..1141fd7a 100644
--- a/tests/test_release_hygiene.py
+++ b/tests/test_release_hygiene.py
@@ -103,7 +103,7 @@ def _reported_manifest_identity(manifest_bytes: bytes) -> dict:
class ReleaseHygieneTests(unittest.TestCase):
def test_version_is_current_supervised_pilot_release(self) -> None:
- self.assertEqual(__version__, "1.4.2")
+ self.assertEqual(__version__, "1.5.0")
def test_dogfood_repo_has_real_root_config(self) -> None:
config_path = ROOT / "code-mower.yml"
@@ -216,7 +216,8 @@ def test_ci_workflow_tests_supported_python_minors(self) -> None:
self.assertIn(" package:\n name: package\n", workflow)
jobs = yaml.safe_load(workflow)["jobs"]
package = jobs["package"]
- self.assertCountEqual(package["needs"], ["package_matrix", "board_qualification"])
+ self.assertCountEqual(package["needs"],
+ ["package_matrix", "board_qualification", "release_rehearsal"])
self.assertEqual(package["if"], "always()")
result_checks = "\n".join(step.get("run", "") for step in package["steps"])
for dependency in package["needs"]:
@@ -1243,7 +1244,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.4.2", completed.stderr)
+ self.assertIn("pipx install code-mower==1.5.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)
@@ -5740,11 +5741,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.4.2"',
+ 'version = "1.5.0"',
(output_dir / "pyproject.toml").read_text(encoding="utf-8"),
)
self.assertIn(
- '__version__ = "1.4.2"',
+ '__version__ = "1.5.0"',
(output_dir / "src/code_mower/__init__.py").read_text(
encoding="utf-8"
),
@@ -8260,10 +8261,10 @@ 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.4.2")
- self.assertEqual(payload["release_tag"], "v1.4.2")
- self.assertEqual(payload["alpha_tag"], "v1.4.2")
- self.assertEqual(payload["package_index_spec"], "code-mower==1.4.2")
+ self.assertEqual(payload["version"], "1.5.0")
+ self.assertEqual(payload["release_tag"], "v1.5.0")
+ self.assertEqual(payload["alpha_tag"], "v1.5.0")
+ self.assertEqual(payload["package_index_spec"], "code-mower==1.5.0")
check_ids = {check["id"]: check for check in payload["checks"]}
self.assertEqual(check_ids["package-version-consistency"]["status"], "pass")
self.assertEqual(
@@ -8272,7 +8273,7 @@ def test_release_readiness_reports_package_index_promotion_gate(self) -> None:
)
manifest_check = check_ids["committed-package-manifest-version"]
self.assertEqual(manifest_check["status"], "pass")
- self.assertEqual(manifest_check["detail"]["manifest_version"], "1.4.2")
+ self.assertEqual(manifest_check["detail"]["manifest_version"], "1.5.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")
@@ -8282,17 +8283,17 @@ 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.4.2", commands["dry-run-release-workflow"])
+ self.assertIn("--ref v1.5.0", commands["dry-run-release-workflow"])
self.assertNotIn("--ref main", commands["dry-run-release-workflow"])
- self.assertIn("--ref v1.4.2", commands["publish-testpypi-candidate"])
+ self.assertIn("--ref v1.5.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"])
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.2", qualification)
- self.assertIn("--package-spec code-mower==1.4.2", qualification)
+ self.assertIn("--release-tag v1.5.0", qualification)
+ self.assertIn("--package-spec code-mower==1.5.0", qualification)
self.assertIn("--package-source testpypi", qualification)
self.assertIn("--execute", qualification)
self.assertNotIn("--pip-extra-index-url", qualification)
@@ -8424,7 +8425,7 @@ 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.4.2")
+ self.assertEqual(check["detail"]["source_version"], "1.5.0")
self.assertEqual(check["detail"]["generated_init_version"], "0.0.0")
def test_release_readiness_fails_on_committed_manifest_version_drift(self) -> None:
@@ -8440,7 +8441,7 @@ def test_release_readiness_fails_on_committed_manifest_version_drift(self) -> No
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.2")
+ self.assertEqual(check["detail"]["init_version"], "1.5.0")
def _manifest_drift_check(self, mutate: Callable[[dict], None]) -> dict:
committed = json.loads(
@@ -8750,8 +8751,21 @@ def test_packaged_graph_docs_link_target_is_packaged(self) -> None:
if "context-graph-lifecycle.md" in text:
self.assertIn(doc, packaged)
+ def _historical_release_readiness(self, docs: dict[str, str] | None = None) -> dict:
+ # A historical checkout has no candidate workflow or publication consumer.
+ # Changing only its version would still select the current workflow contract.
+ docs = release_readiness._release_docs(ROOT) if docs is None else docs
+ with tempfile.TemporaryDirectory() as tmp:
+ repo = Path(tmp)
+ (repo / "pyproject.toml").write_text('[project]\nversion = "1.4.2"\n')
+ for relative_path, text in docs.items():
+ path = repo / relative_path
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text, encoding="utf-8")
+ return release_readiness.render_release_readiness(repo)
+
def test_release_readiness_requires_the_ordered_post_merge_runbook(self) -> None:
- payload = release_readiness.render_release_readiness(ROOT)
+ payload = self._historical_release_readiness()
checks = {check["id"]: check for check in payload["checks"]}
runbook = checks["post-merge-release-runbook-ordered"]
@@ -8766,6 +8780,8 @@ def test_release_readiness_requires_the_ordered_post_merge_runbook(self) -> None
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"])
+ self.assertNotIn("immutable-candidate-first", commands)
+ self.assertNotIn("candidate_run_id", commands["publish-pypi-release"])
def test_release_readiness_fails_when_the_runbook_stops_at_testpypi(self) -> None:
docs = release_readiness._release_docs(ROOT)
@@ -8773,8 +8789,7 @@ def test_release_readiness_fails_when_the_runbook_stops_at_testpypi(self) -> Non
"### 7. Publish production PyPI only"
)[0]
- with mock.patch.object(release_readiness, "_release_docs", return_value=docs):
- payload = release_readiness.render_release_readiness(ROOT)
+ payload = self._historical_release_readiness(docs)
checks = {check["id"]: check for check in payload["checks"]}
runbook = checks["post-merge-release-runbook-ordered"]
@@ -8793,13 +8808,12 @@ def _runbook_section(self) -> str:
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)
+ payload = self._historical_release_readiness(docs)
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)
+ payload = self._historical_release_readiness()
checks = {check["id"]: check for check in payload["checks"]}
asserted = checks["post-merge-release-runbook-asserted"]
required = asserted["detail"]["required_assertions"]
@@ -10155,7 +10169,7 @@ def test_release_readiness_fails_when_the_late_gate_moves_behind_the_release(
) -> None:
runbook = self._runbook_section()
self.assertEqual(
- release_readiness._release_create_binding_problems(runbook), []
+ release_readiness._release_create_binding_problems(runbook, "v1.4.2"), []
)
check = self._asserted_runbook_check(
@@ -11117,8 +11131,8 @@ 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.4.2`, "
- "with pinned package install spec `code-mower==1.4.2`. "
+ "The current package-index release baseline is `v1.5.0`, "
+ "with pinned package install spec `code-mower==1.5.0`. "
"Release evidence is recorded on the GitHub release and in the "
"first-user install rehearsal."
),
@@ -11127,7 +11141,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.4.2/docs/try-in-10-minutes.md"
+ "v1.5.0/docs/try-in-10-minutes.md"
),
)
@@ -11241,8 +11255,8 @@ def test_current_release_docs_record_package_index_procedure(self) -> None:
for text in (readme, current_state, rollout):
self.assertIn(current_status, " ".join(text.split()))
self.assertIn(
- "The current published package-index release entrypoint is\n"
- " `code-mower==1.4.2` (GitHub tag `v1.4.2`)",
+ "The current package-index release entrypoint is\n"
+ " `code-mower==1.5.0` (GitHub tag `v1.5.0`)",
public_release,
)
self.assertIn("The current supervised-pilot release includes", public_release)
@@ -11252,7 +11266,7 @@ def test_current_release_docs_record_package_index_procedure(self) -> None:
)
self.assertIn(
- "The public-release baseline is the published `v1.4.2`",
+ "The historical public-release baseline below is the published `v1.4.2`",
oss_checklist,
)
self.assertIn(
@@ -11623,7 +11637,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.4.2", install)
+ self.assertIn("uv tool install --python 3.12 code-mower==1.5.0", install)
self.assertIn(
'PIP_NO_CACHE_DIR=1 pipx install --force --python "$CODE_MOWER_PYTHON"',
install,
@@ -12006,7 +12020,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.4.2", package_step["command"])
+ self.assertIn("code-mower==1.5.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"])
diff --git a/tests/test_release_v141.py b/tests/test_release_v141.py
index 3e136ea0..04722a72 100644
--- a/tests/test_release_v141.py
+++ b/tests/test_release_v141.py
@@ -10,6 +10,7 @@
import sys
import tempfile
import unittest
+import tomllib
from unittest import mock
import yaml
@@ -122,7 +123,7 @@ def test_opt_in_changes_only_guidance(self):
self.assertEqual(guidance["mode"], "guidance_only")
self.assertEqual(guidance["package_spec"], "graphifyy==0.9.58")
self.assertNotIn("graphify", json.dumps(baseline).lower())
- self.assertNotIn("graphify", (ROOT / "pyproject.toml").read_text().lower())
+ self.assertNotIn("graphify", json.dumps(tomllib.loads((ROOT / "pyproject.toml").read_text())["project"].get("dependencies", [])).lower())
def test_fresh_guidance_preview_never_launches_or_writes(self):
with tempfile.TemporaryDirectory() as tmp:
diff --git a/tests/test_release_v142.py b/tests/test_release_v142.py
index 3d31030d..d3bb541d 100644
--- a/tests/test_release_v142.py
+++ b/tests/test_release_v142.py
@@ -85,8 +85,8 @@ def test_current_docs_do_not_still_call_v141_the_current_release(self):
def test_shared_baseline_sentence_matches_the_published_version(self):
sentence = versioning.public_baseline_sentence(__version__)
- self.assertIn("`v1.4.2`", sentence)
- self.assertIn("`code-mower==1.4.2`", sentence)
+ self.assertIn("`v1.5.0`", sentence)
+ self.assertIn("`code-mower==1.5.0`", sentence)
for relative in ("README.md", "docs/current-state-and-roadmap.md",
"docs/friendly-user-rollout-v05.md"):
with self.subTest(doc=relative):
@@ -188,11 +188,11 @@ def test_never_expiry_is_what_init_actually_advertises(self):
class PublicReleaseChecklistTests(unittest.TestCase):
- def test_checklist_names_v142_as_the_published_entrypoint(self):
+ def test_checklist_names_v150_as_the_published_entrypoint(self):
checklist = " ".join(_read("docs/public-release-checklist.md").split())
self.assertIn(
- "The current published package-index release entrypoint is "
- "`code-mower==1.4.2` (GitHub tag `v1.4.2`)",
+ "The current package-index release entrypoint is "
+ "`code-mower==1.5.0` (GitHub tag `v1.5.0`)",
checklist,
)
@@ -358,15 +358,15 @@ def test_release_records_claim_upgrade_coverage_that_exists(self):
class VersionIdentityTests(unittest.TestCase):
- def test_source_version_is_1_4_2(self):
- self.assertEqual(__version__, "1.4.2")
+ def test_source_version_is_1_5_0(self):
+ self.assertEqual(__version__, "1.5.0")
def test_committed_manifest_version_matches_source(self):
manifest = package_module.generate_committed_package_manifest(ROOT)
self.assertEqual(manifest["package"]["version"], __version__)
def test_release_tag_for_current_version(self):
- self.assertEqual(release_readiness._release_tag_for_version(__version__), "v1.4.2")
+ self.assertEqual(release_readiness._release_tag_for_version(__version__), "v1.5.0")
class RunbookIdentityTests(unittest.TestCase):
@@ -516,53 +516,31 @@ def test_graphify_evaluation_is_framed_as_a_dated_historical_record(self):
self.assertIn("Clean-room experiment", evaluation)
def test_graphify_docs_separate_the_published_package_from_current_main(self):
- """v1.4.2 ships the original integration; #1007's fixes are only on main."""
+ """v1.4.2 history stays distinct from the v1.5.0 compatibility additions."""
setup = " ".join(_read("docs/graphify-setup.md").split())
- self.assertIn("Published `v1.4.2` versus current `main`", setup)
+ self.assertIn("v1.5.0 compatibility and existing generations", setup)
self.assertIn("/pull/1007", setup)
# The boundary is stated in both directions.
self.assertIn("merged to `main`", setup)
- self.assertIn("none of it is in the published `v1.4.2` package", setup)
+ self.assertIn("none of it is in the historical `v1.4.2` package", setup)
# An upgrade alone does not repair a generation built earlier.
self.assertIn("does not repair a generation you already built", setup)
self.assertIn("context-graph refresh", setup)
roadmap = " ".join(_read("docs/current-state-and-roadmap.md").split())
self.assertIn("/pull/1007", roadmap)
- self.assertIn("the published `v1.4.2` package does not contain them", roadmap)
+ self.assertIn("the historical `v1.4.2` package does not contain them", roadmap)
- def test_graphify_setup_does_not_claim_every_paragraph_is_the_published_package(self):
- """#1007's paragraphs sit above the boundary section, so "everything
- above" would be false. The page must scope the claim to the base setup
- and ramp-up, and mark the post-v1.4.2 paragraphs where they appear."""
+ def test_graphify_setup_marks_v150_additions_and_preserves_acquisition_guidance(self):
raw = _read("docs/graphify-setup.md")
setup = " ".join(raw.split())
- # The false blanket claim must not come back in any spelling.
- for blanket in ("Everything above describes that package",
- "Everything above describes the published",
- "All of the above describes that package"):
- with self.subTest(phrase=blanket):
- self.assertNotIn(blanket.lower(), setup.lower())
- # The published package is claimed only for the base setup and ramp-up.
- self.assertIn("The base setup and ramp-up above", setup)
- self.assertIn("describe that published package", setup)
- # The #1007 paragraphs are marked where a reader meets them, above the
- # boundary section, and the boundary section points back at that mark.
- marker = "The next two paragraphs are **post-`v1.4.2`**"
- self.assertIn(marker, setup)
- self.assertLess(
- raw.index("The next two paragraphs are"),
- raw.index("## Published `v1.4.2` versus current `main`"),
- "the post-v1.4.2 marker must precede the boundary section it explains",
- )
- self.assertIn("explicitly marked post-`v1.4.2` describe current `main`", setup)
- # Both #1007 paragraphs still sit under the acquisition heading the
- # boundary section names.
+ self.assertIn("v1.5.0 includes the compatibility and readiness additions", setup)
+ self.assertIn("The historical `v1.4.2` package", setup)
+ self.assertIn("The next two paragraphs are included in v1.5.0", setup)
acquisition = raw.split("## Separate acquisition environment", 1)[1]
acquisition = acquisition.split("## Separate contained offline build", 1)[0]
self.assertIn("Install any required language extras", acquisition)
self.assertIn("If runtime ownership checks refuse", acquisition)
- self.assertIn("The next two paragraphs are", acquisition)
def test_rebuild_guidance_is_scoped_to_generations_the_1007_gaps_affected(self):
"""Not every generation built before the next release needs a rebuild --
@@ -644,7 +622,7 @@ def test_board_demo_does_not_claim_serve_opens_a_browser(self):
class InstalledPromptPackTests(unittest.TestCase):
def test_literal_starter_and_explicit_config_walkthrough(self):
- """Exercise installed 1.4.2 code, with no provider login or network doctor probes."""
+ """Exercise installed 1.5.0 code, with no provider login or network doctor probes."""
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
supplied = os.environ.get("CODE_MOWER_QUALIFICATION_WHEEL")
@@ -678,7 +656,7 @@ def test_literal_starter_and_explicit_config_walkthrough(self):
from code_mower import cli, package
from code_mower.config import load_config
assert Path(code_mower.__file__).resolve().is_relative_to(Path(sys.argv[1]).resolve())
-assert code_mower.__version__ == '1.4.2'
+assert code_mower.__version__ == '1.5.0'
empty_store = Path.cwd() / 'empty-provider-store'
empty_store.mkdir()
def run(args, doctor=False):
diff --git a/tests/test_release_v150.py b/tests/test_release_v150.py
new file mode 100644
index 00000000..122623ba
--- /dev/null
+++ b/tests/test_release_v150.py
@@ -0,0 +1,333 @@
+"""Candidate identity, artifact tampering and pre-tag qualification regressions."""
+import hashlib
+import importlib.util
+import io
+import json
+import os
+from pathlib import Path
+import re
+import subprocess
+import sys
+import tarfile
+import tempfile
+import unittest
+from unittest.mock import patch
+import zipfile
+
+import yaml
+
+from code_mower import __version__, release_readiness
+
+ROOT = Path(__file__).resolve().parents[1]
+spec = importlib.util.spec_from_file_location("release_candidate", ROOT / "scripts/release_candidate.py")
+candidate = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(candidate)
+spec = importlib.util.spec_from_file_location("rehearse_v150", ROOT / "scripts/rehearse_v150.py")
+rehearsal = importlib.util.module_from_spec(spec)
+with patch.dict(sys.modules, {"release_candidate": candidate}):
+ spec.loader.exec_module(rehearsal)
+SHA = "a" * 40
+
+
+class ArtifactTests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp.cleanup)
+ self.dist = Path(self.temp.name)
+ with zipfile.ZipFile(self.dist / candidate.NAMES[0], "w") as archive:
+ archive.writestr("code_mower-1.5.0.dist-info/METADATA",
+ "Name: code-mower\nVersion: 1.5.0\nRequires-Dist: PyYAML>=6.0\nRequires-Dist: packaging>=23.2\n")
+ for module in candidate.MODULES:
+ archive.writestr("code_mower/" + module, b"synthetic")
+ for doc in candidate.DOCS:
+ archive.writestr("code_mower-1.5.0.data/data/share/code-mower/docs/" + doc, b"synthetic")
+ with tarfile.open(self.dist / candidate.NAMES[1], "w:gz") as archive:
+ for path in (["src/code_mower/" + m for m in candidate.MODULES] +
+ ["docs/" + d for d in candidate.DOCS]):
+ info = tarfile.TarInfo("code_mower-1.5.0/" + path)
+ info.size = 9
+ archive.addfile(info, io.BytesIO(b"synthetic"))
+ self.manifest = {"schema": candidate.SCHEMA, "version": "1.5.0", "source_sha": SHA,
+ "kind": "candidate", "release_pr": 42,
+ "artifacts": {name: candidate.digest(self.dist / name) for name in candidate.NAMES},
+ "inventory": candidate.inspect(self.dist)}
+ self.write_manifest()
+
+ def write_manifest(self):
+ (self.dist / "candidate.json").write_text(json.dumps(self.manifest))
+
+ def test_exact_pair_passes_but_wrong_source_and_rehearsal_cannot_publish(self):
+ candidate.verify(self.dist, SHA, candidate=True)
+ with self.assertRaisesRegex(ValueError, "SHA mismatch"):
+ candidate.verify(self.dist, "b" * 40, candidate=True)
+ self.manifest["kind"] = "rehearsal"
+ self.write_manifest()
+ candidate.verify(self.dist, SHA)
+ with self.assertRaisesRegex(ValueError, "pre-merge"):
+ candidate.verify(self.dist, SHA, candidate=True)
+
+ def test_digest_change_or_extra_distribution_refused(self):
+ with (self.dist / candidate.NAMES[0]).open("ab") as stream:
+ stream.write(b"tampered")
+ with self.assertRaisesRegex(ValueError, "digest"):
+ candidate.verify(self.dist, SHA)
+ (self.dist / "unexpected.whl").write_bytes(b"extra")
+ with self.assertRaisesRegex(ValueError, "unexpected distribution"):
+ candidate.verify(self.dist, SHA)
+
+ def test_inventories_must_match_even_if_digests_are_updated(self):
+ with zipfile.ZipFile(self.dist / candidate.NAMES[0], "a") as archive:
+ archive.writestr("code_mower/extra.py", b"extra")
+ self.manifest["artifacts"][candidate.NAMES[0]] = candidate.digest(self.dist / candidate.NAMES[0])
+ self.write_manifest()
+ with self.assertRaisesRegex(ValueError, "inventory"):
+ candidate.verify(self.dist, SHA)
+
+ def test_private_inventory_refused(self):
+ with zipfile.ZipFile(self.dist / candidate.NAMES[0], "a") as archive:
+ archive.writestr("code_mower/.code-mower/private.json", b"synthetic")
+ with self.assertRaisesRegex(ValueError, "private archive"):
+ candidate.inspect(self.dist)
+
+ def test_mixed_extra_marker_cannot_hide_a_default_dependency(self):
+ wheel = self.dist / candidate.NAMES[0]
+ with zipfile.ZipFile(wheel) as archive:
+ files = {name: archive.read(name) for name in archive.namelist()}
+ files["code_mower-1.5.0.dist-info/METADATA"] += (
+ b'Requires-Dist: slack-sdk; python_version >= "3.12" or extra == "coworker"\n'
+ )
+ with zipfile.ZipFile(wheel, "w") as archive:
+ for name, content in files.items():
+ archive.writestr(name, content)
+ with self.assertRaisesRegex(ValueError, "base dependencies"):
+ candidate.inspect(self.dist)
+
+ def test_dirty_or_wrong_checkout_does_not_build(self):
+ for outputs in (("b" * 40,), (SHA, " M README.md")):
+ with self.subTest(outputs=outputs), patch.object(candidate, "run", side_effect=outputs), \
+ patch.object(candidate.subprocess, "run") as build:
+ with self.assertRaises(ValueError):
+ candidate.build(ROOT, self.dist / "new", SHA, None)
+ build.assert_not_called()
+
+
+class RehearsalEvidenceTests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp.cleanup)
+ self.dist = Path(self.temp.name)
+ # A future version uses the manifest's wheel identity, not a v1.5.0 key.
+ self.wheel = "code_mower-1.5.1-py3-none-any.whl"
+ self.manifest = {"source_sha": SHA, "artifacts": {self.wheel: "b" * 64}}
+ self.evidence = {"schema": candidate.REHEARSAL_SCHEMA, "status": "pass",
+ "source_sha": SHA, "artifact_sha256": "b" * 64,
+ "checks": list(candidate.REHEARSAL_CHECKS)}
+
+ def verify(self):
+ (self.dist / "rehearsal.json").write_text(json.dumps(self.evidence))
+ return candidate.verify_rehearsal(self.dist, self.manifest)
+
+ def test_wheel_identity_is_derived_from_manifest_for_later_versions(self):
+ self.assertEqual(self.verify(), self.evidence)
+
+ def test_missing_or_ambiguous_wheel_has_clear_identity_error(self):
+ for artifacts in ({}, {self.wheel: "b" * 64, "another.whl": "c" * 64}):
+ with self.subTest(artifacts=artifacts):
+ self.manifest["artifacts"] = artifacts
+ with self.assertRaisesRegex(ValueError, "identity requires exactly one wheel"):
+ self.verify()
+
+ def test_every_named_check_is_required_even_with_overall_pass(self):
+ self.assertEqual(len(candidate.GRAPHIFY_CHECKS), 3)
+ for name in candidate.REHEARSAL_CHECKS:
+ with self.subTest(name=name):
+ self.evidence["checks"] = [c for c in candidate.REHEARSAL_CHECKS if c != name]
+ with self.assertRaisesRegex(ValueError, "missing required rehearsal checks"):
+ self.verify()
+
+ def test_wrong_identity_or_failure_cannot_qualify(self):
+ for field, value, error in (
+ ("schema", "unknown", "identity"), ("status", "fail", "status"),
+ ("source_sha", "c" * 40, "source SHA"), ("artifact_sha256", "c" * 64, "digest"),
+ ("checks", None, "checks"), ("checks", [{"not": "a check"}], "checks"),
+ ):
+ with self.subTest(field=field), patch.dict(self.evidence, {field: value}):
+ with self.assertRaisesRegex(ValueError, error):
+ self.verify()
+
+
+class WorkflowBindingTests(unittest.TestCase):
+ def setUp(self):
+ self.candidate_steps = yaml.safe_load((ROOT / ".github/workflows/release-candidate.yml").read_text())["jobs"]["candidate"]["steps"]
+ steps = yaml.safe_load((ROOT / ".github/workflows/release.yml").read_text())["jobs"]["build-distributions"]["steps"]
+ self.publish = next(step["run"] for step in steps if "gh run download" in step.get("run", ""))
+
+ def test_candidate_exact_workflow_sha_and_first_attempt_before_checkout(self):
+ validation, *following = self.candidate_steps
+ self.assertIn("actions/checkout@", following[0]["uses"])
+ self.assertNotIn("uses", validation)
+ environment = {"GITHUB_REF": "refs/heads/main", "SOURCE_SHA": SHA,
+ "GITHUB_SHA": SHA, "GITHUB_RUN_ATTEMPT": "1", "RELEASE_PR": "1033"}
+ for overrides, passed in (
+ ({}, True), ({"GITHUB_SHA": "b" * 40}, False),
+ ({"GITHUB_RUN_ATTEMPT": "2"}, False), ({"GITHUB_RUN_ATTEMPT": ""}, False),
+ ({"GITHUB_REF": "refs/heads/other"}, False), ({"SOURCE_SHA": "main"}, False),
+ ({"RELEASE_PR": "0"}, False),
+ ):
+ with self.subTest(overrides=overrides):
+ result = subprocess.run(["/bin/bash", "-c", validation["run"]],
+ env={**environment, **overrides}, capture_output=True)
+ self.assertEqual(result.returncode == 0, passed)
+
+ def test_publication_rejects_wrong_run_identity_before_downloading(self):
+ before_download = self.publish.split("gh run download", 1)[0]
+ code, = re.findall(r"<<'PY'\n(.*?)\nPY", before_download, re.S)
+ run = {"path": ".github/workflows/release-candidate.yml", "event": "workflow_dispatch",
+ "head_branch": "main", "head_sha": SHA, "run_attempt": 1,
+ "status": "completed", "conclusion": "success",
+ "repository": {"full_name": "codemower-ai/code-mower"}}
+ with tempfile.TemporaryDirectory() as temp:
+ path = Path(temp) / "run.json"
+ def validate():
+ path.write_text(json.dumps(run))
+ with patch.object(sys, "argv", ["-", str(path)]), patch.dict(os.environ, SOURCE_SHA=SHA):
+ exec(compile(code, "release.yml run identity", "exec"), {})
+ validate()
+ for field, bad in (("path", "other.yml"), ("event", "push"), ("head_branch", "other"),
+ ("head_sha", "b" * 40), ("run_attempt", 2), ("run_attempt", "1"),
+ ("status", "in_progress"), ("conclusion", "failure"),
+ ("repository", {"full_name": "someone/fork"})):
+ with self.subTest(field=field, bad=bad), patch.dict(run, {field: bad}):
+ with self.assertRaises(AssertionError):
+ validate()
+ for field in ("head_sha", "run_attempt"):
+ value = run.pop(field)
+ with self.subTest(missing=field), self.assertRaises(KeyError):
+ validate()
+ run[field] = value
+
+ def test_publication_requires_verified_artifacts_and_named_rehearsal_before_copy(self):
+ self.assertNotIn("code_mower-1.5.0-py3-none-any.whl", self.publish)
+ self.assertNotIn("python -m build", self.publish)
+ verification = self.publish.index("python scripts/release_candidate.py verify")
+ self.assertIn("--require-candidate", self.publish[verification:])
+ evidence = self.publish.index("verify_rehearsal(Path('candidate'), candidate)")
+ self.assertLess(verification, evidence)
+ self.assertLess(evidence, self.publish.index("cp candidate/*.whl"))
+
+ def test_ci_exercises_the_installed_wheel_without_claiming_a_candidate(self):
+ jobs = yaml.safe_load((ROOT / ".github/workflows/ci.yml").read_text())["jobs"]
+ job = jobs["release_rehearsal"]
+ self.assertEqual(job["steps"][0]["with"]["ref"], "${{ env.SOURCE_SHA }}")
+ commands = "\n".join(step.get("run", "") for step in job["steps"])
+ self.assertIn("python scripts/release_candidate.py build", commands)
+ self.assertIn('python scripts/rehearse_v150.py --dist "$RUNNER_TEMP/rehearsal-dist"', commands)
+ self.assertNotIn("--release-pr", commands)
+ self.assertIn("release_rehearsal", jobs["package"]["needs"])
+ self.assertIn('test "${{ needs.release_rehearsal.result }}" = "success"', jobs["package"]["steps"][0]["run"])
+
+
+class OfflineGuardTests(unittest.TestCase):
+ def test_graph_exception_only_allows_transport_disabled_fixture_git_reads(self):
+ repository = "/synthetic/repository"
+ environment = {"GIT_ALLOW_PROTOCOL": "", "GIT_NO_LAZY_FETCH": "1",
+ "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1"}
+ prefix = ["git", "-C", repository, "--no-optional-locks"]
+ for event, args, allowed in (
+ ("subprocess.Popen", ["git", prefix + ["rev-parse", "HEAD"], None, environment], True),
+ ("subprocess.Popen", ["git", prefix + ["fetch"], None, environment], False),
+ ("subprocess.Popen", ["git", prefix + ["config", "x", "y"], None, environment], False),
+ ("subprocess.Popen", ["git", prefix + ["cat-file", "--batch"], None, {}], False),
+ ("subprocess.Popen", ["git", ["git", "-C", "/other", "rev-parse"], None, environment], False),
+ ("subprocess.Popen", ["sh", ["sh", "-c", "true"], None, environment], False),
+ ("socket.__new__", [], False), ("os.system", ["true"], False),
+ ("os.posix_spawn", ["/bin/sh", [], {}], False),
+ ):
+ with self.subTest(event=event, args=args):
+ # Audit events alone exercise the guard without launching any child.
+ code = rehearsal.installed_code(f"sys.audit({event!r}, *{args!r})", repository)
+ result = subprocess.run([sys.executable, "-I", "-c", code], capture_output=True)
+ self.assertEqual(result.returncode == 0, allowed)
+
+
+class ReleaseContractTests(unittest.TestCase):
+ def test_identity_and_readiness(self):
+ self.assertEqual(__version__, "1.5.0")
+ self.assertEqual(release_readiness.render_release_readiness(ROOT)["status"], "pass")
+
+ def test_removing_private_acceptance_or_moving_tag_first_blocks(self):
+ text = (ROOT / "docs/v150-release-runbook.md").read_text()
+ for bad in (text.replace("## 3. Private acceptance", "## Removed acceptance"),
+ text.replace('git tag -a v1.5.0 "$RELEASE_SHA"', "tag removed"),
+ text.replace("aggregate campaign ACU", "unspecified budget")):
+ with self.subTest(text=bad[:10]), patch.object(release_readiness, "_read_text_if_exists", return_value=bad):
+ order, assertions = release_readiness._candidate_runbook_checks(ROOT)
+ self.assertTrue(order or assertions)
+
+ def test_later_versions_do_not_revert_to_building_at_publication(self):
+ with patch.object(release_readiness, "_python_package_version", return_value="1.5.1"):
+ payload = release_readiness.render_release_readiness(ROOT)
+ checks = {c["id"]: c for c in payload["checks"]}
+ for name in ("distribution-build-and-verify", "post-merge-release-runbook-ordered",
+ "post-merge-release-runbook-asserted", "release-workflow-next-actions-dispatchable"):
+ with self.subTest(check=name):
+ self.assertEqual(checks[name]["status"], "pass")
+ ordered = checks["post-merge-release-runbook-ordered"]["detail"]
+ asserted = checks["post-merge-release-runbook-asserted"]["detail"]
+ self.assertEqual(ordered["release_tag"], "v1.5.1")
+ self.assertIn("docs/v150-release-runbook.md", ordered["required_commands"][0])
+ self.assertNotIn("gh release create v1.5.1", ordered["required_commands"])
+ self.assertEqual(asserted["required_assertions"],
+ ["merge SHA and retained artifact binding; explicit owner gates"])
+ self.assertEqual(payload["next_actions"][0]["id"], "immutable-candidate-first")
+ self.assertIn("gh workflow run release-candidate.yml", payload["next_actions"][0]["command"])
+ dispatches = [a for a in payload["next_actions"] if "gh workflow run release.yml" in a["command"]]
+ self.assertEqual(len(dispatches), 3)
+ for action in dispatches:
+ self.assertIn("--ref v1.5.1", action["command"])
+ self.assertIn('-f candidate_run_id="$CANDIDATE_RUN_ID"', action["command"])
+
+ def test_later_versions_still_reject_missing_candidate_runbook_gates(self):
+ original = release_readiness._read_text_if_exists
+ runbook_path = ROOT / "docs/v150-release-runbook.md"
+ for marker, check_id, detail in (
+ ("## 3. Private acceptance", "post-merge-release-runbook-ordered", "missing_or_out_of_order"),
+ ("aggregate campaign ACU", "post-merge-release-runbook-asserted", "missing_assertions"),
+ ):
+ def read(path, marker=marker):
+ text = original(path)
+ return text.replace(marker, "") if path == runbook_path else text
+ with self.subTest(marker=marker), \
+ patch.object(release_readiness, "_python_package_version", return_value="1.5.1"), \
+ patch.object(release_readiness, "_read_text_if_exists", side_effect=read):
+ checks = release_readiness.render_release_readiness(ROOT)["checks"]
+ check = next(c for c in checks if c["id"] == check_id)
+ self.assertEqual(check["status"], "fail")
+ self.assertIn(marker, check["detail"][detail])
+
+ def test_readiness_rejects_missing_candidate_workflow_or_integrity_gates(self):
+ original = release_readiness._read_text_if_exists
+ candidate_path = ROOT / ".github/workflows/release-candidate.yml"
+ publication_path = ROOT / ".github/workflows/release.yml"
+ for path, marker in (
+ (candidate_path, None),
+ (candidate_path, '[[ "$GITHUB_SHA" == "$SOURCE_SHA" ]]'),
+ (candidate_path, '[[ "$GITHUB_RUN_ATTEMPT" == 1 ]]'),
+ (publication_path, "assert run['head_sha'] == os.environ['SOURCE_SHA']"),
+ (publication_path, "assert run['run_attempt'] == 1"),
+ (publication_path, "verify_rehearsal(Path('candidate'), candidate)"),
+ ):
+ def read(selected, path=path, marker=marker):
+ text = original(selected)
+ return (text.replace(marker, "") if marker else "") if selected == path else text
+ with self.subTest(marker=marker), patch.object(release_readiness, "_read_text_if_exists", side_effect=read):
+ checks = release_readiness.render_release_readiness(ROOT)["checks"]
+ check = next(c for c in checks if c["id"] == "distribution-build-and-verify")
+ self.assertEqual(check["status"], "fail")
+
+ def test_historical_v14_records_are_unchanged(self):
+ # Recorded from the parent release-prep baseline. No Git history needed in sdist tests.
+ expected = json.loads((ROOT / "tests/fixtures/release_identity/v14-evidence-sha256.json").read_text())
+ for name, digest in expected.items():
+ with self.subTest(name=name):
+ self.assertEqual(hashlib.sha256((ROOT / name).read_bytes()).hexdigest(), digest)