diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bee8694..fb92574 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 @@ -150,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 }} @@ -160,15 +158,12 @@ 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 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" @@ -194,6 +189,31 @@ 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 }}" + # 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 + + # 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/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" diff --git a/tests/test_workflows.py b/tests/test_workflows.py index f92cb67..698affd 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -130,3 +130,39 @@ 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 + 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")