diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000..83046a6 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,97 @@ +name: Python CI (reusable) + +# Validace, lint, testy a build Python knihovny. +# Volání: uses: SYSNET-CZ/.github/.github/workflows/python-ci.yml@v1 + +on: + workflow_call: + inputs: + python-versions: + description: 'JSON pole verzí Pythonu pro testy' + type: string + default: '["3.11", "3.12", "3.13"]' + build-python-version: + description: 'Verze Pythonu pro lint a build' + type: string + default: '3.12' + runs-on: + description: 'Runner pro CI (výchozí self-hosted; publikace běží vždy na GitHub-hosted)' + type: string + default: 'self-hosted' + install-command: + description: 'Instalace projektu s dev závislostmi' + type: string + default: 'pip install -e ".[dev]"' + lint-command: + description: 'Lint (prázdné = přeskočit)' + type: string + default: 'ruff check .' + test-command: + description: 'Spuštění testů' + type: string + default: 'python -m pytest' + +permissions: {} + +jobs: + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ${{ inputs.runs-on }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + python-version: ${{ fromJSON(inputs.python-versions) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: ${{ matrix.python-version }} + - name: Create isolated virtualenv + # Nikdy neinstalovat do sdíleného toolcache self-hosted runneru. + run: | + python -m venv "$RUNNER_TEMP/venv" + echo "$RUNNER_TEMP/venv/bin" >> "$GITHUB_PATH" + - name: Install + env: + INSTALL_COMMAND: ${{ inputs.install-command }} + run: | + python -m pip install --upgrade pip + eval "$INSTALL_COMMAND" + - name: Lint + if: inputs.lint-command != '' && matrix.python-version == inputs.build-python-version + env: + LINT_COMMAND: ${{ inputs.lint-command }} + run: eval "$LINT_COMMAND" + - name: Test + env: + TEST_COMMAND: ${{ inputs.test-command }} + run: eval "$TEST_COMMAND" + + build: + name: Build and check distribution + runs-on: ${{ inputs.runs-on }} + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: ${{ inputs.build-python-version }} + - name: Build and twine check + run: | + python -m venv "$RUNNER_TEMP/venv" + . "$RUNNER_TEMP/venv/bin/activate" + python -m pip install --upgrade pip build twine + python -m build --outdir dist + twine check --strict dist/* + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ci-dist + path: dist/ + retention-days: 7 diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..d6e06b5 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,162 @@ +name: Python publish to PyPI (reusable) + +# Bezpečná publikace přes PyPI Trusted Publishing (OIDC), bez API tokenů. +# Volající workflow MUSÍ mít soubor .github/workflows/publish.yml - PyPI Trusted Publisher +# se registruje na název volajícího workflow, ne tohoto. +# Volání: uses: SYSNET-CZ/.github/.github/workflows/python-publish.yml@v1 + +on: + workflow_call: + inputs: + package-name: + description: 'Distribuční název na PyPI (např. sysnet-pyutils)' + type: string + required: true + python-version: + type: string + default: '3.12' + environment: + description: 'GitHub environment publikačního jobu' + type: string + default: 'pypi' + repository-url: + description: 'Prázdné = produkční PyPI; https://test.pypi.org/legacy/ = TestPyPI' + type: string + default: '' + main-branch: + description: 'Větev, ze které smí release tag pocházet' + type: string + default: 'main' + dry-run: + description: 'Pouze ověření a build, bez publikace' + type: boolean + default: false + outputs: + version: + description: 'Publikovaná verze' + value: ${{ jobs.verify.outputs.version }} + +permissions: {} + +jobs: + verify: + name: Verify tag and build + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + version: ${{ steps.dist.outputs.version }} + steps: + - name: Require release tag vX.Y.Z + env: + REF_TYPE: ${{ github.ref_type }} + TAG: ${{ github.ref_name }} + run: | + if [ "$REF_TYPE" != "tag" ] || ! printf '%s' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::Publikace je povolena jen z tagu vX.Y.Z (ref_type=$REF_TYPE, ref=$TAG)" + exit 1 + fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Require tagged commit on main branch + env: + MAIN_BRANCH: ${{ inputs.main-branch }} + run: | + git fetch --no-tags origin "$MAIN_BRANCH" + if ! git merge-base --is-ancestor "$GITHUB_SHA" "origin/$MAIN_BRANCH"; then + echo "::error::Tagovaný commit není na větvi $MAIN_BRANCH" + exit 1 + fi + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: ${{ inputs.python-version }} + - name: Build + run: | + python -m pip install --upgrade pip build twine packaging + python -m build --outdir dist + twine check --strict dist/* + - name: Verify distribution matches tag and package name + id: dist + env: + PACKAGE_NAME: ${{ inputs.package-name }} + TAG: ${{ github.ref_name }} + run: | + python - <<'PY' + import glob, os, sys, tarfile, zipfile + from email.parser import Parser + from packaging.utils import canonicalize_name + from packaging.version import Version + + expected_name = canonicalize_name(os.environ["PACKAGE_NAME"]) + expected_version = Version(os.environ["TAG"].removeprefix("v")) + errors = [] + wheels = glob.glob("dist/*.whl") + sdists = glob.glob("dist/*.tar.gz") + if len(wheels) != 1 or len(sdists) != 1: + errors.append(f"očekáván 1 wheel a 1 sdist, nalezeno {len(wheels)}/{len(sdists)}") + for path in wheels + sdists: + if path.endswith(".whl"): + with zipfile.ZipFile(path) as z: + name = next(n for n in z.namelist() if n.endswith(".dist-info/METADATA")) + meta = Parser().parsestr(z.read(name).decode()) + else: + with tarfile.open(path) as t: + member = next(m for m in t.getmembers() if m.name.count("/") == 1 and m.name.endswith("/PKG-INFO")) + 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: + errors.append(f"{path}: verze {meta['Version']} != tag {os.environ['TAG']}") + if errors: + for e in errors: + print(f"::error::{e}") + sys.exit(1) + with open(os.environ["GITHUB_OUTPUT"], "a") as out: + out.write(f"version={expected_version}\n") + PY + - name: Refuse to republish an existing version + env: + PACKAGE_NAME: ${{ inputs.package-name }} + VERSION: ${{ steps.dist.outputs.version }} + REPOSITORY_URL: ${{ inputs.repository-url }} + run: | + host=pypi.org + case "$REPOSITORY_URL" in *test.pypi.org*) host=test.pypi.org ;; esac + code=$(curl -s -o /dev/null -w '%{http_code}' "https://$host/pypi/$PACKAGE_NAME/$VERSION/json") + if [ "$code" = "200" ]; then + echo "::error::Verze $VERSION balíčku $PACKAGE_NAME už na $host existuje; verzi nelze přepsat, vydejte novou." + exit 1 + elif [ "$code" != "404" ]; then + echo "::error::Neočekávaná odpověď $host: HTTP $code" + exit 1 + fi + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: python-dist + path: dist/ + if-no-files-found: error + retention-days: 7 + + publish: + name: Publish to PyPI + needs: verify + if: ${{ !inputs.dry-run }} + runs-on: ubuntu-latest + environment: + name: ${{ inputs.environment }} + url: ${{ inputs.repository-url == '' && format('https://pypi.org/project/{0}/{1}/', inputs.package-name, needs.verify.outputs.version) || '' }} + permissions: + contents: read + id-token: write # OIDC pro Trusted Publishing + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: python-dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 + with: + repository-url: ${{ inputs.repository-url }} + packages-dir: dist/ + attestations: false # pro privátní repozitáře nejsou podporovány diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml new file mode 100644 index 0000000..8a1cba4 --- /dev/null +++ b/.github/workflows/python-release.yml @@ -0,0 +1,51 @@ +name: Python GitHub Release (reusable) + +# Vytvoří (nebo aktualizuje) GitHub Release z CHANGELOG.md. Idempotentní. +# Volání: uses: SYSNET-CZ/.github/.github/workflows/python-release.yml@v1 + +on: + workflow_call: + inputs: + package-name: + type: string + required: true + changelog-file: + type: string + default: 'CHANGELOG.md' + +permissions: {} + +jobs: + release: + name: GitHub Release + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Create or update release + env: + GH_TOKEN: ${{ github.token }} + PACKAGE_NAME: ${{ inputs.package-name }} + CHANGELOG: ${{ inputs.changelog-file }} + TAG: ${{ github.ref_name }} + REPO: ${{ github.repository }} + run: | + version="${TAG#v}" + { + if [ -f "$CHANGELOG" ]; then + # sekce začínající nadpisem "## [X.Y.Z]" nebo "## X.Y.Z" až do dalšího "## " + awk -v v="$version" ' + /^## / { if (found) exit; if (index($0, "[" v "]") || $2 == v) { found=1; next } } + found { print }' "$CHANGELOG" + fi + echo + echo "**PyPI:** https://pypi.org/project/$PACKAGE_NAME/$version/" + } > "$RUNNER_TEMP/notes.md" + if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then + gh release edit "$TAG" --repo "$REPO" --title "$PACKAGE_NAME $version" --notes-file "$RUNNER_TEMP/notes.md" + else + gh release create "$TAG" --repo "$REPO" --verify-tag --title "$PACKAGE_NAME $version" --notes-file "$RUNNER_TEMP/notes.md" + fi diff --git a/.github/workflows/test-workflows.yml b/.github/workflows/test-workflows.yml new file mode 100644 index 0000000..663e384 --- /dev/null +++ b/.github/workflows/test-workflows.yml @@ -0,0 +1,35 @@ +name: Test reusable workflows + +on: + pull_request: + push: + branches: [main] + +permissions: {} + +jobs: + actionlint: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: actionlint + run: docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9 -color + + verify-logic: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: '3.12' + - run: | + python -m pip install pytest pyyaml build packaging setuptools + python -m pytest tests -q diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c56ff1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +.pytest_cache/ diff --git a/README.md b/README.md index a7473f0..aeec83f 100644 --- a/README.md +++ b/README.md @@ -19,3 +19,15 @@ Všechny šablony jsou přednastaveny na **self-hosted runner** (`hermes-vm`). 1. Jdi do repozitáře → **Actions** → **New workflow** 2. V sekci **SYSNET-CZ** vyber příslušnou šablonu 3. Upravit dle potřeby a commitni + +## Reusable workflows - Python knihovny na PyPI + +| Workflow | Účel | +|---|---| +| `python-ci.yml` | lint, testy v matici verzí, build a `twine check` | +| `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), +[`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 new file mode 100644 index 0000000..4a2ec60 --- /dev/null +++ b/docs/pypi-trusted-publishing.md @@ -0,0 +1,45 @@ +# PyPI Trusted Publishing (OIDC) + +Publikace nepoužívá žádný dlouhodobý PyPI token ani heslo v GitHub Secrets. Job `publish` dostane +krátkodobý OIDC token od GitHubu a PyPI jej vymění za jednorázový upload token. + +## Registrace Trusted Publisheru (manuálně, správce PyPI) + +PyPI nenabízí podporované API pro správu Trusted Publisherů; registrace se dělá ve webovém UI. Automatizace UI +není součástí řešení. + +Existující projekt: PyPI -> projekt -> **Manage** -> **Publishing** -> *Add a new publisher* -> GitHub: + +| Pole | Hodnota | +|---|---| +| PyPI project name | např. `sysnet-pyutils` | +| Owner | `SYSNET-CZ` | +| Repository name | např. `pyutils` | +| Workflow name | `publish.yml` (**název volajícího workflow**, ne `python-publish.yml`) | +| Environment name | `pypi` | + +Nový projekt: stejná tabulka v **Your account -> Publishing** jako *Pending Publisher*; projekt vznikne prvním +publikováním. Projekt, jehož název vlastní jiný subjekt, nelze registrovat - rozhodnutí o názvu je na správci. + +Pro TestPyPI se registruje samostatně na https://test.pypi.org (jiný účet i publisher), environment `testpypi`, +`repository-url: https://test.pypi.org/legacy/`. + +## Ověření shody identity + +OIDC claim `repository` je volající repozitář a `workflow_ref` volající workflow, proto publisher musí +odpovídat repozitáři a souboru `publish.yml` knihovny. Nesoulad se projeví chybou `invalid-publisher` +při publikaci. Pro privátní repozitáře se attestations nevytvářejí (`attestations: false`). + +## Doporučená ochrana (GitHub) + +- Environment `pypi`: omezit na deployment tags `v*`; požadovaní reviewers jen pokud to plán dovoluje + (u privátních repozitářů je to dostupné až v plánu Enterprise, ověřte). +- Alternativa: ruleset `v*` tagů (create/update/delete jen správci) a branch protection / ruleset na `main` + (PR + povinné CI). +- Workflow pinují všechny actions na SHA; org doporučená práva `GITHUB_TOKEN`: read. + +## Správa oprávnění + +- 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. diff --git a/docs/python-packaging.md b/docs/python-packaging.md new file mode 100644 index 0000000..82da67d --- /dev/null +++ b/docs/python-packaging.md @@ -0,0 +1,97 @@ +# Standard Python knihoven SYSNET-CZ + +Jak založit sdílenou Python knihovnu a připojit ji k centrálnímu CI/CD v tomto repozitáři. + +## 1. Založení nové knihovny + +1. Repozitář `SYSNET-CZ/` s topics `library` a `python` (viz `docs/topic-guidelines.md` v `repo-catalog`). +2. Distribuční název s prefixem `sysnet-` (např. `sysnet-pyutils`). **Nikdy** nepoužívejte holý název + (`pyutils`, `audit` už na PyPI patří cizím lidem). Import název se může lišit (`audit_lib`). +3. Před prvním vydáním ověřte, že název na PyPI není obsazen jiným subjektem + (`curl -s -o /dev/null -w '%{http_code}' https://pypi.org/pypi//json`; 404 = volný). + Obsazený název se **nepřejmenovává automaticky** - rozhodnutí patří správci. + +## 2. Povinný obsah `pyproject.toml` + +```toml +[build-system] +requires = ["hatchling"] # nebo setuptools>=68; stávající funkční backend neměňte +build-backend = "hatchling.build" + +[project] +name = "sysnet-priklad" +version = "0.1.0" # PEP 440, jediný zdroj verze (žádný zvláštní soubor VERSION) +description = "..." +readme = "README.md" +requires-python = ">=3.11" +license = "AGPL-3.0-or-later" # SPDX výraz (PEP 639); licenční soubor v repu +license-files = ["LICENSE*"] +authors = [{ name = "SYSNET s.r.o.", email = "info@sysnet.cz" }] +dependencies = ["..."] # jen runtime, s rozumnou dolní mezí + +[project.optional-dependencies] +dev = ["pytest", "ruff", "mypy"] # pro python-ci výchozí install-command: pip install -e ".[dev]" + +[project.urls] +Source = "https://github.com/SYSNET-CZ/" +Issues = "https://github.com/SYSNET-CZ//issues" +Changelog = "https://github.com/SYSNET-CZ//blob/main/CHANGELOG.md" +``` + +Pravidla: do balíčku nepatří secrets, `.env`, interní konfigurace ani testovací data s osobními údaji. +Obsah vzniklých archivů zkontrolujte: `python -m build && unzip -l dist/*.whl && tar tzf dist/*.tar.gz`. + +## 3. Napojení na centrální CI/CD + +`.github/workflows/ci.yml` v knihovně: + +```yaml +name: CI +on: + pull_request: + push: + branches: [main] +permissions: {} +jobs: + ci: + permissions: + contents: read + uses: SYSNET-CZ/.github/.github/workflows/python-ci.yml@v1 + with: + python-versions: '["3.11", "3.12", "3.13"]' +``` + +`.github/workflows/publish.yml` - **název souboru musí být přesně `publish.yml`**, na něj se registruje Trusted Publisher: + +```yaml +name: Publish +on: + push: + tags: ["v[0-9]+.[0-9]+.[0-9]+"] +permissions: {} +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false +jobs: + publish: + permissions: + contents: read + id-token: write + uses: SYSNET-CZ/.github/.github/workflows/python-publish.yml@v1 + with: + package-name: sysnet-priklad + release: + needs: publish + permissions: + contents: write + uses: SYSNET-CZ/.github/.github/workflows/python-release.yml@v1 + 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`. + +## 4. Runnery + +CI (`python-ci.yml`) běží ve výchozím nastavení na `self-hosted`. Publikační job běží vždy na GitHub-hosted +`ubuntu-latest` - je krátký a nesmí používat sdílený dlouhodobý runner, který dostává OIDC identitu. diff --git a/docs/python-release-process.md b/docs/python-release-process.md new file mode 100644 index 0000000..32d5cab --- /dev/null +++ b/docs/python-release-process.md @@ -0,0 +1,39 @@ +# Release proces Python knihoven + +## Postup vydání + +1. Změna kódu přes pull request, zelené CI (`python-ci.yml`), schválení. +2. Samostatný PR "release": zvýšit `version` v `pyproject.toml` (SemVer, PEP 440) a doplnit sekci + `## [X.Y.Z]` do `CHANGELOG.md`. Sloučit do `main`. +3. Správce vytvoří tag na commitu z `main`: `git tag vX.Y.Z && git push origin vX.Y.Z`. + Existující tagy se nepřepisují (žádný `git tag -f`, žádný force push). +4. Spustí se `publish.yml`: + - `verify`: tag musí být `vX.Y.Z`, commit musí být na `main`, sestaví se wheel+sdist, `twine check --strict`, + název a verze v obou archivech musí odpovídat parametru a tagu, verze nesmí na PyPI existovat. + - `publish`: čeká na environment `pypi` (schválení, pokud je dostupné), publikuje přes OIDC. + - `release`: vytvoří GitHub Release z `CHANGELOG.md` s odkazem na PyPI projekt. +5. Ověřte instalaci v čistém venv: `pip install sysnet-priklad==X.Y.Z && python -c "import ..."`. + +## Opakované spuštění + +- Selhal `verify` nebo `publish` před nahráním: opravte příčinu (mimo tag) a použijte *Re-run*; tag se nemění. + Pokud je třeba změnit kód, vydejte novou verzi. +- Verze už na PyPI je: `verify` selže záměrně. Nahrazení neexistuje. +- Selhal jen `release`: *Re-run failed jobs*. Job je idempotentní (vytvoří nebo aktualizuje release). + +## Nápravné vydání a rollback + +Publikovanou verzi na PyPI **nelze přepsat ani znovu nahrát** (ani po smazání). Postup: + +1. Vadnou verzi označte na PyPI jako **yanked** (Manage -> Release -> Yank, s důvodem). Instalace s přesným + pinem funguje dál, běžné `pip install` ji přeskočí. +2. Opravu vydejte jako vyšší verzi (např. 1.2.4), nikdy ne přes opětovné použití čísla. +3. Do GitHub Release vadné verze doplňte upozornění; tag nemažte ani nepřepisujte. + +## Kompromitace vydání + +1. Okamžitě yank dotčených verzí; je-li to nutné, smazání verze/projektu provádí jen vlastník PyPI. +2. Odeberte Trusted Publisher (PyPI -> Publishing), případně dočasně zrušte oprávnění uživatelů projektu. +3. Zkontrolujte audit log PyPI, historii tagů a běhy workflow; zrušte všechny dlouhodobé tokeny (`PYPI_API_TOKEN`). +4. Vydejte čistou verzi z ověřeného commitu, nové přihlašovací údaje a Trusted Publisher nastavte až po analýze. +5. Informujte konzumenty (závislé repozitáře: `sysnet-auth` -> `sysnet-pyutils`, `sysnet-docversion` -> `sysnet-soft-delete`). diff --git a/tests/test_publish_checks.py b/tests/test_publish_checks.py new file mode 100644 index 0000000..ec60ee9 --- /dev/null +++ b/tests/test_publish_checks.py @@ -0,0 +1,72 @@ +"""Tests of the version/name verification embedded in python-publish.yml. + +Extracts the inline Python of the "Verify distribution" step, builds a tiny fixture +package and runs the step with valid and invalid tags/names. +Run: python -m pytest tests/ (needs: pytest, pyyaml, build, packaging) +""" +import os +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest +import yaml + +WORKFLOW = Path(__file__).parent.parent / ".github/workflows/python-publish.yml" + + +def _step_script() -> str: + wf = yaml.safe_load(WORKFLOW.read_text()) + step = next(s for s in wf["jobs"]["verify"]["steps"] if s.get("id") == "dist") + run = step["run"] + start = run.index("<<'PY'\n") + len("<<'PY'\n") + return textwrap.dedent(run[start:run.rindex("PY")]) + + +@pytest.fixture(scope="module") +def dist_dir(tmp_path_factory): + 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' + ) + 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): + out = root / "gh_output" + out.write_text("") + env = {**os.environ, "TAG": tag, "PACKAGE_NAME": name, "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() + + +def test_matching_tag_and_name_passes(dist_dir): + r, out = _run(dist_dir, "v1.2.3", "fixture-pkg") + assert r.returncode == 0, r.stdout + r.stderr + assert "version=1.2.3" in out + + +def test_version_mismatch_fails(dist_dir): + r, _ = _run(dist_dir, "v1.2.4", "fixture-pkg") + assert r.returncode == 1 and "!= tag" in r.stdout + + +def test_name_mismatch_fails(dist_dir): + r, _ = _run(dist_dir, "v1.2.3", "other-pkg") + assert r.returncode == 1 and "název" in r.stdout + + +def test_tag_regex_in_workflow(): + wf = yaml.safe_load(WORKFLOW.read_text()) + script = wf["jobs"]["verify"]["steps"][0]["run"] + for tag, ok in [("v1.2.3", True), ("1.2.3", False), ("v1.2", False), ("v1.2.3-rc1", False), ("main", False)]: + env = {**os.environ, "REF_TYPE": "tag", "TAG": tag} + r = subprocess.run(["bash", "-c", script], env=env, capture_output=True, text=True) + assert (r.returncode == 0) == ok, tag + r = subprocess.run(["bash", "-c", script], env={**os.environ, "REF_TYPE": "branch", "TAG": "v1.2.3"}, capture_output=True, text=True) + assert r.returncode == 1