ADR-0010: TestPyPI and PyPI publishing gated by release state
- Status: Accepted
- Date: 2026-06-11
- Deciders: Michel Gillet
- Specializes: ADR-0009
Context
ADR-0009 distinguishes normal development validation from release validation. Untagged commits may reach code_clean through Development CI; tagged commits must satisfy Release CI before they may be treated as released and promoted to master.
Python package publishing must follow the same model. Publishing to TestPyPI and PyPI is not merely packaging — it is release validation and must be constrained by the same release-state semantics.
The primary goal of this decision is a fully automated, rock-solid publishing flow: no manual publish buttons, no human gates between TestPyPI and PyPI, and no branch-path publication to public registries. The tag pipeline must run build → TestPyPI → smoke → PyPI → smoke end to end without operator intervention, subject only to correct CI secrets and registry availability.
PyPI and TestPyPI enforce version immutability in practice. Once a given package file or version has been accepted, the same filename or version cannot be reused later, even after deletion or a failed later pipeline step. See PyPI help — file name reuse and PEP 440 — Version Identification.
Because of immutability, the publishing workflow must be careful about retry behavior, version allocation, and the distinction between release-candidate publication and final publication.
Current implementation gaps motivated this ADR:
publish:testpypi:branchmay publish fromdevelop, violating release-path-only publication.- Tag pipelines publish the final version
X.Y.Zto TestPyPI instead of a release-candidate form, making retries unsafe when a version slot is consumed. publish:pypiis manual, breaking full automation and allowingreleasedto be inferred before PyPI validation completes.- No registry queries exist to select unused
rcnumbers or fail fast whenX.Y.Zalready exists on PyPI.
This ADR applies to a2c-workflow Python packages (a2c_core, a2c_cli, a2c_tui) per ADR-0006. It does not apply to a2c-vscode-extension, which has no PyPI publication path (ADR-0007).
Decision
Primary objective
Release CI for a2c-workflow must implement fully automated TestPyPI and PyPI publication. Every publish and smoke step runs in the tag pipeline without when: manual or equivalent human gates.
Publication scope
Publishing to TestPyPI and PyPI is allowed only on the tagged release path.
- Feature-branch or
developDevelopment CI must never publish to TestPyPI or PyPI. - Feature-branch or
developCI may build and validate artifacts locally (for examplebuild:package), but must not perform public registry publication. - TestPyPI and PyPI publication run only from a tagged commit
vX.Y.Zin a release context.
Remove branch-path TestPyPI jobs such as publish:testpypi:branch on develop or release/X.Y.Z. Pre-tag validation uses local wheel builds and smokes only.
Two-phase automated publishing flow
Publishing is split into two automatic phases:
- TestPyPI phase — release-candidate version
X.Y.ZrcU - PyPI phase — final release version
X.Y.Z
Version identity split
| Artifact context | Version form | Source of truth |
|---|---|---|
Git tag, VERSION, changelog, verify_release_version.py | X.Y.Z | Release branch and tag |
| TestPyPI wheels and smoke install | X.Y.ZrcU | Computed at publish time from tag X.Y.Z |
| PyPI wheels and smoke install | X.Y.Z | Same as tag |
Release-candidate suffixes are used only on TestPyPI. Final versions without the rc suffix are used only on PyPI. U is a non-negative integer counter compatible with PEP 440 release-candidate semantics.
Build scripts must support producing TestPyPI artifacts at X.Y.ZrcU without changing the repository’s canonical X.Y.Z bump files.
TestPyPI phase
For tagged release version X.Y.Z, the TestPyPI phase always uses X.Y.ZrcU, where U is an integer release-candidate counter.
Workflow:
- Query TestPyPI for existing versions of the package.
- Compute candidate
X.Y.ZrcU, starting atU = 0unless policy choosesU = 1; document the chosen convention indocs/package-publishing.md. - If
X.Y.ZrcUalready exists on TestPyPI, incrementUand check again. - Rebuild or retag wheel metadata for the selected unused
X.Y.ZrcU. - Publish
X.Y.ZrcUto TestPyPI automatically. - Smoke-test installation and basic usability of that exact
X.Y.ZrcUfrom TestPyPI.
This makes retries safe on TestPyPI after partial or failed release attempts without consuming the final PyPI version slot.
Meaning of TestPyPI success
Successful TestPyPI publication and smoke testing of X.Y.ZrcU means the repository content is healthy with respect to packaging and installation:
- artifacts build successfully,
- metadata and versioning are acceptable,
- upload to a PyPI-compatible endpoint succeeds,
- the published artifact installs and passes smoke-level usability checks.
This does not guarantee PyPI publication under all external conditions. It proves publication should succeed when registry credentials, trusted publishing, endpoint availability, network reachability, and registry permissions are correctly configured.
PyPI phase
After TestPyPI publication and smoke succeed, the PyPI phase runs automatically in the same tag pipeline.
Workflow:
- Query PyPI for existing versions of the package.
- If version
X.Y.Zalready exists, fail with a clear error that the version cannot be reused. - If
X.Y.Zdoes not exist, publish the final release versionX.Y.Zto PyPI automatically. - Smoke-test installation and basic usability of
X.Y.Zfrom PyPI.
GitLab project PyPI registry
publish:gitlab (GitLab project package registry) remains tag-path-only and uses final version X.Y.Z. It is not a substitute for TestPyPI or PyPI validation and does not change the released definition below.
Failure handling
TestPyPI — version already exists: increment U and retry with a new X.Y.ZrcU.
TestPyPI — repository-internal failure (build, metadata, smoke): fix release/X.Y.Z, rerun Development CI, retag or move tag per ADR-0009 policy, rerun Release CI.
PyPI — version already exists: fail fast. Corrective action is a new release version, not retrying the same X.Y.Z.
PyPI — external-environment failure (credentials, trusted publishing, endpoint, permissions): report as a publishing-precondition or environment problem, not as a repository content defect. These failures are outside the normal code-repair loop.
PyPI — publish succeeds, smoke fails: release is unsuccessful. Because PyPI versions are immutable, corrective action is a new release version after fixing the defect.
Relationship to release states (ADR-0009)
This ADR specializes ADR-0009’s state model for a2c-workflow Python publishing:
| State | Meaning for a2c-workflow publish path |
|---|---|
code_clean | Development CI green on validating ref; may include local build:package without registry publish |
release_candidate | Tag vX.Y.Z exists; Release CI pending or in progress |
testpypi_validated | TestPyPI publish of X.Y.ZrcU and TestPyPI smoke succeeded for the tag pipeline |
released | PyPI publish of X.Y.Z and PyPI smoke succeeded for the tag pipeline |
A tagged commit is testpypi_validated after the TestPyPI phase completes successfully. It becomes released only after the PyPI phase completes successfully.
A tagged commit must not be considered released, and must not be fast-forward merged to master, until PyPI publication and PyPI smoke both succeed.
testpypi_validated is a publish-path sub-state between release_candidate and released. It is not a substitute for released on master promotion.
CI requirements
Tag pipeline jobs must satisfy:
| Requirement | Rule |
|---|---|
| Automation | No when: manual on TestPyPI or PyPI publish jobs |
| Ordering | PyPI jobs depend on successful TestPyPI publish and smoke |
| Branch isolation | No publish:*pypi* jobs on feature branches, develop, or untagged release/X.Y.Z pushes |
| Version selection | Scripts query registries before upload |
| Smoke alignment | Each smoke job installs the exact version published in the preceding phase |
Introduce or extend scripts (for example registry version query, rc allocation, publish orchestration) under scripts/ and document them in docs/package-publishing.md.
AI automation rules
Agents driving this workflow must:
- Never publish to TestPyPI or PyPI from Development CI.
- Treat TestPyPI and PyPI publication as Release CI-only actions.
- Pursue full automation — do not add or preserve manual publish gates.
- Use
X.Y.ZrcUversions only for TestPyPI. - Before TestPyPI publish, query existing versions and select an unused
rcnumber. - Before PyPI publish, query existing versions and fail if
X.Y.Zis already present. - Never retry final PyPI publication with the same
X.Y.Zafter PyPI has accepted that version. - Distinguish repository-internal failures from external publishing-environment failures.
- Treat
releasedas requiring both PyPI publication and PyPI smoke success. - Do not ff-merge a tagged commit to
masteruntil state isreleasedper this ADR.
Consequences
Positive
- Fully automated tag pipeline from build through PyPI smoke — no manual publish step.
- TestPyPI validates packaging safely before final publication.
- Release-candidate retries do not consume the final PyPI version.
- Final PyPI versions remain immutable and are never reused.
- Clear separation between code-fixable failures and external publishing problems.
- AI agents can decide whether to repair code, increment
rc, fail fast on version reuse, or escalate environment issues.
Negative
- Additional scripts and CI complexity (registry queries, dual version forms).
docs/package-publishing.md,.gitlab-ci.yml, and build scripts require migration.- Removing
publish:testpypi:branchremoves optional ad-hoc TestPyPI trials fromdevelop. - A green TestPyPI phase no longer implies permission to promote to
masteruntil PyPI completes.
Migration
a2c-workflow should:
- Add
docs/adr/0010-testpypi-pypi-publishing-gated-by-release-state.md(this ADR). - Remove
publish:testpypi:branch(and any equivalent branch-path registry publish) from.gitlab-ci.yml. - Remove
when: manualfrompublish:pypi. - Implement registry query and
X.Y.ZrcUselection before TestPyPI upload. - Implement PyPI pre-check that fails when
X.Y.Zexists. - Align
package:smoke-testpypiwith the publishedX.Y.ZrcUversion. - Update
docs/package-publishing.mdanddocs/release-pipeline.mdjob mappings. - Update
rules/13-ci-release-flow.mdor related agent rules to reference this ADR.
Until migration completes, treat current manual PyPI and branch TestPyPI behavior as non-compliant with this ADR.
References
- ADR-0002: Standardized changelog and release documentation
- ADR-0006: Three-package Python structure in a2c-workflow
- ADR-0009: Release semantics and commit promotion to
master - PEP 440 — Version Identification
- PyPI help — file name reuse
- docs/package-publishing.md
- docs/release-pipeline.md
Supersedes
- (none) — specializes ADR-0009 for Python registry publishing; does not replace ADR-0009
Superseded by
- (none)