Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions .github/workflows/testpypi.yml
Original file line number Diff line number Diff line change
@@ -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: <pyproject version>.dev<run><attempt>.
#
# 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
35 changes: 34 additions & 1 deletion RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -57,6 +58,38 @@ One-time setup: add a trusted publisher at
<https://pypi.org/manage/project/freshdata-cleaner/settings/publishing/>
(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<run><attempt>`, 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
<https://test.pypi.org/manage/account/publishing/> 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
Expand Down
47 changes: 47 additions & 0 deletions tests/test_testpypi_workflow.py
Original file line number Diff line number Diff line change
@@ -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
Loading