Skip to main content

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:branch may publish from develop, violating release-path-only publication.
  • Tag pipelines publish the final version X.Y.Z to TestPyPI instead of a release-candidate form, making retries unsafe when a version slot is consumed.
  • publish:pypi is manual, breaking full automation and allowing released to be inferred before PyPI validation completes.
  • No registry queries exist to select unused rc numbers or fail fast when X.Y.Z already 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 develop Development CI must never publish to TestPyPI or PyPI.
  • Feature-branch or develop CI may build and validate artifacts locally (for example build:package), but must not perform public registry publication.
  • TestPyPI and PyPI publication run only from a tagged commit vX.Y.Z in 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:

  1. TestPyPI phase — release-candidate version X.Y.ZrcU
  2. PyPI phase — final release version X.Y.Z

Version identity split

Artifact contextVersion formSource of truth
Git tag, VERSION, changelog, verify_release_version.pyX.Y.ZRelease branch and tag
TestPyPI wheels and smoke installX.Y.ZrcUComputed at publish time from tag X.Y.Z
PyPI wheels and smoke installX.Y.ZSame 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:

  1. Query TestPyPI for existing versions of the package.
  2. Compute candidate X.Y.ZrcU, starting at U = 0 unless policy chooses U = 1; document the chosen convention in docs/package-publishing.md.
  3. If X.Y.ZrcU already exists on TestPyPI, increment U and check again.
  4. Rebuild or retag wheel metadata for the selected unused X.Y.ZrcU.
  5. Publish X.Y.ZrcU to TestPyPI automatically.
  6. Smoke-test installation and basic usability of that exact X.Y.ZrcU from 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:

  1. Query PyPI for existing versions of the package.
  2. If version X.Y.Z already exists, fail with a clear error that the version cannot be reused.
  3. If X.Y.Z does not exist, publish the final release version X.Y.Z to PyPI automatically.
  4. Smoke-test installation and basic usability of X.Y.Z from 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:

StateMeaning for a2c-workflow publish path
code_cleanDevelopment CI green on validating ref; may include local build:package without registry publish
release_candidateTag vX.Y.Z exists; Release CI pending or in progress
testpypi_validatedTestPyPI publish of X.Y.ZrcU and TestPyPI smoke succeeded for the tag pipeline
releasedPyPI 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:

RequirementRule
AutomationNo when: manual on TestPyPI or PyPI publish jobs
OrderingPyPI jobs depend on successful TestPyPI publish and smoke
Branch isolationNo publish:*pypi* jobs on feature branches, develop, or untagged release/X.Y.Z pushes
Version selectionScripts query registries before upload
Smoke alignmentEach 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:

  1. Never publish to TestPyPI or PyPI from Development CI.
  2. Treat TestPyPI and PyPI publication as Release CI-only actions.
  3. Pursue full automation — do not add or preserve manual publish gates.
  4. Use X.Y.ZrcU versions only for TestPyPI.
  5. Before TestPyPI publish, query existing versions and select an unused rc number.
  6. Before PyPI publish, query existing versions and fail if X.Y.Z is already present.
  7. Never retry final PyPI publication with the same X.Y.Z after PyPI has accepted that version.
  8. Distinguish repository-internal failures from external publishing-environment failures.
  9. Treat released as requiring both PyPI publication and PyPI smoke success.
  10. Do not ff-merge a tagged commit to master until state is released per 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:branch removes optional ad-hoc TestPyPI trials from develop.
  • A green TestPyPI phase no longer implies permission to promote to master until PyPI completes.

Migration

a2c-workflow should:

  1. Add docs/adr/0010-testpypi-pypi-publishing-gated-by-release-state.md (this ADR).
  2. Remove publish:testpypi:branch (and any equivalent branch-path registry publish) from .gitlab-ci.yml.
  3. Remove when: manual from publish:pypi.
  4. Implement registry query and X.Y.ZrcU selection before TestPyPI upload.
  5. Implement PyPI pre-check that fails when X.Y.Z exists.
  6. Align package:smoke-testpypi with the published X.Y.ZrcU version.
  7. Update docs/package-publishing.md and docs/release-pipeline.md job mappings.
  8. Update rules/13-ci-release-flow.md or 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

Supersedes

  • (none) — specializes ADR-0009 for Python registry publishing; does not replace ADR-0009

Superseded by

  • (none)