From 56412b848699a79b80c2372147ad3cb0ecddef51 Mon Sep 17 00:00:00 2001 From: Radim Jaeger Date: Fri, 13 Nov 2026 19:33:06 +0100 Subject: [PATCH] feat: TestPyPI mode accepting only dev versions without tag Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/python-publish.yml | 19 +++++++++++++-- README.md | 2 +- docs/pypi-trusted-publishing.md | 10 ++++++++ docs/python-packaging.md | 8 +++---- tests/test_publish_checks.py | 35 ++++++++++++++++++++++++---- 5 files changed, 62 insertions(+), 12 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index d6e06b5..3dfda3e 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -48,6 +48,7 @@ jobs: version: ${{ steps.dist.outputs.version }} steps: - name: Require release tag vX.Y.Z + if: ${{ !contains(inputs.repository-url, 'test.pypi.org') }} env: REF_TYPE: ${{ github.ref_type }} TAG: ${{ github.ref_name }} @@ -61,6 +62,7 @@ jobs: fetch-depth: 0 persist-credentials: false - name: Require tagged commit on main branch + if: ${{ !contains(inputs.repository-url, 'test.pypi.org') }} env: MAIN_BRANCH: ${{ inputs.main-branch }} run: | @@ -82,6 +84,8 @@ jobs: env: PACKAGE_NAME: ${{ inputs.package-name }} TAG: ${{ github.ref_name }} + # TestPyPI: žádný tag, ale verze MUSÍ být dev release (X.Y.Z.devN), aby nešlo vydat produkční verzi + TEST_MODE: ${{ contains(inputs.repository-url, 'test.pypi.org') }} run: | python - <<'PY' import glob, os, sys, tarfile, zipfile @@ -90,7 +94,9 @@ jobs: from packaging.version import Version expected_name = canonicalize_name(os.environ["PACKAGE_NAME"]) - expected_version = Version(os.environ["TAG"].removeprefix("v")) + test_mode = os.environ.get("TEST_MODE") == "true" + expected_version = None if test_mode else Version(os.environ["TAG"].removeprefix("v")) + seen_versions = set() errors = [] wheels = glob.glob("dist/*.whl") sdists = glob.glob("dist/*.tar.gz") @@ -107,8 +113,17 @@ jobs: meta = Parser().parsestr(t.extractfile(member).read().decode()) if canonicalize_name(meta["Name"]) != expected_name: errors.append(f"{path}: název {meta['Name']} != {expected_name}") - if Version(meta["Version"]) != expected_version: + version = Version(meta["Version"]) + seen_versions.add(version) + if test_mode: + if not version.is_devrelease: + errors.append(f"{path}: TestPyPI přijímá jen dev verze (X.Y.Z.devN), nalezeno {version}") + elif version != expected_version: errors.append(f"{path}: verze {meta['Version']} != tag {os.environ['TAG']}") + if test_mode and len(seen_versions) == 1: + expected_version = next(iter(seen_versions)) + elif test_mode: + errors.append(f"nesourodé verze v archivech: {sorted(map(str, seen_versions))}") if errors: for e in errors: print(f"::error::{e}") diff --git a/README.md b/README.md index aeec83f..8305fda 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,6 @@ Všechny šablony jsou přednastaveny na **self-hosted runner** (`hermes-vm`). | `python-publish.yml` | ověření tagu/verze, publikace na PyPI přes OIDC (Trusted Publishing) | | `python-release.yml` | GitHub Release z `CHANGELOG.md` | -Volat přes `@v1`. Dokumentace: [`docs/python-packaging.md`](docs/python-packaging.md), +Volat přes přesný tag (`@v1.1.0`). Dokumentace: [`docs/python-packaging.md`](docs/python-packaging.md), [`docs/python-release-process.md`](docs/python-release-process.md), [`docs/pypi-trusted-publishing.md`](docs/pypi-trusted-publishing.md). diff --git a/docs/pypi-trusted-publishing.md b/docs/pypi-trusted-publishing.md index 4a2ec60..ca1e28d 100644 --- a/docs/pypi-trusted-publishing.md +++ b/docs/pypi-trusted-publishing.md @@ -43,3 +43,13 @@ při publikaci. Pro privátní repozitáře se attestations nevytvářejí (`att - PyPI: role *Owner* jen pro nezbytné osoby, ostatní *Maintainer*; vyžadujte 2FA. - Po zprovoznění Trusted Publishingu smazat secret `PYPI_API_TOKEN` (repo i org) a token na PyPI zneplatnit. - Odchod člena týmu: odebrat z PyPI projektů a z GitHub týmů s právem push na tagy. + +## TestPyPI (od `v1.1.0`) + +`python-publish.yml` s `repository-url: https://test.pypi.org/legacy/` běží v testovacím režimu: nevyžaduje tag ani +větev `main`, ale **přijme jen dev verze** (`X.Y.Z.devN`), takže produkční číslo verze nelze touto cestou spálit. +Doporučené spouštění: `workflow_dispatch` ve vlastním `publish-test.yml` s environmentem `testpypi`. + +## Verze reusable workflows + +Odkazujte přesným tagem (`@v1.1.0`); existující tagy se nepřepisují, ani pohyblivý `v1` (zůstává na `v1.0.0`). diff --git a/docs/python-packaging.md b/docs/python-packaging.md index 82da67d..af2331f 100644 --- a/docs/python-packaging.md +++ b/docs/python-packaging.md @@ -56,7 +56,7 @@ jobs: ci: permissions: contents: read - uses: SYSNET-CZ/.github/.github/workflows/python-ci.yml@v1 + uses: SYSNET-CZ/.github/.github/workflows/python-ci.yml@v1.1.0 with: python-versions: '["3.11", "3.12", "3.13"]' ``` @@ -77,19 +77,19 @@ jobs: permissions: contents: read id-token: write - uses: SYSNET-CZ/.github/.github/workflows/python-publish.yml@v1 + uses: SYSNET-CZ/.github/.github/workflows/python-publish.yml@v1.1.0 with: package-name: sysnet-priklad release: needs: publish permissions: contents: write - uses: SYSNET-CZ/.github/.github/workflows/python-release.yml@v1 + uses: SYSNET-CZ/.github/.github/workflows/python-release.yml@v1.1.0 with: package-name: sysnet-priklad ``` -Volejte vždy přes stabilní tag `@v1`, nikdy `@main`. Reusable workflow se vydává tagem `v1.x.y` a pohyblivým tagem `v1`. +Volejte vždy přes přesný verzovaný tag (`@v1.1.0`), nikdy `@main`. Tagy se nepřepisují, každá změna reusable workflow je nový tag `v1.x.y`. ## 4. Runnery diff --git a/tests/test_publish_checks.py b/tests/test_publish_checks.py index ec60ee9..5d72c63 100644 --- a/tests/test_publish_checks.py +++ b/tests/test_publish_checks.py @@ -24,23 +24,32 @@ def _step_script() -> str: return textwrap.dedent(run[start:run.rindex("PY")]) -@pytest.fixture(scope="module") -def dist_dir(tmp_path_factory): +def _make_dist(tmp_path_factory, version): root = tmp_path_factory.mktemp("pkg") (root / "fixture_pkg").mkdir() (root / "fixture_pkg/__init__.py").write_text("") (root / "pyproject.toml").write_text( '[build-system]\nrequires=["setuptools>=68"]\nbuild-backend="setuptools.build_meta"\n' - '[project]\nname="Fixture_Pkg"\nversion="1.2.3"\n' + '[project]\nname="Fixture_Pkg"\nversion="%s"\n' % version ) subprocess.run([sys.executable, "-m", "build", "--outdir", "dist"], cwd=root, check=True, capture_output=True) return root -def _run(root: Path, tag: str, name: str): +@pytest.fixture(scope="module") +def dist_dir(tmp_path_factory): + return _make_dist(tmp_path_factory, "1.2.3") + + +@pytest.fixture(scope="module") +def dev_dist_dir(tmp_path_factory): + return _make_dist(tmp_path_factory, "1.2.4.dev1") + + +def _run(root: Path, tag: str, name: str, test_mode: bool = False): out = root / "gh_output" out.write_text("") - env = {**os.environ, "TAG": tag, "PACKAGE_NAME": name, "GITHUB_OUTPUT": str(out)} + env = {**os.environ, "TAG": tag, "PACKAGE_NAME": name, "TEST_MODE": "true" if test_mode else "false", "GITHUB_OUTPUT": str(out)} r = subprocess.run([sys.executable, "-c", _step_script()], cwd=root, env=env, capture_output=True, text=True) return r, out.read_text() @@ -61,6 +70,22 @@ def test_name_mismatch_fails(dist_dir): assert r.returncode == 1 and "název" in r.stdout +def test_test_mode_accepts_dev_version_without_tag(dev_dist_dir): + r, out = _run(dev_dist_dir, "feature-branch", "fixture-pkg", test_mode=True) + assert r.returncode == 0, r.stdout + r.stderr + assert "version=1.2.4.dev1" in out + + +def test_test_mode_rejects_release_version(dist_dir): + r, _ = _run(dist_dir, "feature-branch", "fixture-pkg", test_mode=True) + assert r.returncode == 1 and "dev verze" in r.stdout + + +def test_dev_version_rejected_in_production_mode(dev_dist_dir): + r, _ = _run(dev_dist_dir, "v1.2.4", "fixture-pkg") + assert r.returncode == 1 + + def test_tag_regex_in_workflow(): wf = yaml.safe_load(WORKFLOW.read_text()) script = wf["jobs"]["verify"]["steps"][0]["run"]