diff --git a/.github/workflows/build-pypilibrary-task.yml b/.github/workflows/build-pypilibrary-task.yml index fdb05c08..60e926e4 100644 --- a/.github/workflows/build-pypilibrary-task.yml +++ b/.github/workflows/build-pypilibrary-task.yml @@ -65,28 +65,78 @@ jobs: - name: Run pytest step run: uv run pytest + # Compute the PEP 440 version string for this build: + # refs/heads/main -> AssemblyFileVersion as-is + # (`Major.Minor.Patch.BuildNumber`). PEP 440 + # treats this 4-segment numeric form as a + # release. + # refs/heads/develop -> `${AssemblyFileVersion}.dev0` + # (`Major.Minor.Patch.BuildNumber.dev0`). + # The BuildNumber stays in the release + # segment so develop's release segment + # (which grows past main's after every + # new commit) compares higher than main's + # under PEP 440 ordering — so `pip install + # --pre ` picks the develop dev + # build, while default `pip install ` + # filters the dev suffix and picks the + # main release. The `.dev0` literal is a + # constant because BuildNumber alone + # already differentiates each develop + # push (NBGV BuildNumber increments per + # commit), so we don't need a second + # counter in the dev segment. + # + # Edge case: in the window between a + # release merge to main and the next + # commit on develop, develop's + # BuildNumber equals main's (or is one + # lower) — `--pre` will still resolve to + # the main release until a new develop + # commit lands. This is accepted as a + # small, self-healing gap. + # other refs (PR validation via test-release-task, feature + # branches) -> AssemblyFileVersion as-is. These + # refs never publish; we just need a PEP 440 valid + # string for `uv build`. + - name: Compute PyPI version step + id: pypiver + run: | + set -euo pipefail + if [[ "$GITHUB_REF" == "refs/heads/develop" ]]; then + version="${AFV}.dev0" + else + version="$AFV" + fi + echo "PyPI version for $GITHUB_REF: $version" + echo "version=$version" >> "$GITHUB_OUTPUT" + env: + AFV: ${{ needs.get-version.outputs.AssemblyFileVersion }} + # Replace the `__version__` line in `_version.py` (which ships - # hardcoded "0.0.0" so local `uv build` works without CI) with - # NBGV's `AssemblyFileVersion` — always Major.Minor.Patch.BuildNumber, - # all numeric, PEP 440 valid. `sed -i` replaces the line in place so - # the module docstring and any future metadata in the file survive - # into the published wheel / sdist. `_version.py` is the single - # source `hatchling` reads via the `[tool.hatch.version]` path in - # pyproject.toml. Done AFTER tests so the test that asserts - # `__version__` is a non-empty string isn't affected. The PyPI - # version string therefore equals the .NET assemblies' `FileVersion` - # stamp (= NBGV `AssemblyFileVersion`). .NET's `AssemblyVersion` - # is a separate NBGV output and NuGet `PackageVersion` / Docker - # tags use NBGV `SemVer2` (PEP 440 rejects its prerelease / - # build-metadata suffixes), so those strings are not byte-identical - # to PyPI's; all four still derive from the same NBGV computation - # per release commit. + # hardcoded "0.0.0" so local `uv build` works without CI) with the + # branch-aware PEP 440 version computed above. `sed -i` replaces + # the line in place so the module docstring and any future metadata + # in the file survive into the published wheel / sdist. + # `_version.py` is the single source `hatchling` reads via the + # `[tool.hatch.version]` path in pyproject.toml. Done AFTER tests + # so the test that asserts `__version__` is a non-empty string + # isn't affected. On main, the PyPI version string equals the .NET + # assemblies' `FileVersion` stamp (= NBGV `AssemblyFileVersion`, + # `M.N.P.B`); on develop, the PyPI version is `M.N.P.B.dev0` — + # numerically the same `FileVersion` with a trailing `.dev0` + # prerelease marker. .NET keeps the bare `FileVersion`, and + # NuGet/Docker use NBGV `SemVer2`, so strings are not byte- + # identical across artifacts on either channel. All four still + # derive from the same NBGV computation per commit (main pushes + # publish release versions; develop pushes publish PEP 440 dev + # releases / NBGV prereleases). - name: Write version into _version.py step run: | set -euo pipefail sed -i 's/^__version__ = .*/__version__ = "'"$VERSION"'"/' src/ptr727_projecttemplate_library/_version.py env: - VERSION: ${{ needs.get-version.outputs.AssemblyFileVersion }} + VERSION: ${{ steps.pypiver.outputs.version }} - name: Build sdist and wheel step run: uv build diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index edafc722..63842a37 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -25,14 +25,17 @@ jobs: publish-pypi: name: Publish PyPI library job - # Restrict PyPI upload to `main` pushes. The `pypi` GitHub environment - # also has a Deployment branch rule allowing only `main` as defense in - # depth, but without this `if:` the job would still attempt to run on - # develop pushes and be blocked at the env gate — visible as a stalled - # / failed job on every develop release. PyPI tracks releases, not the - # prerelease channel; NuGet/Docker/executables already publish on - # develop with NBGV prerelease versions. - if: github.ref == 'refs/heads/main' + # Runs on pushes to both `main` and `develop`. `build-pypilibrary-task.yml` + # picks the PEP 440 version per branch (`M.N.P.B` release on main, + # `M.N.P.B.dev0` on develop — BuildNumber stays in the release segment + # so develop's release segment grows past main's per commit). Default + # `pip install ` filters `.dev0` and picks the main release; + # `pip install --pre ` includes dev releases and picks develop's + # higher release segment. Matches how NuGet/Docker tag develop builds + # as prerelease via NBGV `SemVer2`. + # The `pypi` GitHub environment's Deployment branch rule + # (Settings → Environments → pypi) restricts uploads to `main` + + # `develop` as defense in depth — see PyPiLibrary/README.md. needs: [create-release] runs-on: ubuntu-latest environment: diff --git a/PyPiLibrary/README.md b/PyPiLibrary/README.md index e6294d4b..38c5f05a 100644 --- a/PyPiLibrary/README.md +++ b/PyPiLibrary/README.md @@ -10,7 +10,7 @@ Python PyPI template — companion to the .NET `NuGetLibrary` in this repo. Publ - **Type checker** — [`pyright`](https://microsoft.github.io/pyright/) - **Tests** — [`pytest`](https://docs.pytest.org/) - **Publish** — [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/) via `pypa/gh-action-pypi-publish` (no API token in repo secrets) -- **Version** — [Nerdbank.GitVersioning](https://github.com/dotnet/Nerdbank.GitVersioning) (NBGV) shared with the .NET side. CI replaces the `__version__` line in `_version.py` (in place) with NBGV's `AssemblyFileVersion` (`Major.Minor.Patch.BuildNumber`, PEP 440 valid) before `uv build`; that matches the .NET assemblies' `FileVersion` stamp. .NET's `AssemblyVersion` (a separate NBGV output) and NuGet/Docker (NBGV `SemVer2`) carry different strings, but all four derive from the same NBGV computation per release commit. +- **Version** — [Nerdbank.GitVersioning](https://github.com/dotnet/Nerdbank.GitVersioning) (NBGV) shared with the .NET side. CI replaces the `__version__` line in `_version.py` (in place) before `uv build`. **Branch-aware**: on `main` the value is NBGV's `AssemblyFileVersion` (`Major.Minor.Patch.BuildNumber`, PEP 440 release); on `develop` it's `Major.Minor.Patch.BuildNumber.dev0` (PEP 440 dev release — `pip install` filters the `.dev` suffix unless `--pre` is passed; the BuildNumber stays in the release segment so develop's segment grows past main's per commit and `--pre` actually prefers develop). Matches how NuGet/Docker tag develop builds as prerelease. All four artifact families (.NET assemblies, NuGet, Docker, PyPI) derive from the same NBGV computation per commit; only the formatting differs. ## Layout @@ -47,6 +47,15 @@ uv build # wheel + sdist into ./dist Releases are produced by `.github/workflows/build-pypilibrary-task.yml` (called from `build-release-task.yml` to build, lint, type-check, test, and upload the wheel + sdist as a workflow-run artifact). Publishing is a separate top-level `publish-pypi` job in `publish-release.yml` that downloads the artifact by name and runs [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) — no `PYPI_API_TOKEN` secret is involved. The publish job has `id-token: write` only at that single job level, so the test-pull-request flow (which calls the same build task during PR validation) doesn't need to propagate that permission through the reusable workflow chain. +**Two-channel publishing**: pushes to both `main` and `develop` trigger `publish-release.yml`, and the **"Compute PyPI version step"** in `build-pypilibrary-task.yml` formats the version per branch: + +- `main` → `Major.Minor.Patch.BuildNumber` (PEP 440 release). `pip install ptr727-projecttemplate-library` picks this up by default. +- `develop` → `Major.Minor.Patch.BuildNumber.dev0` (PEP 440 dev release). The BuildNumber stays in the release segment so develop's release segment grows past main's per commit — that's what lets `pip install --pre ptr727-projecttemplate-library` actually resolve to a develop build (`--pre` would otherwise still pick the higher-on-release-segments main version). Same PyPI project; no separate "test" project required. + +Edge case worth knowing: in the window between a release merge to main and the next commit on develop, develop's BuildNumber equals main's (or is one lower), so `--pre` will still resolve to the main release until a new develop commit lands. Self-healing. + +This matches how NuGet (NBGV `SemVer2` prerelease tags), Docker (NBGV `SemVer2` image tags), and GitHub releases (softprops `prerelease: true` on develop) already mark develop builds. + First-time setup (one-time, on PyPI): Prerequisite: enable **2FA** on the PyPI account (TOTP or hardware key). PyPI requires it before any trusted publisher can be registered. @@ -58,7 +67,7 @@ Prerequisite: enable **2FA** on the PyPI account (TOTP or hardware key). PyPI re - **Workflow filename**: `publish-release.yml` - **Environment name**: `pypi` 2. **GitHub repo** → **Settings** → **Environments** → **New environment** → `pypi`. The environment owns deploy-time guardrails: - - **Deployment branch rule** → **Selected branches and tags** → add `main`. **This step is mandatory — Trusted Publishing without a branch restriction is a documented security anti-pattern.** Defense in depth: the `publish-pypi` job in `.github/workflows/publish-release.yml` *also* has `if: github.ref == 'refs/heads/main'` so develop pushes don't even attempt to enter the environment gate (they'd otherwise stall as blocked deployments). The `if:` is the operational gate; the env branch rule is the security boundary that holds even if the `if:` gets misconfigured. + - **Deployment branch rule** → **Selected branches and tags** → add **both** `main` (release channel) and `develop` (prerelease channel). **This step is mandatory — Trusted Publishing without a branch restriction is a documented security anti-pattern.** Any other branch (feature branches, codegen, etc.) is blocked at the env gate even if a workflow misconfiguration ever tried to publish from it. - (Optional) add yourself as a **required reviewer** so each publish requires a click — useful belt-and-suspenders against an accidental release. 3. The first successful release converts the pending publisher to a real publisher. After that the same OIDC exchange validates against the real publisher on every release. @@ -76,7 +85,7 @@ When deriving a new project from this template: - Replace the package name `ptr727-projecttemplate-library` (in `pyproject.toml`, this README, and CI) with your name. - Rename `src/ptr727_projecttemplate_library/` to your import name. - Re-register the trusted publisher on PyPI under the new project name. -- **Pick a versioning scheme.** The template defaults to **NBGV-driven** versioning shared with the .NET side: `_version.py` holds `__version__ = "0.0.0"` as a local-development placeholder, and the CI step **"Write version into _version.py step"** in [`build-pypilibrary-task.yml`](../.github/workflows/build-pypilibrary-task.yml) replaces the `__version__` line (in place, preserving the docstring) with NBGV's `AssemblyFileVersion` (always `Major.Minor.Patch.BuildNumber`, all numeric, PEP 440 valid) just before `uv build`. PyPI therefore ships the same version string that's stamped into the .NET assemblies as `FileVersion`. .NET's `AssemblyVersion` (the binary-compat identity — a separate NBGV output) and the **NuGet package version** / **Docker tags** (which use NBGV's `SemVer2` — PEP 440 doesn't accept its prerelease / build-metadata suffixes) all carry different strings; but all four derive from the same NBGV computation against `version.json` + git history and correspond to the same release commit. If you want a different scheme, replace both `_version.py` and the workflow step. Two common alternatives: +- **Pick a versioning scheme.** The template defaults to **NBGV-driven** versioning shared with the .NET side: `_version.py` holds `__version__ = "0.0.0"` as a local-development placeholder, and the CI steps **"Compute PyPI version step"** + **"Write version into _version.py step"** in [`build-pypilibrary-task.yml`](../.github/workflows/build-pypilibrary-task.yml) compute and rewrite the value before `uv build`. The version is **branch-aware**: `main` pushes ship `M.N.P.B` (PEP 440 release), `develop` pushes ship `M.N.P.B.dev0` (PEP 440 dev release — same release segment as main, `.dev0` marks it as prerelease so `pip install` filters it unless `--pre` is passed). The BuildNumber stays in the release segment so develop's segment grows past main's per commit, which is what lets `--pre` actually prefer develop. On `main` the PyPI version equals the .NET `FileVersion` stamp exactly; on `develop` it equals the same `FileVersion` numerically but with a trailing `.dev0`. .NET's `AssemblyVersion` (a separate NBGV output) and NuGet/Docker (NBGV `SemVer2`) carry different strings across artifact families on both channels; all four derive from the same NBGV computation against `version.json` + git history per commit. If you want a different scheme, replace both `_version.py` and the workflow steps. Two common alternatives: - [`hatch-vcs`](https://github.com/ofek/hatch-vcs) — derive the version from git tags. Add it to `[build-system].requires` and switch `[tool.hatch.version]` to `source = "vcs"`. Drop the CI overwrite step. Pairs well with tag-driven releases and removes the NBGV dependency. - **Manual bumps** — edit `_version.py` in each release PR. Simplest, but easy to forget. Drop the CI overwrite step. diff --git a/PyPiLibrary/src/ptr727_projecttemplate_library/_version.py b/PyPiLibrary/src/ptr727_projecttemplate_library/_version.py index 65c7de50..7ce72b48 100644 --- a/PyPiLibrary/src/ptr727_projecttemplate_library/_version.py +++ b/PyPiLibrary/src/ptr727_projecttemplate_library/_version.py @@ -4,18 +4,34 @@ The ``0.0.0`` value below is a local-development placeholder so ``uv build`` works outside CI. The release pipeline replaces the ``__version__`` line -(in place, preserving this docstring) with NBGV's ``AssemblyFileVersion`` -(``Major.Minor.Patch.BuildNumber`` — always numeric, PEP 440 valid) just -before ``uv build``, so the wheel and sdist uploaded to PyPI carry the same -version string that's stamped into the .NET assembly metadata as -``FileVersion``. .NET's ``AssemblyVersion`` (the binary-compat identity, a -separate NBGV output) and NuGet ``PackageVersion`` / Docker tags (NBGV -``SemVer2`` — PEP 440 doesn't accept its prerelease / build-metadata -suffixes) all carry different strings. All four artifacts still derive from -the same NBGV computation against ``version.json`` + git history and -correspond to the same release commit. See -``.github/workflows/build-pypilibrary-task.yml`` (the "Write version into -_version.py step"). +(in place, preserving this docstring) with a PEP 440 version computed from +NBGV's ``AssemblyFileVersion`` (``Major.Minor.Patch.BuildNumber``) just +before ``uv build``. The format is **branch-aware**: + +- ``main`` push → ``Major.Minor.Patch.BuildNumber`` (PEP 440 release). + ``pip install `` picks this up by default. Equals the .NET + assemblies' ``FileVersion`` stamp. +- ``develop`` push → ``Major.Minor.Patch.BuildNumber.dev0`` (PEP 440 dev + release). The BuildNumber stays in the release segment so develop's + segment grows past main's per commit — that's what makes + ``pip install --pre `` actually prefer the develop build over the + main release. Without ``--pre``, pip filters the ``.dev`` suffix and + picks the main release. Matches how NuGet/Docker mark develop as + prerelease. Edge case: in the window between a release merge to main + and the next commit on develop, develop's BuildNumber equals main's + (or is one lower), so ``--pre`` still resolves to the main release + until a new develop commit lands. Self-healing. + +.NET's ``AssemblyVersion`` (the binary-compat identity, a separate NBGV +output) and NuGet ``PackageVersion`` / Docker tags (NBGV ``SemVer2`` — +PEP 440 doesn't accept its prerelease / build-metadata suffixes) all +carry different strings across artifact families. All four still derive +from the same NBGV computation against ``version.json`` + git history +and correspond to the same commit (main pushes publish release +versions; develop pushes publish PEP 440 dev releases / NBGV +prereleases). See +``.github/workflows/build-pypilibrary-task.yml`` (the "Compute PyPI +version step" and "Write version into _version.py step"). If you fork this template and want a different versioning scheme, replace both this file's contents and the workflow step that rewrites it. Two common