From 21af56d709b0802b8bdac4bb66ce8a2e4e6f3d51 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Wed, 12 Aug 2026 19:55:44 +0200 Subject: [PATCH] [ticket-058] fix(publish): make Twine retries idempotent Co-authored-by: Koru Agent --- TODO.md | 6 ++ goal/cli/publish.py | 20 ++++- goal/doctor/python_diag_extended.py | 79 +++++++++++++++---- project/TICKETS.md | 1 + project/ticket-058/README.md | 67 ++++++++++++++++ project/ticket-058/ai-codex-logs.txt | 8 ++ project/ticket-058/ai-codex.md | 42 ++++++++++ project/ticket-058/changelog.md | 14 ++++ project/ticket-058/intent.json | 111 +++++++++++++++++++++++++++ project/ticket-058/preprompt.md | 12 +++ tests/test_project_doctor.py | 82 ++++++++++++++++++++ tests/test_publish_pattern.py | 50 +++++++++++- 12 files changed, 473 insertions(+), 19 deletions(-) create mode 100644 project/ticket-058/README.md create mode 100644 project/ticket-058/ai-codex-logs.txt create mode 100644 project/ticket-058/ai-codex.md create mode 100644 project/ticket-058/changelog.md create mode 100644 project/ticket-058/intent.json create mode 100644 project/ticket-058/preprompt.md diff --git a/TODO.md b/TODO.md index ae5b0dff..b32d7cb3 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,12 @@ ## Governed architecture roadmap +- [ ] Deliver [ticket-058](project/ticket-058/README.md): make configured Twine + publication retries idempotent at runtime and migrate legacy commands + through doctor PY013. State: `IN_PROGRESS / PUBLICATION`; configuration producers + and governance-owned `goal.yaml` are deferred to dependent bounded tickets; + classification: `BUG / P0 / regression`; depends on ticket-057. + - [x] Deliver [ticket-057](project/ticket-057/README.md): publish the merged mutation-free PR-resume repair as Goal 2.1.299. State: `DONE / DONE`; PR #94 passed 607 tests (2 skips), Python 3.12/3.13, Ruff, governance, build, Docker diff --git a/goal/cli/publish.py b/goal/cli/publish.py index e33a9cde..bfb2baf5 100644 --- a/goal/cli/publish.py +++ b/goal/cli/publish.py @@ -16,7 +16,7 @@ from goal.toml_validation import validate_project_toml_files from goal.publish.github_fallback import ( get_github_release_config, - github_fallback_actionable, + github_fallback_actionable, # noqa: F401 - retained compatibility patch point is_pypi_blocked, try_github_fallback, ) @@ -267,6 +267,23 @@ def _format_artifact_args(artifacts: list[Path]) -> str: return " ".join(shlex.quote(str(path)) for path in artifacts) +def _ensure_twine_skip_existing(publish_cmd: str) -> str: + """Make a Twine upload retry-safe without changing other publishers.""" + try: + arguments = shlex.split(publish_cmd) + except ValueError: + return publish_cmd + if "twine" not in arguments or "upload" not in arguments: + return publish_cmd + if "--skip-existing" in arguments: + return publish_cmd + + upload = re.search(r"\btwine\s+upload\b", publish_cmd) + if upload is None: + return publish_cmd + return f"{publish_cmd[: upload.end()]} --skip-existing{publish_cmd[upload.end() :]}" + + def _resolve_python_publish_cmd(publish_cmd: str, version: str) -> str: """Use exact built artifacts for the requested version. @@ -274,6 +291,7 @@ def _resolve_python_publish_cmd(publish_cmd: str, version: str) -> str: globs such as ``dist/oldname-{version}*`` when the current metadata produces a different distribution name. """ + publish_cmd = _ensure_twine_skip_existing(publish_cmd) publish_cmd = publish_cmd.replace("{version}", version) # Capture the full artifact path token, including any directory prefix such # as ``adapters/python/dist/...`` so monorepo subdir paths survive intact. diff --git a/goal/doctor/python_diag_extended.py b/goal/doctor/python_diag_extended.py index f872dc4f..b86eb2f1 100644 --- a/goal/doctor/python_diag_extended.py +++ b/goal/doctor/python_diag_extended.py @@ -1,6 +1,7 @@ """Python project diagnostics — extended checks (PY010–PY014).""" import re +import shlex from pathlib import Path from typing import List, Optional @@ -326,7 +327,9 @@ def check_py013_goal_publish_pattern(self) -> None: if not publish_pattern: return - expected = f"twine upload dist/{project_name}-{{version}}*" + expected = ( + f"twine upload --skip-existing dist/{project_name}-{{version}}*" + ) if self._goal_publish_pattern_is_acceptable( project_name, publish_pattern, expected @@ -354,12 +357,32 @@ def check_py013_goal_publish_pattern(self) -> None: @staticmethod def _extract_goal_publish_pattern(goal_content: str) -> Optional[str]: - publish_match = re.search( - r"publish:\s*(.+?)(?:\s*$|\s+\w+:|\n\w+)", goal_content, re.MULTILINE - ) - if not publish_match: + publish_line = PythonDiagnostics._python_publish_line(goal_content) + return publish_line[1] if publish_line is not None else None + + @staticmethod + def _python_publish_line(goal_content: str) -> Optional[tuple[int, str]]: + """Return the Python strategy's publish line without crossing siblings.""" + lines = goal_content.splitlines(keepends=True) + for index, line in enumerate(lines): + strategy_match = re.match(r"^(\s*)python:\s*(?:#.*)?(?:\r?\n)?$", line) + if strategy_match is None: + continue + strategy_indent = len(strategy_match.group(1)) + for child_index in range(index + 1, len(lines)): + child = lines[child_index] + if not child.strip() or child.lstrip().startswith("#"): + continue + child_indent = len(child) - len(child.lstrip()) + if child_indent <= strategy_indent: + break + publish_match = re.match( + r"^\s*publish:\s*([^\r\n]+)", child + ) + if publish_match is not None: + return child_index, publish_match.group(1).strip() return None - return publish_match.group(1).strip() + return None @staticmethod def _goal_publish_pattern_is_acceptable( @@ -367,16 +390,38 @@ def _goal_publish_pattern_is_acceptable( ) -> bool: if publish_pattern == expected: return True - return project_name in publish_pattern and "goal-" not in publish_pattern + try: + arguments = shlex.split(publish_pattern) + except ValueError: + return False + if arguments.count("--skip-existing") != 1: + return False + if "twine" not in arguments or "upload" not in arguments: + return False + normalized_names = { + project_name, + project_name.replace("-", "_"), + project_name.replace("_", "-"), + } + expected_patterns = { + f"dist/{name}-{{version}}*" for name in normalized_names + } + return any(argument in expected_patterns for argument in arguments) @staticmethod def _rewrite_goal_publish_pattern(goal_content: str, expected: str) -> str: - return re.sub( - r"(publish:\s*)(.+?)(\s*$|\s+\w+:|\n\w+)", - rf"\1{expected}\3", - goal_content, - flags=re.MULTILINE, - ) + publish_line = PythonDiagnostics._python_publish_line(goal_content) + if publish_line is None: + return goal_content + line_index, _current = publish_line + lines = goal_content.splitlines(keepends=True) + line = lines[line_index] + line_ending = "\r\n" if line.endswith("\r\n") else "\n" if line.endswith("\n") else "" + prefix_match = re.match(r"^(\s*publish:\s*)", line) + if prefix_match is None: + return goal_content + lines[line_index] = f"{prefix_match.group(1)}{expected}{line_ending}" + return "".join(lines) def check_py014_pypi_token(self) -> None: """PY014: Check for PyPI token configuration before publishing.""" @@ -416,10 +461,14 @@ def _is_publish_enabled(goal_content: str) -> bool: def _has_pypi_credentials(self) -> bool: import os - pypi_token = os.environ.get("PYPI_TOKEN") or os.environ.get("TWINE_PASSWORD") + pypi_credential = os.environ.get("PYPI_TOKEN") or os.environ.get( + "TWINE_PASSWORD" + ) pypirc_project = self.project_dir / ".pypirc" pypirc_home = Path.home() / ".pypirc" - return bool(pypi_token or pypirc_project.exists() or pypirc_home.exists()) + return bool( + pypi_credential or pypirc_project.exists() or pypirc_home.exists() + ) def run_all_checks(self) -> None: """Run all registered check methods in order.""" diff --git a/project/TICKETS.md b/project/TICKETS.md index 0e5a6fe1..8da61dff 100644 --- a/project/TICKETS.md +++ b/project/TICKETS.md @@ -60,4 +60,5 @@ This file indexes governance tickets without taking ownership of | **ticket-055** | [`README.md`](./ticket-055/README.md) | [`preprompt.md`](./ticket-055/preprompt.md) | - | [`ai-codex.md`](./ticket-055/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-055/ai-codex-logs.txt) | [`changelog.md`](./ticket-055/changelog.md) | | **ticket-056** | [`README.md`](./ticket-056/README.md) | [`preprompt.md`](./ticket-056/preprompt.md) | - | [`ai-codex.md`](./ticket-056/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-056/ai-codex-logs.txt) | [`changelog.md`](./ticket-056/changelog.md) | | **ticket-057** | [`README.md`](./ticket-057/README.md) | [`preprompt.md`](./ticket-057/preprompt.md) | - | [`ai-codex.md`](./ticket-057/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-057/ai-codex-logs.txt) | [`changelog.md`](./ticket-057/changelog.md) | +| **ticket-058** | [`README.md`](./ticket-058/README.md) | [`preprompt.md`](./ticket-058/preprompt.md) | - | [`ai-codex.md`](./ticket-058/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-058/ai-codex-logs.txt) | [`changelog.md`](./ticket-058/changelog.md) | diff --git a/project/ticket-058/README.md b/project/ticket-058/README.md new file mode 100644 index 00000000..96fa3457 --- /dev/null +++ b/project/ticket-058/README.md @@ -0,0 +1,67 @@ +# Ticket 058: Make Python publish retries idempotent + +- **ID**: ticket-058 +- **Owner**: unresolved:human +- **Status**: IN_PROGRESS +- **Workflow state**: PUBLICATION +- **Created**: 2026-08-12 + +## Goal and scope + +Make every Twine-based Python publication idempotent, including repositories +whose tracked `goal.yaml` predates the current built-in default. The runtime +must add `--skip-existing` before executing a configured Twine upload, while +doctor PY013 must diagnose and auto-migrate both a stale distribution pattern +and a missing idempotency flag. + +The defect was reproduced while completing Goal 2.1.299: the artifact already +existed on PyPI, but Goal rebuilt it and a legacy `twine upload` returned HTTP +400. The identical command with `--skip-existing` returned zero and explicitly +reported both artifacts as already present. No registry object was replaced. + +## Acceptance criteria + +- [x] AC-01: The user's instruction to repair Goal and continue records + `SESSION_EXECUTION_AUTHORIZATION` for this bounded regression fix. +- [x] AC-02: Runtime resolution adds exactly one `--skip-existing` to Twine + upload commands, preserves custom options and leaves non-Twine commands + unchanged. +- [x] AC-03: PY013 rejects a missing idempotency flag or wrong distribution + pattern and auto-fixes either defect to the safe canonical command. +- [x] AC-04: Focused and full tests, Ruff, governance and a bounded live Glon + reproduction prove a retry succeeds without modifying the source checkout. +- [ ] AC-05: Protected CI and Validator Agent approve the exact final PR head + before merge. + +## Non-goals + +- Do not reinterpret a generic HTTP 400 as successful publication. +- Do not weaken artifact/version filtering or immutable registry semantics. +- Do not add a dependency or change non-Twine publishers. +- Built-in config producers and Goal's governance-owned `goal.yaml` are + intentionally split into dependent tickets to respect the repository's + five-file, two-component and workstream ownership limits. +- Do not publish a new Goal version before this implementation is merged and + revalidated from clean `main`. + +## Participants + +- Human participant: unresolved; no user-* file was created by this script. +- Agent participant: [ai-codex.md](ai-codex.md) + +## Validation evidence + +- 59 focused runtime/doctor tests pass. They cover option preservation, + no duplicate flag, non-Twine commands, wrong names, safe custom Twine + options and isolation of Python from Node/Rust sibling strategies. +- Governance passes with 0 errors/0 warnings after splitting configuration + producers and governance-owned config into dependent bounded tickets. +- A disposable clone of real Glon commit `ae7ea353...` auto-fixed exactly the + Python publish line. Its two other tracked publish lines were byte-unchanged. +- Public Glon 0.1.28 wheel and sdist matched the local SHA-256 hashes + `d845c2a23be9ea87b75ac5587ec5c231ad3f97b48c117198111c41c635f3fe43` + and `e2af788f01f6590935c59a139627f0932a48f3429a2425f601b4f8a7916d68b3`. + The resolved Twine retry contained one `--skip-existing`, returned 0 and + skipped both existing immutable files. The bounded clone was removed. +- The full suite passes 615 tests with 2 existing skips; scoped Ruff, + governance and whitespace validation also pass on the final candidate. diff --git a/project/ticket-058/ai-codex-logs.txt b/project/ticket-058/ai-codex-logs.txt new file mode 100644 index 00000000..4428a5f2 --- /dev/null +++ b/project/ticket-058/ai-codex-logs.txt @@ -0,0 +1,8 @@ +2026-08-12T17:45:36Z ticket-058 allocated on clean accepted base 9c56f79165fe421b61a17deafbc0fcabdbfa9ba0. +2026-08-12T17:46:00Z reproduction: Goal 2.1.299 rebuilt already-published 2.1.299 artifacts and legacy tracked command `twine upload dist/goal-{version}*` returned PyPI HTTP 400; exact retry with `twine upload --skip-existing dist/goal-2.1.299*` returned 0 and explicitly skipped both immutable files. +2026-08-12T17:46:00Z SESSION_EXECUTION_AUTHORIZATION recorded from the user's repeated instruction to repair Goal and continue. +2026-08-12T17:50:00Z initial governance preflight rejected 8 implementation files, 3 architecture components and cross-workstream goal.yaml ownership; scope split to the 4-file runtime/doctor slice with 2 components, leaving dependent config-producer and governance-config tickets. +2026-08-12T17:52:00Z implementation: runtime inserts exactly one --skip-existing only for Twine upload and keeps other arguments/publishers; PY013 now requires the safe flag plus normalized exact distribution pattern and rewrites only strategies.python.publish. +2026-08-12T17:53:00Z focused validation: 59 runtime/doctor tests PASS; scoped Ruff PASS; governance GOV-PASS 0 errors/0 warnings; git diff --check PASS. The module's existing github_fallback_actionable compatibility patch point remains exported with an explicit F401 exemption because two fallback regressions patch it. +2026-08-12T17:55:00Z live Glon: disposable clone of ae7ea3533f0e0c1decffda78a25aed3a6931a63a changed exactly 1 insertion/1 deletion in the Python publish line; sibling Node/Rust publish lines unchanged. Public/local 0.1.28 wheel sha256 d845c2a23be9ea87b75ac5587ec5c231ad3f97b48c117198111c41c635f3fe43 and sdist sha256 e2af788f01f6590935c59a139627f0932a48f3429a2425f601b4f8a7916d68b3 matched; resolved retry returned 0 and skipped both existing artifacts; temporary clone removed. +2026-08-12T17:58:00Z final local candidate: 73 fallback/runtime/doctor tests PASS; full suite 615 PASS/2 SKIP; scoped Ruff, governance 0/0 and git diff --check PASS; ticket moved to IN_PROGRESS/PUBLICATION pending protected exact-head validation. diff --git a/project/ticket-058/ai-codex.md b/project/ticket-058/ai-codex.md new file mode 100644 index 00000000..81fbddb2 --- /dev/null +++ b/project/ticket-058/ai-codex.md @@ -0,0 +1,42 @@ +--- +participant-id: agent:codex +participant: codex +role: agent +ticket: ticket-058 +--- +# Participant: codex (AI agent) + +## Understanding + +Goal's modern release metadata describes a safe command, but doctor PY013 +still accepts an unsafe legacy command. Runtime normalization is required as +the final defense because publish-only delivery can intentionally bypass +doctor and immutable retry must not depend on a prior config rewrite. + +## Execution plan + +1. Record the exact 2.1.299 retry reproduction and bounded affected surfaces. +2. Add idempotent runtime normalization without changing non-Twine commands. +3. Make PY013 migrate both unsafe-command and wrong-name variants. +4. Prove the behavior in a disposable exact Glon checkout, then run full + validation and protected exact-head delivery. + +## Actual changes + +- Initialized the bounded ticket and recorded SESSION_EXECUTION_AUTHORIZATION + from the request to execute this work. +- Bound the fix to runtime normalization and doctor migration; registry error + interpretation remains deliberately unchanged. +- Split configuration producers and governance-owned `goal.yaml` into + dependent tickets after governance enforced the repository's component, + file-count and workstream limits. +- Added the runtime and doctor regressions, including a scoped Python strategy + rewrite that cannot alter sibling Node/Rust publish commands. +- Proved the safe retry on exact Glon 0.1.28 public artifacts from a disposable + clone and removed the clone after source-diff verification. + +## Blockers + +- None inside the recorded intent; proceed without a second confirmation. +- New authority remains required for destructive action, secret access, new + external coordination, material objective expansion and trusted merge. diff --git a/project/ticket-058/changelog.md b/project/ticket-058/changelog.md new file mode 100644 index 00000000..8f1f07a1 --- /dev/null +++ b/project/ticket-058/changelog.md @@ -0,0 +1,14 @@ +# Ticket Changelog (ticket-058) + +## [0.1.0] - 2026-08-12 + +- Initial governance scaffold created. +- No human participant identity or content was generated. +- Recorded the live immutable-publication retry failure and bounded the repair + to runtime normalization and doctor migration. +- Split the implementation at governance ownership/budget boundaries, added + retry-safe runtime normalization and limited PY013 rewriting to Python only. +- Proved byte-identical Glon 0.1.28 retries succeed without touching the live + checkout or sibling publisher commands. +- Passed 615 full tests (2 existing skips), scoped Ruff and deterministic + governance before protected publication. diff --git a/project/ticket-058/intent.json b/project/ticket-058/intent.json new file mode 100644 index 00000000..9e99ed71 --- /dev/null +++ b/project/ticket-058/intent.json @@ -0,0 +1,111 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-058", + "summary": "Make Python publish retries idempotent", + "workstream": "application", + "classification": { + "kind": "BUG", + "priority": "P0", + "origin": "regression" + }, + "allowedPaths": [ + "goal/cli/publish.py", + "goal/doctor/python_diag_extended.py", + "tests/test_project_doctor.py", + "tests/test_publish_pattern.py", + "project/ticket-058/**", + "TODO.md", + "project/TICKETS.md" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md", + "VERSION", + "pyproject.toml", + "uv.lock", + "goal/__init__.py", + ".github/**" + ], + "stacks": [ + "python" + ], + "dependsOn": [ + "ticket-057" + ], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "9c56f79165fe421b61a17deafbc0fcabdbfa9ba0", + "targetBranch": "main", + "outcome": "Configured Twine uploads are retry-safe even when a repository carries legacy Goal configuration", + "nonGoals": [ + "Do not treat generic registry HTTP failures as success", + "Do not change non-Twine publisher commands", + "Do not weaken exact-version artifact selection", + "Do not change package version or dependencies" + ], + "complexity": "S", + "estimatedMinutes": 30, + "budgets": { + "maxImplementationFiles": 4, + "maxAffectedComponents": 2, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Normalize legacy Twine commands at the final runtime boundary and migrate project configuration through doctor with the same explicit flag", + "components": [ + { + "name": "publish-runtime", + "paths": [ + "goal/cli/publish.py", + "tests/test_publish_pattern.py" + ] + }, + { + "name": "doctor-migration", + "paths": [ + "goal/doctor/python_diag_extended.py", + "tests/test_project_doctor.py" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Revert the bounded implementation PR; callers can still add --skip-existing manually" + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-02", + "commands": [ + "pytest -q tests/test_publish_pattern.py" + ], + "evidence": "project/ticket-058/ai-codex-logs.txt" + }, + { + "criterion": "AC-03", + "commands": [ + "pytest -q tests/test_project_doctor.py -k py013" + ], + "evidence": "project/ticket-058/ai-codex-logs.txt" + }, + { + "criterion": "AC-04", + "commands": [ + "pytest -q", + "ruff check scoped paths", + "./project/governance-check.sh", + "bounded Glon retry reproduction" + ], + "evidence": "project/ticket-058/ai-codex-logs.txt" + } + ] + } +} diff --git a/project/ticket-058/preprompt.md b/project/ticket-058/preprompt.md new file mode 100644 index 00000000..f3ec6e84 --- /dev/null +++ b/project/ticket-058/preprompt.md @@ -0,0 +1,12 @@ +# Ticket preprompt + +- **Task ID**: ticket-058 +- **Task title**: Make Python publish retries idempotent +- **Created**: 2026-08-12T17:45:36Z + +Keep executable implementation outside this governance/evidence directory. +Read a human-owned user-*.md file only when one exists. +The request to execute this work creates SESSION_EXECUTION_AUTHORIZATION; +proceed within the recorded intent without a redundant confirmation prompt. +Require new authority for destructive action, secrets, external coordination, +material objective expansion and trusted merge approval. diff --git a/tests/test_project_doctor.py b/tests/test_project_doctor.py index cd7b3cef..687476bc 100644 --- a/tests/test_project_doctor.py +++ b/tests/test_project_doctor.py @@ -235,6 +235,88 @@ def test_py011_no_fix_leaves_files_alone(self, tmp_path): assert 'version = "0.1.35"' in (tmp_path / "pyproject.toml").read_text() assert (tmp_path / "VERSION").read_text().strip() == "0.1.36" + @staticmethod + def _py013_project(tmp_path, publish_command): + (tmp_path / "pyproject.toml").write_text( + '[build-system]\nrequires = ["setuptools"]\n' + 'build-backend = "setuptools.build_meta"\n\n' + '[project]\nname = "demo-pkg"\nversion = "1.2.3"\n' + 'requires-python = ">=3.10"\nlicense = "MIT"\n' + ) + (tmp_path / "goal.yaml").write_text( + "strategies:\n python:\n publish: " + publish_command + "\n" + ) + + def test_py013_rejects_missing_skip_existing_without_fix(self, tmp_path): + self._py013_project( + tmp_path, "twine upload dist/demo-pkg-{version}*" + ) + + issues = _diagnose_python(tmp_path, auto_fix=False) + + py013 = [issue for issue in issues if issue.code == "PY013"] + assert len(py013) == 1 + assert py013[0].fixed is False + assert "--skip-existing" in py013[0].detail + + def test_py013_adds_skip_existing_during_auto_fix(self, tmp_path): + self._py013_project( + tmp_path, "twine upload dist/demo-pkg-{version}*" + ) + + issues = _diagnose_python(tmp_path, auto_fix=True) + + py013 = [issue for issue in issues if issue.code == "PY013"] + assert len(py013) == 1 + assert py013[0].fixed is True + assert ( + "publish: twine upload --skip-existing dist/demo-pkg-{version}*" + in (tmp_path / "goal.yaml").read_text() + ) + + def test_py013_repairs_wrong_package_and_missing_flag(self, tmp_path): + self._py013_project(tmp_path, "twine upload dist/goal-{version}*") + + issues = _diagnose_python(tmp_path, auto_fix=True) + + py013 = [issue for issue in issues if issue.code == "PY013"] + assert len(py013) == 1 + assert py013[0].fixed is True + assert ( + "publish: twine upload --skip-existing dist/demo-pkg-{version}*" + in (tmp_path / "goal.yaml").read_text() + ) + + def test_py013_accepts_safe_custom_twine_options(self, tmp_path): + self._py013_project( + tmp_path, + "python -m twine upload --repository testpypi --skip-existing " + "dist/demo_pkg-{version}*", + ) + + issues = _diagnose_python(tmp_path, auto_fix=False) + + assert not any(issue.code == "PY013" for issue in issues) + + def test_py013_auto_fix_preserves_non_python_publishers(self, tmp_path): + self._py013_project( + tmp_path, "twine upload dist/wrong-{version}*" + ) + goal_yaml = tmp_path / "goal.yaml" + goal_yaml.write_text( + goal_yaml.read_text() + + " nodejs:\n publish: npm publish\n" + + " rust:\n publish: cargo publish\n" + ) + + issues = _diagnose_python(tmp_path, auto_fix=True) + + assert any(issue.code == "PY013" and issue.fixed for issue in issues) + content = goal_yaml.read_text() + assert "twine upload --skip-existing dist/demo-pkg-{version}*" in content + assert "publish: npm publish" in content + assert "publish: cargo publish" in content + # --------------------------------------------------------------------------- # Node.js diagnostics diff --git a/tests/test_publish_pattern.py b/tests/test_publish_pattern.py index ca691e46..0834efc6 100644 --- a/tests/test_publish_pattern.py +++ b/tests/test_publish_pattern.py @@ -24,7 +24,8 @@ def test_resolve_python_publish_cmd_uses_pyproject_name( resolved = _resolve_python_publish_cmd(publish_cmd, "1.2.3") assert resolved == ( - "twine upload dist/cllm-1.2.3-py3-none-any.whl dist/cllm-1.2.3.tar.gz" + "twine upload --skip-existing dist/cllm-1.2.3-py3-none-any.whl " + "dist/cllm-1.2.3.tar.gz" ) @@ -46,7 +47,8 @@ def test_resolve_python_publish_cmd_uses_setup_py_name( resolved = _resolve_python_publish_cmd(publish_cmd, "4.0.1") assert resolved == ( - "twine upload dist/tellm-4.0.1-py3-none-any.whl dist/tellm-4.0.1.tar.gz" + "twine upload --skip-existing dist/tellm-4.0.1-py3-none-any.whl " + "dist/tellm-4.0.1.tar.gz" ) @@ -67,10 +69,52 @@ def test_resolve_python_publish_cmd_filters_broad_dist_glob( resolved = _resolve_python_publish_cmd("twine upload dist/*", "4.0.1") assert resolved == ( - "twine upload dist/tellm-4.0.1-py3-none-any.whl dist/tellm-4.0.1.tar.gz" + "twine upload --skip-existing dist/tellm-4.0.1-py3-none-any.whl " + "dist/tellm-4.0.1.tar.gz" ) +def test_resolve_python_publish_cmd_preserves_custom_twine_options( + tmp_path: Path, monkeypatch +) -> None: + dist = tmp_path / "dist" + dist.mkdir() + (dist / "demo-1.2.3.tar.gz").write_text("sdist") + (tmp_path / "pyproject.toml").write_text('[project]\nname = "demo"\n') + monkeypatch.chdir(tmp_path) + + resolved = _resolve_python_publish_cmd( + "python -m twine upload --repository testpypi dist/*", "1.2.3" + ) + + assert resolved == ( + "python -m twine upload --skip-existing --repository testpypi " + "dist/*" + ) + + +def test_resolve_python_publish_cmd_does_not_duplicate_skip_existing( + tmp_path: Path, monkeypatch +) -> None: + monkeypatch.chdir(tmp_path) + command = "twine upload --skip-existing dist/demo-{version}*" + + resolved = _resolve_python_publish_cmd(command, "1.2.3") + + assert resolved == "twine upload --skip-existing dist/demo-1.2.3*" + assert resolved.count("--skip-existing") == 1 + + +def test_resolve_python_publish_cmd_leaves_non_twine_publishers_unchanged( + tmp_path: Path, monkeypatch +) -> None: + monkeypatch.chdir(tmp_path) + + resolved = _resolve_python_publish_cmd("uv publish dist/*", "1.2.3") + + assert resolved == "uv publish dist/*" + + def test_ensure_python_artifacts_resyncs_setup_py_and_rebuilds( tmp_path: Path, monkeypatch ) -> None: