From cdb07d78ffd12bc02886d07a92c02f0f8abf7b90 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 11 May 2026 14:09:35 -0700 Subject: [PATCH 1/6] Publish PyPI on develop as PEP 440 dev releases (prerelease channel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the NuGet / Docker / GitHub-release pattern where main pushes ship release versions and develop pushes ship prerelease versions — PyPI was the odd one out, only publishing on main. Workflow changes - build-pypilibrary-task.yml: new "Compute PyPI version step" formats the version from NBGV `AssemblyFileVersion` per branch ref: refs/heads/main -> `Major.Minor.Patch.BuildNumber` (PEP 440 release; what `pip install` picks by default). refs/heads/develop -> `Major.Minor.Patch.devBuildNumber` (PEP 440 dev release; filtered out by `pip` unless `--pre` is passed, matching NuGet beta tags / GitHub prerelease flag). other refs (PR validation via test-release-task, feature branches) -> `AssemblyFileVersion` as-is. These refs never publish; this is just so `uv build` accepts the version string during build validation. The "Write version into _version.py step" now consumes `steps.pypiver.outputs.version` instead of NBGV's `AssemblyFileVersion` directly. - publish-release.yml: remove the `if: github.ref == 'refs/heads/main'` guard on `publish-pypi`. The job now runs on every push to main and develop, just like the NuGet / Docker / executable publish jobs do. Comment rewritten to describe the dual-channel model and point at the env Deployment branch rule as the security boundary. Operator action (done out-of-band; documented in PyPiLibrary/README.md) - `pypi` GitHub environment's Deployment branch rule now allows both `main` and `develop`. Without this, develop pushes would be blocked at the env gate. Documentation - PyPiLibrary/README.md: stack-table "Version" entry expanded to describe the branch-aware version format. "Publishing" section gains a new "Two-channel publishing" paragraph explaining the `pip install ` vs `pip install --pre ` flow. First-time setup step 2 updated to list both `main` and `develop` in the env Deployment branch rule. - PyPiLibrary/src/.../_version.py docstring updated to describe both branches' PEP 440 forms. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build-pypilibrary-task.yml | 60 ++++++++++++++----- .github/workflows/publish-release.yml | 17 +++--- PyPiLibrary/README.md | 13 +++- .../_version.py | 31 ++++++---- 4 files changed, 82 insertions(+), 39 deletions(-) diff --git a/.github/workflows/build-pypilibrary-task.yml b/.github/workflows/build-pypilibrary-task.yml index fdb05c08..3e8f5900 100644 --- a/.github/workflows/build-pypilibrary-task.yml +++ b/.github/workflows/build-pypilibrary-task.yml @@ -65,28 +65,56 @@ jobs: - name: Run pytest step run: uv run pytest + # Compute the PEP 440 version string for this build: + # refs/heads/main -> AssemblyFileVersion as-is. PEP 440 treats + # this 4-segment numeric form as a release. + # refs/heads/develop -> Major.Minor.Patch.devN (PEP 440 dev + # release; pip filters it out of `pip + # install ` unless `--pre` is passed, + # giving us a "prerelease channel" on the + # same PyPI project. N is NBGV's + # BuildNumber, so each develop push gets a + # unique upload.) + # other refs (PR validation via test-release-task, feature + # branches) -> AssemblyFileVersion as-is. These + # refs never publish, so any PEP 440 valid string + # that lets `uv build` succeed is fine. + - name: Compute PyPI version step + id: pypiver + run: | + set -euo pipefail + if [[ "$GITHUB_REF" == "refs/heads/develop" ]]; then + IFS='.' read -r major minor patch build <<< "$AFV" + version="${major}.${minor}.${patch}.dev${build}" + 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`); + # on develop, the PyPI version is `M.N.P.devB` while .NET still + # uses the numeric `FileVersion` and NuGet/Docker use NBGV + # `SemVer2` (different formats — strings are not byte-identical + # across artifacts on develop). All four still derive from the + # same NBGV computation per release commit. - 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..fd9f1dd7 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -25,14 +25,15 @@ 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 right PEP 440 version per branch (release on main, + # `M.N.P.devN` on develop) so `pip install ` keeps installing the + # latest main release by default while `pip install --pre ` opts + # into the develop prerelease channel. This matches how NuGet/Docker + # already publish on both branches with NBGV prerelease semantics. + # 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..3062239c 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.devBuildNumber` (PEP 440 dev release — `pip install` filters it out unless `--pre` is passed, matching how NuGet/Docker tag develop builds as prerelease). All four artifact families (.NET assemblies, NuGet, Docker, PyPI) derive from the same NBGV computation per release commit; only the formatting differs. ## Layout @@ -47,6 +47,13 @@ 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.devBuildNumber` (PEP 440 dev release). `pip install` filters dev releases out unless `--pre` is passed; consumers opt in with `pip install --pre ptr727-projecttemplate-library`. Same package on PyPI; no separate "test" project required. + +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 +65,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 +83,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.devB` (PEP 440 dev release — `pip` filters these unless `--pre` is passed). On `main` the PyPI version equals the .NET `FileVersion` stamp exactly; on `develop` it's the same numeric build with a `.dev` segment so pip treats it as prerelease. .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 release 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..911cd73a 100644 --- a/PyPiLibrary/src/ptr727_projecttemplate_library/_version.py +++ b/PyPiLibrary/src/ptr727_projecttemplate_library/_version.py @@ -4,18 +4,25 @@ 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.devBuildNumber`` (PEP 440 dev + release). ``pip`` filters dev releases out unless ``--pre`` is passed, + matching how NuGet/Docker mark develop builds as prerelease. + +.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 release commit. 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 From f475ed87df86e9d254deebc1aa7f865ad6f137ff Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 11 May 2026 14:17:12 -0700 Subject: [PATCH 2/6] Fix PEP 440 ordering: develop publishes M.N.P.B.dev0 not M.N.P.devB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review on PR #74 caught a real ordering bug across four locations: the develop version `Major.Minor.Patch.devBuildNumber` won't behave as the docs claim because PEP 440 compares release segments first. `1.0.47.devX` has release `[1,0,47]`, while main's `1.0.47.53309` has release `[1,0,47,53309]` — main wins on the release-segment comparison, so `pip install --pre ` still resolves to main, never to the develop dev build. Fix: keep the BuildNumber in the release segment and put the dev marker after it as `.dev0`: main: Major.Minor.Patch.BuildNumber (e.g., 1.0.47.53309) develop: Major.Minor.Patch.BuildNumber.dev0 (e.g., 1.0.47.53400.dev0) Develop's BuildNumber grows past main's after every new commit on develop, so develop's release segment `[1,0,47,53400]` compares higher than main's `[1,0,47,53309]`, and the `.dev0` suffix marks develop as a prerelease. Result: - `pip install ` → main (dev filtered out by default) - `pip install --pre ` → develop (highest release segment, dev) Each develop push has a unique BuildNumber (NBGV increments it per commit), so a constant `.dev0` is enough to disambiguate uploads — no second counter needed in the dev segment. Edge case documented: 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. Updates in four places: - .github/workflows/build-pypilibrary-task.yml: version computation now produces `${AFV}.dev0` on develop instead of splitting AFV and rebuilding with `.dev${build}`. Comment block rewritten to spell out the PEP 440 ordering argument and the edge case. - .github/workflows/publish-release.yml: publish-pypi comment rewritten to match. - PyPiLibrary/README.md: stack-table "Version" entry, Publishing section's "Two-channel publishing" paragraph, and Template Adoption section all rewritten. - PyPiLibrary/src/.../_version.py docstring rewritten to match. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build-pypilibrary-task.yml | 45 +++++++++++++------ .github/workflows/publish-release.yml | 12 ++--- PyPiLibrary/README.md | 8 ++-- .../_version.py | 10 +++-- 4 files changed, 51 insertions(+), 24 deletions(-) diff --git a/.github/workflows/build-pypilibrary-task.yml b/.github/workflows/build-pypilibrary-task.yml index 3e8f5900..9b52054d 100644 --- a/.github/workflows/build-pypilibrary-task.yml +++ b/.github/workflows/build-pypilibrary-task.yml @@ -66,26 +66,45 @@ jobs: run: uv run pytest # Compute the PEP 440 version string for this build: - # refs/heads/main -> AssemblyFileVersion as-is. PEP 440 treats - # this 4-segment numeric form as a release. - # refs/heads/develop -> Major.Minor.Patch.devN (PEP 440 dev - # release; pip filters it out of `pip - # install ` unless `--pre` is passed, - # giving us a "prerelease channel" on the - # same PyPI project. N is NBGV's - # BuildNumber, so each develop push gets a - # unique upload.) + # 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, so any PEP 440 valid string - # that lets `uv build` succeed is fine. + # 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 - IFS='.' read -r major minor patch build <<< "$AFV" - version="${major}.${minor}.${patch}.dev${build}" + version="${AFV}.dev0" else version="$AFV" fi diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index fd9f1dd7..63842a37 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -26,11 +26,13 @@ jobs: publish-pypi: name: Publish PyPI library job # Runs on pushes to both `main` and `develop`. `build-pypilibrary-task.yml` - # picks the right PEP 440 version per branch (release on main, - # `M.N.P.devN` on develop) so `pip install ` keeps installing the - # latest main release by default while `pip install --pre ` opts - # into the develop prerelease channel. This matches how NuGet/Docker - # already publish on both branches with NBGV prerelease semantics. + # 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. diff --git a/PyPiLibrary/README.md b/PyPiLibrary/README.md index 3062239c..228dc8df 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) 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.devBuildNumber` (PEP 440 dev release — `pip install` filters it out unless `--pre` is passed, matching how NuGet/Docker tag develop builds as prerelease). All four artifact families (.NET assemblies, NuGet, Docker, PyPI) derive from the same NBGV computation per release commit; only the formatting differs. +- **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 release commit; only the formatting differs. ## Layout @@ -50,7 +50,9 @@ Releases are produced by `.github/workflows/build-pypilibrary-task.yml` (called **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.devBuildNumber` (PEP 440 dev release). `pip install` filters dev releases out unless `--pre` is passed; consumers opt in with `pip install --pre ptr727-projecttemplate-library`. Same package on PyPI; no separate "test" project required. +- `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. @@ -83,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 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.devB` (PEP 440 dev release — `pip` filters these unless `--pre` is passed). On `main` the PyPI version equals the .NET `FileVersion` stamp exactly; on `develop` it's the same numeric build with a `.dev` segment so pip treats it as prerelease. .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 release commit. If you want a different scheme, replace both `_version.py` and the workflow steps. 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 release 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 911cd73a..50ffde7c 100644 --- a/PyPiLibrary/src/ptr727_projecttemplate_library/_version.py +++ b/PyPiLibrary/src/ptr727_projecttemplate_library/_version.py @@ -11,9 +11,13 @@ - ``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.devBuildNumber`` (PEP 440 dev - release). ``pip`` filters dev releases out unless ``--pre`` is passed, - matching how NuGet/Docker mark develop builds as prerelease. +- ``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. .NET's ``AssemblyVersion`` (the binary-compat identity, a separate NBGV output) and NuGet ``PackageVersion`` / Docker tags (NBGV ``SemVer2`` — From a123bab718faee1e378ea3f88a47d9040254ddb8 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 11 May 2026 14:22:44 -0700 Subject: [PATCH 3/6] Update stale "M.N.P.devB" comment to match the new scheme The "Write version into _version.py step" comment block still referenced the discarded `M.N.P.devB` scheme that f475ed8 replaced with `M.N.P.B.dev0`. Update the prose to match the actual workflow behaviour. No code change; comment only. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build-pypilibrary-task.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-pypilibrary-task.yml b/.github/workflows/build-pypilibrary-task.yml index 9b52054d..4033f93f 100644 --- a/.github/workflows/build-pypilibrary-task.yml +++ b/.github/workflows/build-pypilibrary-task.yml @@ -122,12 +122,13 @@ jobs: # `[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`); - # on develop, the PyPI version is `M.N.P.devB` while .NET still - # uses the numeric `FileVersion` and NuGet/Docker use NBGV - # `SemVer2` (different formats — strings are not byte-identical - # across artifacts on develop). All four still derive from the - # same NBGV computation per release commit. + # 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 release commit. - name: Write version into _version.py step run: | set -euo pipefail From afc3949ae7d49f217ede2f83f9b852a80cf45064 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 11 May 2026 14:30:21 -0700 Subject: [PATCH 4/6] Note --pre edge case in _version.py docstring Copilot caught that _version.py's docstring claims `pip install --pre` always prefers develop over main, but doesn't note the post-release window where develop's BuildNumber can be equal to or lower than main's. Add the edge-case note here for consistency with the workflow comment and PyPiLibrary/README.md, which both already document it. Co-Authored-By: Claude Opus 4.7 (1M context) --- PyPiLibrary/src/ptr727_projecttemplate_library/_version.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/PyPiLibrary/src/ptr727_projecttemplate_library/_version.py b/PyPiLibrary/src/ptr727_projecttemplate_library/_version.py index 50ffde7c..8afdec84 100644 --- a/PyPiLibrary/src/ptr727_projecttemplate_library/_version.py +++ b/PyPiLibrary/src/ptr727_projecttemplate_library/_version.py @@ -17,7 +17,10 @@ ``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. + 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`` — From 92d20b052bd91b46b8726775d71c2cdf26784db4 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 11 May 2026 14:37:23 -0700 Subject: [PATCH 5/6] Reword "per release commit" to "per commit" in two-channel docs Copilot review on afc3949 caught that the docs in three places still say all four artifact families correspond to the same NBGV "release commit", but the two-channel publishing model means develop publishes artifacts for ordinary (non-release) commits too. Rewording: - PyPiLibrary/README.md: both occurrences ("Version" stack-table entry and "Template Adoption" section) now say "per commit". - PyPiLibrary/src/.../_version.py docstring: rewrites the closing sentence to say "the same commit (main pushes publish release versions; develop pushes publish PEP 440 dev releases / NBGV prereleases)" so the distinction is explicit. Co-Authored-By: Claude Opus 4.7 (1M context) --- PyPiLibrary/README.md | 4 ++-- PyPiLibrary/src/ptr727_projecttemplate_library/_version.py | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/PyPiLibrary/README.md b/PyPiLibrary/README.md index 228dc8df..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) 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 release commit; only the formatting differs. +- **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 @@ -85,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 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 release commit. If you want a different scheme, replace both `_version.py` and the workflow steps. 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 8afdec84..7ce72b48 100644 --- a/PyPiLibrary/src/ptr727_projecttemplate_library/_version.py +++ b/PyPiLibrary/src/ptr727_projecttemplate_library/_version.py @@ -27,7 +27,9 @@ 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 release commit. See +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"). From 34b697ce7ccb104d306ac5719bc4a8df27d86ba7 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 11 May 2026 14:42:11 -0700 Subject: [PATCH 6/6] Reword last "per release commit" in Write-version workflow comment Final occurrence of "per release commit" that I missed in 92d20b0. Same fix: reword to "per commit (main pushes publish release versions; develop pushes publish PEP 440 dev releases / NBGV prereleases)" to match the README and _version.py docstring. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build-pypilibrary-task.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-pypilibrary-task.yml b/.github/workflows/build-pypilibrary-task.yml index 4033f93f..60e926e4 100644 --- a/.github/workflows/build-pypilibrary-task.yml +++ b/.github/workflows/build-pypilibrary-task.yml @@ -128,7 +128,9 @@ jobs: # 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 release commit. + # 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