Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion goal/cli/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -267,13 +267,31 @@ 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.

This avoids uploading stale files from ``dist/*`` and fixes stale project-name
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.
Expand Down
79 changes: 64 additions & 15 deletions goal/doctor/python_diag_extended.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Python project diagnostics — extended checks (PY010–PY014)."""

import re
import shlex
from pathlib import Path
from typing import List, Optional

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -354,29 +357,71 @@ 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(
project_name: str, publish_pattern: str, expected: str
) -> 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."""
Expand Down Expand Up @@ -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."""
Expand Down
1 change: 1 addition & 0 deletions project/TICKETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
<!-- AUTO:TICKET_INDEX:END -->
67 changes: 67 additions & 0 deletions project/ticket-058/README.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions project/ticket-058/ai-codex-logs.txt
Original file line number Diff line number Diff line change
@@ -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.
42 changes: 42 additions & 0 deletions project/ticket-058/ai-codex.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions project/ticket-058/changelog.md
Original file line number Diff line number Diff line change
@@ -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.
Loading