From 0da3ab79171350c8aeb4c397620454cb8ca7fac8 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 16 Sep 2026 10:20:23 +0530 Subject: [PATCH 1/5] ci: drop the `none` bump and start the committed version at 0.0.0 `none` existed so the first release could publish the 0.1.0 already in the file. With nothing published yet the file can start at 0.0.0 instead, and the first release is an ordinary `minor` bump. After that `none` has no use: the tag guard refuses a version already released, and so does PyPI. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- .github/workflows/release.yml | 15 ++++++--------- src/unstract_cli/__init__.py | 2 +- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bee8694..65c0062 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: version_bump: - description: "Version bump type. `none` publishes the version already in the repo, which is what the first release needs." + description: "Version bump type." required: true default: "patch" type: choice @@ -12,9 +12,8 @@ on: - patch - minor - major - - none pre_release: - description: "Publish a release candidate (`rcN`) instead of the version itself. Dispatch again with this off to promote the same version to stable." + description: "Publish a release candidate (`rcN`) instead of the version itself. Dispatch again with the same bump and this off to promote it to stable." required: false default: false type: boolean @@ -31,8 +30,8 @@ jobs: release-and-publish: runs-on: ubuntu-latest # Dispatchable from any branch by anyone who can dispatch it, and a - # candidate or a `none` bump reaches PyPI without a commit to review: the - # published artefact must come from the reviewed branch. + # candidate reaches PyPI without a commit to review: the published artefact + # must come from the reviewed branch. if: github.ref == 'refs/heads/main' permissions: contents: write @@ -160,12 +159,10 @@ jobs: run: | NEW_VERSION="${{ steps.version.outputs.version }}" - # A pre-release leaves the committed version alone, and `none` - # publishes the version already in the file, so both reach here with - # nothing to commit. + # A pre-release leaves the committed version alone. if [ "${{ github.event.inputs.pre_release }}" = "true" ]; then git checkout -- src/unstract_cli/__init__.py - elif ! git diff --quiet; then + else git add src/unstract_cli/__init__.py git commit -m "chore: bump version to $NEW_VERSION [skip ci]" git push origin main diff --git a/src/unstract_cli/__init__.py b/src/unstract_cli/__init__.py index c85c094..0e93683 100644 --- a/src/unstract_cli/__init__.py +++ b/src/unstract_cli/__init__.py @@ -1,3 +1,3 @@ """Unstract CLI.""" -__version__ = "0.1.0" +__version__ = "0.0.0" From 0a0a2af870435efc0aac8069bfb303af5304e9fb Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 16 Sep 2026 10:24:59 +0530 Subject: [PATCH 2/5] ci: keep the committed version at 0.1.0 The first release is being published from the version already in the file, so it stays; only the `none` option goes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- .github/workflows/release.yml | 2 +- src/unstract_cli/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 65c0062..cfcc598 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -162,7 +162,7 @@ jobs: # A pre-release leaves the committed version alone. if [ "${{ github.event.inputs.pre_release }}" = "true" ]; then git checkout -- src/unstract_cli/__init__.py - else + elif ! git diff --quiet; then git add src/unstract_cli/__init__.py git commit -m "chore: bump version to $NEW_VERSION [skip ci]" git push origin main diff --git a/src/unstract_cli/__init__.py b/src/unstract_cli/__init__.py index 0e93683..c85c094 100644 --- a/src/unstract_cli/__init__.py +++ b/src/unstract_cli/__init__.py @@ -1,3 +1,3 @@ """Unstract CLI.""" -__version__ = "0.0.0" +__version__ = "0.1.0" From 48d0553357b04d4194f01e24a992103f53d0bf15 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 16 Sep 2026 11:24:36 +0530 Subject: [PATCH 3/5] ci: leave nothing behind when the publish fails A publish that failed left the tag and the draft release in place, and for a stable release the bump commit already on main; the next dispatch then stopped on the tag guard until all three were removed by hand. The bump commit now reaches main only after the publish, carried until then by the tag alone, and a step that runs on failure removes the tag and the draft release. It sits directly after the publish so a failure later in the run, with the version already on PyPI, cannot remove the tag that names it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- .github/workflows/release.yml | 25 +++++++++++++++++++++---- tests/test_workflows.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cfcc598..e2d76d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -149,9 +149,8 @@ jobs: # Publishing is the one step that cannot be undone, so it runs last, and # the release is created as a draft so a publish that fails leaves - # nothing public. What a failure before the publish leaves behind, in - # cleanup order: the draft release, the tag, the release branch, and the - # bump commit on main. + # nothing public. Until then the bump commit exists only in the tag, so + # a failed publish leaves main untouched. - name: Commit version bump and create draft release env: RELEASE_NOTES: ${{ github.event.inputs.release_notes }} @@ -165,7 +164,6 @@ jobs: elif ! git diff --quiet; then git add src/unstract_cli/__init__.py git commit -m "chore: bump version to $NEW_VERSION [skip ci]" - git push origin main fi git tag "v$NEW_VERSION" @@ -191,6 +189,25 @@ jobs: - name: Publish to PyPI run: uv publish + # Everything the run created before the publish, so a rerun after the + # cause is fixed starts clean rather than on the tag guard. Each removal + # tolerates its target being absent: the failure may have come partway + # through creating them. + - name: Remove the tag and draft release a failed publish leaves behind + if: failure() && steps.version.outcome == 'success' + env: + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} + run: | + NEW_VERSION="${{ steps.version.outputs.version }}" + gh release delete "v$NEW_VERSION" --yes || true + git push origin ":refs/tags/v$NEW_VERSION" || true + + # If this fails the published version is on PyPI and in the tag but not + # on main; `git push origin v:main` finishes the job by hand. + - name: Push version bump + if: github.event.inputs.pre_release != 'true' + run: git push origin main + - name: Publish release env: GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} diff --git a/tests/test_workflows.py b/tests/test_workflows.py index f92cb67..bc5f691 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -130,3 +130,37 @@ def test_the_release_goes_public_only_once_the_package_is_on_pypi() -> None: assert creates.count("gh release create") == creates.count("--draft") assert "--draft=false" in by_name["Publish release"]["run"] assert names.index("Publish release") > names.index("Publish to PyPI") + + +def _release_steps() -> list[dict]: + return _load(Path(__file__).resolve().parents[1] / ".github/workflows/release.yml")[ + "jobs" + ]["release-and-publish"]["steps"] + + +def test_main_moves_only_once_the_package_is_on_pypi() -> None: + """A bump commit pushed before the publish is the one leftover of a failed + run that cannot be deleted without rewriting main.""" + steps = _release_steps() + names = [step.get("name", "") for step in steps] + by_name = {step.get("name", ""): step for step in steps} + + assert ( + "git push origin main" + not in by_name["Commit version bump and create draft release"]["run"] + ) + assert names.index("Push version bump") > names.index("Publish to PyPI") + + +def test_a_failed_publish_removes_what_it_created_and_nothing_published() -> None: + """The cleanup runs on failure and sits directly after the publish: a step + that fails later, with the version already on PyPI, must not delete the + tag that names it.""" + steps = _release_steps() + names = [step.get("name", "") for step in steps] + cleanup = "Remove the tag and draft release a failed publish leaves behind" + + assert "failure()" in steps[names.index(cleanup)]["if"] + assert names.index(cleanup) == names.index("Publish to PyPI") + 1 + assert "gh release delete" in steps[names.index(cleanup)]["run"] + assert ":refs/tags/" in steps[names.index(cleanup)]["run"] From 66c9b1a3b4d02328fb8e8841c30f63b85f43d447 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 16 Sep 2026 11:55:51 +0530 Subject: [PATCH 4/5] ci: keep the tag when a failed publish still put the version on PyPI The wheel and the sdist upload one after the other, so a publish can fail with the version already served. The cleanup now asks PyPI first and leaves the tag and draft release in place when it is. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- .github/workflows/release.yml | 6 ++++++ tests/test_workflows.py | 6 ++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2d76d2..fb92574 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -199,6 +199,12 @@ jobs: GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} run: | NEW_VERSION="${{ steps.version.outputs.version }}" + # The wheel and the sdist upload separately, so a failed publish can + # still have put the version on PyPI; then the tag has to stay. + if curl -fsS -o /dev/null "https://pypi.org/pypi/unstract-cli/$NEW_VERSION/json"; then + echo "PyPI serves $NEW_VERSION; keeping the tag and draft release." >&2 + exit 0 + fi gh release delete "v$NEW_VERSION" --yes || true git push origin ":refs/tags/v$NEW_VERSION" || true diff --git a/tests/test_workflows.py b/tests/test_workflows.py index bc5f691..698affd 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -162,5 +162,7 @@ def test_a_failed_publish_removes_what_it_created_and_nothing_published() -> Non assert "failure()" in steps[names.index(cleanup)]["if"] assert names.index(cleanup) == names.index("Publish to PyPI") + 1 - assert "gh release delete" in steps[names.index(cleanup)]["run"] - assert ":refs/tags/" in steps[names.index(cleanup)]["run"] + run = steps[names.index(cleanup)]["run"] + assert "gh release delete" in run + assert ":refs/tags/" in run + assert run.index("pypi.org/pypi/unstract-cli/") < run.index("gh release delete") From bff7df2a12ef54628061efd4999340972ce11307 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 16 Sep 2026 11:55:51 +0530 Subject: [PATCH 5/5] chore: start the committed version at 0.0.0 With nothing published, the first release is an ordinary `minor` bump rather than a special case. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/unstract_cli/__init__.py b/src/unstract_cli/__init__.py index c85c094..0e93683 100644 --- a/src/unstract_cli/__init__.py +++ b/src/unstract_cli/__init__.py @@ -1,3 +1,3 @@ """Unstract CLI.""" -__version__ = "0.1.0" +__version__ = "0.0.0"