Skip to content
15 changes: 15 additions & 0 deletions docs/pypi-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
77 changes: 75 additions & 2 deletions docs/release-qualification.md

Large diffs are not rendered by default.

134 changes: 111 additions & 23 deletions src/code_mower/campaign_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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/<verified-artifact>`. "
"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.",
Expand All @@ -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}")
Expand All @@ -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.",
"",
Expand Down Expand Up @@ -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.

Expand All @@ -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,
Expand All @@ -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])
Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand All @@ -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,
Expand Down Expand Up @@ -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])
Expand All @@ -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,
)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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="")
Expand All @@ -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,
Expand Down
Loading
Loading