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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 66 additions & 16 deletions .github/workflows/build-pypilibrary-task.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pkg>` picks the develop dev
# build, while default `pip install <pkg>`
# 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
Expand Down
19 changes: 11 additions & 8 deletions .github/workflows/publish-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pkg>` filters `.dev0` and picks the main release;
# `pip install --pre <pkg>` 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:
Expand Down
15 changes: 12 additions & 3 deletions PyPiLibrary/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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.

Expand All @@ -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.

Expand Down
40 changes: 28 additions & 12 deletions PyPiLibrary/src/ptr727_projecttemplate_library/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pkg>`` 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 <pkg>`` 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
Expand Down
Loading