From d66b450e647afeb04fd0a88627df04b7a38c0861 Mon Sep 17 00:00:00 2001 From: Kevin Costner <120246174+kevincostner17@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:03:00 +0530 Subject: [PATCH] ci: add an optional TestPyPI dry-run release workflow A manual workflow rehearses a release without touching real PyPI or storing tokens: it builds the current commit as a unique X.Y.Z.dev version (TestPyPI never accepts re-uploads), publishes it to TestPyPI with Trusted Publishing (OIDC, environment `testpypi`), then installs it from TestPyPI in a clean venv with retries, imports it, prints fd.__version__ and asserts it matches, and runs a tiny fd.clean smoke. RELEASE.md documents the workflow, the manual verification command and the one-time maintainer setup (TestPyPI pending publisher + `testpypi` environment). A contract test pins the dispatch-only trigger, OIDC-only publishing to test.pypi.org and the version print. --- .github/workflows/testpypi.yml | 129 ++++++++++++++++++++++++++++++++ RELEASE.md | 35 ++++++++- tests/test_testpypi_workflow.py | 47 ++++++++++++ 3 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/testpypi.yml create mode 100644 tests/test_testpypi_workflow.py diff --git a/.github/workflows/testpypi.yml b/.github/workflows/testpypi.yml new file mode 100644 index 00000000..8afd0ffe --- /dev/null +++ b/.github/workflows/testpypi.yml @@ -0,0 +1,129 @@ +name: TestPyPI dry run + +# Optional pre-release rehearsal: build the current commit, publish it to +# TestPyPI through Trusted Publishing (OIDC, no stored tokens), then install it +# from TestPyPI in a clean environment and import it. Never touches real PyPI. +# +# TestPyPI never accepts a re-upload of an existing version, so every run +# publishes a unique development version: .dev. +# +# One-time setup (see RELEASE.md): register a trusted publisher on +# https://test.pypi.org for workflow `testpypi.yml`, environment `testpypi`, +# and create the `testpypi` environment in the repository settings. +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: testpypi + cancel-in-progress: false + +env: + PACKAGE_NAME: freshdata-cleaner + +jobs: + build: + name: Build a unique development version + if: github.repository == 'FreshCode-Org/freshdata' + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Stamp a unique TestPyPI version + id: version + run: | + set -euo pipefail + python -m pip install --upgrade pip packaging + BASE=$(python -c "import tomllib, pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])") + VERSION="${BASE}.dev${GITHUB_RUN_NUMBER}${GITHUB_RUN_ATTEMPT}" + python -c "from packaging.version import Version; import sys; Version(sys.argv[1])" "$VERSION" + # Only this CI checkout is modified; nothing is committed. + sed -i -E "s/^version = \"[^\"]+\"/version = \"${VERSION}\"/" pyproject.toml + sed -i -E "s/^__version__ = \"[^\"]+\"/__version__ = \"${VERSION}\"/" src/freshdata/__init__.py + grep -q "^version = \"${VERSION}\"" pyproject.toml + grep -q "^__version__ = \"${VERSION}\"" src/freshdata/__init__.py + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "TestPyPI version: \`${VERSION}\`" >> "$GITHUB_STEP_SUMMARY" + - name: Build sdist and wheel + run: | + set -euo pipefail + python -m pip install build twine + python -m build + twine check dist/* + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: testpypi-dist + path: dist/ + retention-days: 7 + + publish-testpypi: + name: Publish to TestPyPI + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: testpypi + url: https://test.pypi.org/project/freshdata-cleaner/${{ needs.build.outputs.version }}/ + permissions: + contents: read + id-token: write # Trusted Publishing: short-lived OIDC credential, no tokens + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: testpypi-dist + path: dist/ + - name: Publish to TestPyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + repository-url: https://test.pypi.org/legacy/ + + smoke: + name: Install from TestPyPI and import + needs: [build, publish-testpypi] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Install the published version (retries while the index catches up) + env: + VERSION: ${{ needs.build.outputs.version }} + run: | + set -euo pipefail + python -m venv /tmp/smoke + /tmp/smoke/bin/pip install --upgrade pip + for attempt in $(seq 1 10); do + if /tmp/smoke/bin/pip install --no-cache-dir \ + --index-url https://test.pypi.org/simple/ \ + --extra-index-url https://pypi.org/simple/ \ + "${PACKAGE_NAME}==${VERSION}"; then + exit 0 + fi + echo "not installable yet (attempt ${attempt}/10); retrying in 30s" + sleep 30 + done + echo "::error::${PACKAGE_NAME}==${VERSION} never became installable from TestPyPI" + exit 1 + - name: Import and print the version + env: + VERSION: ${{ needs.build.outputs.version }} + run: | + set -euo pipefail + INSTALLED=$(/tmp/smoke/bin/python -c "import freshdata as fd; print(fd.__version__)") + echo "freshdata ${INSTALLED}" + test "$INSTALLED" = "$VERSION" + /tmp/smoke/bin/python - <<'PY' + import pandas as pd + import freshdata as fd + out = fd.clean(pd.DataFrame({"a": [1, None, 2], "b": ["x", "x", None]}), verbose=False) + assert len(out.columns) == 2, out.columns + print("smoke ok:", out.shape) + PY diff --git a/RELEASE.md b/RELEASE.md index d6dd445a..bb20114c 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -24,7 +24,8 @@ backward-compatible fixes. `## [X.Y.Z] - YYYY-MM-DD` heading. 4. **Commit & PR** — merge to `main`. 5. **Build & validate** locally (see below). -6. **Publish to TestPyPI**, smoke-test the install. +6. **Publish to TestPyPI**, smoke-test the install — run the *TestPyPI dry run* + workflow (see [TestPyPI dry run](#testpypi-dry-run-optional)). 7. **Publish to PyPI** (push the tag and let CI do it through trusted publishing). 8. **Tag & GitHub release** — `git tag vX.Y.Z` and create the release with notes from the changelog. @@ -57,6 +58,38 @@ One-time setup: add a trusted publisher at (workflow `release.yml`, environment `pypi`). +### TestPyPI dry run (optional) + +Rehearse a release without touching real PyPI and without any stored token: +the manual **TestPyPI dry run** workflow (`.github/workflows/testpypi.yml`) +builds the current commit as a unique development version +(`X.Y.Z.dev`, because TestPyPI never accepts a re-upload), +publishes it to TestPyPI through Trusted Publishing, then installs it from +TestPyPI in a clean environment, imports it and prints `fd.__version__`. + +```bash +gh workflow run testpypi.yml --ref main +gh run watch "$(gh run list --workflow testpypi.yml --limit 1 --json databaseId --jq '.[0].databaseId')" +``` + +To verify a TestPyPI upload by hand (use the version from the run summary): + +```bash +python -m venv /tmp/testpypi-smoke +/tmp/testpypi-smoke/bin/pip install --index-url https://test.pypi.org/simple/ \ + --extra-index-url https://pypi.org/simple/ "freshdata-cleaner==X.Y.Z.devN" +/tmp/testpypi-smoke/bin/python -c "import freshdata as fd; print(fd.__version__)" +``` + +One-time setup (a maintainer, once): + +1. Add a pending trusted publisher at + for project + `freshdata-cleaner`, owner `FreshCode-Org`, repository `freshdata`, + workflow `testpypi.yml`, environment `testpypi`. +2. Create a `testpypi` environment under the repository's + *Settings → Environments* (no secrets needed). + ### Option B — manual with `twine` (fallback only) ```bash diff --git a/tests/test_testpypi_workflow.py b/tests/test_testpypi_workflow.py new file mode 100644 index 00000000..5b2aec50 --- /dev/null +++ b/tests/test_testpypi_workflow.py @@ -0,0 +1,47 @@ +"""Contract for the optional TestPyPI dry-run release workflow (#6).""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "testpypi.yml" + + +def _workflow() -> tuple[str, dict]: + text = WORKFLOW.read_text(encoding="utf-8") + return text, yaml.safe_load(text) + + +def test_testpypi_workflow_is_manual_only() -> None: + _, workflow = _workflow() + # PyYAML 1.1 treats the unquoted GitHub Actions key `on` as a boolean. + triggers = workflow.get("on", workflow.get(True)) + assert set(triggers) == {"workflow_dispatch"} + + +def test_testpypi_publish_uses_trusted_publishing_without_tokens() -> None: + text, workflow = _workflow() + publish = workflow["jobs"]["publish-testpypi"] + assert publish["permissions"]["id-token"] == "write" + assert publish["environment"]["name"] == "testpypi" + step = next(s for s in publish["steps"] if "gh-action-pypi-publish" in s.get("uses", "")) + assert step["with"]["repository-url"] == "https://test.pypi.org/legacy/" + assert "password" not in step["with"] + assert "secrets." not in text + + +def test_testpypi_smoke_installs_from_testpypi_and_prints_version() -> None: + text, workflow = _workflow() + assert workflow["jobs"]["smoke"]["needs"] == ["build", "publish-testpypi"] + assert "--index-url https://test.pypi.org/simple/" in text + assert 'import freshdata as fd; print(fd.__version__)' in text + + +def test_release_guide_documents_the_testpypi_verification_command() -> None: + guide = (ROOT / "RELEASE.md").read_text(encoding="utf-8") + assert "testpypi.yml" in guide + assert "pip install --index-url https://test.pypi.org/simple/" in guide + assert 'python -c "import freshdata as fd; print(fd.__version__)"' in guide