diff --git a/docs/pypi-release.md b/docs/pypi-release.md index 52b1be65..4de28019 100644 --- a/docs/pypi-release.md +++ b/docs/pypi-release.md @@ -198,6 +198,21 @@ For a TestPyPI candidate, add: --pip-extra-index-url https://pypi.org/simple/ ``` +`code-mower release qualify` and `code-mower release campaign` accept the +equivalent closed `--package-source testpypi` flag (default: `pypi`) to +qualify the same TestPyPI candidate before it is announced or marked current +on production PyPI -- see +[Release Qualification](release-qualification.md#testpypi-candidates): + +```bash +code-mower release qualify \ + --release-tag v1.0.7 \ + --package-spec code-mower==1.0.7 \ + --output result.json \ + --package-source testpypi \ + --execute +``` + See [First-User Install Rehearsal](first-user-install-rehearsal.md) for the full artifact contract. If you need to debug a step manually, the equivalent toy-repo flow is: diff --git a/docs/release-qualification.md b/docs/release-qualification.md index 21ff7c56..52053094 100644 --- a/docs/release-qualification.md +++ b/docs/release-qualification.md @@ -27,6 +27,40 @@ the package qualified. Runs use the current directory by default. Specify To rehearse an upgrade in one isolated environment, pass `--qualification-context upgrade --starting-version `. +To qualify an exact TestPyPI candidate before it is announced or marked +current on production PyPI, add `--package-source testpypi`: + +```bash +code-mower release qualify \ + --release-tag v1.0.0 \ + --package-spec code-mower==1.0.0 \ + --output result.json \ + --qualification-context cold_install \ + --package-source testpypi \ + --execute +``` + +`--package-source` is a closed vocabulary: `pypi` (the default) or `testpypi`. +It never accepts an arbitrary index URL. Pip does not prioritize +`--index-url` over `--extra-index-url`: a single install command naming both +the canonical TestPyPI simple index and production PyPI cannot prove which +one actually supplied the candidate, since an identical version already on +production PyPI could silently satisfy it instead. `testpypi` therefore runs +a closed two-stage install: it first downloads the exact candidate artifact +with the canonical TestPyPI simple index (`https://test.pypi.org/simple/`) +as the *only* configured index and `--no-deps`, verifies exactly one +artifact came back and that its filename names the requested package +identity and version -- failing closed on zero, multiple, malformed, or +mismatched artifacts -- and only then installs that verified local artifact +file, resolving its dependencies (which are not part of this release +candidate) from production PyPI (`https://pypi.org/simple/`). Both steps run +pip in isolated mode with ambient pip index variables and configuration +disabled, so a workstation's extra indexes cannot widen either source. Omit the flag, +or pass `--package-source pypi` explicitly, for the production-PyPI default, +which applies no index override at all. TestPyPI installs reuse the same +bounded pip-install retry behavior as any other package-index install (see +[Cache Bypass And Propagation Triage](pypi-release.md#cache-bypass-and-propagation-triage)). + ## Schema `code_mower.adoptionResult.v1` includes: @@ -93,11 +127,50 @@ code-mower release campaign \ --qualification-context cold_install ``` +### TestPyPI candidates + +Add `--package-source testpypi` to qualify an exact TestPyPI candidate before +it is announced or marked current on production PyPI: + +```bash +code-mower release campaign \ + --release-tag v1.0.0 \ + --package-spec code-mower==1.0.0 \ + --qualification-context cold_install \ + --package-source testpypi +``` + +`--package-source` is closed to `pypi` (the default) or `testpypi` -- never an +arbitrary index URL or credential. It is bound into the campaign the same way +`--package-spec` and `--qualification-context` are: + +- it is part of every provider's idempotency key, so a dispatch/result pair + for one source can never satisfy a same-tag campaign from a different + source; +- local adapters receive it (as `{package_source}`) and construct their + install commands against the canonical TestPyPI simple index + (`https://test.pypi.org/simple/`) with production PyPI + (`https://pypi.org/simple/`) as a dependency-only extra index; +- a hosted dispatch comment states the source (and, for `testpypi`, both + canonical index URLs) in its machine-readable marker and human-facing + instructions, so a remote provider never has to guess which index to + install from; a reply's own declared source must match, or it is ignored + like any other unbound comment; +- resuming or dispatching an existing campaign with a different + `--package-source` is rejected as an identity conflict, exactly like a + conflicting `--qualification-context`. + +A campaign created without `--package-source` -- including one stored before +this field existed -- reads back as `pypi`, the default production behavior. +TestPyPI installs reuse the same bounded pip-install retry behavior as any +other package-index install; see +[Cache Bypass And Propagation Triage](pypi-release.md#cache-bypass-and-propagation-triage). + - **Dry-run by default, applied once and for all:** Omit `--apply` for a safe preview. Add `--apply` for live local execution, GitHub comment dispatch, or paid runs. `applied` is a *monotonic* transition: a dry-run campaign becomes applied the first time it is dispatched with `--apply`, and nothing moves it back. A later `resume` or `--status` poll that simply omits `--apply` is not a claim that the dispatches and attempts already made never happened, so it leaves the campaign (and each dispatched provider's `dispatch_mode`) applied -- in stored state, in the rendered text, and on the Board. Previously such a poll relabelled real evidence as a dry-run preview and regressed the aggregate status to "run with --apply to dispatch providers" for providers that had already been dispatched. A poll still never dispatches anything: only `--apply` does that. The aggregate headline of a preview is held to the same standard as the individual providers it summarizes: `queued` / "run with --apply to dispatch providers" is only reported while at least one provider is genuinely dispatchable. When every provider that is not already complete is `unavailable` -- for a missing `--issue`, a missing `--repo-slug`, missing credentials, or an unconfigured adapter alike -- the campaign reports `unavailable` and the actionable "configure prerequisites for unavailable providers: ..." next action instead of pointing at an `--apply` run that could dispatch nothing. A mixed preview stays `queued`, and its detail line still counts the queued and unavailable providers separately. - **Provider diversity:** Tracks Claude, Codex, Antigravity, Muse, Cursor/Grok Bot, and Devin. Missing tools, tokens, or adapters -- and a `code-mower.yml` that configures one lane under two spellings -- degrade gracefully to `unavailable` without failing the campaign. Each provider may appear at most once: `--providers` is canonicalized before any participant is built, so naming the same provider twice -- directly, or through two aliases of one lane such as `cursor` and `grok_bot` -- is rejected with an explicit error instead of creating two participants that share a single idempotency key and result path (which would let one provider's evidence count twice). - **Idempotent resume:** Pass `--resume` to re-poll running providers or advance queued participants without duplicating dispatch or re-invoking an adapter that already completed. Once a provider's applied dispatch/adapter has been attempted (even if it failed or its outcome was uncertain), ordinary resume never repeats it automatically -- pass `--retry-provider ` to explicitly retry that one provider. `--retry-provider` is rejected unless the named provider is already part of the campaign. - **A hosted dispatch is checkpointed as pollable before it is posted:** Posting the dispatch comment is an external side effect that the campaign cannot undo and cannot re-observe, so everything a later resume needs is persisted *first*: the attempt (`attempted_at`), the `running` state, the issue the comment is addressed to (in `dispatch_ref`), the applied dispatch mode, and the matching campaign status and next action. A process killed anywhere around the post therefore leaves a campaign that an ordinary `--resume` polls to a conclusion against the original issue -- accepting the trusted, identity-bound result if the comment did get posted and answered -- and that never reposts on its own. Previously the campaign recorded only `attempted_at` and stayed `queued` until the post returned, which resume neither polled (not running) nor redispatched (already attempted): the provider stalled until an explicit retry posted a second comment for a dispatch that may well have succeeded. The checkpoint never claims the post succeeded -- `dispatched_at` is stamped and `dispatch_ref` is replaced with the returned dispatch metadata only when the post returns successfully; a dispatch that fails in-process records the usual `github_dispatch_failed` unavailable result. If nothing was ever posted, resume simply keeps polling and finds nothing, and dispatching again still requires an explicit `--retry-provider` (which, being explicit, may post a second comment). A retry that cannot dispatch for a prerequisite reason -- no `--issue`, missing credentials -- leaves an outstanding dispatch `running` and pollable rather than demoting it to `unavailable`, since refusing to dispatch reveals nothing about the comment already posted. -- **Never reinitialized:** An existing campaign is never replaced by a fresh queued one. Repeating the same invocation (same `--release-tag` or `--campaign-id`, with or without `--resume`) advances the stored campaign under resume semantics, so a repeated `--apply` never reruns a local adapter, reposts a hosted dispatch, or discards recorded provider state and evidence. The explicit `create` action fails when that campaign already exists, and `resume`/`dispatch` fail when it does not -- neither falls through to creating one. A request that asks for two actions at once is refused rather than resolved to one of them: an action may be spelled with the equivalent legacy flag (`status` with `--status`, `resume`/`dispatch` with `--resume`), but combining an action with a flag naming a *different* action -- `create --resume` above all -- exits non-zero with a bounded conflict message before any campaign lookup, directory creation, lock, state write, adapter run, dispatch, or poll, so the rejected request leaves nothing behind. Previously `create --resume` reached the command body with both intents live and was answered by whichever branch tested its flag first, reporting "no existing campaign to resume" for an explicit `create`. Creation arguments that describe a different campaign (`--package-spec`, `--providers`, `--qualification-context`, `--starting-version`, or a `--campaign-id`/`--release-tag` pair that disagree) are rejected explicitly rather than silently ignored. `--qualification-context` is compared whenever it is supplied, including an explicit `--qualification-context cold_install` against a stored upgrade campaign: the flag has no default value of its own, so an omitted flag (which advances the stored campaign under its own context, and creates a `cold_install` campaign when there is none) is distinguishable from an explicitly requested `cold_install` and the latter is never silently ignored. `--repo-slug` is the one field an existing campaign can still be *completed* with: a campaign created without a repository slug has nowhere to dispatch, so supplying `--repo-slug` on a later `resume`/`dispatch` fills the empty stored value and persists it before any hosted dispatch uses it. A `--repo-slug` that disagrees with a non-empty stored slug is rejected like any other identity change -- an in-flight campaign is never repointed at a different repository. +- **Never reinitialized:** An existing campaign is never replaced by a fresh queued one. Repeating the same invocation (same `--release-tag` or `--campaign-id`, with or without `--resume`) advances the stored campaign under resume semantics, so a repeated `--apply` never reruns a local adapter, reposts a hosted dispatch, or discards recorded provider state and evidence. The explicit `create` action fails when that campaign already exists, and `resume`/`dispatch` fail when it does not -- neither falls through to creating one. A request that asks for two actions at once is refused rather than resolved to one of them: an action may be spelled with the equivalent legacy flag (`status` with `--status`, `resume`/`dispatch` with `--resume`), but combining an action with a flag naming a *different* action -- `create --resume` above all -- exits non-zero with a bounded conflict message before any campaign lookup, directory creation, lock, state write, adapter run, dispatch, or poll, so the rejected request leaves nothing behind. Previously `create --resume` reached the command body with both intents live and was answered by whichever branch tested its flag first, reporting "no existing campaign to resume" for an explicit `create`. Creation arguments that describe a different campaign (`--package-spec`, `--providers`, `--qualification-context`, `--starting-version`, `--package-source`, or a `--campaign-id`/`--release-tag` pair that disagree) are rejected explicitly rather than silently ignored. `--qualification-context` and `--package-source` are each compared whenever supplied, including an explicit `--qualification-context cold_install` or `--package-source pypi` against a stored campaign with the same value: neither flag has a default value of its own, so an omitted flag (which advances the stored campaign under its own context/source, and creates a `cold_install`/`pypi` campaign when there is none) is distinguishable from an explicitly requested default, and an explicit default is never silently ignored. A campaign created without `--package-source` (or an explicit `pypi`) stays `pypi`; resuming it with `--package-source testpypi` is rejected as an identity conflict, not a resume, exactly like a conflicting `--qualification-context`. A campaign stored before this field existed reads back as `pypi`, its documented default. `--repo-slug` is the one field an existing campaign can still be *completed* with: a campaign created without a repository slug has nowhere to dispatch, so supplying `--repo-slug` on a later `resume`/`dispatch` fills the empty stored value and persists it before any hosted dispatch uses it. A `--repo-slug` that disagrees with a non-empty stored slug is rejected like any other identity change -- an in-flight campaign is never repointed at a different repository. - **Serialized mutations, lock-free reads:** Every *mutating* `code-mower release campaign` invocation -- create, implicit create/advance, `resume`, `dispatch`, `--record-result`, `--retry-provider`, and the repository-slug fill -- takes an exclusive OS lock on the campaign directory and holds it across the whole sequence: reading stored state, claiming a provider attempt by stamping `attempted_at`, invoking a local adapter or posting a hosted dispatch, and writing the campaign back. Two such commands launched at the same time therefore run one after the other, and the second one reloads *after* the first has finished, so it sees the recorded `attempted_at` and declines the repeat exactly as a sequential resume would -- concurrency can never duplicate a local adapter run or a paid/hosted dispatch. The lock lives in `.code-mower/campaigns/.campaigns.lock`; because it is an OS lock on an open file descriptor, it is released automatically when a command exits, crashes, or is killed, so there is no stale-lock file to clean up and a dead run never blocks the next one. The lock is taken through one shared portable helper (`code_mower.file_locks`) used by the Board event store as well: POSIX `flock` where `fcntl` exists, and a `msvcrt` byte-range lock on Windows, retried on a bounded sleeping schedule rather than a spin. Both backends release on descriptor close, and neither module imports `fcntl` unconditionally any more, so the package imports on Windows. An invalid `--campaign-id` is rejected before the lock is taken, so it creates no directory, no lock file, and no campaign state. Reads are deliberately *not* serialized: `--status` and the Board projection take no lock at all, so they answer against a read-only campaign directory and complete while a long applied run holds the lock. That is safe because status is *strictly* read-only: `--status` (or the `status` action) combined with a mutating intent -- `--retry-provider`, `--record-result`, `--apply`, `--resume`, or a conflicting non-status action -- is rejected with a bounded non-zero error before any lock, mutation, poll, or dispatch. A mutation is never executed under a read-only spelling (which would also run it outside the serialization contract), and never silently dropped while the command reports success, as `--status --retry-provider` previously did. Campaign files are published with a single atomic rename from a per-write temporary file, so a lock-free read never observes a half-written or blended campaign. - **One-to-one campaign ids:** A campaign id is its storage key: it is used verbatim as the stem of `.code-mower/campaigns/.json`, with no substitution. `--campaign-id` accepts only lowercase ASCII letters, digits, `.`, `_`, and `-`, starting with a letter or digit, up to 64 characters; anything else is rejected before any lookup or save with a bounded error and no traceback. Lowercase-only keeps ids stable on case-insensitive volumes -- APFS on macOS is case-insensitive by default -- where `Campaign-A` and `campaign-a` would otherwise be one file holding two campaigns' state. The leading letter-or-digit rule excludes `.`, `..`, and every dotfile spelling (including the `.tmp.` write-staging prefix and the directory lock file), and the alphabet excludes path separators, so an id can neither traverse out of the campaign directory nor address internal storage. The generated default, `campaign-`, always satisfies this contract. Previously ids were sanitized to fit a filename, which made `campaign/a` and `campaign_a` collide on one file so naming either could advance and overwrite the other. - **Bounded status lookups:** `--status` with an explicit `--campaign-id` or `--release-tag` reports that campaign or exits non-zero with a bounded "no campaign found" message -- it never falls back to an unrelated campaign's data. Only an unqualified `--status` (no identifier) reports the most recently updated campaign. @@ -146,7 +219,7 @@ lanes: `campaign_adapter_timeout_seconds` must be a positive integer number of seconds, written as one: a fractional value such as `1.9` is rejected with the bounded `adapter_configuration_invalid` error rather than truncated to a shorter budget than was configured, as are `0`, negatives, non-finite numbers, and booleans. Omitting it uses the built-in default. -Supported placeholders: `{command}` (resolved binary), `{release_tag}`, `{package_spec}`, `{qualification_context}`, `{starting_version}`, `{output}`, `{repo_path}`, `{python}` (running interpreter), `{target_python}` (resolved Python 3.12+ binary), `{target_runtime}`, `{adapter_timeout}`. The adapter command must write a `code_mower.adoptionResult.v1` JSON document to the `{output}` path whose `provider` and `executor` both match the invoked provider, and whose `release_tag`, `qualification_context`, and `starting_version` fields all match the campaign's -- a cold-install result cannot complete an upgrade campaign, and an upgrade result must match the campaign's exact starting version. Anything else (extra fields, mismatched identity, no file, non-zero exit, or a timeout) leaves the provider `unavailable`/`blocked` with a bounded error code -- never a fabricated pass. A local drop-in result file and `--record-result` are bound the same way. Install the provider's CLI binary on PATH and verify local authentication. +Supported placeholders: `{command}` (resolved binary), `{release_tag}`, `{package_spec}`, `{qualification_context}`, `{starting_version}`, `{package_source}`, `{output}`, `{repo_path}`, `{python}` (running interpreter), `{target_python}` (resolved Python 3.12+ binary), `{target_runtime}`, `{adapter_timeout}`. The maintained adapters pass `{package_source}` straight through to `code_mower.campaign_adapters`, which tells the provider CLI which closed index to install the candidate from (see [TestPyPI candidates](#testpypi-candidates) below); the adoption-result schema itself carries no separate source field, since it does not change which package or version a result is bound to. The adapter command must write a `code_mower.adoptionResult.v1` JSON document to the `{output}` path whose `provider` and `executor` both match the invoked provider, and whose `release_tag`, `qualification_context`, and `starting_version` fields all match the campaign's -- a cold-install result cannot complete an upgrade campaign, and an upgrade result must match the campaign's exact starting version. Anything else (extra fields, mismatched identity, no file, non-zero exit, or a timeout) leaves the provider `unavailable`/`blocked` with a bounded error code -- never a fabricated pass. A local drop-in result file and `--record-result` are bound the same way. Install the provider's CLI binary on PATH and verify local authentication. Codex campaign runs use an isolated `CODEX_HOME` at `~/.config/code-mower/provider-homes/codex` (override with `CODE_MOWER_CODEX_CAMPAIGN_HOME`). Code Mower creates its non-secret restricted config automatically and refuses a readable `auth.json`. Authenticate that home once with `CODEX_HOME="$HOME/.config/code-mower/provider-homes/codex" codex login --device-auth -c 'cli_auth_credentials_store="keyring"' --enable secret_auth_storage`; the explicit login flags make Codex store that home-specific credential in the OS keyring even before Code Mower has created the config file. The adapter preserves the real OS `HOME` only so the platform keyring can locate the user's login keychain; Codex configuration and state remain isolated under `CODEX_HOME`, ambient token variables are removed, and the root-deny policy lets the agent write only its disposable workspace. Network remains available for package installation. A previous result file is removed before every adapter attempt, and a failed run never leaves stale evidence for a caller to accept. diff --git a/src/code_mower/campaign_adapters.py b/src/code_mower/campaign_adapters.py index 69570882..e0fe5ddd 100644 --- a/src/code_mower/campaign_adapters.py +++ b/src/code_mower/campaign_adapters.py @@ -15,6 +15,7 @@ --package-spec {package_spec} \ --qualification-context {qualification_context} \ --starting-version {starting_version} \ + --package-source {package_source} \ --timeout-seconds {adapter_timeout} --output {output} ``{command}`` resolves to the installed provider CLI (the campaign refuses to @@ -69,7 +70,11 @@ build_allowlisted_child_env, ) from code_mower.release_qualify import ( + DEFAULT_PACKAGE_SOURCE, + PRODUCTION_PYPI_INDEX_URL, + TESTPYPI_INDEX_URL, _parse_exact_package_spec, + _validate_package_source, _validate_qualification_context, _validate_starting_version, _validate_tag_format, @@ -81,7 +86,11 @@ from . import muse_cli_audit_pr as code_mower_muse_cli from .provider_runners import DEFAULT_HOME_ENV_KEYS, build_allowlisted_child_env from .release_qualify import ( + DEFAULT_PACKAGE_SOURCE, + PRODUCTION_PYPI_INDEX_URL, + TESTPYPI_INDEX_URL, _parse_exact_package_spec, + _validate_package_source, _validate_qualification_context, _validate_starting_version, _validate_tag_format, @@ -124,17 +133,38 @@ CLAUDE_DEFAULT_MODEL = "sonnet" CLAUDE_BUDGET_ENV_NAME = "CLAUDE_AUDIT_MAX_BUDGET_USD" CLAUDE_DEFAULT_MAX_BUDGET_USD = "5.00" -CLAUDE_SANDBOX_SETTINGS: dict[str, Any] = { - "permissions": {"allow": ["Bash"]}, - "sandbox": { - "enabled": True, - "failIfUnavailable": True, - "autoAllowBashIfSandboxed": True, - "allowUnsandboxedCommands": False, - "filesystem": {"denyRead": ["~"], "denyWrite": ["~"]}, - "network": {"allowedDomains": ["pypi.org", "files.pythonhosted.org"]}, - }, +#: Domains the Claude campaign adapter's sandbox may reach for package +#: downloads, keyed by the closed ``package_source`` vocabulary. ``testpypi`` +#: adds the canonical TestPyPI file-serving domains on top of the production +#: ones, since a verified candidate's dependencies resolve from production +#: PyPI in a separate install step. +CLAUDE_SANDBOX_ALLOWED_DOMAINS: dict[str, tuple[str, ...]] = { + "pypi": ("pypi.org", "files.pythonhosted.org"), + "testpypi": ( + "pypi.org", + "files.pythonhosted.org", + "test.pypi.org", + "test-files.pythonhosted.org", + ), } + + +def _claude_sandbox_settings(package_source: str) -> dict[str, Any]: + """Build the Claude campaign adapter's sandbox settings for one package source.""" + allowed_domains = CLAUDE_SANDBOX_ALLOWED_DOMAINS.get( + package_source, CLAUDE_SANDBOX_ALLOWED_DOMAINS[DEFAULT_PACKAGE_SOURCE] + ) + return { + "permissions": {"allow": ["Bash"]}, + "sandbox": { + "enabled": True, + "failIfUnavailable": True, + "autoAllowBashIfSandboxed": True, + "allowUnsandboxedCommands": False, + "filesystem": {"denyRead": ["~"], "denyWrite": ["~"]}, + "network": {"allowedDomains": list(allowed_domains)}, + }, + } ANTIGRAVITY_MODEL_ENV_NAMES = ("CODE_MOWER_ANTIGRAVITY_MODEL", "ANTIGRAVITY_MODEL") ANTIGRAVITY_AMBIENT_HOME_ENV = "ANTIGRAVITY_CLI_USE_AMBIENT_HOME" MUSE_MODEL_ENV_NAMES = ("CODE_MOWER_MUSE_MODEL", "MUSE_MODEL", "META_MUSE_MODEL") @@ -278,35 +308,79 @@ def build_qualification_prompt( normalized_version: str, qualification_context: str, starting_version: str, + package_source: str = DEFAULT_PACKAGE_SOURCE, python_bin: str = "python3", target_runtime: str = "", ) -> str: """Build the shared release-qualification prompt for one provider run. The prompt carries only campaign identity (provider, tag, spec, context, - versions). It never includes credentials, home-directory paths, tokens, or - local checkout paths: the agent works in a fresh disposable directory it - creates itself. + versions, source). It never includes credentials, home-directory paths, + tokens, or local checkout paths: the agent works in a fresh disposable + directory it creates itself. ``package_source`` is the closed vocabulary + (``pypi``/``testpypi``); when it is ``testpypi`` the prompt keeps candidate + retrieval exclusive to TestPyPI and dependency resolution exclusive to + production PyPI. """ python_cmd = shlex.quote(python_bin or "python3") - if qualification_context == "upgrade": + if package_source == "testpypi": + pip = ( + "env -u PIP_INDEX_URL -u PIP_EXTRA_INDEX_URL PIP_CONFIG_FILE=/dev/null " + ".venv/bin/python -m pip --isolated" + ) + source_reset = "--extra-index-url '' --find-links ''" + steps = [ + "1. In the current disposable directory, run " + f"`{python_cmd} -m venv .venv`. Use only `.venv/bin/python` and installed " + "entry points for the remaining steps." + ] + step_number = 2 + if qualification_context == "upgrade": + steps.append( + f'{step_number}. Install the starting version from production PyPI with ' + f'`{pip} install --index-url "{PRODUCTION_PYPI_INDEX_URL}" ' + f'{source_reset} {package_identity}=={starting_version}`.' + ) + step_number += 1 + steps.extend( + [ + f"{step_number}. Create an empty `candidate` directory, then download the " + f"candidate only with `{pip} download --no-deps --no-cache-dir " + f'--index-url "{TESTPYPI_INDEX_URL}" --dest candidate {source_reset} ' + f'"{package_spec}"`.', + f"{step_number + 1}. Before installing, fail closed unless `candidate` contains " + "exactly one wheel or source archive and its normalized distribution name and " + f"version are exactly `{package_identity}` and `{normalized_version}`.", + f"{step_number + 2}. Install that verified local artifact path with `{pip} install " + f'--index-url "{PRODUCTION_PYPI_INDEX_URL}" {source_reset} ' + f"candidate/`. " + "Do not provide a non-empty `--extra-index-url` or install the release spec " + "from a combined index.", + ] + ) + install_plan = "\n".join(steps) + verification_step = step_number + 3 + elif qualification_context == "upgrade": install_plan = ( "1. In the current disposable directory, run " f"`{python_cmd} -m venv .venv`. Use only `.venv/bin/python` and installed " "entry points for the remaining steps.\n" - f"2. Install the starting version with `.venv/bin/python -m pip install " + f"2. Install the starting version with `.venv/bin/python -m pip install" + " " f"{package_identity}=={starting_version}` to rehearse " f"an upgrade from exactly that version.\n" f'3. Upgrade with `.venv/bin/python -m pip install "{package_spec}"`.' ) + verification_step = 4 else: install_plan = ( "1. In the current disposable directory, run " f"`{python_cmd} -m venv .venv`. Use only `.venv/bin/python` and installed " "entry points for the remaining steps.\n" - f"2. Install the exact release with `.venv/bin/python -m pip install " - f'"{package_spec}"`. No other version is acceptable.' + f"2. Install the exact release with `.venv/bin/python -m pip install" + f' "{package_spec}"`. No other version is acceptable.' ) + verification_step = 4 lines = [ f"You are the {provider} release-qualification agent for Code Mower.", "Qualify exactly one release in a disposable environment you create.", @@ -324,6 +398,7 @@ def build_qualification_prompt( f"- package_spec: {package_spec}", f"- qualification_context: {qualification_context}", f"- starting_version: {starting_version if starting_version else '(empty)'}", + f"- package_source: {package_source}", ] if target_runtime: lines.append(f"- target_runtime: {target_runtime}") @@ -332,14 +407,14 @@ def build_qualification_prompt( "", "Procedure (measure wall-clock seconds for each step):", install_plan, - f"4. Assert the installed {package_identity} version is exactly", + f"{verification_step}. Assert the installed {package_identity} version is exactly", f" {normalized_version}, using `.venv/bin/python -c` and", " `importlib.metadata.version` to read installed metadata.", - "5. Smoke-check the installed distribution using its `.venv/bin/`", + f"{verification_step + 1}. Smoke-check the installed distribution using its `.venv/bin/`", " entry point (for code-mower run `.venv/bin/code-mower --help` and", " `.venv/bin/code-mower doctor --help`) and confirm the", " operational surfaces respond.", - "6. Report failures honestly: a failed step has status fail and the", + f"{verification_step + 2}. Report failures honestly: a failed step has status fail and the", " overall outcome follows the rule below. Never invent timings or", " claim work you did not perform.", "", @@ -425,6 +500,7 @@ def build_claude_argv( max_budget_usd: str, schema_json: str, workspace_dir: str = "", + package_source: str = DEFAULT_PACKAGE_SOURCE, ) -> list[str]: """Argv for ``claude --print``: stdin prompt, single JSON envelope. @@ -433,8 +509,10 @@ def build_claude_argv( agent gets exactly one tool -- ``Bash`` -- inside Claude's OS-level sandbox. Sandboxed commands are auto-approved, the sandbox must be available, its unsandboxed escape hatch is disabled, home reads/writes are - denied, and network access is limited to the package index. The disabled - escape hatch makes anything outside that boundary fail closed. + denied, and network access is limited to the package index for the + requested closed ``package_source`` (production PyPI, plus TestPyPI's + fixed file-serving domains when qualifying a TestPyPI candidate). The + disabled escape hatch makes anything outside that boundary fail closed. """ argv = [ claude_bin, @@ -452,7 +530,9 @@ def build_claude_argv( "--tools", "Bash", "--settings", - json.dumps(CLAUDE_SANDBOX_SETTINGS, separators=(",", ":"), sort_keys=True), + json.dumps( + _claude_sandbox_settings(package_source), separators=(",", ":"), sort_keys=True + ), ] if workspace_dir: argv.extend(["--add-dir", workspace_dir]) @@ -947,6 +1027,7 @@ def _check_campaign_identity( package_spec: str, qualification_context: str, starting_version: str, + package_source: str = DEFAULT_PACKAGE_SOURCE, ) -> tuple[str, str]: """Return (package_identity, normalized_version) or raise ValueError.""" valid, normalized_version, tag_error = _validate_tag_format(release_tag) @@ -957,6 +1038,7 @@ def _check_campaign_identity( raise ValueError("release tag and package spec versions disagree") _validate_qualification_context(qualification_context) _validate_starting_version(starting_version) + _validate_package_source(package_source) if qualification_context == "upgrade": if not starting_version: raise ValueError("upgrade qualification requires starting_version") @@ -982,6 +1064,7 @@ def run_campaign_adapter( muse_max_model_steps: int = MUSE_DEFAULT_MAX_MODEL_STEPS, muse_reasoning_effort: str = "", codex_home: Path | None = None, + package_source: str = DEFAULT_PACKAGE_SOURCE, python_bin: str = "", target_runtime: str = "", provider_runner: ProviderRunner = run_provider_command, @@ -1014,6 +1097,7 @@ def run_campaign_adapter( package_spec=package_spec, qualification_context=qualification_context, starting_version=starting_version, + package_source=package_source, ) except ValueError as exc: return _fail(provider, str(exc)[:180]) @@ -1026,6 +1110,7 @@ def run_campaign_adapter( normalized_version=normalized_version, qualification_context=qualification_context, starting_version=starting_version, + package_source=package_source, python_bin=python_bin, target_runtime=target_runtime, ) @@ -1098,6 +1183,7 @@ def run_campaign_adapter( max_budget_usd=budget, schema_json=json.dumps(ADOPTION_RESULT_JSON_SCHEMA, separators=(",", ":")), workspace_dir=str(workdir), + package_source=package_source, ) completed = provider_runner(argv, prompt, timeout_seconds, workdir, child_env) if completed.returncode != 0: @@ -1196,6 +1282,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--package-spec", required=True) parser.add_argument("--qualification-context", required=True) parser.add_argument("--starting-version", default="") + parser.add_argument("--package-source", default=DEFAULT_PACKAGE_SOURCE) parser.add_argument("--output", type=Path, required=True) parser.add_argument("--timeout-seconds", type=int, default=DEFAULT_PROVIDER_TIMEOUT_SECONDS) parser.add_argument("--model", default="") @@ -1219,6 +1306,7 @@ def main(argv: Sequence[str] | None = None) -> int: package_spec=args.package_spec, qualification_context=args.qualification_context, starting_version=args.starting_version, + package_source=args.package_source, output_path=args.output, timeout_seconds=args.timeout_seconds, model=args.model, diff --git a/src/code_mower/migration_install.py b/src/code_mower/migration_install.py index 5932b384..7ea899ec 100644 --- a/src/code_mower/migration_install.py +++ b/src/code_mower/migration_install.py @@ -7,6 +7,7 @@ import importlib import json import os +import re import shutil import subprocess import sys @@ -17,6 +18,30 @@ from .git_identity import scratch_git_config_commands +# Grammar for the one exact package-index spec shape a candidate-only download +# accepts: ==. Anything else (ranges, extras, paths, URLs) has +# no single artifact a downloaded file could be checked against, so it is +# refused rather than downloaded. +_EXACT_NAME_VERSION_SPEC_PATTERN = re.compile( + r"^(?P[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)==(?P[A-Za-z0-9.]+)$" +) +# Wheel and sdist filenames as pip/setuptools emit them: the distribution name +# and version are escaped (runs of ``-_.`` collapsed to ``_``) ahead of a +# fixed suffix -- wheel tags for a wheel, ``.tar.gz``/``.zip`` for an sdist. +_WHEEL_FILENAME_PATTERN = re.compile( + r"^(?P[A-Za-z0-9_.]+)-(?P[A-Za-z0-9_.!+]+)" + r"(?:-\d[^-]*)?-[^-]+-[^-]+-[^-]+\.whl$" +) +_SDIST_FILENAME_PATTERN = re.compile( + r"^(?P[A-Za-z0-9_.]+)-(?P[A-Za-z0-9_.!+]+)\.(?:tar\.gz|zip)$" +) +_PIP_SOURCE_RESET_ARGS = ( + "--extra-index-url", + "", + "--find-links", + "", +) + MIRRORED_IMPLEMENTATION_PATTERNS = ( "tools/code_mower_*.py", "tools/*_audit_pr.py", @@ -239,18 +264,116 @@ def _pip_install_command( pip_extra_index_urls: Sequence[str] | None = None, pip_no_cache: bool = False, ) -> list[str]: - command = [str(venv_python), "-m", "pip", "install"] + extra_index_urls = tuple(url for url in pip_extra_index_urls or () if url) + source_isolated = bool(pip_index_url or extra_index_urls) + command = [str(venv_python), "-m", "pip"] + if source_isolated: + command.append("--isolated") + command.append("install") if pip_no_cache: command.append("--no-cache-dir") if pip_index_url: command.extend(["--index-url", pip_index_url]) - for extra_index_url in pip_extra_index_urls or (): - if extra_index_url: - command.extend(["--extra-index-url", extra_index_url]) + if source_isolated: + command.extend(_PIP_SOURCE_RESET_ARGS) + for extra_index_url in extra_index_urls: + command.extend(["--extra-index-url", extra_index_url]) + command.append(package_spec) + return command + + +def _normalize_distribution_name(name: str) -> str: + """PEP 503 normalization, so a spec and a downloaded filename agree on identity.""" + return re.sub(r"[-_.]+", "-", name.strip()).lower() + + +def _parse_exact_name_version_spec(package_spec: str) -> tuple[str, str]: + """Parse a candidate-only download spec into ``(identity, version)``. + + Only the exact ``==`` shape has one artifact a downloaded + file can be checked against; anything else is refused rather than + downloaded, matching :func:`_package_spec_uses_package_index`'s refusal of + inexact specs elsewhere in this module. + """ + match = _EXACT_NAME_VERSION_SPEC_PATTERN.match(package_spec.strip()) + if not match: + raise ValueError( + "candidate-only download requires an exact == spec, " + f"got: {package_spec!r}" + ) + return _normalize_distribution_name(match.group("name")), match.group("version") + + +def _parse_downloaded_artifact_identity(filename: str) -> tuple[str, str]: + """Parse a downloaded wheel/sdist filename into ``(identity, version)``. + + Raises ``ValueError`` for any filename that is not a recognized wheel or + sdist shape, so a candidate-only download can fail closed on a malformed + artifact instead of accepting it on faith. + """ + match = _WHEEL_FILENAME_PATTERN.match(filename) or _SDIST_FILENAME_PATTERN.match(filename) + if not match: + raise ValueError(f"downloaded candidate artifact has an unrecognized filename: {filename!r}") + return _normalize_distribution_name(match.group("name")), match.group("version") + + +def _pip_download_candidate_command( + venv_python: Path, + package_spec: str, + *, + index_url: str, + dest_dir: Path, + pip_no_cache: bool = False, +) -> list[str]: + """Download the exact candidate artifact with ``index_url`` as the *only* index. + + No ``--extra-index-url`` is ever added here: pip does not prioritize + ``--index-url`` over ``--extra-index-url``, so a single combined install + command cannot prove which configured index actually supplied a + candidate. Passing exactly one index -- and ``--no-deps``, since + dependencies are not part of this release candidate -- is what makes the + proof possible: nothing but ``index_url`` can satisfy this command. + """ + command = [str(venv_python), "-m", "pip", "--isolated", "download", "--no-deps"] + if pip_no_cache: + command.append("--no-cache-dir") + command.extend(["--index-url", index_url, "--dest", str(dest_dir)]) + command.extend(_PIP_SOURCE_RESET_ARGS) command.append(package_spec) return command +def _pip_install_local_artifact_command( + venv_python: Path, + artifact_path: Path, + *, + dependency_index_url: str = "", + pip_no_cache: bool = False, +) -> list[str]: + """Install an already-downloaded local artifact file, not a name/version spec. + + The candidate's own distribution comes from the local file, so its + identity is not subject to index resolution at all here; only its + dependencies -- which are not part of this release candidate -- resolve + against ``dependency_index_url``. + """ + command = [str(venv_python), "-m", "pip", "--isolated", "install"] + if pip_no_cache: + command.append("--no-cache-dir") + if dependency_index_url: + command.extend(["--index-url", dependency_index_url]) + command.extend(_PIP_SOURCE_RESET_ARGS) + command.append(str(artifact_path)) + return command + + +def _isolated_pip_environment() -> dict[str, str]: + """Return the process environment with ambient pip index policy disabled.""" + env = {key: value for key, value in os.environ.items() if not key.startswith("PIP_")} + env["PIP_CONFIG_FILE"] = os.devnull + return env + + def _preview_timeout_output(output: bytes | str | None) -> str: if output is None: return "" @@ -296,6 +419,7 @@ def _run_pip_install_with_retries( attempts: int, retry_delay_seconds: float, package_index: bool, + command_label: str = "pip install", ) -> subprocess.CompletedProcess[str]: if attempts < 1: raise ValueError("--pip-install-attempts must be at least 1") @@ -334,7 +458,7 @@ def _run_pip_install_with_retries( if package_index: raise RehearsalError( ( - f"pip install failed after {attempts} attempts. " + f"{command_label} failed after {attempts} attempts. " "If this followed a fresh package publish, retry after " "PyPI/TestPyPI propagation or install the local wheel to " "separate source failures from package-index lag." diff --git a/src/code_mower/migration_rehearsal.py b/src/code_mower/migration_rehearsal.py index 37242031..1aeedc37 100644 --- a/src/code_mower/migration_rehearsal.py +++ b/src/code_mower/migration_rehearsal.py @@ -20,9 +20,14 @@ _default_product_rehearsal_local_command, _glob_relative_files, _json_payload, + _isolated_pip_environment, _load_release_readiness, _package_spec_uses_package_index, + _parse_downloaded_artifact_identity, + _parse_exact_name_version_spec, + _pip_download_candidate_command, _pip_install_command, + _pip_install_local_artifact_command, _pip_upgrade_command, _resolve_install_package_spec, _run, @@ -49,9 +54,14 @@ _default_product_rehearsal_local_command, _glob_relative_files, _json_payload, + _isolated_pip_environment, _load_release_readiness, _package_spec_uses_package_index, + _parse_downloaded_artifact_identity, + _parse_exact_name_version_spec, + _pip_download_candidate_command, _pip_install_command, + _pip_install_local_artifact_command, _pip_upgrade_command, _resolve_install_package_spec, _run, @@ -91,6 +101,7 @@ "_resolve_install_package_spec", "_run", "_run_pip_install_with_retries", + "_run_two_stage_candidate_install", "_run_rehearsal_step", "_run_rehearsal_step_to_file", "_write_json", @@ -268,6 +279,95 @@ def _pip_install_policy( return cache_disabled, attempts +def _run_two_stage_candidate_install( + *, + venv_python: Path, + package_spec: str, + candidate_index_url: str, + dependency_index_url: str, + candidate_dir: Path, + work_dir: Path, + steps: list[dict[str, Any]], + timeout: int, + attempts: int, + retry_delay_seconds: float, + pip_no_cache: bool, +) -> None: + """Prove a candidate package-index spec came exclusively from ``candidate_index_url``. + + Pip does not prioritize ``--index-url`` over ``--extra-index-url``: a + single install command that names both cannot prove which configured + index actually supplied the candidate, since either index may hold a + matching version. This closed two-stage flow instead (1) downloads the + exact candidate artifact with ``candidate_index_url`` as the *only* + configured index and ``--no-deps`` -- nothing but that index can satisfy + this command -- verifies exactly one artifact was downloaded and that its + filename names the requested package identity and version, then (2) + installs that verified local artifact file directly (not by name/version, + so its identity is no longer subject to any index resolution), letting + only its dependencies -- not the candidate itself -- resolve against + ``dependency_index_url``. + """ + expected_identity, expected_version = _parse_exact_name_version_spec(package_spec) + candidate_dir.mkdir(parents=True, exist_ok=True) + _run_pip_install_with_retries( + _pip_download_candidate_command( + venv_python, + package_spec, + index_url=candidate_index_url, + dest_dir=candidate_dir, + pip_no_cache=pip_no_cache, + ), + cwd=work_dir, + env=_isolated_pip_environment(), + steps=steps, + timeout=timeout, + attempts=attempts, + retry_delay_seconds=retry_delay_seconds, + package_index=True, + command_label="pip download", + ) + downloaded = sorted(p for p in candidate_dir.iterdir() if p.is_file()) + if not downloaded: + raise RehearsalError( + f"no candidate artifact was downloaded from {candidate_index_url}", + steps, + ) + if len(downloaded) > 1: + raise RehearsalError( + "expected exactly one candidate artifact from " + f"{candidate_index_url}, found {len(downloaded)}: " + f"{', '.join(p.name for p in downloaded)}", + steps, + ) + artifact = downloaded[0] + try: + artifact_identity, artifact_version = _parse_downloaded_artifact_identity(artifact.name) + except ValueError as exc: + raise RehearsalError(str(exc), steps) from exc + if artifact_identity != expected_identity or artifact_version != expected_version: + raise RehearsalError( + f"candidate artifact {artifact.name!r} does not match the requested " + f"{expected_identity}=={expected_version} spec", + steps, + ) + _run_pip_install_with_retries( + _pip_install_local_artifact_command( + venv_python, + artifact, + dependency_index_url=dependency_index_url, + pip_no_cache=pip_no_cache, + ), + cwd=work_dir, + env=_isolated_pip_environment(), + steps=steps, + timeout=timeout, + attempts=attempts, + retry_delay_seconds=retry_delay_seconds, + package_index=True, + ) + + def run_package_install_rehearsal( *, package_spec: str, @@ -281,6 +381,8 @@ def run_package_install_rehearsal( standalone_default_cycles: int = 1, pip_index_url: str = "", pip_extra_index_urls: Sequence[str] | None = None, + candidate_index_url: str = "", + candidate_dependency_index_url: str = "", allow_package_index: bool = False, upgrade_pip: bool = False, pip_no_cache: bool = False, @@ -305,6 +407,11 @@ def run_package_install_rehearsal( raise ValueError( "package-index preinstall specs require --allow-package-index" ) + if candidate_index_url and not (uses_package_index and allow_package_index): + raise ValueError( + "candidate_index_url requires an exact package-index package_spec " + "and --allow-package-index" + ) pip_cache_disabled, pip_install_max_attempts = _pip_install_policy( uses_package_index=uses_package_index, allow_package_index=allow_package_index, @@ -323,7 +430,8 @@ def run_package_install_rehearsal( venv_dir = work_dir / "venv" toy_repo = work_dir / "toy-repo" outputs = work_dir / "outputs" - if venv_dir.exists() or toy_repo.exists(): + candidate_dir = work_dir / "testpypi-candidate" + if venv_dir.exists() or toy_repo.exists() or candidate_dir.exists(): raise ValueError(f"work directory is not clean: {work_dir}") work_dir.mkdir(parents=True, exist_ok=True) outputs.mkdir(parents=True, exist_ok=True) @@ -361,7 +469,7 @@ def run_package_install_rehearsal( pip_no_cache=pip_cache_disabled, ), cwd=work_dir, - env=None, + env=_isolated_pip_environment() if pip_index_url else None, steps=steps, timeout=timeout, attempts=pip_install_max_attempts, @@ -384,22 +492,41 @@ def run_package_install_rehearsal( timeout=timeout, ).stdout.strip() pip_install_start = len(steps) - _run_pip_install_with_retries( - _pip_install_command( - venv_python, - package_spec, - pip_index_url=pip_index_url, - pip_extra_index_urls=pip_extra_index_urls, + if candidate_index_url: + _run_two_stage_candidate_install( + venv_python=venv_python, + package_spec=package_spec, + candidate_index_url=candidate_index_url, + dependency_index_url=candidate_dependency_index_url, + candidate_dir=candidate_dir, + work_dir=work_dir, + steps=steps, + timeout=timeout, + attempts=pip_install_max_attempts, + retry_delay_seconds=pip_retry_delay_seconds, pip_no_cache=pip_cache_disabled, - ), - cwd=work_dir, - env=None, - steps=steps, - timeout=timeout, - attempts=pip_install_max_attempts, - retry_delay_seconds=pip_retry_delay_seconds, - package_index=uses_package_index and allow_package_index, - ) + ) + else: + _run_pip_install_with_retries( + _pip_install_command( + venv_python, + package_spec, + pip_index_url=pip_index_url, + pip_extra_index_urls=pip_extra_index_urls, + pip_no_cache=pip_cache_disabled, + ), + cwd=work_dir, + env=( + _isolated_pip_environment() + if pip_index_url or pip_extra_index_urls + else None + ), + steps=steps, + timeout=timeout, + attempts=pip_install_max_attempts, + retry_delay_seconds=pip_retry_delay_seconds, + package_index=uses_package_index and allow_package_index, + ) pip_install_attempt_count = len(steps) - pip_install_start _run_rehearsal_step( [str(venv_python), "-m", "pip", "check"], @@ -752,6 +879,8 @@ def run_package_install_rehearsal( "pip_retry_delay_seconds": pip_retry_delay_seconds, "pip_index_url": pip_index_url, "pip_extra_index_urls": list(pip_extra_index_urls or ()), + "candidate_index_url": candidate_index_url, + "candidate_dependency_index_url": candidate_dependency_index_url, "python": str(python_bin), "work_dir": str(work_dir), "venv_dir": str(venv_dir), diff --git a/src/code_mower/provider_registry.py b/src/code_mower/provider_registry.py index feca8d72..a849fe69 100644 --- a/src/code_mower/provider_registry.py +++ b/src/code_mower/provider_registry.py @@ -96,6 +96,8 @@ def _maintained_campaign_adapter_argv(provider: str) -> tuple[str, ...]: "{qualification_context}", "--starting-version", "{starting_version}", + "--package-source", + "{package_source}", "--timeout-seconds", "{adapter_timeout}", "--output", diff --git a/src/code_mower/release_campaigns.py b/src/code_mower/release_campaigns.py index 89384d95..f60e20e0 100644 --- a/src/code_mower/release_campaigns.py +++ b/src/code_mower/release_campaigns.py @@ -35,10 +35,14 @@ from code_mower.file_locks import FileLockError, exclusive_file_lock from code_mower.provider_registry import REFERENCE_PROVIDERS, ProviderLane from code_mower.release_qualify import ( + DEFAULT_PACKAGE_SOURCE, + PRODUCTION_PYPI_INDEX_URL, + TESTPYPI_INDEX_URL, _detect_host_class, _detect_runtime_class, _extract_package_identity, _parse_exact_package_spec, + _validate_package_source, _validate_qualification_context, _validate_starting_version, _validate_tag_format, @@ -58,10 +62,14 @@ from .file_locks import FileLockError, exclusive_file_lock from .provider_registry import REFERENCE_PROVIDERS, ProviderLane from .release_qualify import ( + DEFAULT_PACKAGE_SOURCE, + PRODUCTION_PYPI_INDEX_URL, + TESTPYPI_INDEX_URL, _detect_host_class, _detect_runtime_class, _extract_package_identity, _parse_exact_package_spec, + _validate_package_source, _validate_qualification_context, _validate_starting_version, _validate_tag_format, @@ -271,6 +279,7 @@ class ReleaseCampaign: normalized_version: str qualification_context: str starting_version: str + package_source: str repo_slug: str status: str dry_run: bool @@ -355,9 +364,11 @@ def _compute_idempotency_key( release_tag: str, qualification_context: str, starting_version: str = "", + package_source: str = DEFAULT_PACKAGE_SOURCE, ) -> str: seed = ( - f"{campaign_id}:{provider}:{release_tag}:{qualification_context}:{starting_version}" + f"{campaign_id}:{provider}:{release_tag}:{qualification_context}:" + f"{starting_version}:{package_source}" ).encode("utf-8") return hashlib.sha256(seed).hexdigest()[:16] @@ -863,6 +874,7 @@ def _extract_bound_adoption_result( qualification_context: str, starting_version: str, package_identity: str, + package_source: str = DEFAULT_PACKAGE_SOURCE, ) -> dict[str, Any] | None: """Extract an adoptionResult from a GitHub comment, requiring explicit identity binding. @@ -880,6 +892,11 @@ def _extract_bound_adoption_result( generated incorrectly. The embedded result's ``package_identity`` is bound the same way, against the identity derived from the campaign's own exact package spec, so a result for another distribution can never complete it. + The wrapper's own ``package_source`` (missing treated as ``pypi``, the + legacy default) is likewise checked directly against the campaign's + expected source: the closed adoptionResult schema carries no source field + of its own to cross-check, so this is the one place that binding is + enforced. """ if not package_identity: return None @@ -895,6 +912,7 @@ def _extract_bound_adoption_result( or wrapper.get("provider") != provider or wrapper.get("release_tag") != release_tag or wrapper.get("idempotency_key") != idempotency_key + or str(wrapper.get("package_source") or DEFAULT_PACKAGE_SOURCE) != package_source ): continue adoption_result = wrapper.get("adoption_result") @@ -928,6 +946,7 @@ def _dispatch_github_comment( idempotency_key: str, *, starting_version: str = "", + package_source: str = DEFAULT_PACKAGE_SOURCE, trigger_comments: tuple[str, ...] = (), reconciliation_key: str = "", command_runner: lane_status.CommandRunner = lane_status.run_command, @@ -949,6 +968,7 @@ def _dispatch_github_comment( if qualification_context == "upgrade" and not starting_version: return False, {}, _safe_error("campaign_identity_incomplete") + _validate_package_source(package_source) dispatch_marker = { "schema": DISPATCH_SCHEMA, "campaign_id": campaign_id, @@ -956,6 +976,7 @@ def _dispatch_github_comment( "package_spec": package_spec, "provider": provider, "qualification_context": qualification_context, + "package_source": package_source, "idempotency_key": idempotency_key, } if starting_version: @@ -966,6 +987,19 @@ def _dispatch_github_comment( starting_version_line = ( f"- **Starting Version:** `{starting_version}`\n" if starting_version else "" ) + # The source line names only the closed identifier plus the fixed, + # canonical index URLs that identifier resolves to -- never an arbitrary + # or user-supplied URL -- so a remote runner installs from the right + # index without guessing. + package_source_line = ( + f"- **Package Source:** `{package_source}` (candidate index: `{TESTPYPI_INDEX_URL}`, " + f"dependency index: `{PRODUCTION_PYPI_INDEX_URL}`). Download the candidate with " + f"`--no-deps` from TestPyPI, verify its exact package identity and version, then install " + f"the verified local artifact with dependencies from production PyPI. Never combine " + f"the indexes with `--extra-index-url`.\n" + if package_source == "testpypi" + else f"- **Package Source:** `{package_source}`\n" + ) trigger_comments_line = "" if trigger_comments: formatted_triggers = ", ".join(f"`{tc}`" for tc in trigger_comments) @@ -985,11 +1019,12 @@ def _dispatch_github_comment( f"- **Provider:** `{provider}`\n" f"- **Context:** `{qualification_context}`\n" f"{starting_version_line}" + f"{package_source_line}" f"{trigger_comments_line}" f"- **Idempotency Key:** `{idempotency_key}`\n\n" f"Reply with a comment containing a `CODE_MOWER_ADOPTION_RESULT` " f"marker wrapping schema `{RESULT_MARKER_SCHEMA}` with matching " - f"campaign_id, provider, release_tag, and idempotency_key, plus an " + f"campaign_id, provider, release_tag, package_source, and idempotency_key, plus an " f"embedded `adoption_result`. The marker must be a single-line HTML " f"comment on a line of its own.{starting_version_requirement} " f"See docs/release-qualification.md.\n\n" @@ -1279,6 +1314,7 @@ def _build_adapter_argv( package_spec: str, qualification_context: str, starting_version: str, + package_source: str = DEFAULT_PACKAGE_SOURCE, output_path: Path, repo_path: Path, argv_template: Any, @@ -1293,6 +1329,7 @@ def _build_adapter_argv( "package_spec": package_spec, "qualification_context": qualification_context, "starting_version": starting_version, + "package_source": package_source, "output": str(output_path), "repo_path": str(repo_path), # The running interpreter running Code Mower @@ -1547,6 +1584,7 @@ def _invoke_local_adapter( package_spec: str, qualification_context: str, starting_version: str, + package_source: str = DEFAULT_PACKAGE_SOURCE, output_path: Path, repo_path: Path, which_fn: Callable[[str], str | None], @@ -1628,6 +1666,7 @@ def _invoke_local_adapter( package_spec=package_spec, qualification_context=qualification_context, starting_version=starting_version, + package_source=package_source, output_path=output_path, repo_path=repo_path, argv_template=argv_template, @@ -1682,6 +1721,7 @@ def initialize_campaign( package_spec: str = "", qualification_context: str = "cold_install", starting_version: str = "", + package_source: str = DEFAULT_PACKAGE_SOURCE, providers: Sequence[str] = (), repo_slug: str = "", campaign_id: str = "", @@ -1713,6 +1753,7 @@ def initialize_campaign( and _version_key(starting_version) >= _version_key(normalized_version) ): raise ValueError("starting_version must be lower than the target version") + _validate_package_source(package_source) if not campaign_id: campaign_id = f"campaign-{release_tag}" @@ -1748,7 +1789,12 @@ def initialize_campaign( campaign_providers: list[dict[str, Any]] = [] for canonical_name, lane in resolved_providers: idemp_key = _compute_idempotency_key( - campaign_id, canonical_name, release_tag, qualification_context, starting_version + campaign_id, + canonical_name, + release_tag, + qualification_context, + starting_version, + package_source, ) cp = CampaignProvider( provider=canonical_name, @@ -1778,6 +1824,7 @@ def initialize_campaign( normalized_version=normalized_version, qualification_context=qualification_context, starting_version=starting_version, + package_source=package_source, repo_slug=repo_slug, status=overall_status, dry_run=True, @@ -2061,6 +2108,7 @@ def dispatch_or_advance_campaign( package_identity = campaign_package_identity(package_spec) context = str(campaign.get("qualification_context") or "cold_install") starting_version = str(campaign.get("starting_version") or "") + package_source = str(campaign.get("package_source") or DEFAULT_PACKAGE_SOURCE) # A watch may need a repository solely to poll an older campaign that did # not persist one. Use that value for this invocation without assigning it # to the campaign, so observed provider transitions can be saved while the @@ -2184,6 +2232,7 @@ def dispatch_or_advance_campaign( qualification_context=context, starting_version=starting_version, package_identity=package_identity, + package_source=package_source, ) if found_result: prior_found = provider_data.get("adoption_result") @@ -2633,6 +2682,7 @@ def dispatch_or_advance_campaign( package_spec=package_spec, qualification_context=context, starting_version=starting_version, + package_source=package_source, output_path=result_path, repo_path=repo_path, which_fn=which_fn, @@ -2815,6 +2865,7 @@ def dispatch_or_advance_campaign( context, provider_data["idempotency_key"], starting_version=starting_version, + package_source=package_source, trigger_comments=trigger_comments, reconciliation_key=str( provider_data.get("dispatch_reconciliation_key") or "" @@ -3667,6 +3718,9 @@ def _watch_campaign_validation_error(campaign: Any) -> str: raise ValueError if context != "upgrade" and starting_version: raise ValueError + # Missing is a legacy campaign predating this field; it reads as the + # documented default rather than failing validation. + _validate_package_source(str(campaign.get("package_source") or DEFAULT_PACKAGE_SOURCE)) if not campaign_id: raise ValueError except (TypeError, ValueError): @@ -4925,6 +4979,7 @@ def _existing_campaign_conflict( package_spec: str, qualification_context: str, starting_version: str, + package_source: str = "", providers: Sequence[str], repo_slug: str = "", ) -> str: @@ -4962,6 +5017,13 @@ def _existing_campaign_conflict( f"--qualification-context {qualification_context!r} does not match existing " f"campaign context {stored_context!r}" ) + stored_source = str(campaign.get("package_source") or DEFAULT_PACKAGE_SOURCE) + if package_source and package_source != stored_source: + return ( + f"--package-source {package_source!r} does not match existing campaign " + f"source {stored_source!r}; an existing campaign's package source is " + "fixed once set" + ) if starting_version and starting_version != str(campaign.get("starting_version") or ""): return ( f"--starting-version {starting_version!r} does not match existing campaign " @@ -4998,6 +5060,7 @@ def campaign_command( providers: Sequence[str] = (), qualification_context: str = "", starting_version: str = "", + package_source: str = "", repo_path: Path | None = None, repo_slug: str = "", issue: str | int = "", @@ -5178,6 +5241,7 @@ def campaign_command( providers=providers, qualification_context=qualification_context, starting_version=starting_version, + package_source=package_source, repo_slug=repo_slug, issue=issue, apply=apply, @@ -5225,6 +5289,7 @@ def _campaign_command_impl( providers: Sequence[str] = (), qualification_context: str = "", starting_version: str = "", + package_source: str = "", repo_slug: str = "", issue: str | int = "", apply: bool = False, @@ -5436,6 +5501,7 @@ def _campaign_command_impl( package_spec=package_spec, qualification_context=qualification_context, starting_version=starting_version, + package_source=package_source, providers=providers, repo_slug=repo_slug, ) @@ -5509,6 +5575,9 @@ def _campaign_command_impl( # tell an omitted flag from an explicit `cold_install`. qualification_context=qualification_context or "cold_install", starting_version=starting_version, + # An omitted source creates a pypi campaign, the documented default; + # same "unspecified vs. explicit default" distinction as context. + package_source=package_source or DEFAULT_PACKAGE_SOURCE, providers=providers, repo_slug=repo_slug, campaign_id=campaign_id, diff --git a/src/code_mower/release_qualify.py b/src/code_mower/release_qualify.py index bc0963f6..b8c5f527 100644 --- a/src/code_mower/release_qualify.py +++ b/src/code_mower/release_qualify.py @@ -40,6 +40,18 @@ VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+(?:[ab]\d+|rc\d+)?$") RUNTIME_CLASS_PATTERN = re.compile(r"^python_\d+\.\d+$") VALID_CONTEXTS = {"cold_install", "upgrade", "unknown"} +# Closed package-index source contract: never an arbitrary or user-supplied +# URL, only this fixed vocabulary. "pypi" (default) uses pip's own default +# index. "testpypi" is a bounded candidate source for qualifying a release +# before it is announced or marked current on production PyPI. +VALID_PACKAGE_SOURCES = {"pypi", "testpypi"} +DEFAULT_PACKAGE_SOURCE = "pypi" +# The one canonical TestPyPI simple index, and the one canonical production +# PyPI simple index used only as a dependency-only extra index for a TestPyPI +# candidate (TestPyPI does not mirror every dependency). Fixed constants, not +# configurable: no credentials, and no other index is ever accepted. +TESTPYPI_INDEX_URL = "https://test.pypi.org/simple/" +PRODUCTION_PYPI_INDEX_URL = "https://pypi.org/simple/" VALID_STEP_STATUSES = {"pass", "fail", "warn", "unavailable", "planned"} VALID_EXECUTION_STATES = {"planned", "executed"} VALID_HOST_CLASSES = {"local", "ci", "github_actions", "unknown"} @@ -217,6 +229,51 @@ def _validate_qualification_context(value: str) -> None: raise ValueError(f"qualification_context must be one of: {', '.join(sorted(VALID_CONTEXTS))}") +def _validate_package_source(value: str) -> None: + """Validate package source is in the closed set (default: pypi).""" + if value not in VALID_PACKAGE_SOURCES: + raise ValueError(f"package_source must be one of: {', '.join(sorted(VALID_PACKAGE_SOURCES))}") + + +def _package_source_pip_index_args(package_source: str) -> tuple[str, tuple[str, ...]]: + """Return the closed ``(pip_index_url, pip_extra_index_urls)`` pair for a source. + + Used only for the upgrade preinstall spec (the already-published starting + version being upgraded from, not the candidate under qualification). + ``pypi`` uses pip's own production default with no override. ``testpypi`` + names production PyPI explicitly and never includes TestPyPI, proving the + baseline is the published production package. See + :func:`_package_source_candidate_index_args` for the release candidate + itself, which cannot use a combined index/extra-index install. + """ + _validate_package_source(package_source) + if package_source == "testpypi": + return PRODUCTION_PYPI_INDEX_URL, () + return "", () + + +def _package_source_candidate_index_args(package_source: str) -> tuple[str, str]: + """Return the closed ``(candidate_index_url, dependency_index_url)`` pair. + + Used for the exact release candidate under qualification -- the + package_spec this run's result is bound to and verified against. Pip does + not prioritize ``--index-url`` over ``--extra-index-url``: a single + install command naming both cannot prove which configured index actually + supplied the candidate, since an identical version on either index could + silently satisfy it. The closed two-stage install in + ``run_package_install_rehearsal`` instead uses ``candidate_index_url`` as + the *only* index for a ``--no-deps`` download, verifies the downloaded + artifact's identity and version, then installs that verified local + artifact with dependencies resolved from ``dependency_index_url``. + ``pypi`` (the default) returns two empty strings: the ordinary + single-stage install applies unchanged, with no index override at all. + """ + _validate_package_source(package_source) + if package_source == "testpypi": + return TESTPYPI_INDEX_URL, PRODUCTION_PYPI_INDEX_URL + return "", "" + + def _normalize_package_name(name: str) -> str: """Normalize a distribution name the way PEP 503 does. @@ -667,6 +724,7 @@ def run_release_qualification( provider: str = "local_cli", executor: str = "unknown", timeout: int = 180, + package_source: str = DEFAULT_PACKAGE_SOURCE, ) -> dict[str, Any]: """Run release qualification and emit adoption result.""" start_time = time.time() @@ -675,6 +733,7 @@ def run_release_qualification( _validate_safe_identifier(provider, "provider") _validate_safe_identifier(executor, "executor") _validate_starting_version(starting_version) + _validate_package_source(package_source) valid, normalized_version, error = _validate_tag_format(release_tag) if not valid: @@ -746,6 +805,10 @@ def run_release_qualification( ) else: rehearsal_start = time.time() + pip_index_url, pip_extra_index_urls = _package_source_pip_index_args(package_source) + candidate_index_url, candidate_dependency_index_url = ( + _package_source_candidate_index_args(package_source) + ) try: rehearsal_result = run_package_install_rehearsal( package_spec=package_spec, @@ -757,6 +820,10 @@ def run_release_qualification( repo_path=repo_path, timeout=timeout, allow_package_index=True, + pip_index_url=pip_index_url, + pip_extra_index_urls=pip_extra_index_urls, + candidate_index_url=candidate_index_url, + candidate_dependency_index_url=candidate_dependency_index_url, ) rehearsal_version_raw = rehearsal_result.get("version", "") rehearsal_version = _normalize_version(rehearsal_version_raw) @@ -903,6 +970,15 @@ def main(argv: Sequence[str] | None = None) -> int: default=180, help="Timeout in seconds", ) + qualify.add_argument( + "--package-source", + default=DEFAULT_PACKAGE_SOURCE, + help=( + "Closed package-index source: pypi (default) or testpypi. testpypi " + "installs from the canonical TestPyPI simple index, with production " + "PyPI as a dependency-only extra index." + ), + ) qualify.add_argument("--json", action="store_true") campaign = subparsers.add_parser( @@ -993,6 +1069,16 @@ def main(argv: Sequence[str] | None = None) -> int: default="", help="Starting version (required for upgrade qualification)", ) + campaign.add_argument( + "--package-source", + default="", + help=( + "Closed package-index source: pypi (default) or testpypi. Compared " + "against an existing campaign's stored source whenever it is " + "supplied, including an explicit pypi; omit it to advance a " + "campaign under whatever source it was created with." + ), + ) campaign.add_argument( "--repo-path", type=Path, @@ -1154,6 +1240,7 @@ def main(argv: Sequence[str] | None = None) -> int: provider=args.provider, executor=args.executor, timeout=args.timeout, + package_source=args.package_source, ) if args.json: print(json.dumps(result, indent=2, sort_keys=True)) @@ -1197,6 +1284,7 @@ def main(argv: Sequence[str] | None = None) -> int: providers=providers_list, qualification_context=args.qualification_context, starting_version=args.starting_version, + package_source=args.package_source, repo_path=args.repo_path, repo_slug=args.repo_slug, issue=args.issue, diff --git a/tests/test_campaign_adapters.py b/tests/test_campaign_adapters.py index d95ed030..65140a94 100644 --- a/tests/test_campaign_adapters.py +++ b/tests/test_campaign_adapters.py @@ -391,6 +391,144 @@ def test_muse_argv_uses_exec_with_prompt_file_and_workspace(self) -> None: self.assertIn("--no-session-log", argv) +class PackageSourceTests(unittest.TestCase): + """Local adapters receive the same closed `package_source` contract.""" + + def test_claude_argv_defaults_to_pypi_domains(self) -> None: + argv = campaign_adapters.build_claude_argv( + claude_bin="/bin/claude", + model="sonnet", + max_budget_usd="5.00", + schema_json="{}", + ) + settings = json.loads(argv[argv.index("--settings") + 1]) + self.assertEqual( + settings["sandbox"]["network"]["allowedDomains"], + ["pypi.org", "files.pythonhosted.org"], + ) + + def test_claude_argv_adds_testpypi_domains_for_testpypi_source(self) -> None: + argv = campaign_adapters.build_claude_argv( + claude_bin="/bin/claude", + model="sonnet", + max_budget_usd="5.00", + schema_json="{}", + package_source="testpypi", + ) + settings = json.loads(argv[argv.index("--settings") + 1]) + allowed = settings["sandbox"]["network"]["allowedDomains"] + self.assertIn("test.pypi.org", allowed) + self.assertIn("test-files.pythonhosted.org", allowed) + # Production PyPI stays allowed too: a TestPyPI candidate's + # dependencies still resolve from the production extra index. + self.assertIn("pypi.org", allowed) + self.assertIn("files.pythonhosted.org", allowed) + + def test_prompt_names_pip_default_index_for_pypi_source(self) -> None: + prompt = campaign_adapters.build_qualification_prompt( + provider="codex", + release_tag="v1.0.0", + package_spec="code-mower==1.0.0", + package_identity="code-mower", + normalized_version="1.0.0", + qualification_context="cold_install", + starting_version="", + ) + self.assertIn("- package_source: pypi", prompt) + self.assertNotIn("--index-url", prompt) + self.assertNotIn("test.pypi.org", prompt) + + def test_prompt_names_canonical_testpypi_index_for_testpypi_source(self) -> None: + prompt = campaign_adapters.build_qualification_prompt( + provider="codex", + release_tag="v1.0.0", + package_spec="code-mower==1.0.0", + package_identity="code-mower", + normalized_version="1.0.0", + qualification_context="cold_install", + starting_version="", + package_source="testpypi", + ) + self.assertIn("- package_source: testpypi", prompt) + self.assertIn("https://test.pypi.org/simple/", prompt) + self.assertIn("https://pypi.org/simple/", prompt) + self.assertIn("download --no-deps --no-cache-dir", prompt) + self.assertIn("exactly one wheel or source archive", prompt) + self.assertIn("candidate/", prompt) + self.assertNotIn("--extra-index-url \"https://pypi.org/simple/\"", prompt) + + def test_prompt_names_canonical_testpypi_index_for_upgrade_context(self) -> None: + prompt = campaign_adapters.build_qualification_prompt( + provider="codex", + release_tag="v1.0.1", + package_spec="code-mower==1.0.1", + package_identity="code-mower", + normalized_version="1.0.1", + qualification_context="upgrade", + starting_version="1.0.0", + package_source="testpypi", + ) + self.assertIn("https://test.pypi.org/simple/", prompt) + self.assertEqual(prompt.count("https://test.pypi.org/simple/"), 1) + self.assertIn("Install the starting version from production PyPI", prompt) + self.assertIn("code-mower==1.0.0", prompt) + self.assertIn("--extra-index-url ''", prompt) + self.assertIn("Do not provide a non-empty `--extra-index-url`", prompt) + + def test_check_campaign_identity_rejects_unknown_source(self) -> None: + with self.assertRaises(ValueError) as ctx: + campaign_adapters._check_campaign_identity( + release_tag="v1.0.0", + package_spec="code-mower==1.0.0", + qualification_context="cold_install", + starting_version="", + package_source="bogus", + ) + self.assertIn("package_source must be one of", str(ctx.exception)) + + def test_cli_accepts_package_source_flag(self) -> None: + parser = campaign_adapters.build_parser() + args = parser.parse_args( + [ + "--provider", + "codex", + "--provider-bin", + "/bin/codex", + "--release-tag", + "v1.0.0", + "--package-spec", + "code-mower==1.0.0", + "--qualification-context", + "cold_install", + "--output", + "/tmp/out.json", + "--package-source", + "testpypi", + ] + ) + self.assertEqual(args.package_source, "testpypi") + + def test_cli_defaults_package_source_to_pypi(self) -> None: + parser = campaign_adapters.build_parser() + args = parser.parse_args( + [ + "--provider", + "codex", + "--provider-bin", + "/bin/codex", + "--release-tag", + "v1.0.0", + "--package-spec", + "code-mower==1.0.0", + "--qualification-context", + "cold_install", + "--output", + "/tmp/out.json", + ] + ) + self.assertEqual(args.package_source, "pypi") + + class AdapterTransportTests(unittest.TestCase): """End-to-end adapter runs with a mocked provider subprocess.""" diff --git a/tests/test_migration_rehearsal.py b/tests/test_migration_rehearsal.py new file mode 100644 index 00000000..44dd6341 --- /dev/null +++ b/tests/test_migration_rehearsal.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python3 +"""Tests for the closed two-stage TestPyPI candidate install flow. + +Pip does not prioritize --index-url over --extra-index-url, so a single +combined install command cannot prove a candidate came exclusively from one +index -- an identical version on the other configured index could silently +satisfy it. These tests cover the command sequence that instead (1) +downloads the candidate with a single index and --no-deps, verifies exactly +one matching artifact came back, then (2) installs that local artifact with +dependencies resolved from a separate index. +""" + +from __future__ import annotations + +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from code_mower import migration_install +from code_mower import migration_rehearsal + + +class CandidateCommandBuilderTests(unittest.TestCase): + """Pure pip-command construction for the two-stage candidate flow.""" + + def test_download_command_names_only_the_candidate_index(self) -> None: + command = migration_install._pip_download_candidate_command( + Path("/venv/bin/python"), + "code-mower==1.0.0", + index_url="https://test.pypi.org/simple/", + dest_dir=Path("/tmp/candidate"), + ) + self.assertEqual( + command, + [ + "/venv/bin/python", + "-m", + "pip", + "--isolated", + "download", + "--no-deps", + "--index-url", + "https://test.pypi.org/simple/", + "--dest", + "/tmp/candidate", + "--extra-index-url", + "", + "--find-links", + "", + "code-mower==1.0.0", + ], + ) + # The explicit empty value overrides global and site pip config. + self.assertEqual(command[command.index("--extra-index-url") + 1], "") + + def test_candidate_environment_ignores_ambient_pip_indexes(self) -> None: + with mock.patch.dict( + migration_install.os.environ, + { + "PIP_INDEX_URL": "https://ambient.invalid/simple/", + "PIP_EXTRA_INDEX_URL": "https://extra.invalid/simple/", + "PATH": "/usr/bin", + }, + clear=True, + ): + env = migration_install._isolated_pip_environment() + self.assertNotIn("PIP_INDEX_URL", env) + self.assertNotIn("PIP_EXTRA_INDEX_URL", env) + self.assertEqual(env["PIP_CONFIG_FILE"], migration_install.os.devnull) + self.assertEqual(env["PATH"], "/usr/bin") + + def test_candidate_commands_override_global_and_site_source_lists(self) -> None: + download = migration_install._pip_download_candidate_command( + Path("/venv/bin/python"), + "code-mower==1.0.0", + index_url="https://test.pypi.org/simple/", + dest_dir=Path("/tmp/candidate"), + ) + install = migration_install._pip_install_local_artifact_command( + Path("/venv/bin/python"), + Path("/tmp/candidate/code_mower-1.0.0-py3-none-any.whl"), + dependency_index_url="https://pypi.org/simple/", + ) + for command in (download, install): + for flag in ("--extra-index-url", "--find-links"): + self.assertIn(flag, command) + self.assertEqual(command[command.index(flag) + 1], "") + + def test_download_command_honors_no_cache(self) -> None: + command = migration_install._pip_download_candidate_command( + Path("/venv/bin/python"), + "code-mower==1.0.0", + index_url="https://test.pypi.org/simple/", + dest_dir=Path("/tmp/candidate"), + pip_no_cache=True, + ) + self.assertIn("--no-cache-dir", command) + + def test_install_local_artifact_command_uses_a_path_not_a_spec(self) -> None: + command = migration_install._pip_install_local_artifact_command( + Path("/venv/bin/python"), + Path("/tmp/candidate/code_mower-1.0.0-py3-none-any.whl"), + dependency_index_url="https://pypi.org/simple/", + ) + self.assertEqual( + command, + [ + "/venv/bin/python", + "-m", + "pip", + "--isolated", + "install", + "--index-url", + "https://pypi.org/simple/", + "--extra-index-url", + "", + "--find-links", + "", + "/tmp/candidate/code_mower-1.0.0-py3-none-any.whl", + ], + ) + # The candidate's own identity is a local file, not a name==version + # spec subject to index resolution. + self.assertNotIn("code-mower==1.0.0", command) + self.assertIn("--isolated", command) + + def test_install_local_artifact_command_without_dependency_index_has_no_override( + self, + ) -> None: + command = migration_install._pip_install_local_artifact_command( + Path("/venv/bin/python"), + Path("/tmp/candidate/code_mower-1.0.0-py3-none-any.whl"), + ) + self.assertNotIn("--index-url", command) + + +class ParseExactNameVersionSpecTests(unittest.TestCase): + def test_parses_name_and_version(self) -> None: + self.assertEqual( + migration_install._parse_exact_name_version_spec("code-mower==1.0.0"), + ("code-mower", "1.0.0"), + ) + + def test_normalizes_pep503_identity(self) -> None: + identity, version = migration_install._parse_exact_name_version_spec( + "Code_Mower==1.0.0" + ) + self.assertEqual(identity, "code-mower") + self.assertEqual(version, "1.0.0") + + def test_rejects_version_range(self) -> None: + with self.assertRaises(ValueError): + migration_install._parse_exact_name_version_spec("code-mower>=1.0.0") + + def test_rejects_extras(self) -> None: + with self.assertRaises(ValueError): + migration_install._parse_exact_name_version_spec("code-mower[extra]==1.0.0") + + def test_rejects_local_path(self) -> None: + with self.assertRaises(ValueError): + migration_install._parse_exact_name_version_spec("./dist/code_mower-1.0.0.whl") + + +class ParseDownloadedArtifactIdentityTests(unittest.TestCase): + def test_parses_wheel_filename(self) -> None: + self.assertEqual( + migration_install._parse_downloaded_artifact_identity( + "code_mower-1.0.0-py3-none-any.whl" + ), + ("code-mower", "1.0.0"), + ) + + def test_parses_sdist_filename(self) -> None: + self.assertEqual( + migration_install._parse_downloaded_artifact_identity( + "code_mower-1.0.0.tar.gz" + ), + ("code-mower", "1.0.0"), + ) + + def test_parses_prerelease_wheel_version(self) -> None: + self.assertEqual( + migration_install._parse_downloaded_artifact_identity( + "code_mower-1.0.0rc1-py3-none-any.whl" + ), + ("code-mower", "1.0.0rc1"), + ) + + def test_rejects_unrecognized_filename(self) -> None: + with self.assertRaises(ValueError): + migration_install._parse_downloaded_artifact_identity("not-a-package-file.txt") + + def test_rejects_empty_filename(self) -> None: + with self.assertRaises(ValueError): + migration_install._parse_downloaded_artifact_identity("") + + +def _fake_run_rehearsal_step_factory(*, artifacts=None): + """Build a fake `_run_rehearsal_step` that writes `artifacts` on download.""" + + def _fake(command, *, cwd, env, steps, timeout): + steps.append( + { + "command": list(command), + "cwd": str(cwd), + "returncode": 0, + "stdout_preview": "", + "stderr_preview": "", + } + ) + if "download" in command and artifacts is not None: + dest = Path(command[command.index("--dest") + 1]) + for name in artifacts: + (dest / name).write_bytes(b"x") + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + return _fake + + +class TwoStageCandidateInstallTests(unittest.TestCase): + """`_run_two_stage_candidate_install`: command sequence and fail-closed cases.""" + + def _run(self, *, artifacts, work_dir: Path) -> list[dict]: + steps: list[dict] = [] + fake = _fake_run_rehearsal_step_factory(artifacts=artifacts) + with mock.patch.object(migration_install, "_run_rehearsal_step", side_effect=fake): + migration_rehearsal._run_two_stage_candidate_install( + venv_python=Path("/venv/bin/python"), + package_spec="code-mower==1.0.0", + candidate_index_url="https://test.pypi.org/simple/", + dependency_index_url="https://pypi.org/simple/", + candidate_dir=work_dir / "testpypi-candidate", + work_dir=work_dir, + steps=steps, + timeout=60, + attempts=1, + retry_delay_seconds=0, + pip_no_cache=False, + ) + return steps + + def test_success_runs_download_then_local_install_in_order(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + steps = self._run( + artifacts=["code_mower-1.0.0-py3-none-any.whl"], + work_dir=Path(tmp), + ) + self.assertEqual(len(steps), 2) + download_command = steps[0]["command"] + install_command = steps[1]["command"] + + self.assertIn("download", download_command) + self.assertIn("--index-url", download_command) + self.assertIn("https://test.pypi.org/simple/", download_command) + self.assertEqual(download_command[download_command.index("--extra-index-url") + 1], "") + self.assertIn("--no-deps", download_command) + + self.assertIn("install", install_command) + self.assertIn("--index-url", install_command) + self.assertIn("https://pypi.org/simple/", install_command) + self.assertEqual(install_command[install_command.index("--extra-index-url") + 1], "") + self.assertTrue(install_command[-1].endswith("code_mower-1.0.0-py3-none-any.whl")) + + def test_fails_closed_on_zero_artifacts(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(migration_rehearsal.RehearsalError) as ctx: + self._run(artifacts=[], work_dir=Path(tmp)) + self.assertIn("no candidate artifact", str(ctx.exception)) + + def test_fails_closed_on_multiple_artifacts(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(migration_rehearsal.RehearsalError) as ctx: + self._run( + artifacts=[ + "code_mower-1.0.0-py3-none-any.whl", + "code_mower-1.0.0.tar.gz", + ], + work_dir=Path(tmp), + ) + self.assertIn("exactly one candidate artifact", str(ctx.exception)) + + def test_fails_closed_on_malformed_artifact_filename(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(migration_rehearsal.RehearsalError) as ctx: + self._run(artifacts=["not-a-package-file.txt"], work_dir=Path(tmp)) + self.assertIn("unrecognized filename", str(ctx.exception)) + + def test_fails_closed_on_version_mismatch(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(migration_rehearsal.RehearsalError) as ctx: + self._run( + artifacts=["code_mower-9.9.9-py3-none-any.whl"], + work_dir=Path(tmp), + ) + self.assertIn("does not match the requested", str(ctx.exception)) + + def test_fails_closed_on_identity_mismatch(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(migration_rehearsal.RehearsalError) as ctx: + self._run( + artifacts=["other-package-1.0.0-py3-none-any.whl"], + work_dir=Path(tmp), + ) + self.assertIn("does not match the requested", str(ctx.exception)) + + def test_rejects_non_exact_package_spec_before_downloading(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + work_dir = Path(tmp) + steps: list[dict] = [] + fake = _fake_run_rehearsal_step_factory(artifacts=None) + with mock.patch.object(migration_install, "_run_rehearsal_step", side_effect=fake): + with self.assertRaises(ValueError): + migration_rehearsal._run_two_stage_candidate_install( + venv_python=Path("/venv/bin/python"), + package_spec="code-mower>=1.0.0", + candidate_index_url="https://test.pypi.org/simple/", + dependency_index_url="https://pypi.org/simple/", + candidate_dir=work_dir / "testpypi-candidate", + work_dir=work_dir, + steps=steps, + timeout=60, + attempts=1, + retry_delay_seconds=0, + pip_no_cache=False, + ) + # Fails before any command runs: no download of an inexact spec. + self.assertEqual(steps, []) + + +class RunPackageInstallRehearsalCandidateWiringTests(unittest.TestCase): + """`run_package_install_rehearsal` routes to the two-stage flow only when asked.""" + + def test_candidate_index_url_routes_to_two_stage_install(self) -> None: + with mock.patch.object( + migration_rehearsal, "_run_two_stage_candidate_install" + ) as mock_two_stage: + with mock.patch.object(migration_rehearsal, "_run_rehearsal_step") as mock_step: + mock_step.return_value.stdout = "code-mower 1.0.0" + with mock.patch.object( + migration_rehearsal, "_write_public_rehearsal_toy_repo" + ): + with mock.patch.object( + migration_rehearsal, "_first_user_readiness_scorecard" + ) as mock_readiness: + mock_readiness.return_value = {"status": "pass"} + with tempfile.TemporaryDirectory() as tmp: + try: + migration_rehearsal.run_package_install_rehearsal( + package_spec="code-mower==1.0.0", + work_dir=Path(tmp) / "work", + allow_package_index=True, + candidate_index_url="https://test.pypi.org/simple/", + candidate_dependency_index_url="https://pypi.org/simple/", + ) + except Exception: + # The rest of the rehearsal (toy-repo steps, CLI + # invocations) is out of scope for this test -- + # only the routing to the two-stage install matters. + pass + mock_two_stage.assert_called_once() + self.assertEqual( + mock_two_stage.call_args.kwargs["candidate_index_url"], + "https://test.pypi.org/simple/", + ) + self.assertEqual( + mock_two_stage.call_args.kwargs["dependency_index_url"], + "https://pypi.org/simple/", + ) + + def test_no_candidate_index_url_does_not_use_two_stage_install(self) -> None: + with mock.patch.object( + migration_rehearsal, "_run_two_stage_candidate_install" + ) as mock_two_stage: + with mock.patch.object(migration_rehearsal, "_run_rehearsal_step") as mock_step: + mock_step.return_value.stdout = "code-mower 1.0.0" + with mock.patch.object( + migration_rehearsal, "_write_public_rehearsal_toy_repo" + ): + with mock.patch.object( + migration_rehearsal, "_first_user_readiness_scorecard" + ) as mock_readiness: + mock_readiness.return_value = {"status": "pass"} + with tempfile.TemporaryDirectory() as tmp: + try: + migration_rehearsal.run_package_install_rehearsal( + package_spec="code-mower==1.0.0", + work_dir=Path(tmp) / "work", + allow_package_index=True, + ) + except Exception: + pass + mock_two_stage.assert_not_called() + + def test_candidate_index_url_requires_allow_package_index(self) -> None: + with self.assertRaises(ValueError): + migration_rehearsal.run_package_install_rehearsal( + package_spec="code-mower==1.0.0", + candidate_index_url="https://test.pypi.org/simple/", + allow_package_index=False, + ) + + def test_candidate_index_url_requires_exact_package_index_spec(self) -> None: + with self.assertRaises(ValueError): + migration_rehearsal.run_package_install_rehearsal( + package_spec="./local/path", + candidate_index_url="https://test.pypi.org/simple/", + allow_package_index=True, + ) + + def test_work_dir_must_be_clean_of_candidate_dir(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + work_dir = Path(tmp) / "work" + (work_dir / "testpypi-candidate").mkdir(parents=True) + with self.assertRaises(ValueError) as ctx: + migration_rehearsal.run_package_install_rehearsal( + package_spec="code-mower==1.0.0", + work_dir=work_dir, + allow_package_index=True, + candidate_index_url="https://test.pypi.org/simple/", + ) + self.assertIn("not clean", str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_campaigns.py b/tests/test_release_campaigns.py index 2122ebe9..f0871aba 100644 --- a/tests/test_release_campaigns.py +++ b/tests/test_release_campaigns.py @@ -5012,6 +5012,537 @@ def test_cli_preserves_the_unspecified_context_through_to_dispatch(self) -> None self.assertEqual(after, before) +def _capturing_dispatch_body_runner(bodies: list[str], *, returncode: int = 0): + """A gh command runner that records the dispatch comment body text. + + Reads the ``--body-file`` argument's file content before returning, since + the caller deletes that temporary file once the (mocked) post completes. + """ + + def _run(args, **kwargs): + argv = list(args) + if "--body-file" in argv: + body_path = Path(argv[argv.index("--body-file") + 1]) + bodies.append(body_path.read_text(encoding="utf-8")) + else: + bodies.append("") + + class MockCompleted: + pass + + completed = MockCompleted() + completed.returncode = returncode + completed.stdout = "" + completed.stderr = "" + return completed + + return _run + + +class CampaignPackageSourceSupplyTests(unittest.TestCase): + """`--package-source` follows the same "supplied vs. unspecified" contract as context. + + `pypi` is both the creation default and a source a caller can explicitly + request, so an omitted flag asserts nothing about a stored campaign while + an explicit value -- including an explicit `pypi` -- is compared against + the stored source and rejected on mismatch, exactly like + `--qualification-context`. + """ + + _ENV = {"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"} + + def _create_testpypi(self, campaigns_dir: Path) -> dict[str, Any]: + ret = release_campaigns.campaign_command( + action="create", + release_tag="v1.0.0", + package_spec="code-mower==1.0.0", + providers=["cursor_bugbot"], + package_source="testpypi", + campaigns_dir=campaigns_dir, + repo_slug="owner/repo", + apply=False, + command_runner=mock.MagicMock(), + env=self._ENV, + ) + self.assertEqual(ret, 0) + created = release_campaigns.load_campaign_by_id("campaign-v1.0.0", campaigns_dir) + assert created is not None + self.assertEqual(created["package_source"], "testpypi") + return created + + def test_omitted_source_creates_pypi_campaign(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + campaigns_dir = Path(tmp) / "campaigns" + ret = release_campaigns.campaign_command( + action="create", + release_tag="v1.0.0", + package_spec="code-mower==1.0.0", + providers=["cursor_bugbot"], + campaigns_dir=campaigns_dir, + apply=False, + command_runner=mock.MagicMock(), + env=self._ENV, + ) + self.assertEqual(ret, 0) + created = release_campaigns.load_campaign_by_id("campaign-v1.0.0", campaigns_dir) + assert created is not None + self.assertEqual(created["package_source"], "pypi") + + def test_explicit_testpypi_creates_testpypi_campaign(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + self._create_testpypi(Path(tmp) / "campaigns") + + def test_omitted_source_advances_existing_testpypi_campaign(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + campaigns_dir = Path(tmp) / "campaigns" + self._create_testpypi(campaigns_dir) + calls: list[list[str]] = [] + + def mock_gh_json(args, **kwargs): + return {"comments": []}, "" + + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + ret = release_campaigns.campaign_command( + action="dispatch", + release_tag="v1.0.0", + campaigns_dir=campaigns_dir, + issue="42", + apply=True, + command_runner=_capturing_dispatch_argv_runner(calls), + gh_json_runner=mock_gh_json, + env=self._ENV, + ) + + self.assertEqual(ret, 0) + self.assertNotIn("Traceback", stderr.getvalue()) + advanced = release_campaigns.load_campaign_by_id("campaign-v1.0.0", campaigns_dir) + assert advanced is not None + self.assertEqual(advanced["package_source"], "testpypi") + self.assertEqual(advanced["providers"][0]["state"], "running") + + def test_explicit_matching_source_advances(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + campaigns_dir = Path(tmp) / "campaigns" + self._create_testpypi(campaigns_dir) + calls: list[list[str]] = [] + + def mock_gh_json(args, **kwargs): + return {"comments": []}, "" + + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + ret = release_campaigns.campaign_command( + action="dispatch", + release_tag="v1.0.0", + package_source="testpypi", + campaigns_dir=campaigns_dir, + issue="42", + apply=True, + command_runner=_capturing_dispatch_argv_runner(calls), + gh_json_runner=mock_gh_json, + env=self._ENV, + ) + + self.assertEqual(ret, 0) + self.assertNotIn("Traceback", stderr.getvalue()) + + def test_explicit_pypi_against_testpypi_campaign_is_rejected(self) -> None: + """Resuming a campaign with a different source is an identity mismatch.""" + with tempfile.TemporaryDirectory() as tmp: + campaigns_dir = Path(tmp) / "campaigns" + before = self._create_testpypi(campaigns_dir) + calls: list[list[str]] = [] + + def unexpected_gh_json(args, **kwargs): + raise AssertionError("polled GitHub despite a conflicting source") + + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + ret = release_campaigns.campaign_command( + action="dispatch", + release_tag="v1.0.0", + package_source="pypi", + campaigns_dir=campaigns_dir, + issue="42", + apply=True, + command_runner=_capturing_dispatch_argv_runner(calls), + gh_json_runner=unexpected_gh_json, + env=self._ENV, + ) + + self.assertEqual(ret, 1) + self.assertEqual(calls, []) + self.assertIn("--package-source 'pypi'", stderr.getvalue()) + self.assertIn("'testpypi'", stderr.getvalue()) + self.assertNotIn("Traceback", stderr.getvalue()) + after = release_campaigns.load_campaign_by_id("campaign-v1.0.0", campaigns_dir) + self.assertEqual(after, before) + + def test_cli_preserves_the_unspecified_source_through_to_dispatch(self) -> None: + """The CLI parser has no default of its own, so omission stays visible.""" + with tempfile.TemporaryDirectory() as tmp: + campaigns_dir = Path(tmp) / "campaigns" + self._create_testpypi(campaigns_dir) + + stdout, stderr = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + omitted = release_qualify.main( + [ + "campaign", + "resume", + "--release-tag", + "v1.0.0", + "--campaigns-dir", + str(campaigns_dir), + ] + ) + + self.assertEqual(omitted, 0) + self.assertNotIn("Traceback", stderr.getvalue()) + before = release_campaigns.load_campaign_by_id("campaign-v1.0.0", campaigns_dir) + assert before is not None + self.assertEqual(before["package_source"], "testpypi") + + stdout, stderr = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + conflicting = release_qualify.main( + [ + "campaign", + "resume", + "--release-tag", + "v1.0.0", + "--campaigns-dir", + str(campaigns_dir), + "--package-source", + "pypi", + ] + ) + + self.assertEqual(conflicting, 1) + self.assertIn("--package-source 'pypi'", stderr.getvalue()) + self.assertNotIn("Traceback", stderr.getvalue()) + after = release_campaigns.load_campaign_by_id("campaign-v1.0.0", campaigns_dir) + self.assertEqual(after, before) + + +class PackageSourceBackwardCompatibilityTests(unittest.TestCase): + """A campaign stored before this field existed reads back as `pypi`.""" + + def test_legacy_campaign_missing_the_field_is_read_as_pypi(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + campaigns_dir = Path(tmp) / "campaigns" + campaigns_dir.mkdir(parents=True) + legacy = release_campaigns.initialize_campaign( + release_tag="v1.0.0", + package_spec="code-mower==1.0.0", + providers=["cursor_bugbot"], + repo_slug="owner/repo", + ).to_dict() + del legacy["package_source"] + release_campaigns.save_campaign(legacy, campaigns_dir) + + loaded = release_campaigns.load_campaign_by_id("campaign-v1.0.0", campaigns_dir) + assert loaded is not None + self.assertNotIn("package_source", loaded) + + # Watch validation, which runs on stored data before polling, + # treats the missing field as the documented default rather than + # refusing the whole campaign. + self.assertEqual(release_campaigns._watch_campaign_validation_error(loaded), "") + + # A later explicit --package-source pypi is not a conflict against + # a legacy campaign that predates the field. + conflict = release_campaigns._existing_campaign_conflict( + loaded, + package_spec="", + qualification_context="", + starting_version="", + package_source="pypi", + providers=(), + ) + self.assertEqual(conflict, "") + + # But an explicit testpypi does conflict with the implied pypi default. + conflict = release_campaigns._existing_campaign_conflict( + loaded, + package_spec="", + qualification_context="", + starting_version="", + package_source="testpypi", + providers=(), + ) + self.assertIn("does not match existing campaign source 'pypi'", conflict) + + def test_idempotency_key_computation_defaults_source_to_pypi(self) -> None: + with_default = release_campaigns._compute_idempotency_key( + "campaign-v1.0.0", "claude", "v1.0.0", "cold_install", "" + ) + with_explicit_pypi = release_campaigns._compute_idempotency_key( + "campaign-v1.0.0", "claude", "v1.0.0", "cold_install", "", "pypi" + ) + self.assertEqual(with_default, with_explicit_pypi) + + +class PackageSourceIdempotencyTests(unittest.TestCase): + """Two otherwise-identical dispatches that differ only by source must get different keys.""" + + def test_idempotency_key_binds_package_source(self) -> None: + key_pypi = release_campaigns._compute_idempotency_key( + "campaign-v1.0.0", "claude", "v1.0.0", "cold_install", "", "pypi" + ) + key_testpypi = release_campaigns._compute_idempotency_key( + "campaign-v1.0.0", "claude", "v1.0.0", "cold_install", "", "testpypi" + ) + self.assertNotEqual(key_pypi, key_testpypi) + + +class LocalAdapterPackageSourceCommandConstructionTests(unittest.TestCase): + """Local adapter argv construction receives the closed `package_source` contract.""" + + def test_build_adapter_argv_substitutes_package_source(self) -> None: + lane = ProviderLane( + lane_id="claude_audit", + lane_type="audit", + driver="local_cli", + provider="claude", + labels=LaneLabels(needs="x", done="y", blocked="z"), + ) + argv = release_campaigns._build_adapter_argv( + lane, + "/usr/bin/claude", + release_tag="v1.0.0", + package_spec="code-mower==1.0.0", + qualification_context="cold_install", + starting_version="", + package_source="testpypi", + output_path=Path("/tmp/out.json"), + repo_path=Path("/tmp/repo"), + argv_template=( + "{command}", + "qualify", + "--package-spec", + "{package_spec}", + "--package-source", + "{package_source}", + ), + ) + self.assertEqual( + argv, + [ + "/usr/bin/claude", + "qualify", + "--package-spec", + "code-mower==1.0.0", + "--package-source", + "testpypi", + ], + ) + + def test_build_adapter_argv_defaults_package_source_to_pypi(self) -> None: + lane = ProviderLane( + lane_id="claude_audit", + lane_type="audit", + driver="local_cli", + provider="claude", + labels=LaneLabels(needs="x", done="y", blocked="z"), + ) + argv = release_campaigns._build_adapter_argv( + lane, + "/usr/bin/claude", + release_tag="v1.0.0", + package_spec="code-mower==1.0.0", + qualification_context="cold_install", + starting_version="", + output_path=Path("/tmp/out.json"), + repo_path=Path("/tmp/repo"), + argv_template=("{command}", "--package-source", "{package_source}"), + ) + self.assertEqual(argv, ["/usr/bin/claude", "--package-source", "pypi"]) + + def test_maintained_adapter_argv_template_includes_package_source(self) -> None: + from code_mower.provider_registry import REFERENCE_PROVIDERS + + template = REFERENCE_PROVIDERS["codex"].provider_config["campaign_adapter_argv"] + self.assertIn("--package-source", template) + self.assertEqual( + template[template.index("--package-source") + 1], "{package_source}" + ) + + +class HostedDispatchPackageSourceTests(unittest.TestCase): + """Hosted dispatch instructions receive the same closed source contract.""" + + _ENV = {"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"} + + def test_pypi_dispatch_marker_and_body_name_the_default_source(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + campaigns_dir = Path(tmp) / "campaigns" + ret = release_campaigns.campaign_command( + action="create", + release_tag="v1.0.0", + package_spec="code-mower==1.0.0", + providers=["cursor_bugbot"], + campaigns_dir=campaigns_dir, + repo_slug="owner/repo", + apply=False, + command_runner=mock.MagicMock(), + env=self._ENV, + ) + self.assertEqual(ret, 0) + bodies: list[str] = [] + + def mock_gh_json(args, **kwargs): + return {"comments": []}, "" + + ret = release_campaigns.campaign_command( + action="dispatch", + release_tag="v1.0.0", + campaigns_dir=campaigns_dir, + issue="42", + apply=True, + command_runner=_capturing_dispatch_body_runner(bodies), + gh_json_runner=mock_gh_json, + env=self._ENV, + ) + self.assertEqual(ret, 0) + dispatch_body = bodies[0] + self.assertIn("Package Source:", dispatch_body) + self.assertIn("`pypi`", dispatch_body) + marker_match = re.search( + r"CODE_MOWER_RELEASE_CAMPAIGN: (\{.*\}) -->", dispatch_body + ) + assert marker_match is not None + marker = json.loads(marker_match.group(1)) + self.assertEqual(marker["package_source"], "pypi") + + def test_testpypi_dispatch_marker_and_body_name_the_canonical_index_urls(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + campaigns_dir = Path(tmp) / "campaigns" + ret = release_campaigns.campaign_command( + action="create", + release_tag="v1.0.0", + package_spec="code-mower==1.0.0", + providers=["cursor_bugbot"], + package_source="testpypi", + campaigns_dir=campaigns_dir, + repo_slug="owner/repo", + apply=False, + command_runner=mock.MagicMock(), + env=self._ENV, + ) + self.assertEqual(ret, 0) + bodies: list[str] = [] + + def mock_gh_json(args, **kwargs): + return {"comments": []}, "" + + ret = release_campaigns.campaign_command( + action="dispatch", + release_tag="v1.0.0", + campaigns_dir=campaigns_dir, + issue="42", + apply=True, + command_runner=_capturing_dispatch_body_runner(bodies), + gh_json_runner=mock_gh_json, + env=self._ENV, + ) + self.assertEqual(ret, 0) + dispatch_body = bodies[0] + self.assertIn("`testpypi`", dispatch_body) + self.assertIn("https://test.pypi.org/simple/", dispatch_body) + self.assertIn("https://pypi.org/simple/", dispatch_body) + self.assertIn("Download the candidate with `--no-deps`", dispatch_body) + self.assertIn("verified local artifact", dispatch_body) + self.assertIn("Never combine the indexes", dispatch_body) + self.assertIn( + "campaign_id, provider, release_tag, package_source, and idempotency_key", + dispatch_body, + ) + marker_match = re.search( + r"CODE_MOWER_RELEASE_CAMPAIGN: (\{.*\}) -->", dispatch_body + ) + assert marker_match is not None + marker = json.loads(marker_match.group(1)) + self.assertEqual(marker["package_source"], "testpypi") + # Never an arbitrary or credential-bearing URL: only the two fixed + # canonical index URLs ever appear. + urls = re.findall(r"https?://\S+", dispatch_body) + self.assertTrue( + all( + u.rstrip("`,).") in { + "https://test.pypi.org/simple/", + "https://pypi.org/simple/", + } + for u in urls + ) + ) + + +class ExtractBoundAdoptionResultPackageSourceTests(unittest.TestCase): + """A hosted-comment result is bound to the campaign's own package source.""" + + def _wrapper_text(self, *, package_source: str, include_field: bool = True) -> str: + wrapper = { + "schema": release_campaigns.RESULT_MARKER_SCHEMA, + "campaign_id": "campaign-v1.0.0", + "provider": "cursor_bugbot", + "release_tag": "v1.0.0", + "idempotency_key": "deadbeefcafef00d", + "adoption_result": _mock_adoption_result( + release_tag="v1.0.0", provider="cursor_bugbot" + ), + } + if include_field: + wrapper["package_source"] = package_source + return f"" + + def test_matching_source_is_accepted(self) -> None: + text = self._wrapper_text(package_source="testpypi") + result = release_campaigns._extract_bound_adoption_result( + text, + campaign_id="campaign-v1.0.0", + provider="cursor_bugbot", + release_tag="v1.0.0", + idempotency_key="deadbeefcafef00d", + qualification_context="cold_install", + starting_version="", + package_identity="code-mower", + package_source="testpypi", + ) + self.assertIsNotNone(result) + + def test_mismatching_source_is_rejected(self) -> None: + text = self._wrapper_text(package_source="pypi") + result = release_campaigns._extract_bound_adoption_result( + text, + campaign_id="campaign-v1.0.0", + provider="cursor_bugbot", + release_tag="v1.0.0", + idempotency_key="deadbeefcafef00d", + qualification_context="cold_install", + starting_version="", + package_identity="code-mower", + package_source="testpypi", + ) + self.assertIsNone(result) + + def test_missing_source_field_defaults_to_pypi(self) -> None: + text = self._wrapper_text(package_source="pypi", include_field=False) + result = release_campaigns._extract_bound_adoption_result( + text, + campaign_id="campaign-v1.0.0", + provider="cursor_bugbot", + release_tag="v1.0.0", + idempotency_key="deadbeefcafef00d", + qualification_context="cold_install", + starting_version="", + package_identity="code-mower", + package_source="pypi", + ) + self.assertIsNotNone(result) + + class CampaignIdContractTests(unittest.TestCase): """Campaign ids are storage keys: the id-to-filename mapping is one-to-one. diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py index b38f4cbd..089420fd 100644 --- a/tests/test_release_hygiene.py +++ b/tests/test_release_hygiene.py @@ -7501,20 +7501,22 @@ def test_package_install_rehearsal_supports_index_aware_pip_install(self) -> Non code_mower_migration._pip_install_command( Path("/tmp/venv/bin/python"), "code-mower==0.6.0b3", - pip_index_url="https://test.pypi.org/simple/", - pip_extra_index_urls=["https://pypi.org/simple/"], + pip_index_url="https://pypi.org/simple/", pip_no_cache=True, ), [ "/tmp/venv/bin/python", "-m", "pip", + "--isolated", "install", "--no-cache-dir", "--index-url", - "https://test.pypi.org/simple/", - "--extra-index-url", "https://pypi.org/simple/", + "--extra-index-url", + "", + "--find-links", + "", "code-mower==0.6.0b3", ], ) diff --git a/tests/test_release_qualify.py b/tests/test_release_qualify.py index 3e50f48a..e8d0e004 100644 --- a/tests/test_release_qualify.py +++ b/tests/test_release_qualify.py @@ -896,5 +896,225 @@ def test_validate_adoption_result_rejects_unknown_runtime_across_result_types(se release_qualify.validate_adoption_result_payload(custom_valid) +class PackageSourceTests(unittest.TestCase): + """Closed `package_source` vocabulary: parsing, index construction, defaults.""" + + def test_default_source_is_pypi(self) -> None: + self.assertEqual(release_qualify.DEFAULT_PACKAGE_SOURCE, "pypi") + + def test_valid_sources_are_closed(self) -> None: + self.assertEqual(release_qualify.VALID_PACKAGE_SOURCES, {"pypi", "testpypi"}) + + def test_validate_rejects_anything_outside_the_closed_set(self) -> None: + with self.assertRaises(ValueError) as ctx: + release_qualify._validate_package_source("https://example.invalid/simple") + self.assertIn("package_source must be one of", str(ctx.exception)) + # An attempted arbitrary URL never survives into the error message. + self.assertNotIn("example.invalid", str(ctx.exception)) + + def test_validate_accepts_pypi_and_testpypi(self) -> None: + release_qualify._validate_package_source("pypi") + release_qualify._validate_package_source("testpypi") + + def test_pypi_uses_no_index_override(self) -> None: + index_url, extra_urls = release_qualify._package_source_pip_index_args("pypi") + self.assertEqual(index_url, "") + self.assertEqual(extra_urls, ()) + + def test_testpypi_upgrade_baseline_uses_only_production_pypi(self) -> None: + index_url, extra_urls = release_qualify._package_source_pip_index_args("testpypi") + self.assertEqual(index_url, "https://pypi.org/simple/") + self.assertEqual(extra_urls, ()) + + def test_package_source_pip_index_args_rejects_unknown_source(self) -> None: + with self.assertRaises(ValueError): + release_qualify._package_source_pip_index_args("bogus") + + def test_pypi_candidate_index_args_have_no_override(self) -> None: + candidate_index_url, dependency_index_url = ( + release_qualify._package_source_candidate_index_args("pypi") + ) + self.assertEqual(candidate_index_url, "") + self.assertEqual(dependency_index_url, "") + + def test_testpypi_candidate_index_args_are_the_canonical_pair(self) -> None: + candidate_index_url, dependency_index_url = ( + release_qualify._package_source_candidate_index_args("testpypi") + ) + self.assertEqual(candidate_index_url, "https://test.pypi.org/simple/") + self.assertEqual(dependency_index_url, "https://pypi.org/simple/") + + def test_package_source_candidate_index_args_rejects_unknown_source(self) -> None: + with self.assertRaises(ValueError): + release_qualify._package_source_candidate_index_args("bogus") + + def test_run_release_qualification_rejects_unknown_source(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(ValueError) as ctx: + release_qualify.run_release_qualification( + release_tag="v1.0.0", + package_spec="code-mower==1.0.0", + output_path=Path(tmp) / "result.json", + repo_path=Path(tmp), + dry_run=True, + package_source="bogus", + ) + self.assertIn("package_source must be one of", str(ctx.exception)) + + def test_execute_with_pypi_source_passes_no_index_override(self) -> None: + """The default source builds a pip command with no index override at all.""" + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / "result.json" + with mock.patch("code_mower.release_qualify._run_doctor_check") as mock_doctor: + with mock.patch( + "code_mower.release_qualify.run_package_install_rehearsal" + ) as mock_rehearsal: + mock_doctor.return_value = release_qualify.StepResult( + id="doctor", status="pass", elapsed_seconds=1.0, + warning_count=0, owner_action_count=0 + ) + mock_rehearsal.return_value = {"version": "code-mower 1.0.0"} + + release_qualify.run_release_qualification( + release_tag="v1.0.0", + package_spec="code-mower==1.0.0", + output_path=output_path, + dry_run=False, + repo_path=Path(tmpdir), + ) + + self.assertEqual(mock_rehearsal.call_args.kwargs["pip_index_url"], "") + self.assertEqual(mock_rehearsal.call_args.kwargs["pip_extra_index_urls"], ()) + # The default source never triggers the closed two-stage candidate + # flow: no index override at all applies to the candidate either. + self.assertEqual(mock_rehearsal.call_args.kwargs["candidate_index_url"], "") + self.assertEqual( + mock_rehearsal.call_args.kwargs["candidate_dependency_index_url"], "" + ) + + def test_testpypi_upgrade_separates_baseline_candidate_and_dependencies(self) -> None: + """A TestPyPI upgrade uses one exclusive source for each package role.""" + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / "result.json" + with mock.patch("code_mower.release_qualify._run_doctor_check") as mock_doctor: + with mock.patch( + "code_mower.release_qualify.run_package_install_rehearsal" + ) as mock_rehearsal: + mock_doctor.return_value = release_qualify.StepResult( + id="doctor", status="pass", elapsed_seconds=1.0, + warning_count=0, owner_action_count=0 + ) + mock_rehearsal.return_value = { + "preinstall_version": "code-mower 1.0.0", + "version": "code-mower 1.0.1", + } + + result = release_qualify.run_release_qualification( + release_tag="v1.0.1", + package_spec="code-mower==1.0.1", + output_path=output_path, + dry_run=False, + repo_path=Path(tmpdir), + qualification_context="upgrade", + starting_version="1.0.0", + package_source="testpypi", + ) + + self.assertEqual( + mock_rehearsal.call_args.kwargs["preinstall_package_spec"], + "code-mower==1.0.0", + ) + self.assertEqual( + mock_rehearsal.call_args.kwargs["pip_index_url"], + "https://pypi.org/simple/", + ) + self.assertEqual( + mock_rehearsal.call_args.kwargs["pip_extra_index_urls"], + (), + ) + # allow_package_index stays on, so the existing bounded pip-install + # retry behavior (see migration_rehearsal) applies unchanged. + self.assertTrue(mock_rehearsal.call_args.kwargs["allow_package_index"]) + # The candidate itself goes through the closed two-stage flow: + # TestPyPI as the only candidate index, production PyPI only for + # the local artifact's own dependencies. + self.assertEqual( + mock_rehearsal.call_args.kwargs["candidate_index_url"], + "https://test.pypi.org/simple/", + ) + self.assertEqual( + mock_rehearsal.call_args.kwargs["candidate_dependency_index_url"], + "https://pypi.org/simple/", + ) + self.assertEqual(result["outcome"], "pass") + + def test_cli_qualify_accepts_package_source_flag(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / "result.json" + with mock.patch("code_mower.release_qualify._run_doctor_check") as mock_doctor: + with mock.patch( + "code_mower.release_qualify.run_package_install_rehearsal" + ) as mock_rehearsal: + mock_doctor.return_value = release_qualify.StepResult( + id="doctor", status="pass", elapsed_seconds=1.0, + warning_count=0, owner_action_count=0 + ) + mock_rehearsal.return_value = {"version": "code-mower 1.0.0"} + ret = release_qualify.main( + [ + "qualify", + "--release-tag", + "v1.0.0", + "--package-spec", + "code-mower==1.0.0", + "--output", + str(output_path), + "--repo-path", + tmpdir, + "--execute", + "--package-source", + "testpypi", + ] + ) + self.assertEqual(ret, 0) + self.assertEqual( + mock_rehearsal.call_args.kwargs["pip_index_url"], + "https://pypi.org/simple/", + ) + self.assertEqual( + mock_rehearsal.call_args.kwargs["candidate_index_url"], + "https://test.pypi.org/simple/", + ) + + def test_cli_qualify_defaults_package_source_to_pypi(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / "result.json" + with mock.patch("code_mower.release_qualify._run_doctor_check") as mock_doctor: + with mock.patch( + "code_mower.release_qualify.run_package_install_rehearsal" + ) as mock_rehearsal: + mock_doctor.return_value = release_qualify.StepResult( + id="doctor", status="pass", elapsed_seconds=1.0, + warning_count=0, owner_action_count=0 + ) + mock_rehearsal.return_value = {"version": "code-mower 1.0.0"} + ret = release_qualify.main( + [ + "qualify", + "--release-tag", + "v1.0.0", + "--package-spec", + "code-mower==1.0.0", + "--output", + str(output_path), + "--repo-path", + tmpdir, + "--execute", + ] + ) + self.assertEqual(ret, 0) + self.assertEqual(mock_rehearsal.call_args.kwargs["pip_index_url"], "") + + if __name__ == "__main__": unittest.main()