From 37b10b44328436b3627c5ab8c5442470535c0cc3 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 08:22:42 -0700 Subject: [PATCH 01/24] Rename Library project to NuGetLibrary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disambiguate the .NET project name in preparation for adding a sibling Python PyPi project. The folder, csproj filename, RootNamespace, and namespace declarations move from `Library` to `NuGetLibrary`. The companion GitHub Actions reusable workflow `build-library-task.yml` is renamed to `build-nugetlibrary-task.yml` for the same reason; the artifact name and zip filename track the rename. The published NuGet package id is intentionally preserved as `ptr727.ProjectTemplate.Library` via an explicit `` element so existing consumers and the README NuGet badges continue to work without a new package or a 404 on the existing nuget.org URL. Class names `TemplateLibrary` and `StaticTemplateLibrary` are left alone — they describe the type, not the project, and are referenced by tests and benchmarks. dotnet build: 0 warnings, 0 errors. dotnet test: 15 passed, 0 failed. dotnet pack: produces ptr727.ProjectTemplate.Library.1.0.0-pre.nupkg as expected. --- .github/copilot-instructions.md | 12 +++++------ ...y-task.yml => build-nugetlibrary-task.yml} | 20 +++++++++---------- .github/workflows/build-release-task.yml | 12 +++++------ AGENTS.md | 2 +- Benchmarks/Benchmarks.csproj | 2 +- Console/Console.csproj | 2 +- Console/Program.cs | 2 +- {Library => NuGetLibrary}/.editorconfig | 0 {Library => NuGetLibrary}/Extensions.cs | 2 +- {Library => NuGetLibrary}/GlobalUsings.cs | 0 {Library => NuGetLibrary}/Library.cs | 2 +- {Library => NuGetLibrary}/LogOptions.cs | 2 +- .../NuGetLibrary.csproj | 2 +- {Library => NuGetLibrary}/Options.cs | 2 +- ProjectTemplate.code-workspace | 1 + ProjectTemplate.slnx | 10 +++++----- Tests/LoggingTests.cs | 2 +- Tests/Tests.csproj | 2 +- 18 files changed, 39 insertions(+), 38 deletions(-) rename .github/workflows/{build-library-task.yml => build-nugetlibrary-task.yml} (75%) rename {Library => NuGetLibrary}/.editorconfig (100%) rename {Library => NuGetLibrary}/Extensions.cs (92%) rename {Library => NuGetLibrary}/GlobalUsings.cs (100%) rename {Library => NuGetLibrary}/Library.cs (92%) rename {Library => NuGetLibrary}/LogOptions.cs (96%) rename Library/Library.csproj => NuGetLibrary/NuGetLibrary.csproj (94%) rename {Library => NuGetLibrary}/Options.cs (85%) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f61f6468..aa7fab8c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,7 +4,7 @@ **ProjectTemplate** is a C# .NET template project that demonstrates best practices for C# .NET development. The project includes: -- **Library**: Core library with AOT compatibility (`Library.csproj`) +- **NuGetLibrary**: Core .NET NuGet library with AOT compatibility (`NuGetLibrary.csproj`, published as `ptr727.ProjectTemplate.Library`) - **Console**: Command-line application using System.CommandLine (`Console.csproj`) - **Tests**: Unit tests using xUnit and AwesomeAssertions (`Tests.csproj`) - **Benchmarks**: Performance benchmarks using BenchmarkDotNet (`Benchmarks.csproj`) @@ -43,7 +43,7 @@ Available VS Code tasks (use via `run_task` tool): 1. **File-Scoped Namespaces**: Always use file-scoped namespaces ```csharp - namespace ptr727.ProjectTemplate.Library; + namespace ptr727.ProjectTemplate.NuGetLibrary; ``` 2. **Nullable Reference Types**: Enabled (`enable`) @@ -92,7 +92,7 @@ Available VS Code tasks (use via `run_task` tool): ``` 4. **Namespace**: Follow format `ptr727.ProjectTemplate.` - - Library: `ptr727.ProjectTemplate.Library` + - NuGetLibrary: `ptr727.ProjectTemplate.NuGetLibrary` - Console: `ptr727.ProjectTemplate.Console` - Tests: `ptr727.ProjectTemplate.Tests` @@ -110,7 +110,7 @@ Available VS Code tasks (use via `run_task` tool): ```csharp using System.CommandLine; using System.Runtime.CompilerServices; - using ptr727.ProjectTemplate.Library; + using ptr727.ProjectTemplate.NuGetLibrary; namespace ptr727.ProjectTemplate.Console; ``` @@ -211,7 +211,7 @@ Available VS Code tasks (use via `run_task` tool): 1. **Target Framework**: .NET 10.0 (`net10.0`) -2. **AOT Compatibility**: Library is AOT compatible +2. **AOT Compatibility**: NuGetLibrary is AOT compatible - `true` - `true` @@ -292,7 +292,7 @@ Available VS Code tasks (use via `run_task` tool): - `CodeGen/` - Code generation utilities (internal tooling) - `Console/` - Console/CLI application using System.CommandLine - `Docker/` - Docker build scripts and Dockerfile -- `Library/` - Core reusable library +- `NuGetLibrary/` - Core reusable .NET NuGet library (published as `ptr727.ProjectTemplate.Library`) - `Tests/` - Unit tests using xUnit and AwesomeAssertions ## Best Practices diff --git a/.github/workflows/build-library-task.yml b/.github/workflows/build-nugetlibrary-task.yml similarity index 75% rename from .github/workflows/build-library-task.yml rename to .github/workflows/build-nugetlibrary-task.yml index 6bd1279b..0a5de77f 100644 --- a/.github/workflows/build-library-task.yml +++ b/.github/workflows/build-nugetlibrary-task.yml @@ -1,9 +1,9 @@ -name: Build library task +name: Build NuGet library task on: workflow_call: inputs: - # Input to control whether to push the library to NuGet.org + # Input to control whether to push the NuGet library to NuGet.org push: required: false type: boolean @@ -11,7 +11,7 @@ on: outputs: # Output of the uploaded artifact id artifact-id: - value: ${{ jobs.build-library.outputs.artifact-id }} + value: ${{ jobs.build-nugetlibrary.outputs.artifact-id }} jobs: @@ -20,8 +20,8 @@ jobs: uses: ./.github/workflows/get-version-task.yml secrets: inherit - build-library: - name: Build library project job + build-nugetlibrary: + name: Build NuGet library project job runs-on: ubuntu-latest outputs: artifact-id: ${{ steps.artifact-upload-step.outputs.artifact-id }} @@ -37,9 +37,9 @@ jobs: - name: Checkout code step uses: actions/checkout@v6 - - name: Build library project step + - name: Build NuGet library project step run: | - dotnet build ./Library/Library.csproj \ + dotnet build ./NuGetLibrary/NuGetLibrary.csproj \ -property:OutputPath=${{ runner.temp }}/publish/ \ -property:PackageOutputPath=${{ runner.temp }}/publish/ \ --configuration ${{ github.ref_name == 'main' && 'Release' || 'Debug' }} \ @@ -58,11 +58,11 @@ jobs: --skip-duplicate - name: Zip output step - run: 7z a -t7z ${{ runner.temp }}/Library.7z ${{ runner.temp }}/publish/* + run: 7z a -t7z ${{ runner.temp }}/NuGetLibrary.7z ${{ runner.temp }}/publish/* - name: Upload build artifacts step id: artifact-upload-step uses: actions/upload-artifact@v6 with: - name: library-build - path: ${{ runner.temp }}/Library.7z + name: nugetlibrary-build + path: ${{ runner.temp }}/NuGetLibrary.7z diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 20130c2c..4bccfdff 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -26,9 +26,9 @@ jobs: uses: ./.github/workflows/get-version-task.yml secrets: inherit - build-library: - name: Build library job - uses: ./.github/workflows/build-library-task.yml + build-nugetlibrary: + name: Build NuGet library job + uses: ./.github/workflows/build-nugetlibrary-task.yml secrets: inherit with: # Conditional push to NuGet.org @@ -51,17 +51,17 @@ jobs: name: Publish GitHub release job if: ${{ inputs.github }} runs-on: ubuntu-latest - needs: [get-version, build-library, build-executable, build-docker] + needs: [get-version, build-nugetlibrary, build-executable, build-docker] steps: - name: Checkout code step uses: actions/checkout@v6 - - name: Download library build artifacts job + - name: Download NuGet library build artifacts job uses: actions/download-artifact@v7 with: - artifact-ids: ${{ needs.build-library.outputs.artifact-id }} + artifact-ids: ${{ needs.build-nugetlibrary.outputs.artifact-id }} path: ./Publish - name: Download executable build artifacts job diff --git a/AGENTS.md b/AGENTS.md index ac3a0281..62c4cc23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ For comprehensive coding standards and detailed conventions, refer to [`.github/ ### Project Structure -- **Library**: Core reusable library +- **NuGetLibrary**: Core reusable .NET NuGet library (published as `ptr727.ProjectTemplate.Library`) - **Console**: CLI application using System.CommandLine - **Tests**: xUnit with AwesomeAssertions (Arrange-Act-Assert pattern) - **Benchmarks**: BenchmarkDotNet performance measurements diff --git a/Benchmarks/Benchmarks.csproj b/Benchmarks/Benchmarks.csproj index ab6aa801..fbf90517 100644 --- a/Benchmarks/Benchmarks.csproj +++ b/Benchmarks/Benchmarks.csproj @@ -7,6 +7,6 @@ - + diff --git a/Console/Console.csproj b/Console/Console.csproj index bc4ea6ae..ae14c9a1 100644 --- a/Console/Console.csproj +++ b/Console/Console.csproj @@ -21,6 +21,6 @@ - + diff --git a/Console/Program.cs b/Console/Program.cs index 45752492..5df60863 100644 --- a/Console/Program.cs +++ b/Console/Program.cs @@ -1,4 +1,4 @@ -using ptr727.ProjectTemplate.Library; +using ptr727.ProjectTemplate.NuGetLibrary; namespace ptr727.ProjectTemplate.Console; diff --git a/Library/.editorconfig b/NuGetLibrary/.editorconfig similarity index 100% rename from Library/.editorconfig rename to NuGetLibrary/.editorconfig diff --git a/Library/Extensions.cs b/NuGetLibrary/Extensions.cs similarity index 92% rename from Library/Extensions.cs rename to NuGetLibrary/Extensions.cs index 59709850..bdf0e4cd 100644 --- a/Library/Extensions.cs +++ b/NuGetLibrary/Extensions.cs @@ -1,6 +1,6 @@ using System.Runtime.CompilerServices; -namespace ptr727.ProjectTemplate.Library; +namespace ptr727.ProjectTemplate.NuGetLibrary; internal static partial class LogExtensions { diff --git a/Library/GlobalUsings.cs b/NuGetLibrary/GlobalUsings.cs similarity index 100% rename from Library/GlobalUsings.cs rename to NuGetLibrary/GlobalUsings.cs diff --git a/Library/Library.cs b/NuGetLibrary/Library.cs similarity index 92% rename from Library/Library.cs rename to NuGetLibrary/Library.cs index 1f661bc7..b3cedf4f 100644 --- a/Library/Library.cs +++ b/NuGetLibrary/Library.cs @@ -1,4 +1,4 @@ -namespace ptr727.ProjectTemplate.Library; +namespace ptr727.ProjectTemplate.NuGetLibrary; /// /// Provides the primary library functionality. diff --git a/Library/LogOptions.cs b/NuGetLibrary/LogOptions.cs similarity index 96% rename from Library/LogOptions.cs rename to NuGetLibrary/LogOptions.cs index 9c63601c..382fc04a 100644 --- a/Library/LogOptions.cs +++ b/NuGetLibrary/LogOptions.cs @@ -1,4 +1,4 @@ -namespace ptr727.ProjectTemplate.Library; +namespace ptr727.ProjectTemplate.NuGetLibrary; /// /// Provides global logging configuration for the library. diff --git a/Library/Library.csproj b/NuGetLibrary/NuGetLibrary.csproj similarity index 94% rename from Library/Library.csproj rename to NuGetLibrary/NuGetLibrary.csproj index 6736346e..f08d6fd9 100644 --- a/Library/Library.csproj +++ b/NuGetLibrary/NuGetLibrary.csproj @@ -21,7 +21,7 @@ 1.0.0-pre true https://github.com/ptr727/ProjectTemplate - ptr727.ProjectTemplate.Library + ptr727.ProjectTemplate.NuGetLibrary snupkg 1.0.0.0 diff --git a/Library/Options.cs b/NuGetLibrary/Options.cs similarity index 85% rename from Library/Options.cs rename to NuGetLibrary/Options.cs index cef2b103..04d51a70 100644 --- a/Library/Options.cs +++ b/NuGetLibrary/Options.cs @@ -1,4 +1,4 @@ -namespace ptr727.ProjectTemplate.Library; +namespace ptr727.ProjectTemplate.NuGetLibrary; /// /// Options used to configure the library. diff --git a/ProjectTemplate.code-workspace b/ProjectTemplate.code-workspace index 0ad06819..b8714f6a 100644 --- a/ProjectTemplate.code-workspace +++ b/ProjectTemplate.code-workspace @@ -32,6 +32,7 @@ "logfile", "nameof", "nbgv", + "nugetlibrary", "nektos", "Nerdbank", "noninteractive", diff --git a/ProjectTemplate.slnx b/ProjectTemplate.slnx index c8e900d4..7e3cc9c6 100644 --- a/ProjectTemplate.slnx +++ b/ProjectTemplate.slnx @@ -3,7 +3,7 @@ - + @@ -24,14 +24,14 @@ - + - + - + - + diff --git a/Tests/LoggingTests.cs b/Tests/LoggingTests.cs index 9f43dca7..2d8f8a65 100644 --- a/Tests/LoggingTests.cs +++ b/Tests/LoggingTests.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using ptr727.ProjectTemplate.Library; +using ptr727.ProjectTemplate.NuGetLibrary; namespace ptr727.ProjectTemplate.Tests; diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 8c53010d..6cf10d59 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -14,6 +14,6 @@ - + From 5243352c8075cc7297c71f29db571629b0427c50 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 08:31:14 -0700 Subject: [PATCH 02/24] Add devcontainer + per-OS host and SSH signing docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single unified devcontainer hosts both .NET 10 and the upcoming PyPi sibling. Host SSH key, allowed_signers, and gh config are bind-mounted so commits sign correctly inside the container without the private key ever leaving the host. Lifecycle scripts install uv, restore .NET local tools, and set up Husky.Net hooks. Devcontainer extension list mirrors the workspace `recommendations` so the two stay in sync; Python tooling extensions are added now so they'll be installed when PyPiLibrary lands in PR 5. New docs decompose the verbose template setup section into focused files: - docs/host-setup.md: git identity, SSH key generation, allowed_signers, gh auth, per-OS ssh-agent / Keychain handling, verify checklist. - docs/devcontainer.md: bind-mount table, lifecycle commands, gh credential-store nuance (Keychain vs libsecret vs file), verify checklist, troubleshooting matrix. - docs/ssh-signing.md: per-OS deltas (systemd ssh-agent, Apple Keychain, WSL2 caveats), allowed_signers format, devcontainer interaction, troubleshooting matrix. README links to the new docs from the existing Development Environment Setup section; verbose host-setup snippets stay in the docs. Native Windows hosts are explicitly out-of-scope for the devcontainer — WSL2 is the supported Windows path, matching what Docker Desktop's WSL2 backend cleanly supports. --- .devcontainer/devcontainer.json | 62 +++++++++++++++ .devcontainer/post-create.sh | 20 +++++ ProjectTemplate.code-workspace | 15 ++++ README.md | 17 +++- docs/devcontainer.md | 83 ++++++++++++++++++++ docs/host-setup.md | 132 ++++++++++++++++++++++++++++++++ docs/ssh-signing.md | 120 +++++++++++++++++++++++++++++ 7 files changed, 447 insertions(+), 2 deletions(-) create mode 100644 .devcontainer/devcontainer.json create mode 100755 .devcontainer/post-create.sh create mode 100644 docs/devcontainer.md create mode 100644 docs/host-setup.md create mode 100644 docs/ssh-signing.md diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..a7f92bef --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,62 @@ +{ + "name": "ProjectTemplate", + "image": "mcr.microsoft.com/devcontainers/dotnet:1-10.0", + + "features": { + "ghcr.io/devcontainers/features/common-utils:2": {}, + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + + "mounts": [ + { + "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.ssh/id_ed25519.pub", + "target": "/home/vscode/.ssh/id_ed25519.pub", + "type": "bind", + "readonly": true + }, + { + "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.config/git/allowed_signers", + "target": "/home/vscode/.config/git/allowed_signers", + "type": "bind", + "readonly": true + }, + { + "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.config/gh", + "target": "/home/vscode/.config/gh", + "type": "bind", + "readonly": false + } + ], + + "remoteUser": "vscode", + "workspaceFolder": "/workspaces/ProjectTemplate", + + // The bind-mount on macOS hosts surfaces /home/vscode/.ssh as root-owned; + // chown it back so writes from inside the container (known_hosts updates + // by gh / git) land cleanly. Idempotent on Linux/WSL2. + "onCreateCommand": "sudo install -d -m 700 -o vscode -g vscode /home/vscode/.ssh", + + // Install uv for the Python sibling project, restore .NET local tools, + // and install the husky git hooks. uv is installed under $HOME/.local/bin + // and added to PATH by uv's install script. + "postCreateCommand": ".devcontainer/post-create.sh", + + "customizations": { + "vscode": { + "extensions": [ + "csharpier.csharpier-vscode", + "davidanson.vscode-markdownlint", + "editorconfig.editorconfig", + "github.vscode-github-actions", + "gruntfuggly.todo-tree", + "ms-azuretools.vscode-docker", + "ms-dotnettools.csdevkit", + "streetsidesoftware.code-spell-checker", + "yzhang.markdown-all-in-one", + "ms-python.python", + "charliermarsh.ruff", + "ms-pyright.pyright" + ] + } + } +} diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100755 index 00000000..cc43eafa --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Install uv (Astral) for the Python sibling project. Idempotent — re-running +# overwrites in place. Adds $HOME/.local/bin to PATH via uv's installer hook. +if ! command -v uv >/dev/null 2>&1; then + curl -LsSf https://astral.sh/uv/install.sh | sh +fi + +# Restore the .NET local-tool manifest (CSharpier, Husky.Net, dotnet-outdated). +dotnet tool restore + +# Install Husky.Net git hooks so commits run pre-commit checks. +dotnet husky install || true + +# Pre-warm uv environment for PyPiLibrary if it exists. Guarded so this script +# is safe before PyPiLibrary lands in the repo. +if [[ -f PyPiLibrary/pyproject.toml ]]; then + (cd PyPiLibrary && "$HOME/.local/bin/uv" sync) +fi diff --git a/ProjectTemplate.code-workspace b/ProjectTemplate.code-workspace index b8714f6a..12c72702 100644 --- a/ProjectTemplate.code-workspace +++ b/ProjectTemplate.code-workspace @@ -9,6 +9,7 @@ "accessibilities", "Allman", "apikey", + "astral", "autoremove", "buildcache", "buildtransitive", @@ -19,6 +20,7 @@ "datebadge", "davidanson", "debuglevel", + "devcontainer", "dockerhub", "dotnettools", "dryrun", @@ -26,8 +28,11 @@ "finalizers", "gpgsign", "gruntfuggly", + "hatchling", "Jellyfin", + "Keychain", "lastbuild", + "libsecret", "LINQ", "logfile", "nameof", @@ -36,12 +41,19 @@ "nektos", "Nerdbank", "noninteractive", + "onCreateCommand", "othercommand", "Pieter", + "postCreateCommand", "ProjectTemplate", + "pyproject", + "pypi", + "pypilibrary", + "pyright", "quoteoftheday", "resharper", "Rubba", + "ruff", "Serilog", "settingsfile", "signingkey", @@ -88,6 +100,9 @@ "ms-dotnettools.csdevkit", "streetsidesoftware.code-spell-checker", "yzhang.markdown-all-in-one", + "ms-python.python", + "charliermarsh.ruff", + "ms-pyright.pyright", ] } } diff --git a/README.md b/README.md index ccfe3891..1314dc0c 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,16 @@ Options: ## Development Environment Setup +The recommended setup is the [Dev Container](./docs/devcontainer.md) — a single image with the .NET 10 SDK, the `uv` Python toolchain, and the GitHub CLI. It bind-mounts your SSH public key, allowed-signers file, and `gh` config from the host so commits sign correctly and `gh` is pre-authenticated. + +**Recommended (devcontainer)**: + +1. Complete [host setup](./docs/host-setup.md) once per machine (git identity, SSH key, allowed_signers, `gh auth login`, [SSH commit signing](./docs/ssh-signing.md)). +2. Clone the repo, open in VS Code with the [Dev Containers extension][devcontainers-link], and run **Reopen in Container**. +3. The `postCreateCommand` runs `dotnet tool restore`, installs Husky.Net hooks, and installs `uv`. + +**Alternative (host install)**: + - **Install Developer Tools**: - Install [.NET SDK](https://dotnet.microsoft.com/en-us/download): @@ -306,8 +316,9 @@ Licensed under the [MIT License][license-link]\ #### Template - Git Setup - **⚠️ Prerequisites**: - - Configure git for SSH signing. - - Configure SSH forwarding for dev containers. + - Configure git for SSH signing — see [SSH commit signing](./docs/ssh-signing.md). + - Configure host prerequisites (SSH key, `allowed_signers`, `gh` auth) — see [host setup](./docs/host-setup.md). + - Configure SSH forwarding for dev containers — see [devcontainer setup](./docs/devcontainer.md). - Setup new project from template: ```shell @@ -499,6 +510,8 @@ Licensed under the [MIT License][license-link]\ +[devcontainers-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers + [apininjas-link]: https://api-ninjas.com/api/quotes [awesomeassertions-link]: https://awesomeassertions.org/ [byob-link]: https://github.com/marketplace/actions/bring-your-own-badge diff --git a/docs/devcontainer.md b/docs/devcontainer.md new file mode 100644 index 00000000..9252773a --- /dev/null +++ b/docs/devcontainer.md @@ -0,0 +1,83 @@ +# Devcontainer Setup + +The repo ships a single unified [Dev Container](https://containers.dev/) that hosts both the .NET 10 SDK and the Python `uv` toolchain. Open the repo in VS Code with the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) installed and pick **Reopen in Container**. + +Prerequisite: complete [host setup](./host-setup.md) first — without git config, an SSH key, and the allowed-signers file on the host, the devcontainer will not be able to sign commits. + +## What's Inside + +| Component | Source | Purpose | +|---|---|---| +| .NET 10 SDK | base image `mcr.microsoft.com/devcontainers/dotnet:1-10.0` | Build, test, pack the .NET projects | +| `uv` | `astral.sh/uv/install.sh` in `post-create.sh` | Python env, dependency, build, and publish manager for the PyPi sibling | +| `gh` CLI | `ghcr.io/devcontainers/features/github-cli:1` | Issue/PR/release management from inside the container | +| Common utilities | `ghcr.io/devcontainers/features/common-utils:2` | bash, curl, wget, sudo, `vscode` user | +| VS Code extensions | `customizations.vscode.extensions` in `devcontainer.json` | Mirrors `ProjectTemplate.code-workspace` recommendations so the container has the same tooling | + +The extension list in `.devcontainer/devcontainer.json` and the `recommendations` array in `ProjectTemplate.code-workspace` are kept identical — when you add an extension to one, add it to the other. + +## Bind Mounts + +The host SSH key, allowed-signers file, and `gh` config directory are mounted into the container so commits sign correctly and `gh` is pre-authenticated. + +| Host path | Container path | Mode | Purpose | +|---|---|---|---| +| `~/.ssh/id_ed25519.pub` | `/home/vscode/.ssh/id_ed25519.pub` | read-only | Public half of the SSH key. The private key never enters the container — SSH agent forwarding handles signing. | +| `~/.config/git/allowed_signers` | `/home/vscode/.config/git/allowed_signers` | read-only | Maps your email to your public key so `git verify-commit` and `git log --show-signature` work inside the container. | +| `~/.config/gh` | `/home/vscode/.config/gh` | read-write | `gh` CLI auth state shared with the host. See [`gh` credential store](#gh-credential-store) below. | + +VS Code Dev Containers automatically copies your host `~/.gitconfig` into the container at startup, so `user.name`, `user.email`, `user.signingkey`, `gpg.format`, and `commit.gpgsign` propagate without an explicit mount. + +The SSH agent is forwarded automatically by the Dev Containers extension via `SSH_AUTH_SOCK`, so signing works as long as the agent on the host has your key loaded. + +## Lifecycle Commands + +`devcontainer.json` runs two scripts at well-defined points: + +- **`onCreateCommand`** — `sudo install -d -m 700 -o vscode -g vscode /home/vscode/.ssh`. On macOS hosts the bind-mount surfaces `/home/vscode/.ssh` as root-owned, which would block writes from inside the container (e.g. `gh` updating `known_hosts`). This chown fixes it. Idempotent on Linux and WSL2. +- **`postCreateCommand`** — `.devcontainer/post-create.sh`, which installs `uv`, runs `dotnet tool restore`, installs Husky.Net hooks, and pre-syncs `PyPiLibrary` if it exists. Re-runs are idempotent. + +To force them to run again after editing the script: VS Code → Command Palette → **Dev Containers: Rebuild Container**. + +## `gh` Credential Store + +`gh auth login` writes its token to either a file or an OS credential store. Which one depends on your host: + +| Host | Default token storage | +|---|---| +| Linux | libsecret (gnome-keyring) when available, otherwise file | +| WSL2 | file (no native credential store) | +| macOS | macOS Keychain | + +The bind-mount of `~/.config/gh` covers the **file** case. If your host stores the token in Keychain or libsecret, the bind-mount carries the rest of `gh` config but **not the token** — the container will report "no authentication" until you either: + +1. Re-run `gh auth login` inside the container (writes a file token to the mounted directory), or +2. Skip in-container `gh` and run those commands on the host instead. + +The file-token path is slightly less secure than Keychain/libsecret because it's plaintext on disk inside `~/.config/gh/hosts.yml`. For most contributors that's an acceptable trade-off; if it isn't, use option 2. + +## Verify the Devcontainer + +After **Reopen in Container** finishes, run: + +```shell +dotnet --version # 10.x +uv --version # uv 0.x +gh auth status # logged in as you +git -c gpg.format=ssh commit -S --allow-empty -m "verify-signing" +git log --show-signature -1 # "Good 'git' signature for ..." +dotnet build # 0 warnings, 0 errors +dotnet test # tests pass +``` + +If `git -c gpg.format=ssh commit -S` errors with `signing failed: no allowed signers`, the bind-mount of `allowed_signers` is missing or the file on the host is empty — re-run the snippet in [host setup](./host-setup.md). + +## Troubleshooting + +**Permission denied writing to `~/.ssh/known_hosts` in the container** — The `onCreateCommand` should have chowned `~/.ssh` to `vscode`. Rebuild the container; if it persists, open a shell and run the same `sudo install -d -m 700 -o vscode -g vscode ~/.ssh` manually. + +**`git commit` fails with "no SSH agent socket"** — VS Code Dev Containers forwards `SSH_AUTH_SOCK` automatically, but only if the host has `ssh-agent` running with at least one key. Run `ssh-add -l` on the host first; if it says "could not open a connection to your authentication agent", start the agent (see [host setup](./host-setup.md)). + +**uv not on `PATH` after rebuild** — The post-create installer adds `~/.local/bin` to `PATH` via the user shell init scripts, which take effect on next shell. Either re-open the integrated terminal or `source ~/.bashrc`. + +**Container builds but extensions don't auto-install** — Make sure VS Code is using the Dev Containers extension (not "Remote - SSH" or "Remote - Tunnels"). The extension auto-install is keyed on `customizations.vscode.extensions` and only Dev Containers honors that. diff --git a/docs/host-setup.md b/docs/host-setup.md new file mode 100644 index 00000000..4e922730 --- /dev/null +++ b/docs/host-setup.md @@ -0,0 +1,132 @@ +# Host Setup + +Prerequisites for working with this repo locally — apply once per machine before opening the devcontainer or building outside one. + +Supported hosts: **Linux**, **WSL2 on Windows** (native Windows is not supported for the devcontainer; use WSL2), **macOS**. + +## Git Identity + +Configure your name and email — used for commit authorship. + +```shell +git config --global user.name "Your Name" +git config --global user.email "you@example.com" +``` + +## SSH Key + +Generate an Ed25519 SSH key for both authentication and commit signing. One key serves both roles. + +```shell +ssh-keygen -t ed25519 -C "you@example.com" -f ~/.ssh/id_ed25519 +``` + +Add the public key (`~/.ssh/id_ed25519.pub`) to GitHub twice: + +1. **Authentication key** — [GitHub → Settings → SSH and GPG keys → New SSH key](https://github.com/settings/keys), key type **Authentication Key**. +2. **Signing key** — same page, but **Signing Key** type. GitHub treats these independently even though it's the same public key. + +Test the auth key: + +```shell +ssh -T git@github.com +``` + +## SSH Config + +Tell SSH which key to use for `github.com`. Pick the snippet for your platform. + +### Linux / WSL2 + +```sshconfig +# ~/.ssh/config +Host github.com + HostName github.com + User git + IdentityFile ~/.ssh/id_ed25519 + IdentitiesOnly yes +``` + +Make sure ssh-agent is running and the key is loaded. On systemd-based distros: + +```shell +systemctl --user enable --now ssh-agent.socket +ssh-add ~/.ssh/id_ed25519 +``` + +For non-systemd shells, add to `~/.bashrc` or `~/.zshrc`: + +```shell +if [ -z "$SSH_AUTH_SOCK" ]; then + eval "$(ssh-agent -s)" >/dev/null + ssh-add ~/.ssh/id_ed25519 2>/dev/null +fi +``` + +### macOS + +```sshconfig +# ~/.ssh/config +Host github.com + HostName github.com + User git + IdentityFile ~/.ssh/id_ed25519 + IdentitiesOnly yes + UseKeychain yes + AddKeysToAgent yes +``` + +Load the key into the macOS Keychain so it's available without re-entering the passphrase: + +```shell +ssh-add --apple-use-keychain ~/.ssh/id_ed25519 +``` + +## Allowed Signers File + +Required for SSH signature verification by `git verify-commit` and similar tools. Without it git can sign commits but not verify them locally. + +```shell +mkdir -p ~/.config/git +echo "$(git config user.email) namespaces=\"git\" $(cat ~/.ssh/id_ed25519.pub)" \ + >> ~/.config/git/allowed_signers +git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers +``` + +## Configure Git for SSH Signing + +```shell +git config --global gpg.format ssh +git config --global user.signingkey ~/.ssh/id_ed25519.pub +git config --global commit.gpgsign true +git config --global tag.gpgsign true +``` + +See [SSH commit signing](./ssh-signing.md) for verification steps and per-OS troubleshooting. + +## GitHub CLI + +Install [`gh`](https://cli.github.com/) and authenticate. + +```shell +gh auth login --hostname github.com --git-protocol ssh +``` + +Choose the SSH key generated above when prompted. + +## Verify Host Setup + +```shell +git config --global --list | grep -E "user\.|signing|gpg\." +ssh-add -L # should list your public key +git -c gpg.format=ssh commit -S --allow-empty -m "verify-signing" +git log --show-signature -1 +gh auth status +``` + +If signing fails locally, the devcontainer will fail too — fix here first. + +## Next Steps + +- [Devcontainer setup](./devcontainer.md) — open the repo in the unified .NET + Python devcontainer. +- [SSH commit signing](./ssh-signing.md) — per-OS setup details, verification, and troubleshooting. diff --git a/docs/ssh-signing.md b/docs/ssh-signing.md new file mode 100644 index 00000000..be3b9c16 --- /dev/null +++ b/docs/ssh-signing.md @@ -0,0 +1,120 @@ +# SSH Commit Signing + +This repo enforces signed commits on `main` and `develop` via branch protection. Use SSH signing — one Ed25519 key serves both authentication (push) and signing. + +If you haven't generated a key and configured git yet, follow [host setup](./host-setup.md) first. + +## Why SSH Signing + +- **One key for everything**. Same `id_ed25519` you use for `git push` also signs commits. No GPG keyring, no expirations to chase. +- **GitHub native**. GitHub treats authentication and signing keys independently but accepts the same public key for both — register it twice on the SSH and GPG keys page. +- **Survives rotation cleanly**. When you rotate the key, update the `allowed_signers` file and old signatures still verify against the historical entry. + +## Configuration + +Per-user (host) git config — set once: + +```shell +git config --global gpg.format ssh +git config --global user.signingkey ~/.ssh/id_ed25519.pub +git config --global commit.gpgsign true +git config --global tag.gpgsign true +git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers +``` + +The `allowed_signers` file is what `git verify-commit` consults — without it, signatures sign fine but verify as "unknown signer". Format: + +```text +you@example.com namespaces="git" ssh-ed25519 AAAA... your_public_key_contents_here +``` + +Build it from your existing public key: + +```shell +mkdir -p ~/.config/git +echo "$(git config user.email) namespaces=\"git\" $(cat ~/.ssh/id_ed25519.pub)" \ + >> ~/.config/git/allowed_signers +``` + +If you collaborate with others, append their entries to the same file — each line maps an email to a public key. + +## Per-OS Setup Notes + +### Linux / WSL2 + +The SSH agent must be running for git to find the private key without prompting for the passphrase every commit. On systemd-based distros: + +```shell +systemctl --user enable --now ssh-agent.socket +ssh-add ~/.ssh/id_ed25519 +``` + +The agent socket lives at `$XDG_RUNTIME_DIR/ssh-agent.socket`. Make sure your shell exports `SSH_AUTH_SOCK` to point at it — most distros do this in `/etc/X11/Xsession.d` or systemd user environment. + +For shells without systemd integration, fall back to ad-hoc agent in `~/.bashrc` or `~/.zshrc`: + +```shell +if [ -z "$SSH_AUTH_SOCK" ] || ! ssh-add -l >/dev/null 2>&1; then + eval "$(ssh-agent -s)" >/dev/null + ssh-add ~/.ssh/id_ed25519 2>/dev/null +fi +``` + +WSL2 specifically: WSL inherits no agent from Windows. Run `ssh-agent` inside WSL; do not try to forward an agent from the Windows side. + +### macOS + +macOS has its own `ssh-agent` integrated with Keychain. To load your key once and have it persist across reboots: + +```shell +ssh-add --apple-use-keychain ~/.ssh/id_ed25519 +``` + +Add to `~/.ssh/config` so `ssh` and `git` use the Keychain-aware agent automatically: + +```sshconfig +Host github.com + HostName github.com + User git + IdentityFile ~/.ssh/id_ed25519 + IdentitiesOnly yes + UseKeychain yes + AddKeysToAgent yes +``` + +The Keychain prompt for the passphrase appears on first use after each reboot; subsequent sessions are silent. + +### Windows (without WSL) + +Native Windows is **not supported** for the devcontainer setup in this repo. Use WSL2 instead. The reason: VS Code Dev Containers needs a Linux-like file system for the bind-mounts to behave consistently, and Docker Desktop's WSL2 backend is the supported path. + +If you must work on Windows directly without a devcontainer, OpenSSH for Windows can sign with `gpg.format=ssh` — but the bind-mounted devcontainer setup expects Linux/WSL2 paths. + +## Verify Signing + +```shell +git commit --allow-empty -m "verify-signing" +git log --show-signature -1 +``` + +Expected output includes `Good "git" signature for `. If you see `error: gpg.ssh.allowedSignersFile needs to be configured` or `No signature`, walk back through the host setup — most often `allowed_signers` is missing the entry, or `commit.gpgsign` is not set. + +## Inside the Devcontainer + +The container picks up: + +- Your `~/.gitconfig` automatically (VS Code Dev Containers copies it on start). +- The `~/.ssh/id_ed25519.pub` and `~/.config/git/allowed_signers` files via bind-mount declared in `devcontainer.json`. +- The forwarded SSH agent socket from `SSH_AUTH_SOCK`, so signing happens with the host's loaded private key without the private key ever entering the container. + +If the container's `~/.ssh` directory exists with the wrong owner (root, surfaced by macOS bind-mount semantics), `gh auth login` writes to `~/.ssh/known_hosts` may fail. The `onCreateCommand` in `devcontainer.json` chowns the directory to `vscode` to fix this — see [devcontainer setup](./devcontainer.md) for the rationale. + +## Troubleshooting + +**`gpg.ssh.allowedSignersFile needs to be configured`** — Set `git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers` and ensure the file exists. + +**`signing failed: no allowed signers`** — The `allowed_signers` file exists but doesn't contain a line matching `user.email` + a key. Re-run the `echo $(git config user.email) namespaces="git" $(cat ~/.ssh/id_ed25519.pub) >> …` snippet. + +**Verifies on the host but not in the container** — The bind-mount source path differs. `${localEnv:HOME}` resolves on Linux/macOS hosts; on Windows hosts (WSL2 backend) the `${localEnv:USERPROFILE}` fallback in `devcontainer.json` handles it. Check the actual mount with `mount | grep ssh` inside the container. + +**SSH agent says "could not open a connection"** — The host's agent isn't running. Linux: `systemctl --user start ssh-agent.socket`. macOS: open a new terminal so launchd starts the agent. From 515507572a69fa22291ef82b5fd059ff1b2aa48a Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 08:37:08 -0700 Subject: [PATCH 03/24] Add PyPiLibrary Python sibling project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Python PyPi template project that lives alongside the .NET NuGetLibrary, so this template repo serves as a polyglot starting point. Modern stack: hatchling build backend, uv for env/deps/publish, ruff for lint and format, pyright for typing, pytest for tests, PyPI Trusted Publishing via OIDC (no PYPI_API_TOKEN). Folder is `PyPiLibrary/` (qualifier on disk to disambiguate from NuGetLibrary), but the published package name has no `pypi` qualifier and mirrors the NuGet identity: `ptr727-projecttemplate-library`. Import name `ptr727_projecttemplate_library`. Workflow plumbing: - New reusable `.github/workflows/build-pypilibrary-task.yml` that runs ruff check, ruff format --check, pyright, pytest, then `uv build`. Publish job uses `pypa/gh-action-pypi-publish` with `id-token: write` — Trusted Publishing requires no API token in repo secrets. - `build-release-task.yml` adds a `pypi: bool` input mirroring `nuget`, calls the new reusable workflow, and gates publishing on it. - `publish-release.yml` passes `pypi: true` so on-push releases include the PyPi publish. - `test-release-task.yml` passes `pypi: false` so PR validation exercises the build but skips publishing. Other plumbing: - `.github/dependabot.yml` adds the `uv` ecosystem targeting `/PyPiLibrary`. - `.husky/task-runner.json` adds Ruff Format and Ruff Check tasks scoped to `PyPiLibrary/**/*.py`, guarded by `command -v uv` so `.cs`-only commits do not fail when uv is not installed. - `ProjectTemplate.code-workspace` adds Python interpreter, ruff, and format-on-save settings scoped via the workspace file (not split into `.vscode/settings.json`). - `ProjectTemplate.slnx` adds the new workflow file under GitHub Actions. - `.gitignore` adds Python build artifacts (.venv, dist, __pycache__, .pytest_cache, .ruff_cache, .pyright). - `README.md` adds PyPI badge to the build/distribution and releases sections; template TODO list reminds the deriver to delete the unused language side. Verification (local, host has uv 0.11.8): - `uv sync` installs deps cleanly. - `uv run ruff check`: All checks passed. - `uv run pyright`: 0 errors, 0 warnings. - `uv run pytest`: 3 passed. - `uv build`: produces ptr727_projecttemplate_library-0.0.0.tar.gz and -0.0.0-py3-none-any.whl. - `dotnet build`: 0 warnings, 0 errors. - `dotnet test`: 15 passed (no regression on .NET side). --- .github/dependabot.yml | 9 ++ .github/workflows/build-pypilibrary-task.yml | 88 +++++++++++ .github/workflows/build-release-task.yml | 18 ++- .github/workflows/publish-release.yml | 3 +- .github/workflows/test-release-task.yml | 1 + .gitignore | 10 ++ .husky/task-runner.json | 22 +++ ProjectTemplate.code-workspace | 11 ++ ProjectTemplate.slnx | 1 + PyPiLibrary/README.md | 65 ++++++++ PyPiLibrary/pyproject.toml | 76 ++++++++++ .../__init__.py | 6 + .../_version.py | 8 + .../ptr727_projecttemplate_library/example.py | 6 + PyPiLibrary/tests/__init__.py | 0 PyPiLibrary/tests/test_example.py | 16 ++ PyPiLibrary/uv.lock | 140 ++++++++++++++++++ README.md | 10 +- 18 files changed, 486 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/build-pypilibrary-task.yml create mode 100644 PyPiLibrary/README.md create mode 100644 PyPiLibrary/pyproject.toml create mode 100644 PyPiLibrary/src/ptr727_projecttemplate_library/__init__.py create mode 100644 PyPiLibrary/src/ptr727_projecttemplate_library/_version.py create mode 100644 PyPiLibrary/src/ptr727_projecttemplate_library/example.py create mode 100644 PyPiLibrary/tests/__init__.py create mode 100644 PyPiLibrary/tests/test_example.py create mode 100644 PyPiLibrary/uv.lock diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 21030f41..8c6b2b65 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -21,3 +21,12 @@ updates: actions-deps: patterns: - "*" +- package-ecosystem: "uv" + target-branch: "main" + directory: "/PyPiLibrary" + schedule: + interval: "daily" + groups: + pypi-deps: + patterns: + - "*" diff --git a/.github/workflows/build-pypilibrary-task.yml b/.github/workflows/build-pypilibrary-task.yml new file mode 100644 index 00000000..26ccde12 --- /dev/null +++ b/.github/workflows/build-pypilibrary-task.yml @@ -0,0 +1,88 @@ +name: Build PyPi library task + +on: + workflow_call: + inputs: + # Input to control whether to publish the PyPi library to PyPI + push: + required: false + type: boolean + default: false + outputs: + # Output of the uploaded artifact id + artifact-id: + value: ${{ jobs.build-pypilibrary.outputs.artifact-id }} + +jobs: + + build-pypilibrary: + name: Build PyPi library project job + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./PyPiLibrary + outputs: + artifact-id: ${{ steps.artifact-upload-step.outputs.artifact-id }} + + steps: + + - name: Checkout code step + uses: actions/checkout@v6 + + - name: Setup uv step + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + cache-dependency-glob: "PyPiLibrary/uv.lock" + + - name: Sync dependencies step + run: uv sync --all-groups --frozen + + - name: Lint with ruff step + run: uv run ruff check + + - name: Verify formatting with ruff step + run: uv run ruff format --check + + - name: Type check with pyright step + run: uv run pyright + + - name: Run pytest step + run: uv run pytest + + - name: Build sdist and wheel step + run: uv build + + - name: Upload build artifacts step + id: artifact-upload-step + uses: actions/upload-artifact@v6 + with: + name: pypilibrary-build + path: PyPiLibrary/dist/* + + publish-pypilibrary: + name: Publish PyPi library job + if: ${{ inputs.push }} + needs: [build-pypilibrary] + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/ptr727-projecttemplate-library + # Trusted Publishing requires id-token: write at job level so the OIDC + # token is available to the publish action; the action exchanges it for a + # short-lived PyPI upload token. No PYPI_API_TOKEN secret is involved. + permissions: + id-token: write + + steps: + + - name: Download build artifacts step + uses: actions/download-artifact@v7 + with: + artifact-ids: ${{ needs.build-pypilibrary.outputs.artifact-id }} + path: ./dist + + - name: Publish to PyPI step + uses: pypa/gh-action-pypi-publish@6733eb7d741f0b11ec6a39b58540dab7590f9b7d # v1.14.0 + with: + packages-dir: ./dist diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 4bccfdff..30a8fdf2 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -18,6 +18,11 @@ on: required: false type: boolean default: false + # Input to control whether to publish the PyPi library to PyPI + pypi: + required: false + type: boolean + default: false jobs: @@ -34,6 +39,17 @@ jobs: # Conditional push to NuGet.org push: ${{ inputs.nuget }} + build-pypilibrary: + name: Build PyPi library job + uses: ./.github/workflows/build-pypilibrary-task.yml + secrets: inherit + permissions: + contents: read + id-token: write + with: + # Conditional publish to PyPI via Trusted Publishing + push: ${{ inputs.pypi }} + build-executable: name: Build executable job uses: ./.github/workflows/build-executable-task.yml @@ -51,7 +67,7 @@ jobs: name: Publish GitHub release job if: ${{ inputs.github }} runs-on: ubuntu-latest - needs: [get-version, build-nugetlibrary, build-executable, build-docker] + needs: [get-version, build-nugetlibrary, build-pypilibrary, build-executable, build-docker] steps: diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index ac31c745..db8caf4c 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -18,10 +18,11 @@ jobs: permissions: contents: write with: - # Push to GitHub and NuGet and Docker Hub + # Push to GitHub and NuGet and Docker Hub and PyPI github: true nuget: true dockerhub: true + pypi: true date-badge: name: Create BYOB date badge job diff --git a/.github/workflows/test-release-task.yml b/.github/workflows/test-release-task.yml index bb9ce6a5..3d6fb3cb 100644 --- a/.github/workflows/test-release-task.yml +++ b/.github/workflows/test-release-task.yml @@ -39,3 +39,4 @@ jobs: github: false nuget: false dockerhub: false + pypi: false diff --git a/.gitignore b/.gitignore index 193ff244..8e1e603e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,13 @@ .artifacts .DS_Store *.user + +# Python / uv +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +dist/ +.pytest_cache/ +.ruff_cache/ +.pyright/ diff --git a/.husky/task-runner.json b/.husky/task-runner.json index 009e6b3a..8b275d8c 100644 --- a/.husky/task-runner.json +++ b/.husky/task-runner.json @@ -27,6 +27,28 @@ "include": [ "**/*.cs" ] + }, + { + "name": "Ruff Format", + "command": "bash", + "args": [ + "-lc", + "command -v uv >/dev/null 2>&1 && (cd PyPiLibrary && uv run ruff format ${staged}) || true" + ], + "include": [ + "PyPiLibrary/**/*.py" + ] + }, + { + "name": "Ruff Check", + "command": "bash", + "args": [ + "-lc", + "command -v uv >/dev/null 2>&1 && (cd PyPiLibrary && uv run ruff check ${staged}) || true" + ], + "include": [ + "PyPiLibrary/**/*.py" + ] } ] } diff --git a/ProjectTemplate.code-workspace b/ProjectTemplate.code-workspace index 12c72702..075d6e35 100644 --- a/ProjectTemplate.code-workspace +++ b/ProjectTemplate.code-workspace @@ -86,6 +86,17 @@ "editor.formatOnSave": true, "editor.defaultFormatter": "csharpier.csharpier-vscode" }, + "[python]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + } + }, + "python.defaultInterpreterPath": "${workspaceFolder}/PyPiLibrary/.venv/bin/python", + "python.terminal.activateEnvironment": false, + "ruff.path": ["${workspaceFolder}/PyPiLibrary/.venv/bin/ruff"], + "ruff.configuration": "${workspaceFolder}/PyPiLibrary/pyproject.toml", "git.alwaysSignOff": true, "markdown.extension.toc.levels": "2..3" }, diff --git a/ProjectTemplate.slnx b/ProjectTemplate.slnx index 7e3cc9c6..e751e769 100644 --- a/ProjectTemplate.slnx +++ b/ProjectTemplate.slnx @@ -4,6 +4,7 @@ + diff --git a/PyPiLibrary/README.md b/PyPiLibrary/README.md new file mode 100644 index 00000000..9540f52c --- /dev/null +++ b/PyPiLibrary/README.md @@ -0,0 +1,65 @@ +# PyPiLibrary + +Python PyPi template — companion to the .NET `NuGetLibrary` in this repo. Published to PyPI as [`ptr727-projecttemplate-library`](https://pypi.org/project/ptr727-projecttemplate-library/). + +## Stack + +- **Build backend** — [`hatchling`](https://hatch.pypa.io/latest/) via `pyproject.toml` +- **Env / deps / publish** — [`uv`](https://docs.astral.sh/uv/) (Astral) +- **Lint + format** — [`ruff`](https://docs.astral.sh/ruff/) +- **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) + +## Layout + +```text +PyPiLibrary/ + pyproject.toml + README.md + src/ + ptr727_projecttemplate_library/ + __init__.py + _version.py + example.py + tests/ + __init__.py + test_example.py +``` + +## Local Development + +The repo's [devcontainer](../docs/devcontainer.md) installs `uv` automatically and runs `uv sync` for this project on first open. To work outside the devcontainer: + +```shell +# from the repo root +cd PyPiLibrary +uv sync # creates .venv, installs deps + dev group +uv run ruff check # lint +uv run ruff format --check # formatting check +uv run pyright # type check +uv run pytest # tests +uv build # wheel + sdist into ./dist +``` + +## Publishing + +Releases are produced by `.github/workflows/build-pypilibrary-task.yml`, called from `build-release-task.yml` when `pypi: true` is passed by `publish-release.yml`. The publish job uses [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) — no `PYPI_API_TOKEN` secret is involved; the workflow exchanges its OIDC token for a short-lived PyPI upload token. + +First-time setup (one-time, on PyPI): + +1. PyPI → **Account settings** → **Publishing** → **Add a new pending publisher**. +2. Project name: `ptr727-projecttemplate-library`. Owner: `ptr727`. Repo: `ProjectTemplate`. Workflow: `publish-release.yml`. Environment: `pypi`. +3. GitHub repo → **Settings** → **Environments** → create `pypi` environment (optionally with required reviewers). +4. The first successful release converts the pending publisher to a real publisher. + +## Template Adoption + +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. +- Update `_version.py` versioning policy to match your release cadence (Nerdbank.GitVersioning is .NET-only; for Python use a tag-driven scheme like [`hatch-vcs`](https://github.com/ofek/hatch-vcs) or manual bumps in `_version.py`). + +If you don't want a Python project at all, delete the `PyPiLibrary/` folder, the `build-pypilibrary-task.yml` workflow, and the `pip` block in `dependabot.yml`. diff --git a/PyPiLibrary/pyproject.toml b/PyPiLibrary/pyproject.toml new file mode 100644 index 00000000..332bd9e3 --- /dev/null +++ b/PyPiLibrary/pyproject.toml @@ -0,0 +1,76 @@ +[build-system] +requires = ["hatchling>=1.27"] +build-backend = "hatchling.build" + +[project] +name = "ptr727-projecttemplate-library" +description = "Python PyPi template library — companion to the .NET NuGetLibrary in this template repo." +readme = "README.md" +license = { text = "MIT" } +authors = [{ name = "Pieter Viljoen" }] +requires-python = ">=3.13" +keywords = ["template", "pypi", "library"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dynamic = ["version"] +dependencies = [] + +[project.urls] +Homepage = "https://github.com/ptr727/ProjectTemplate" +Source = "https://github.com/ptr727/ProjectTemplate" +Issues = "https://github.com/ptr727/ProjectTemplate/issues" + +[dependency-groups] +dev = [ + "pytest>=8.3", + "ruff>=0.9", + "pyright>=1.1.390", +] + +[tool.hatch.version] +path = "src/ptr727_projecttemplate_library/_version.py" + +[tool.hatch.build.targets.wheel] +packages = ["src/ptr727_projecttemplate_library"] + +[tool.hatch.build.targets.sdist] +include = ["src", "tests", "README.md", "pyproject.toml"] + +[tool.ruff] +line-length = 120 +target-version = "py313" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "UP", # pyupgrade + "N", # pep8-naming + "SIM", # flake8-simplify + "RUF", # ruff-specific +] + +[tool.ruff.format] +docstring-code-format = true + +[tool.pyright] +include = ["src", "tests"] +strict = ["src/**"] +pythonVersion = "3.13" +typeCheckingMode = "standard" + +[tool.pytest.ini_options] +minversion = "8.0" +testpaths = ["tests"] +addopts = ["-ra", "--strict-markers", "--strict-config"] diff --git a/PyPiLibrary/src/ptr727_projecttemplate_library/__init__.py b/PyPiLibrary/src/ptr727_projecttemplate_library/__init__.py new file mode 100644 index 00000000..1f0a1200 --- /dev/null +++ b/PyPiLibrary/src/ptr727_projecttemplate_library/__init__.py @@ -0,0 +1,6 @@ +"""Python PyPi template library.""" + +from ptr727_projecttemplate_library._version import __version__ +from ptr727_projecttemplate_library.example import greet + +__all__ = ["__version__", "greet"] diff --git a/PyPiLibrary/src/ptr727_projecttemplate_library/_version.py b/PyPiLibrary/src/ptr727_projecttemplate_library/_version.py new file mode 100644 index 00000000..66b584a9 --- /dev/null +++ b/PyPiLibrary/src/ptr727_projecttemplate_library/_version.py @@ -0,0 +1,8 @@ +"""Single-source-of-truth for the package version. + +Hatchling reads ``__version__`` from this module via ``[tool.hatch.version]``. +For tag-driven versioning, swap this for ``hatch-vcs`` and configure the build +backend to derive the version from git tags. +""" + +__version__ = "0.0.0" diff --git a/PyPiLibrary/src/ptr727_projecttemplate_library/example.py b/PyPiLibrary/src/ptr727_projecttemplate_library/example.py new file mode 100644 index 00000000..84e2fcfb --- /dev/null +++ b/PyPiLibrary/src/ptr727_projecttemplate_library/example.py @@ -0,0 +1,6 @@ +"""Trivial example module — replace with your library code.""" + + +def greet(name: str) -> str: + """Return a friendly greeting for ``name``.""" + return f"Hello, {name}!" diff --git a/PyPiLibrary/tests/__init__.py b/PyPiLibrary/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/PyPiLibrary/tests/test_example.py b/PyPiLibrary/tests/test_example.py new file mode 100644 index 00000000..fe97742d --- /dev/null +++ b/PyPiLibrary/tests/test_example.py @@ -0,0 +1,16 @@ +"""Tests for ``ptr727_projecttemplate_library.example``.""" + +from ptr727_projecttemplate_library import __version__, greet + + +def test_version_is_string() -> None: + assert isinstance(__version__, str) + assert len(__version__) > 0 + + +def test_greet_uses_name() -> None: + assert greet("world") == "Hello, world!" + + +def test_greet_with_empty_name() -> None: + assert greet("") == "Hello, !" diff --git a/PyPiLibrary/uv.lock b/PyPiLibrary/uv.lock new file mode 100644 index 00000000..56ab5bfe --- /dev/null +++ b/PyPiLibrary/uv.lock @@ -0,0 +1,140 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "ptr727-projecttemplate-library" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.390" }, + { name = "pytest", specifier = ">=8.3" }, + { name = "ruff", specifier = ">=0.9" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.409" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/4e/3aa27f74211522dba7e9cbc3e74de779c6d4b654c54e50a4840623be8014/pyright-1.1.409.tar.gz", hash = "sha256:986ee05beca9e077c165758ad123667c679e050059a2546aa02473930394bc93", size = 4430434, upload-time = "2026-04-23T11:02:03.799Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/6b/330d8ebae582b30c2959a1ef4c3bc344ebde48c2ff0c3f113c4710735e11/pyright-1.1.409-py3-none-any.whl", hash = "sha256:aa3ea228cab90c845c7a60d28db7a844c04315356392aa09fafcee98c8c22fb3", size = 6438161, upload-time = "2026-04-23T11:02:01.309Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] diff --git a/README.md b/README.md index 1314dc0c..0c44ed52 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ C# .NET project template. - **Versioned Releases**: [GitHub Releases][releases-link] - Version tagged source code and build artifacts. - **Docker Images**: [Docker Hub][docker-link] - Container images with all tools pre-installed. - **NuGet Packages** [NuGet Packages][nuget-link] - .NET libraries published to NuGet.org. +- **PyPI Packages** [PyPI Package][pypi-link] - Python library published to PyPI.org. ### Build Status @@ -25,7 +26,8 @@ C# .NET project template. [![Docker Latest][dockerlatestversion-shield]][docker-link]\ [![Docker Develop][dockerdevelopversion-shield]][docker-link]\ [![NuGet Release][nugetreleaseversion-shield]][nuget-link]\ -[![NuGet Pre-Release][nugetprereleaseversion-shield]][nuget-link] +[![NuGet Pre-Release][nugetprereleaseversion-shield]][nuget-link]\ +[![PyPI Release][pypireleaseversion-shield]][pypi-link] ### Release Notes @@ -293,7 +295,8 @@ Licensed under the [MIT License][license-link]\ ### Template - TODO List -- [ ] Configure git for SSH signing and SSH forwarding in dev containers. +- [ ] Configure git for SSH signing and SSH forwarding in dev containers — see [docs/host-setup.md](./docs/host-setup.md), [docs/ssh-signing.md](./docs/ssh-signing.md), and [docs/devcontainer.md](./docs/devcontainer.md). +- [ ] Decide whether your project needs the .NET (`NuGetLibrary/`) side, the Python (`PyPiLibrary/`) side, or both. Delete the unused folder and remove its references from `ProjectTemplate.slnx`, `dependabot.yml`, and the corresponding `.github/workflows/build-*-task.yml`. - [ ] Start on Linux to avoid file permission issues when moving from Windows. - [ ] Configure the [Developer Environment](#template---developer-environment-setup). - [ ] Open the project directory (*not the workspace*) in Visual Studio Code, and rename (Ctrl-Shift-H) all instances of `ProjectTemplate` to `[NewProject]` in code. @@ -508,6 +511,9 @@ Licensed under the [MIT License][license-link]\ [nugetreleaseversion-shield]: https://img.shields.io/nuget/v/ptr727.ProjectTemplate.Library?logo=nuget&label=NuGet%20Release [nugetprereleaseversion-shield]: https://img.shields.io/nuget/vpre/ptr727.ProjectTemplate.Library?logo=nuget&&label=NuGet%20Pre-Release&color=orange +[pypi-link]: https://pypi.org/project/ptr727-projecttemplate-library/ +[pypireleaseversion-shield]: https://img.shields.io/pypi/v/ptr727-projecttemplate-library?logo=pypi&label=PyPI%20Release + [devcontainers-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers From 410f17443e04bdc99a4eb6ff6ced50592c4be23f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 08:39:06 -0700 Subject: [PATCH 04/24] Address Copilot review on PR #63 - Remove the hard-coded `workspaceFolder` from `devcontainer.json`. The default `/workspaces/${localWorkspaceFolderBasename}` tracks the host folder name automatically, so derived projects with a different repo name don''t need to edit this config. - Drop `|| true` from `dotnet husky install` in `post-create.sh`. Husky hook installation failing silently would let the container come up without pre-commit enforcement, masking a real setup problem. Let it fail loudly instead. - After installing uv, prepend `$HOME/.local/bin` to PATH for the rest of the script and call `uv sync` from PATH instead of by hard-coded path. Handles the case where uv is already installed elsewhere on PATH. --- .devcontainer/devcontainer.json | 5 ++++- .devcontainer/post-create.sh | 14 ++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a7f92bef..601202c8 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -29,7 +29,10 @@ ], "remoteUser": "vscode", - "workspaceFolder": "/workspaces/ProjectTemplate", + // workspaceFolder defaults to /workspaces/${localWorkspaceFolderBasename}, + // which makes the devcontainer config portable: when this template is + // forked into a repo with a different folder name, the mount path tracks + // the host folder name automatically. // The bind-mount on macOS hosts surfaces /home/vscode/.ssh as root-owned; // chown it back so writes from inside the container (known_hosts updates diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index cc43eafa..25b2eac3 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -2,19 +2,25 @@ set -euo pipefail # Install uv (Astral) for the Python sibling project. Idempotent — re-running -# overwrites in place. Adds $HOME/.local/bin to PATH via uv's installer hook. +# overwrites in place. The installer drops the binary in $HOME/.local/bin and +# updates user shell init to add it to PATH for new shells; we add it to the +# current PATH explicitly so the rest of this script can invoke `uv` without a +# hard-coded path. if ! command -v uv >/dev/null 2>&1; then curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" fi # Restore the .NET local-tool manifest (CSharpier, Husky.Net, dotnet-outdated). dotnet tool restore -# Install Husky.Net git hooks so commits run pre-commit checks. -dotnet husky install || true +# Install Husky.Net git hooks so commits run pre-commit checks. Failures here +# (e.g. missing .git directory, broken tool restore) should surface — the +# devcontainer setup is not "successful" if hook installation fails silently. +dotnet husky install # Pre-warm uv environment for PyPiLibrary if it exists. Guarded so this script # is safe before PyPiLibrary lands in the repo. if [[ -f PyPiLibrary/pyproject.toml ]]; then - (cd PyPiLibrary && "$HOME/.local/bin/uv" sync) + (cd PyPiLibrary && uv sync) fi From aa0a1e75f76e1657c2dd7ad236f353b69a78465e Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 10:06:38 -0700 Subject: [PATCH 05/24] Drop USERPROFILE concat from devcontainer mount sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `${localEnv:HOME}${localEnv:USERPROFILE}` produces an invalid concatenated path on hosts where both variables are set (e.g. native Windows shells). The supported devcontainer hosts are Linux, macOS, and WSL2 — all of which have HOME set unconditionally — so HOME alone covers every supported case. --- .devcontainer/devcontainer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 601202c8..c2d3a54b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -9,19 +9,19 @@ "mounts": [ { - "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.ssh/id_ed25519.pub", + "source": "${localEnv:HOME}/.ssh/id_ed25519.pub", "target": "/home/vscode/.ssh/id_ed25519.pub", "type": "bind", "readonly": true }, { - "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.config/git/allowed_signers", + "source": "${localEnv:HOME}/.config/git/allowed_signers", "target": "/home/vscode/.config/git/allowed_signers", "type": "bind", "readonly": true }, { - "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.config/gh", + "source": "${localEnv:HOME}/.config/gh", "target": "/home/vscode/.config/gh", "type": "bind", "readonly": false From 03347296e9a535e208d3f07e868e93182351f5ef Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 10:07:30 -0700 Subject: [PATCH 06/24] Address Copilot review on PR #64 - Husky Ruff Format and Ruff Check tasks no longer mask failures with `|| true`. Replace with `if command -v uv ...; then ...; fi` so ruff errors surface and block the commit when uv IS installed; the guard cleanly skips when uv is not on PATH (.cs-only commit on a contributor without uv installed should not fail). - Replace the `cd PyPiLibrary && uv run ruff ...` pattern with `uv run --project PyPiLibrary ruff ...`. The `cd` form broke because Husky.Net passes `${staged}` paths repo-relative (`PyPiLibrary/src/...`); resolving those from inside `PyPiLibrary/` produced a non-existent path. - Normalize dependabot.yml indentation: nest the three update entries under `updates:` with two-space indentation instead of leaving them at column 0. Functionally equivalent for Dependabot but matches the documented YAML style and removes ambiguity for human readers. --- .github/dependabot.yml | 65 +++++++++++++++++++++-------------------- .husky/task-runner.json | 4 +-- 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8c6b2b65..66d6bd2b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,32 +1,33 @@ -# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file -version: 2 -updates: - - # main -- package-ecosystem: "nuget" - target-branch: "main" - directory: "/" - schedule: - interval: "daily" - groups: - nuget-deps: - patterns: - - "*" -- package-ecosystem: "github-actions" - target-branch: "main" - directory: "/" - schedule: - interval: "daily" - groups: - actions-deps: - patterns: - - "*" -- package-ecosystem: "uv" - target-branch: "main" - directory: "/PyPiLibrary" - schedule: - interval: "daily" - groups: - pypi-deps: - patterns: - - "*" +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +version: 2 +updates: + + - package-ecosystem: "nuget" + target-branch: "main" + directory: "/" + schedule: + interval: "daily" + groups: + nuget-deps: + patterns: + - "*" + + - package-ecosystem: "github-actions" + target-branch: "main" + directory: "/" + schedule: + interval: "daily" + groups: + actions-deps: + patterns: + - "*" + + - package-ecosystem: "uv" + target-branch: "main" + directory: "/PyPiLibrary" + schedule: + interval: "daily" + groups: + pypi-deps: + patterns: + - "*" diff --git a/.husky/task-runner.json b/.husky/task-runner.json index 8b275d8c..25827b22 100644 --- a/.husky/task-runner.json +++ b/.husky/task-runner.json @@ -33,7 +33,7 @@ "command": "bash", "args": [ "-lc", - "command -v uv >/dev/null 2>&1 && (cd PyPiLibrary && uv run ruff format ${staged}) || true" + "if command -v uv >/dev/null 2>&1; then uv run --project PyPiLibrary ruff format ${staged}; else echo 'uv not on PATH; skipping ruff format' >&2; fi" ], "include": [ "PyPiLibrary/**/*.py" @@ -44,7 +44,7 @@ "command": "bash", "args": [ "-lc", - "command -v uv >/dev/null 2>&1 && (cd PyPiLibrary && uv run ruff check ${staged}) || true" + "if command -v uv >/dev/null 2>&1; then uv run --project PyPiLibrary ruff check ${staged}; else echo 'uv not on PATH; skipping ruff check' >&2; fi" ], "include": [ "PyPiLibrary/**/*.py" From 2e9dbb43ce92d7a53808b2dda1c74f3a526e2197 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 10:13:35 -0700 Subject: [PATCH 07/24] Restore HOME/USERPROFILE fallback for devcontainer mount sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts a regression introduced in 449d494. The `${localEnv:HOME}${localEnv:USERPROFILE}` pattern is the canonical devcontainer.json fallback idiom: at most one of the two is set in practice in the contexts where devcontainer.json `localEnv` is evaluated (Windows VS Code: USERPROFILE only; macOS / Linux / WSL2: HOME only). The "concatenation produces an invalid path" concern is theoretical for shells that set both, but those shells aren''t the context VS Code Dev Containers resolves variables in. Without the USERPROFILE half, Windows VS Code launching the container sees `${localEnv:HOME}` as empty and the mount source becomes `/.ssh/id_ed25519.pub` — broken signing and gh auth. --- .devcontainer/devcontainer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c2d3a54b..601202c8 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -9,19 +9,19 @@ "mounts": [ { - "source": "${localEnv:HOME}/.ssh/id_ed25519.pub", + "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.ssh/id_ed25519.pub", "target": "/home/vscode/.ssh/id_ed25519.pub", "type": "bind", "readonly": true }, { - "source": "${localEnv:HOME}/.config/git/allowed_signers", + "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.config/git/allowed_signers", "target": "/home/vscode/.config/git/allowed_signers", "type": "bind", "readonly": true }, { - "source": "${localEnv:HOME}/.config/gh", + "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.config/gh", "target": "/home/vscode/.config/gh", "type": "bind", "readonly": false From 4c939f605974fe34bb7dfca5c4a91456648b24b1 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 10:20:32 -0700 Subject: [PATCH 08/24] Move PyPi publish job out of reusable workflow chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix `startup_failure` on the test-pull-request workflow. The original structure put `id-token: write` at the publish job inside the deeply- nested reusable workflow `build-pypilibrary-task.yml`. Per the workflow YAML conventions (see AGENTS.md), job-level permissions are validated *before* the `if:` evaluates, so even the gated publish job''s permission declaration had to be granted by every caller in the chain. The test path (test-pull-request → test-release-task → build-release-task → build-pypilibrary-task) does not need to publish, so granting id-token write up that whole chain was both unnecessary and a permission-scope smell. New structure: - `build-pypilibrary-task.yml`: build only (lint, typecheck, test, build, upload artifact). No publish job, no id-token. Same artifact name as before (`pypilibrary-build`). - `build-release-task.yml`: drops the `pypi: bool` input and the permissions block on the build-pypilibrary call. Now identical in shape to the build-nugetlibrary call. - `publish-release.yml`: gains a top-level `publish-pypi` job that runs after `create-release`, downloads the `pypilibrary-build` artifact by name (artifacts uploaded by reusable workflows are accessible to sibling jobs in the same run), and publishes via Trusted Publishing. `id-token: write` lives at this single job level. - `test-release-task.yml`: drops the `pypi: false` input (no longer needed; PyPi build runs unconditionally as part of build-release). The PyPi build still runs during PR validation (via test-release → build-release → build-pypilibrary), so lint, typecheck, test, and build all gate every PR. Publishing only happens on push to main/develop, in a job that has the minimal id-token: write permission scope. --- .github/workflows/build-pypilibrary-task.yml | 46 +++++--------------- .github/workflows/build-release-task.yml | 15 ++----- .github/workflows/publish-release.yml | 31 ++++++++++++- .github/workflows/test-release-task.yml | 1 - 4 files changed, 45 insertions(+), 48 deletions(-) diff --git a/.github/workflows/build-pypilibrary-task.yml b/.github/workflows/build-pypilibrary-task.yml index 26ccde12..c3852903 100644 --- a/.github/workflows/build-pypilibrary-task.yml +++ b/.github/workflows/build-pypilibrary-task.yml @@ -1,15 +1,19 @@ name: Build PyPi library task +# This reusable workflow only builds the PyPi library and uploads the +# wheel + sdist as a workflow-run artifact. It does NOT publish to PyPI. +# Publishing happens directly in `publish-release.yml` so that the +# `id-token: write` permission required by Trusted Publishing is granted +# at the entry-point job, not propagated through a reusable-workflow +# chain (which would require every caller — including `test-release-task.yml` +# during PR validation — to also grant id-token write, even when no +# publishing happens). + on: workflow_call: - inputs: - # Input to control whether to publish the PyPi library to PyPI - push: - required: false - type: boolean - default: false outputs: - # Output of the uploaded artifact id + artifact-name: + value: ${{ jobs.build-pypilibrary.outputs.artifact-name }} artifact-id: value: ${{ jobs.build-pypilibrary.outputs.artifact-id }} @@ -22,6 +26,7 @@ jobs: run: working-directory: ./PyPiLibrary outputs: + artifact-name: pypilibrary-build artifact-id: ${{ steps.artifact-upload-step.outputs.artifact-id }} steps: @@ -59,30 +64,3 @@ jobs: with: name: pypilibrary-build path: PyPiLibrary/dist/* - - publish-pypilibrary: - name: Publish PyPi library job - if: ${{ inputs.push }} - needs: [build-pypilibrary] - runs-on: ubuntu-latest - environment: - name: pypi - url: https://pypi.org/p/ptr727-projecttemplate-library - # Trusted Publishing requires id-token: write at job level so the OIDC - # token is available to the publish action; the action exchanges it for a - # short-lived PyPI upload token. No PYPI_API_TOKEN secret is involved. - permissions: - id-token: write - - steps: - - - name: Download build artifacts step - uses: actions/download-artifact@v7 - with: - artifact-ids: ${{ needs.build-pypilibrary.outputs.artifact-id }} - path: ./dist - - - name: Publish to PyPI step - uses: pypa/gh-action-pypi-publish@6733eb7d741f0b11ec6a39b58540dab7590f9b7d # v1.14.0 - with: - packages-dir: ./dist diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 30a8fdf2..3f2cc885 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -18,11 +18,6 @@ on: required: false type: boolean default: false - # Input to control whether to publish the PyPi library to PyPI - pypi: - required: false - type: boolean - default: false jobs: @@ -39,16 +34,14 @@ jobs: # Conditional push to NuGet.org push: ${{ inputs.nuget }} + # PyPi publishing happens in `publish-release.yml`, not here, so that + # `id-token: write` only needs to be granted at the entry-point job. + # This reusable workflow just builds and uploads the artifact; the + # publish-release workflow downloads it by name in a sibling job. build-pypilibrary: name: Build PyPi library job uses: ./.github/workflows/build-pypilibrary-task.yml secrets: inherit - permissions: - contents: read - id-token: write - with: - # Conditional publish to PyPI via Trusted Publishing - push: ${{ inputs.pypi }} build-executable: name: Build executable job diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index db8caf4c..b9a30210 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -18,11 +18,38 @@ jobs: permissions: contents: write with: - # Push to GitHub and NuGet and Docker Hub and PyPI + # Push to GitHub and NuGet and Docker Hub github: true nuget: true dockerhub: true - pypi: true + + publish-pypi: + name: Publish PyPi library job + needs: [create-release] + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/ptr727-projecttemplate-library + # Trusted Publishing requires id-token: write at job level so the OIDC + # token is available to pypa/gh-action-pypi-publish; the action exchanges + # it for a short-lived PyPI upload token. No PYPI_API_TOKEN secret is + # involved. Granting this permission directly on the entry-point job + # avoids having to propagate it through the reusable workflow chain. + permissions: + id-token: write + + steps: + + - name: Download PyPi library build artifacts step + uses: actions/download-artifact@v7 + with: + name: pypilibrary-build + path: ./dist + + - name: Publish to PyPI step + uses: pypa/gh-action-pypi-publish@6733eb7d741f0b11ec6a39b58540dab7590f9b7d # v1.14.0 + with: + packages-dir: ./dist date-badge: name: Create BYOB date badge job diff --git a/.github/workflows/test-release-task.yml b/.github/workflows/test-release-task.yml index 3d6fb3cb..bb9ce6a5 100644 --- a/.github/workflows/test-release-task.yml +++ b/.github/workflows/test-release-task.yml @@ -39,4 +39,3 @@ jobs: github: false nuget: false dockerhub: false - pypi: false From 6b95dd4f234e4b12c526d40f4c18d38ca78fccd8 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 11:33:43 -0700 Subject: [PATCH 09/24] Address Copilot review on PR #63 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pin uv to a specific version in `post-create.sh` via the version-prefixed Astral install URL (https://astral.sh/uv// install.sh). The `latest` install script remains a supply-chain attack surface; pinning means a compromised `latest` cannot silently change what runs on contributors'' machines or CI. Bump `UV_VERSION` on upgrade after reviewing release notes. - Clarify in `docs/host-setup.md` that the WSL2-only constraint applies to the devcontainer flow specifically. The host-install path (`README.md` → "Alternative (host install)") supports native Windows with winget; the devcontainer flow does not because the bind-mounts rely on POSIX paths. - Strengthen the non-systemd ssh-agent snippet: probe the agent for at least one loaded key via `ssh-add -l`. The previous `[ -z "$SSH_AUTH_SOCK" ]`-only check missed the stale-socket and agent-running-but-empty cases. --- .devcontainer/post-create.sh | 8 +++++++- docs/host-setup.md | 10 +++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 25b2eac3..c01dc580 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -6,8 +6,14 @@ set -euo pipefail # updates user shell init to add it to PATH for new shells; we add it to the # current PATH explicitly so the rest of this script can invoke `uv` without a # hard-coded path. +# +# uv is pinned to a specific version (via the version-prefixed install URL, +# https://astral.sh/uv//install.sh) so a compromised or broken +# upstream `latest` script cannot silently change what runs on contributors' +# machines and CI runners. Bump UV_VERSION when you've reviewed release notes. +UV_VERSION="0.11.8" if ! command -v uv >/dev/null 2>&1; then - curl -LsSf https://astral.sh/uv/install.sh | sh + curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" | sh export PATH="$HOME/.local/bin:$PATH" fi diff --git a/docs/host-setup.md b/docs/host-setup.md index 4e922730..c48f228d 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -2,7 +2,11 @@ Prerequisites for working with this repo locally — apply once per machine before opening the devcontainer or building outside one. -Supported hosts: **Linux**, **WSL2 on Windows** (native Windows is not supported for the devcontainer; use WSL2), **macOS**. +Supported hosts: + +- **Linux** — both the devcontainer flow and the host-install flow. +- **macOS** — both the devcontainer flow and the host-install flow. +- **Windows** — the devcontainer flow requires **WSL2**; native Windows (PowerShell + winget) is supported only for the host-install flow described in `README.md`. The bind-mounts in `.devcontainer/devcontainer.json` rely on POSIX paths and only work from Linux/macOS/WSL2. ## Git Identity @@ -54,10 +58,10 @@ systemctl --user enable --now ssh-agent.socket ssh-add ~/.ssh/id_ed25519 ``` -For non-systemd shells, add to `~/.bashrc` or `~/.zshrc`: +For non-systemd shells, add to `~/.bashrc` or `~/.zshrc`. The check probes the agent for at least one loaded key — `[ -z "$SSH_AUTH_SOCK" ]` alone would miss the case where `SSH_AUTH_SOCK` is set but points at a stale socket or a keyless agent: ```shell -if [ -z "$SSH_AUTH_SOCK" ]; then +if [ -z "$SSH_AUTH_SOCK" ] || ! ssh-add -l >/dev/null 2>&1; then eval "$(ssh-agent -s)" >/dev/null ssh-add ~/.ssh/id_ed25519 2>/dev/null fi From 6dbe5ba12cc58900f97f2bb51b2d5158f1915825 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 11:34:38 -0700 Subject: [PATCH 10/24] Address Copilot review on PR #64 - Rewrite Ruff Format and Ruff Check husky tasks to pass `${staged}` as positional args via `bash -c "..." -- ${staged}` and reference them through `"$@"` in the script. Husky.Net expands `${staged}` into separate array elements, so threading them through positional args preserves space-containing paths and prevents shell metacharacter re-interpretation. The earlier embedded-string form would have broken on a path containing whitespace. - Update PyPiLibrary/README.md "Publishing" section to reflect the current workflow shape (build in build-pypilibrary-task.yml; publish in a top-level `publish-pypi` job in publish-release.yml). Removes the stale `pypi: true` reference from the prior reusable-workflow design that was reverted in 4c939f6 to fix the startup_failure. - Update the Template Adoption "delete the Python side" instructions to refer to the `uv` block in dependabot.yml (not `pip`) and to the actual job names that need removing in build-release-task.yml and publish-release.yml. --- .husky/task-runner.json | 12 ++++++++---- PyPiLibrary/README.md | 4 ++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.husky/task-runner.json b/.husky/task-runner.json index 25827b22..c974f397 100644 --- a/.husky/task-runner.json +++ b/.husky/task-runner.json @@ -32,8 +32,10 @@ "name": "Ruff Format", "command": "bash", "args": [ - "-lc", - "if command -v uv >/dev/null 2>&1; then uv run --project PyPiLibrary ruff format ${staged}; else echo 'uv not on PATH; skipping ruff format' >&2; fi" + "-c", + "command -v uv >/dev/null 2>&1 || { echo 'uv not on PATH; skipping ruff format' >&2; exit 0; }; exec uv run --project PyPiLibrary ruff format \"$@\"", + "--", + "${staged}" ], "include": [ "PyPiLibrary/**/*.py" @@ -43,8 +45,10 @@ "name": "Ruff Check", "command": "bash", "args": [ - "-lc", - "if command -v uv >/dev/null 2>&1; then uv run --project PyPiLibrary ruff check ${staged}; else echo 'uv not on PATH; skipping ruff check' >&2; fi" + "-c", + "command -v uv >/dev/null 2>&1 || { echo 'uv not on PATH; skipping ruff check' >&2; exit 0; }; exec uv run --project PyPiLibrary ruff check \"$@\"", + "--", + "${staged}" ], "include": [ "PyPiLibrary/**/*.py" diff --git a/PyPiLibrary/README.md b/PyPiLibrary/README.md index 9540f52c..66bd54e4 100644 --- a/PyPiLibrary/README.md +++ b/PyPiLibrary/README.md @@ -44,7 +44,7 @@ uv build # wheel + sdist into ./dist ## Publishing -Releases are produced by `.github/workflows/build-pypilibrary-task.yml`, called from `build-release-task.yml` when `pypi: true` is passed by `publish-release.yml`. The publish job uses [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) — no `PYPI_API_TOKEN` secret is involved; the workflow exchanges its OIDC token for a short-lived PyPI upload token. +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. First-time setup (one-time, on PyPI): @@ -62,4 +62,4 @@ When deriving a new project from this template: - Re-register the trusted publisher on PyPI under the new project name. - Update `_version.py` versioning policy to match your release cadence (Nerdbank.GitVersioning is .NET-only; for Python use a tag-driven scheme like [`hatch-vcs`](https://github.com/ofek/hatch-vcs) or manual bumps in `_version.py`). -If you don't want a Python project at all, delete the `PyPiLibrary/` folder, the `build-pypilibrary-task.yml` workflow, and the `pip` block in `dependabot.yml`. +If you don't want a Python project at all, delete the `PyPiLibrary/` folder, the `build-pypilibrary-task.yml` workflow, the `build-pypilibrary` job in `build-release-task.yml`, the `publish-pypi` job in `publish-release.yml`, and the `uv` block in `dependabot.yml`. From e2626a2becd2743327f5f70b8a825d13ffb19c7b Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 11:48:25 -0700 Subject: [PATCH 11/24] Drop hard-coded ruff and Python interpreter paths from workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous workspace settings hard-coded ``${workspaceFolder}/PyPiLibrary/.venv/bin/{ruff,python}``, which is broken in three ways: - The ``.venv`` directory does not exist until a contributor has run ``uv sync`` inside ``PyPiLibrary/``. Until then, VS Code's ruff extension shows a "could not find ruff binary" popup on every Python file open. - The path is Linux/macOS-only (``.venv/bin/...``). On native Windows hosts, the binary lives at ``.venv\Scripts\ruff.exe``; the literal ``/bin/`` path resolves to nothing and the extension errors out. - It pins to the venv-installed ruff. The Astral ruff VS Code extension ships with a bundled ruff that works without any setup, and the venv version is what CI uses anyway — so matching versions in the IDE was optional, not required. After this change: - The ruff extension uses its bundled binary (``ruff.importStrategy`` default ``"useBundled"``). Works on every host out of the box. - ``ruff`` auto-discovers ``[tool.ruff]`` from ``PyPiLibrary/pyproject.toml`` by walking up from the file being linted, so dropping ``ruff.configuration`` doesn't lose the project ruleset. - The Python extension auto-detects ``PyPiLibrary/.venv`` once it exists; contributors pick the interpreter via Command Palette → "Python: Select Interpreter" instead of relying on a path that may not resolve. --- ProjectTemplate.code-workspace | 3 --- 1 file changed, 3 deletions(-) diff --git a/ProjectTemplate.code-workspace b/ProjectTemplate.code-workspace index 075d6e35..e4e15ab2 100644 --- a/ProjectTemplate.code-workspace +++ b/ProjectTemplate.code-workspace @@ -93,10 +93,7 @@ "source.organizeImports": "explicit" } }, - "python.defaultInterpreterPath": "${workspaceFolder}/PyPiLibrary/.venv/bin/python", "python.terminal.activateEnvironment": false, - "ruff.path": ["${workspaceFolder}/PyPiLibrary/.venv/bin/ruff"], - "ruff.configuration": "${workspaceFolder}/PyPiLibrary/pyproject.toml", "git.alwaysSignOff": true, "markdown.extension.toc.levels": "2..3" }, From 07a101a773d81fe98149bb9eaa6b3054978e8e90 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 12:02:08 -0700 Subject: [PATCH 12/24] Enforce uv version pin even when uv is already installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compare the installed uv --version output to UV_VERSION; if they differ (including the case where uv was already on PATH from a prior install or a system package), re-install the pinned version. Without this check the pin only applied when uv was missing entirely, undermining the lockfile reproducibility goal — the lockfile is generated against a specific uv version, and a different installed uv could resolve different dependency graphs. --- .devcontainer/post-create.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index c01dc580..4cd30487 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -11,8 +11,17 @@ set -euo pipefail # https://astral.sh/uv//install.sh) so a compromised or broken # upstream `latest` script cannot silently change what runs on contributors' # machines and CI runners. Bump UV_VERSION when you've reviewed release notes. +# +# We re-install when uv is missing OR when the installed version doesn't +# match the pin. The latter handles the case where a contributor (or a +# previous run with a different pin) left a different uv version on PATH — +# the pin is what's reproducible and what the lockfile is generated against. UV_VERSION="0.11.8" -if ! command -v uv >/dev/null 2>&1; then +installed_uv_version="" +if command -v uv >/dev/null 2>&1; then + installed_uv_version="$(uv --version | awk '{print $2}')" +fi +if [[ "$installed_uv_version" != "$UV_VERSION" ]]; then curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" | sh export PATH="$HOME/.local/bin:$PATH" fi From 692f27e124d3761067b85d57700dc081cd6f32d3 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 12:03:04 -0700 Subject: [PATCH 13/24] Make publish-pypi permissions explicit for download-artifact Adding a `permissions:` block to a job collapses every unspecified scope to `none`, so listing only `id-token: write` left the job without `contents: read` or `actions: read`. While same-run artifact downloads via `actions/download-artifact` happen to work without `actions: read` today, declaring the scopes the job actually needs makes the intent explicit and is the pattern the pypa/gh-action-pypi-publish docs recommend. --- .github/workflows/publish-release.yml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index b9a30210..e37048ea 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -30,13 +30,20 @@ jobs: environment: name: pypi url: https://pypi.org/p/ptr727-projecttemplate-library - # Trusted Publishing requires id-token: write at job level so the OIDC - # token is available to pypa/gh-action-pypi-publish; the action exchanges - # it for a short-lived PyPI upload token. No PYPI_API_TOKEN secret is - # involved. Granting this permission directly on the entry-point job - # avoids having to propagate it through the reusable workflow chain. + # When a `permissions:` block is present, every scope not listed + # collapses to `none`. The job needs three things explicitly: + # - `id-token: write` for Trusted Publishing's OIDC exchange + # (pypa/gh-action-pypi-publish swaps the token for a short-lived + # PyPI upload token; no PYPI_API_TOKEN secret involved). + # - `contents: read` so `actions/checkout`-style operations and any + # repo metadata reads continue to work. + # - `actions: read` so `actions/download-artifact` can list and + # fetch the artifact uploaded by the build workflow earlier in + # the same run. permissions: id-token: write + contents: read + actions: read steps: From ba24ee07021e81a72eca0fb60c38f0e8990a8bd4 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 12:10:42 -0700 Subject: [PATCH 14/24] Address Copilot review on PR #63 (post-merge round) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/devcontainer.md: update the "What's Inside" table row for uv to show the version-pinned install URL and the actual script path (`.devcontainer/post-create.sh`). Earlier wording matched an older unpinned form. - docs/devcontainer.md and README.md: soften "gh is pre-authenticated" wording. The bind-mount of `~/.config/gh` only carries file-backed tokens; macOS Keychain and Linux libsecret-backed tokens require an in-container `gh auth login`. Both docs now point at the credential- store nuance section in `docs/devcontainer.md` so contributors set expectations correctly. - .devcontainer/post-create.sh: download the pinned uv installer to a tempfile, log its sha256 to stderr, then run it (instead of `curl … | sh`). The hash provides an audit trail of exactly what was executed and lets an operator pin a known-good checksum via `EXPECTED_SHA` later. Astral does not currently publish per-version installer checksums in a machine-verifiable form, so the check is opt-in for now; once they do, set `EXPECTED_SHA` and the script refuses to run on mismatch. --- .devcontainer/post-create.sh | 16 +++++++++++++++- README.md | 2 +- docs/devcontainer.md | 4 ++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 4cd30487..b67cd9ab 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -22,7 +22,21 @@ if command -v uv >/dev/null 2>&1; then installed_uv_version="$(uv --version | awk '{print $2}')" fi if [[ "$installed_uv_version" != "$UV_VERSION" ]]; then - curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" | sh + # Download the pinned installer to a temp file first instead of piping + # `curl … | sh`. This produces a logged sha256 of exactly the bytes we + # ran, so a compromised installer leaves a forensic trail; it also lets + # a future change pin a known-good checksum (set EXPECTED_SHA below). + installer=$(mktemp -t uv-install.XXXXXX.sh) + trap 'rm -f "$installer"' EXIT + curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" -o "$installer" + actual_sha=$(sha256sum "$installer" | awk '{print $1}') + echo "uv installer (v${UV_VERSION}) sha256: ${actual_sha}" >&2 + # EXPECTED_SHA="" # set to enforce + if [[ -n "${EXPECTED_SHA:-}" && "${actual_sha}" != "${EXPECTED_SHA}" ]]; then + echo "uv installer sha256 mismatch — refusing to run" >&2 + exit 1 + fi + sh "$installer" export PATH="$HOME/.local/bin:$PATH" fi diff --git a/README.md b/README.md index 0c44ed52..986715ad 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,7 @@ Options: ## Development Environment Setup -The recommended setup is the [Dev Container](./docs/devcontainer.md) — a single image with the .NET 10 SDK, the `uv` Python toolchain, and the GitHub CLI. It bind-mounts your SSH public key, allowed-signers file, and `gh` config from the host so commits sign correctly and `gh` is pre-authenticated. +The recommended setup is the [Dev Container](./docs/devcontainer.md) — a single image with the .NET 10 SDK, the `uv` Python toolchain, and the GitHub CLI. It bind-mounts your SSH public key, allowed-signers file, and `gh` config from the host so commits sign correctly. `gh` is pre-authenticated when the host token is file-backed; macOS Keychain and Linux libsecret-backed tokens require an in-container `gh auth login` — see the [credential-store nuance](./docs/devcontainer.md#gh-credential-store) section. **Recommended (devcontainer)**: diff --git a/docs/devcontainer.md b/docs/devcontainer.md index 9252773a..4a7f0c07 100644 --- a/docs/devcontainer.md +++ b/docs/devcontainer.md @@ -9,7 +9,7 @@ Prerequisite: complete [host setup](./host-setup.md) first — without git confi | Component | Source | Purpose | |---|---|---| | .NET 10 SDK | base image `mcr.microsoft.com/devcontainers/dotnet:1-10.0` | Build, test, pack the .NET projects | -| `uv` | `astral.sh/uv/install.sh` in `post-create.sh` | Python env, dependency, build, and publish manager for the PyPi sibling | +| `uv` | `https://astral.sh/uv//install.sh` (version-pinned) downloaded by `.devcontainer/post-create.sh` | Python env, dependency, build, and publish manager for the PyPi sibling | | `gh` CLI | `ghcr.io/devcontainers/features/github-cli:1` | Issue/PR/release management from inside the container | | Common utilities | `ghcr.io/devcontainers/features/common-utils:2` | bash, curl, wget, sudo, `vscode` user | | VS Code extensions | `customizations.vscode.extensions` in `devcontainer.json` | Mirrors `ProjectTemplate.code-workspace` recommendations so the container has the same tooling | @@ -18,7 +18,7 @@ The extension list in `.devcontainer/devcontainer.json` and the `recommendations ## Bind Mounts -The host SSH key, allowed-signers file, and `gh` config directory are mounted into the container so commits sign correctly and `gh` is pre-authenticated. +The host SSH key, allowed-signers file, and `gh` config directory are mounted into the container so commits sign correctly and `gh` is pre-authenticated **when the host stores its `gh` token in a file** (`~/.config/gh/hosts.yml`). Hosts that store the token in macOS Keychain or Linux libsecret will need an in-container `gh auth login` instead — see [`gh` credential store](#gh-credential-store) below for the full picture. | Host path | Container path | Mode | Purpose | |---|---|---|---| From dd7073d82c851f1f404406689853b6e1b1eb4011 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 12:13:04 -0700 Subject: [PATCH 15/24] Drop ms-pyright.pyright; mark mypy/pylint/black/etc as unwanted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove `ms-pyright.pyright` from recommendations. Microsoft now ships pyright inside Pylance, which `ms-python.python` auto-installs; having both active causes the standalone pyright extension to fight Pylance for the same files. The standalone extension is in maintenance mode per Microsoft's own guidance. - Add `unwantedRecommendations` for mypy, pylint, flake8, isort, and black. We use ruff (lint + format + import sort) and pyright (via Pylance) — every other Python linter/formatter overlaps and shows duplicate diagnostics or, worse, "could not find binary" connection errors when the venv doesn''t have it installed (which is the current symptom). If a contributor already has any of these extensions installed manually, VS Code now flags them as not recommended for this workspace and offers a one-click disable. --- ProjectTemplate.code-workspace | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ProjectTemplate.code-workspace b/ProjectTemplate.code-workspace index e4e15ab2..cfc1aa56 100644 --- a/ProjectTemplate.code-workspace +++ b/ProjectTemplate.code-workspace @@ -110,7 +110,14 @@ "yzhang.markdown-all-in-one", "ms-python.python", "charliermarsh.ruff", + ], + "unwantedRecommendations": [ "ms-pyright.pyright", + "ms-python.mypy-type-checker", + "ms-python.pylint", + "ms-python.flake8", + "ms-python.isort", + "ms-python.black-formatter" ] } } From a1092d23a01b8990aad52f2b72ab4656f77c2b74 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 12:13:37 -0700 Subject: [PATCH 16/24] Drop ms-pyright.pyright from devcontainer extension list Mirrors the workspace `recommendations` change in dd7073d. Pylance (auto-installed with ms-python.python) provides pyright; the standalone extension fights it for the same files. --- .devcontainer/devcontainer.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 601202c8..e1d6597e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -46,6 +46,10 @@ "customizations": { "vscode": { + // Mirror of `recommendations` in ProjectTemplate.code-workspace. + // Pyright type checking is provided by Pylance, which the + // ms-python.python extension auto-installs — no separate pyright + // extension needed (and the standalone one is in maintenance mode). "extensions": [ "csharpier.csharpier-vscode", "davidanson.vscode-markdownlint", @@ -57,8 +61,7 @@ "streetsidesoftware.code-spell-checker", "yzhang.markdown-all-in-one", "ms-python.python", - "charliermarsh.ruff", - "ms-pyright.pyright" + "charliermarsh.ruff" ] } } From 6067aa33f363b265cfadc9e6989cb2da516cccfd Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 12:20:40 -0700 Subject: [PATCH 17/24] Pin uv in CI; skip-existing on PyPI publish; document version scheme - `build-pypilibrary-task.yml` now passes `version: "0.11.8"` to `astral-sh/setup-uv` so CI uses the same uv as the devcontainer''s pin in `.devcontainer/post-create.sh`. Without this CI was free to drift to whatever uv `setup-uv` defaulted to, potentially resolving the lockfile differently than what contributors run locally. - `publish-release.yml` now passes `skip-existing: true` to pypa/gh-action-pypi-publish. The template ships with `__version__ = "0.0.0"` as a placeholder, and "release on every push" would otherwise re-upload version 0.0.0 on every push and fail the workflow. Skip-existing makes the upload idempotent until the adopter wires a real version scheme. - `PyPiLibrary/README.md` now spells out the versioning gap explicitly: the publish workflow won''t fail without a version scheme, but no new PyPI versions will land until `_version.py` is wired to something that increments. Lists three options (hatch-vcs, version.json, manual) with short trade-offs. --- .github/workflows/build-pypilibrary-task.yml | 5 +++++ .github/workflows/publish-release.yml | 6 ++++++ PyPiLibrary/README.md | 5 ++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-pypilibrary-task.yml b/.github/workflows/build-pypilibrary-task.yml index c3852903..db287a80 100644 --- a/.github/workflows/build-pypilibrary-task.yml +++ b/.github/workflows/build-pypilibrary-task.yml @@ -37,6 +37,11 @@ jobs: - name: Setup uv step uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: + # Pin uv to the same version as `.devcontainer/post-create.sh` + # (UV_VERSION) so CI and local devcontainer behavior cannot drift + # — same uv resolves the same lockfile the same way. Bump in lock- + # step with the devcontainer pin. + version: "0.11.8" enable-cache: true cache-dependency-glob: "PyPiLibrary/uv.lock" diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index e37048ea..f44c3ce2 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -57,6 +57,12 @@ jobs: uses: pypa/gh-action-pypi-publish@6733eb7d741f0b11ec6a39b58540dab7590f9b7d # v1.14.0 with: packages-dir: ./dist + # Skip rather than fail when the version already exists on PyPI. + # The template ships with `__version__ = "0.0.0"` as a placeholder + # — the release-on-every-push model would otherwise re-upload the + # same version and fail the workflow until the adopter wires a + # real version scheme (see PyPiLibrary/README.md). + skip-existing: true date-badge: name: Create BYOB date badge job diff --git a/PyPiLibrary/README.md b/PyPiLibrary/README.md index 66bd54e4..377b72d2 100644 --- a/PyPiLibrary/README.md +++ b/PyPiLibrary/README.md @@ -60,6 +60,9 @@ 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. -- Update `_version.py` versioning policy to match your release cadence (Nerdbank.GitVersioning is .NET-only; for Python use a tag-driven scheme like [`hatch-vcs`](https://github.com/ofek/hatch-vcs) or manual bumps in `_version.py`). +- **Wire up a versioning scheme before the first publish.** `_version.py` ships with `__version__ = "0.0.0"` as a placeholder. The publish workflow uses `skip-existing: true` so the workflow won't fail on duplicate uploads — but **no new versions will land on PyPI** until you replace `0.0.0` with something that increments. Common options: + - [`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"`. Pairs well with tag-driven releases. + - **Read from `version.json`** — the .NET side uses Nerdbank.GitVersioning which reads from `version.json`. A small custom Hatchling plugin or a CI step can pull the version into `_version.py` so .NET and Python ship with matching versions. + - **Manual bumps** — edit `_version.py` in each release PR. Simplest, but easy to forget. If you don't want a Python project at all, delete the `PyPiLibrary/` folder, the `build-pypilibrary-task.yml` workflow, the `build-pypilibrary` job in `build-release-task.yml`, the `publish-pypi` job in `publish-release.yml`, and the `uv` block in `dependabot.yml`. From a1b4d4be7f914282a0e555491cff9121b589dc42 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 12:33:12 -0700 Subject: [PATCH 18/24] Restructure agent docs for polyglot template; add review runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This separates language-specific style from cross-cutting agent rules, following the model evolved in the homeassistant-purpleair sibling repo (notably PR ptr727/homeassistant-purpleair#91), and ports its GitHub Copilot Review Runbook so the review-loop contract has explicit provider-specific mechanics. Changes: - AGENTS.md: language-agnostic only. Moves project structure into a per-language section that points at the per-language style guides (NuGetLibrary -> CODESTYLE.md, PyPiLibrary -> PyPiLibrary/CODESTYLE.md). Adds a PR Review Etiquette section describing the loop contract (request review, verify head SHA, triage findings as bug/style/ architectural, reply + resolve, escalate when stuck) — same contract applies to both languages, so it lives here, not in either CODESTYLE. Workflow YAML conventions expanded with the boolean-input workflow_call vs workflow_dispatch gotcha and the success/skipped chaining gotcha (both bit us during PR #64). Devcontainer notes, branching model, git rules, PR-title rules unchanged. - .github/copilot-instructions.md: drops the heavy duplication of CODESTYLE.md's .NET style content (member ordering, naming, var, Allman braces, xUnit patterns — all now reachable via CODESTYLE.md). Keeps commit/PR-title rules so VS Code's AI generators pick them up without an extra fetch. Adds the GitHub Copilot Review Runbook: triggering and polling, head-SHA coverage verification, bounded retry, reply/resolve workflow with copy-pastable GraphQL. - CODESTYLE.md: adds a one-paragraph preamble naming this as the .NET-only style guide and pointing at PyPiLibrary/CODESTYLE.md for the Python equivalent. Body unchanged. - PyPiLibrary/CODESTYLE.md: NEW. Python style guide — uv/ruff/pyright/ pytest toolchain, src layout, formatting (ruff is authoritative), comments and docstrings (PEP 257; behavior contracts in docstrings, implementation rationale in inline `#` comments), modern type-hint syntax, naming, ruff-managed imports, anti-patterns (no compat shims, no error-handling-for-impossible-cases, no exception-as- control-flow), pytest conventions, versioning placeholder pointer, and a delete-the-Python-side checklist for adopters who don't need it. mypy is explicitly NOT used here (Pylance + pyright cover it). The review loop documented in AGENTS.md is the contract; the runbook in copilot-instructions.md is the mechanic. Other agents (Claude Code, etc.) inherit the contract automatically; they would need their own runbook section if they're ever the configured reviewer. --- .github/copilot-instructions.md | 519 +++++++++----------------------- AGENTS.md | 290 +++++++++--------- CODESTYLE.md | 6 +- PyPiLibrary/CODESTYLE.md | 125 ++++++++ 4 files changed, 424 insertions(+), 516 deletions(-) create mode 100644 PyPiLibrary/CODESTYLE.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 082a7b06..b1d48d8c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,380 +1,139 @@ -# GitHub Copilot Instructions for ProjectTemplate - -## Project Overview - -**ProjectTemplate** is a C# .NET template project that demonstrates best practices for C# .NET development. The project includes: - -- **NuGetLibrary**: Core .NET NuGet library with AOT compatibility (`NuGetLibrary.csproj`, published as `ptr727.ProjectTemplate.Library`) -- **Console**: Command-line application using System.CommandLine (`Console.csproj`) -- **Tests**: Unit tests using xUnit and AwesomeAssertions (`Tests.csproj`) -- **Benchmarks**: Performance benchmarks using BenchmarkDotNet (`Benchmarks.csproj`) -- **Docker**: Docker build configurations for Linux containers - -## Build Requirements - -### Zero Warnings Policy - -**CRITICAL**: All builds must complete without warnings. The project enforces this through: - -1. **VS Code Task**: The `.Net Format` task must run successfully with `--verify-no-changes` flag - - Command: `dotnet format style --verify-no-changes --severity=info --verbosity=detailed` - - This task must pass before any code is committed - - Task dependencies: `CSharpier Format` → `.Net Build` → `.Net Format` - -2. **Analysis Level**: Projects use `latest-all` - - All .NET analyzers enabled: `true` - - Analyzer severity: `suggestion` (but must be addressed) - -3. **Husky.Net Pre-commit Hooks**: Automated checks run before commits - -### Build Tasks - -Available VS Code tasks (use via `run_task` tool): -- `.Net Build`: Build with diagnostic verbosity -- `.Net Format`: Verify formatting and style (must pass) -- `CSharpier Format`: Auto-format code with CSharpier -- `.Net Tool Update`: Update dotnet tools -- `.Net Outdated Upgrade`: Upgrade outdated NuGet dependencies (interactive prompt) -- `Husky.Net Run`: Run pre-commit hooks manually - -## Coding Standards and Conventions - -### C# Language Features - -1. **File-Scoped Namespaces**: Always use file-scoped namespaces - ```csharp - namespace ptr727.ProjectTemplate.NuGetLibrary; - ``` - -2. **Nullable Reference Types**: Enabled (`enable`) - - Always use nullable annotations appropriately - - Use `required` modifier for mandatory properties - -3. **Modern C# Features**: Prefer modern language constructs - - Primary constructors when appropriate - - Top-level statements for console apps - - Pattern matching over traditional checks - - Collection expressions when types loosely match - - Extension methods using `extension()` syntax (C# 13) - - Implicit object creation when type is apparent - - Range and index operators - -4. **Expression-Bodied Members**: Use for all applicable members - - Methods, properties, accessors, operators, lambdas, local functions - -5. **var Keyword**: Do NOT use `var` - always use explicit types - ```csharp - // Correct - int count = 42; - string name = "test"; - - // Incorrect - var count = 42; - var name = "test"; - ``` - -### Naming Conventions - -1. **Private Fields**: Use underscore prefix with camelCase - ```csharp - private readonly HttpClient _httpClient; - private int _counter; - ``` - -2. **Static Fields**: Use `s_` prefix with camelCase - ```csharp - private static int s_instanceCount; - ``` - -3. **Constants**: Use PascalCase - ```csharp - private const int MaxRetries = 3; - ``` - -4. **Namespace**: Follow format `ptr727.ProjectTemplate.` - - NuGetLibrary: `ptr727.ProjectTemplate.NuGetLibrary` - - Console: `ptr727.ProjectTemplate.Console` - - Tests: `ptr727.ProjectTemplate.Tests` - -### Code Structure - -1. **Global Usings**: Use `GlobalUsings.cs` for common namespaces - ```csharp - global using System; - global using System.Net.Http; - global using System.Threading.Tasks; - global using Serilog; - ``` - -2. **Usings Placement**: Outside namespace, sorted with System directives first - ```csharp - using System.CommandLine; - using System.Runtime.CompilerServices; - using ptr727.ProjectTemplate.NuGetLibrary; - - namespace ptr727.ProjectTemplate.Console; - ``` - -3. **Braces**: New line before all braces (Allman style) - ```csharp - public void Method() - { - if (condition) - { - // code - } - } - ``` - -4. **Indentation**: - - C# files: 4 spaces - - XML/csproj files: 2 spaces - - YAML files: 2 spaces - - JSON files: 4 spaces - -5. **Line Endings**: - - C#, XML, YAML, JSON, Windows scripts: CRLF - - Linux scripts (.sh): LF - -### Comments and Documentation - -1. **XML Documentation**: Generate documentation files - - `true` - - Missing XML comments for public APIs are suppressed (NoWarn 1591) - -2. **Code Analysis Suppressions**: Use attributes with justifications - ```csharp - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Design", - "CA1034:Nested types should not be visible", - Justification = "https://github.com/dotnet/sdk/issues/51681" - )] - ``` - -3. **Spelling**: All code must pass the Code Spell Checker extension - - Configure exceptions in workspace settings if needed - - British and American spelling both accepted - -4. **Markdown Quality**: Markdown files must pass Markdownlint - - Proper heading hierarchy, spacing, and formatting - - -### Error Handling and Logging - -1. **Serilog Logging**: Use structured logging with Serilog - ```csharp - logger.Error(exception, "{Function}", function); - ``` - -2. **CallerMemberName**: Use for automatic function name tracking - ```csharp - public bool LogAndPropagate( - Exception exception, - [CallerMemberName] string function = "unknown" - ) - ``` - -3. **Extension Methods**: Use for logger extensions - ```csharp - extension(ILogger logger) - { - public bool LogAndPropagate(Exception exception, ...) { } - } - ``` - -### Testing Conventions - -1. **Test Framework**: xUnit with AwesomeAssertions - ```csharp - [Fact] - public void MethodName_Scenario_ExpectedBehavior() - { - // Arrange - int expected = 42; - - // Act - int actual = GetValue(); - - // Assert - actual.Should().Be(expected); - } - ``` - -2. **Test Organization**: Arrange-Act-Assert pattern -3. **Test Naming**: Use descriptive names with underscores separating parts -4. **Theory Tests**: Use `[Theory]` with `[InlineData]` for parameterized tests -5. **Avoid Regions**: Don't use regions in test files -6. **Logical Grouping**: Organize tests in separate files by feature or class - - -### Project Configuration - -1. **Target Framework**: .NET 10.0 (`net10.0`) - -2. **AOT Compatibility**: NuGetLibrary is AOT compatible - - `true` - - `true` - -3. **Assembly Information**: - - Use semantic versioning - - Include SourceLink: `true` - - Embed untracked sources: `true` - -4. **Internal Visibility**: Use `InternalsVisibleTo` for test and console access - ```xml - - - - - ``` - -5. **Directory.Build.props**: Common MSBuild properties shared across all projects - (`TargetFramework`, `Nullable`, `ImplicitUsings`, `AnalysisLevel`, `AnalysisMode`, - `EnableNETAnalyzers`, `ArtifactsPath`, `IsPackable`, `ManagePackageVersionsCentrally`) - live here at the solution root. Only add a property to a `.csproj` when it is - specific to that project or requires an explicit override of the shared default. - -6. **Directory.Packages.props**: All NuGet package versions are centralised here via - `PackageVersion` items. Individual `.csproj` files use `PackageReference Include="..."` - with no `Version` attribute. Asset metadata (`PrivateAssets`, `IncludeAssets`) stays - in the `.csproj` `PackageReference` element. Use `VersionOverride` only when a project - genuinely requires a different version from the central default. - -### Code Formatting Tools - -1. **CSharpier**: Primary code formatter - - Run before committing: `dotnet csharpier format --log-level=debug .` - -2. **dotnet format**: Style verification - - Verify no changes: `dotnet format style --verify-no-changes --severity=info --verbosity=detailed` - -3. **Husky.Net**: Git hooks for automated checks - - Installed via restore target in `.csproj` - - Pre-commit hooks run formatting checks - -## Dependencies and Packages - -### Core Dependencies - -- **CliWrap**: Command-line process execution -- **System.CommandLine**: Command-line argument parsing -- **Serilog**: Structured logging with sinks (Console, File, Async) -- **Microsoft.Extensions.Http.Resilience**: HTTP client with resilience -- **Microsoft.SourceLink.GitHub**: Source link for debugging - -### Testing Dependencies - -- **xUnit**: Test framework -- **AwesomeAssertions**: Fluent assertion library -- **BenchmarkDotNet**: Performance benchmarking - -### Development Tools - -- **CSharpier**: Code formatter -- **Husky.Net**: Git hooks -- **dotnet-outdated-tool**: Dependency update checks -- **Nerdbank.GitVersioning**: Version management - -## Docker - -- Base images: Ubuntu Rolling -- Multi-platform support: linux/amd64, linux/arm64 -- Build script: `Build.sh` -- Debug tools: `InstallDebugTools.sh` - -## Project Structure - -- `.config/` - .NET tools configuration -- `.github/` - GitHub Actions workflows and Copilot instructions -- `.husky/` - Husky.Net git hooks -- `.vscode/` - Visual Studio Code settings and launch configurations -- `Benchmarks/` - BenchmarkDotNet performance measurement project -- `CodeGen/` - Code generation utilities (internal tooling) -- `Console/` - Console/CLI application using System.CommandLine -- `Docker/` - Docker build scripts and Dockerfile -- `NuGetLibrary/` - Core reusable .NET NuGet library (published as `ptr727.ProjectTemplate.Library`) -- `Tests/` - Unit tests using xUnit and AwesomeAssertions - -## Best Practices - -1. **Immutability**: Prefer `readonly` and `required` for fields and properties -2. **Async/Await**: Use async patterns consistently -3. **Cancellation Tokens**: Support cancellation in async methods -4. **Parallel Processing**: Use `ParallelOptions` for controlled parallelism -5. **HTTP Clients**: Use `HttpClientFactory` for HTTP client creation -6. **Dispose Pattern**: Implement IDisposable/IAsyncDisposable when managing resources -7. **Static Analysis**: Address all analyzer warnings - zero warnings policy -8. **Code Reviews**: All changes go through pull requests -9. **Git Versioning**: Use Nerdbank.GitVersioning for version management -10. **No Regions**: Avoid code regions - use logical file separation instead - - -## Editor Configuration - -The project includes comprehensive `.editorconfig` settings that enforce: -- Character encoding (UTF-8) -- Indentation rules -- Line ending conventions -- C# style preferences -- Naming conventions -- Code analysis settings - -**Always respect the .editorconfig settings** - these are verified by the build process. - -## Git and Commit Rules - -**These rules are absolute — no exceptions:** - -- **Never make git commits.** All commits must be cryptographically signed (SSH/GPG). AI coding agents cannot produce signed commits. Stage changes with `git add` and leave `git commit` to the developer, who must run it in their own environment where signing keys are available. -- **Never force push.** Do not run `git push --force` or `git push --force-with-lease`. Force pushing rewrites shared branch history and is blocked by branch protection rules. -- **Never run destructive git commands** (`git reset --hard`, `git checkout .`, `git restore .`, `git clean -f`) without explicit developer instruction. -- **Staging is the limit.** Prepare changes and stage files; the developer handles all commits and pushes. - -## Pull Request Title and Commit Message Conventions - -### Format - -- Imperative subject summarizing the change, ≤72 characters, no trailing period. ("Add 24-hour PM2.5 average sensor", not "Added X" or "Adds X".) -- Optional body, blank-line separated, explaining *why* the change is being made when that's non-obvious. The diff shows *what*. - -### Rules - -- Don't write `update stuff`, `wip`, or other vague titles. (Dependabot's default `Bump X from Y to Z` titles are fine — keep them.) -- Don't add `Co-Authored-By:` lines unless the developer explicitly asks. -- Don't put release-bump magnitude in the title — no "minor", "patch", "release v0.2.0", etc. Nerdbank.GitVersioning computes the next release version from `version.json` + git history. Dependency versions in dependency-bump titles are fine and expected. -- Use US English spelling and match the existing heading style of the file you're editing: title case with lowercase short bind words (a, an, the, and, but, or, of, in, on, at, to, by, for, from); hyphenated compounds capitalize both parts unless the second is a short preposition (*Built-in*, *EPA-Corrected*, *24-Hour*). - -### Examples - -```text -Add structured logging extensions to library -Pin softprops/action-gh-release to commit SHA -Drop net8.0 multi-targeting from console project -Bump xunit.v3 from 3.2.2 to 3.3.0 -Clarify devcontainer setup steps in README -``` - -## Workflow - -1. **Before coding**: Run `dotnet tool restore` to ensure tools are installed -2. **During development**: Use CSharpier for formatting as you go -3. **Before committing**: - - Run `.Net Format` task to verify compliance - - Husky hooks will run automatically -4. **Dependency updates**: Run `.Net Outdated Upgrade` task (`dotnet outdated --upgrade:prompt`) regularly -5. **Testing**: Run tests via VS Code test explorer or `dotnet test` - -## Reference Links - -- [Microsoft C# Coding Conventions](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions) -- [.NET Runtime Coding Style](https://github.com/dotnet/runtime/blob/main/docs/coding-guidelines/coding-style.md) -- [dotnet format Documentation](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-format) -- [EditorConfig Documentation](https://editorconfig.org) -- [CSharpier Documentation](https://csharpier.com) -- [Husky.Net Documentation](https://alirezanet.github.io/Husky.Net) -- [xUnit Documentation](https://xunit.net) -- [AwesomeAssertions Documentation](https://awesomeassertions.org/) -- [BenchmarkDotNet Documentation](https://benchmarkdotnet.org) -- [System.CommandLine Documentation](https://learn.microsoft.com/en-us/dotnet/standard/commandline/) -- [Serilog Documentation](https://serilog.net) - +# Copilot Instructions + +Repository conventions for GitHub Copilot (and any other AI agent reading this file). + +The **canonical guide is [AGENTS.md](../AGENTS.md)** at the repo root — read it first. It covers project layout, branch flow, PR review etiquette, the release pipeline, devcontainer behavior, workflow YAML conventions, and what NOT to touch. + +This file is intentionally narrow: commit/PR-title conventions (so VS Code's AI commit-message and PR-title generators get them without an extra fetch), plus a GitHub Copilot Review Runbook that documents the provider-specific mechanics behind the review-loop contract defined in AGENTS.md. + +For language-specific style rules, see: + +- .NET — [`CODESTYLE.md`](../CODESTYLE.md) at the repo root. +- Python — [`PyPiLibrary/CODESTYLE.md`](../PyPiLibrary/CODESTYLE.md). + +Do not duplicate language-specific rules here. + +## Commit Messages and Pull Request Titles + +Feature → develop PRs squash-merge — the PR title becomes the single commit on develop. Develop → main PRs merge-commit — main's history shows one merge commit per release with develop's tip as the second parent. Titles are descriptive and have no versioning effect — versioning is handled by [Nerdbank.GitVersioning](https://github.com/dotnet/Nerdbank.GitVersioning) reading [version.json](../version.json) and git history, not by parsing commit messages. + +### Format + +- Imperative subject summarizing the change, ≤ 72 characters, no trailing period. ("Add 24-hour PM2.5 average sensor", not "Added X" or "Adds X".) +- Optional body, blank-line separated, explaining *why* the change is being made when that's non-obvious. The diff shows *what*. + +### Rules + +- Don't write `update stuff`, `wip`, or other vague titles. (Dependabot's default `Bump X from Y to Z` titles are fine — keep them.) +- Don't add `Co-Authored-By:` lines unless the user explicitly asks. +- Don't put release-bump magnitude in the title — no "minor", "patch", "release v0.2.0", etc. NBGV computes the next release version from `version.json` + git history. Dependency versions in dependency-bump titles are fine and expected. +- Use US English spelling and match the existing heading style of the file you're editing: title case with lowercase short bind words (a, an, the, and, but, or, of, in, on, at, to, by, for, from); hyphenated compounds capitalize both parts unless the second is a short preposition (*Built-in*, *EPA-Corrected*, *24-Hour*). + +### Examples + +```text +Add structured logging extensions to library +Pin softprops/action-gh-release to commit SHA +Drop net8.0 multi-targeting from console project +Bump xunit.v3 from 3.2.2 to 3.3.0 +Clarify devcontainer setup steps in README +``` + +## GitHub Copilot Review Runbook + +Use this section for provider-specific mechanics. The expected review loop *contract* (request review on every push, verify head-SHA coverage, triage findings, reply + resolve, escalate when stuck) is defined in [AGENTS.md → PR Review Etiquette](../AGENTS.md#pr-review-etiquette). This section only describes how to make GitHub Copilot reliably execute it. + +### Triggering and Polling + +Auto-review on push is configured (via the branch ruleset's `copilot_code_review` rule with `review_on_push: true`) but fires inconsistently in practice — treat it as best-effort, not guaranteed. Request review explicitly through the GitHub PR UI (request `Copilot` as a reviewer) after every push. + +**Do NOT post `@Copilot review` as a PR comment.** That comment triggers the Copilot *coding agent* (`copilot-swe-agent[bot]`), which makes code changes rather than posting a review. + +Known non-working request paths (don't rely on them): + +- `POST /requested_reviewers` with `reviewers=[Copilot]` can return 200 but no-op. +- `copilot-pull-request-reviewer` as a requested reviewer slug returns 422. +- GraphQL `requestReviews` rejects Copilot's bot node. + +### Verify Review Covered Current Head + +Before merging, confirm Copilot reviewed the current PR head SHA. Copilot may respond as either a formal review (carries an exact commit SHA) or an issue comment (no SHA — use the most recent Copilot comment for manual confirmation). Check both. + +```sh +PR_HEAD=$(gh pr view --json headRefOid --jq '.headRefOid') + +# 1. Formal review — exact SHA match. +gh pr view --json reviews --jq \ + '.reviews[] | select(.author.login=="copilot-pull-request-reviewer") | .commit.oid' \ + | grep -q "$PR_HEAD" && echo "covered via formal review" + +# 2. Issue comment — show the most recent Copilot comment for manual confirmation. +gh api repos///issues//comments --jq \ + '[.[] | select(.user.login=="copilot-pull-request-reviewer")] | last | {created_at, body: .body[:200]}' +``` + +Coverage is confirmed when (1) exits 0. For issue comments (path 2), body content is the only reliable signal — `created_at` is not: `git log -1 --format=%cI` is the **commit** timestamp, not the push timestamp, so amended or rebased commits can have an earlier timestamp and an older Copilot comment could satisfy a time check even though Copilot never saw the current head. Treat path (2) as confirmed only when the comment body explicitly refers to the current changes. + +### Bounded Retry Workflow + +If a review did not run on the current head, retry: + +1. Wait briefly and check head-SHA coverage (see above). +1. Request review again via the GitHub PR UI. +1. Retry up to two more times (three total). +1. If still missing, mark review as blocked and escalate to the user/maintainer with what was attempted. + +### Reply and Thread Resolution Workflow + +List unresolved threads. Use `first: 100` with cursor-based pagination; if `hasNextPage` is true, re-run with `after: ""` to retrieve the next page: + +```sh +gh api graphql -f query=' +{ + repository(owner: "", name: "") { + pullRequest(number: ) { + reviewThreads(first: 100) { + nodes { + id isResolved path + comments(first: 1) { nodes { author { login } body } } + } + pageInfo { hasNextPage endCursor } + } + } + } +}' | jq ' + .data.repository.pullRequest.reviewThreads | + (.pageInfo | "hasNextPage=\(.hasNextPage) endCursor=\(.endCursor)"), + (.nodes[] | select(.isResolved == false)) +' +``` + +Reply on a thread, then resolve it: + +```sh +gh api graphql -f query=' +mutation($threadId: ID!, $body: String!) { + addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) { + comment { id } + } +}' -F threadId="PRRT_..." -F body="Fixed in : ." + +gh api graphql -f query=' +mutation($threadId: ID!) { + resolveReviewThread(input: { threadId: $threadId }) { thread { id isResolved } } +}' -F threadId="PRRT_..." +``` + +Issue-level Copilot comments (those in `issues//comments`) have no resolution action — GitHub provides no API or UI to resolve them. Reply if the finding warrants it; no resolution step is needed or possible. + +Reply-body conventions: + +- Accepted bug/style fix: include fixing commit SHA and a one-line summary. +- Declined style comment: cite the rule (AGENTS.md or language CODESTYLE) and the existing-tree precedent. +- Declined architecture proposal: one-sentence rationale. + +After the final push, sweep-resolve stale older threads for removed code paths. + +## When in Doubt + +Read [AGENTS.md](../AGENTS.md) for the full picture (release flow, files you must not touch, branching, workflow YAML, devcontainer). For language-specific rules, the per-language CODESTYLE files are authoritative. Don't restate any of these files' rules in commit bodies or PR descriptions — keep those focused on the change itself. diff --git a/AGENTS.md b/AGENTS.md index 7028ac11..e2d4e657 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,135 +1,155 @@ -# Instructions for AI Coding Agents - -**ProjectTemplate** is a C# .NET template project demonstrating best practices. Developers use this as a baseline to create their own projects. - -For comprehensive coding standards and detailed conventions, refer to [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) and [`CODESTYLE.md`](./CODESTYLE.md). - -## Git and Commit Rules - -**These rules are absolute — no exceptions:** - -- **Never make git commits.** AI coding agents cannot produce cryptographically signed commits. All commits must be signed (SSH/GPG) and must be made by the developer. Stage changes with `git add` and leave the commit to the developer. -- **Never force push.** Do not run `git push --force` or `git push --force-with-lease` under any circumstances. Force pushing rewrites shared history and can cause data loss. -- **Never run destructive git commands** (`git reset --hard`, `git checkout .`, `git restore .`, `git clean -f`) without explicit developer instruction. -- **Staging is the limit.** Prepare and stage file changes; the developer runs `git commit` in their own environment where signing keys are available. - -## Pull Request Title and Commit Message Conventions - -### Format - -- Imperative subject summarizing the change, ≤72 characters, no trailing period. ("Add 24-hour PM2.5 average sensor", not "Added X" or "Adds X".) -- Optional body, blank-line separated, explaining *why* the change is being made when that's non-obvious. The diff shows *what*. - -### Rules - -- Don't write `update stuff`, `wip`, or other vague titles. (Dependabot's default `Bump X from Y to Z` titles are fine — keep them.) -- Don't add `Co-Authored-By:` lines unless the developer explicitly asks. -- Don't put release-bump magnitude in the title — no "minor", "patch", "release v0.2.0", etc. Nerdbank.GitVersioning computes the next release version from `version.json` + git history. Dependency versions in dependency-bump titles are fine and expected. -- Use US English spelling and match the existing heading style of the file you're editing: title case with lowercase short bind words (a, an, the, and, but, or, of, in, on, at, to, by, for, from); hyphenated compounds capitalize both parts unless the second is a short preposition (*Built-in*, *EPA-Corrected*, *24-Hour*). - -### Examples - -```text -Add structured logging extensions to library -Pin softprops/action-gh-release to commit SHA -Drop net8.0 multi-targeting from console project -Bump xunit.v3 from 3.2.2 to 3.3.0 -Clarify devcontainer setup steps in README -``` - -## Documentation Style Conventions - -### Markdown - -- Use reference-style links for any URL referenced more than once or appearing in lists; alphabetize the reference definitions block. -- Inline single-use relative links (e.g. `[CODESTYLE.md](./CODESTYLE.md)`) are fine. -- One logical paragraph per line; no hard-wrap line-length limit. -- Headings follow the title-case-with-short-bind-words rule from the PR-title section. - -### Quantitative Claims - -- Any quantitative claim in `README.md` (counts, sizes, version floors, supported platforms) must be verified against current code. If a doc number is derived from a code constant, mark the dependency in a source-code comment so the next editor knows to update both. - -## Workflow YAML Conventions - -These conventions describe the target state. New and modified workflows must respect them; existing workflows are migrated opportunistically when they're being touched for other reasons. Don't open a PR purely to apply these rules across the repo — the churn isn't worth it. - -- **Action pinning**: pin third-party actions to a commit SHA with a trailing `# vX.Y.Z` comment so Renovate / Dependabot can still bump it but a tag swap can't change the executed code. First-party `actions/*` are encouraged but not required to follow the same convention. -- **Naming**: every step's `name:` ends in `step`; every job's `name:` ends in `job`. Reusable workflow filenames end in `-task.yml`. -- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. -- **Shells**: multi-line `run:` blocks with bash start with `set -euo pipefail` — fail fast, fail on undefined vars, fail on a failed pipe segment. -- **Conditionals**: multi-line `if:` uses folded scalar `if: >-` so YAML preserves whitespace correctly. Literal block (`if: |`) is wrong because it embeds newlines inside the boolean expression. -- **Boolean inputs**: workflows triggered both via `workflow_call` and `workflow_dispatch` must declare each boolean input in *both* trigger blocks — one definition does not propagate to the other. -- **Reusable workflows**: job-level `permissions:` are validated *before* the `if:` evaluates, so even a skipped job needs valid permissions declared. -- **Tag pinning on releases**: when using `softprops/action-gh-release` (or any tag-creating action), pass `target_commitish: ${{ github.sha }}` explicitly. Without it, GitHub's REST API defaults the new tag to the repository's default branch instead of the commit that built the artifact. - -## Branching Model - -- `develop` is the integration branch. Feature branches → `develop` is **squash-only**; the develop branch is kept linear. -- `develop` → `main` is **merge-commit only** (no squash, no rebase). Merge commits preserve develop's commit list as a real second-parent reference on main; this is what allows the "release on every push" model to attribute releases to the develop commits that produced them. Branch protection enforces this: the develop ruleset allows only `squash`, the main ruleset allows only `merge`. -- All commits on both branches must be cryptographically signed (SSH or GPG). Squash and merge commits created via the GitHub UI are signed by GitHub's web-flow key. - -## Key Requirements for All Projects Derived from This Template - -### Build & Quality Standards - -- **Zero Warnings Policy**: All builds must complete without errors or warnings - - Use `CSharpier Format`, `.Net Format`, and `Husky.Net Run` tasks - -- **Code Analysis**: Enable all .NET analyzers - - `true` - - `latest-all` - -### Project Configuration - -- Common MSBuild properties (`TargetFramework`, `Nullable`, `ImplicitUsings`, `AnalysisLevel`, etc.) - live in `Directory.Build.props` at the solution root. Do not duplicate these in individual `.csproj` - files — only add a property to a `.csproj` when it is project-specific or overrides the shared default. -- All NuGet package versions are centralised in `Directory.Packages.props`. `PackageReference` elements - in `.csproj` files must not include a `Version` attribute. Asset metadata (`PrivateAssets`, - `IncludeAssets`) stays in the `.csproj` `PackageReference` element. - -### Development Environment - -- Target latest .NET SDK (currently .NET 10 with C# 14) -- Support Visual Studio Code (`.code-workspace`) and Visual Studio Community (`.slnx`) -- Support Linux, Windows, and macOS with correct line endings and permissions -- Use `.editorconfig` for style enforcement - -### Project Structure - -- **NuGetLibrary**: Core reusable .NET NuGet library (published as `ptr727.ProjectTemplate.Library`) -- **Console**: CLI application using System.CommandLine -- **Tests**: xUnit with AwesomeAssertions (Arrange-Act-Assert pattern) -- **Benchmarks**: BenchmarkDotNet performance measurements -- **Docker**: Multi-platform Linux containers - -### Testing - -- Use xUnit v3 and AwesomeAssertions -- Organize tests logically in separate files -- Follow Arrange-Act-Assert pattern -- Test naming: `MethodName_Scenario_ExpectedBehavior()` - -## Authoritative References - -For detailed specifications, see: - -- [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) - Complete coding conventions and style guide -- [`CODESTYLE.md`](./CODESTYLE.md) - Code style and formatting rules -- [`.editorconfig`](./.editorconfig) - Automated style enforcement -- Project task definitions - `CSharpier Format`, `.Net Build`, `.Net Format`, `.Net Outdated Upgrade`, `Husky.Net Run` - -## Quick Start for Derived Projects - -1. **Clone this template** as baseline for your project -2. **Review** [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) thoroughly -3. **Update** project-specific values: - - `PackageId`, `RootNamespace` in `.csproj` files - - Namespace conventions with your organization name - - `README.md`, `HISTORY.md`, `version.json`, `LICENSE` -4. **Run tools** before first commit: - - `dotnet tool restore` - - `.Net Format` task - - `CSharpier Format` task -5. **Enable Husky.Net** hooks: `dotnet husky install` +# Instructions for AI Coding Agents + +**ProjectTemplate** is a polyglot template repo. The .NET side ships under [`NuGetLibrary/`](./NuGetLibrary/) (plus `Console/`, `Tests/`, `Benchmarks/`, `CodeGen/`); the Python side ships under [`PyPiLibrary/`](./PyPiLibrary/). This file is the single source of truth for cross-cutting rules. Language-specific style guides live next to the code: + +- .NET — [`CODESTYLE.md`](./CODESTYLE.md) +- Python — [`PyPiLibrary/CODESTYLE.md`](./PyPiLibrary/CODESTYLE.md) + +Treat this file as authoritative for everything else; don't restate its rules elsewhere. + +## Git and Commit Rules + +**These rules are absolute — no exceptions:** + +- **Never make git commits.** AI coding agents cannot produce cryptographically signed commits. All commits must be signed (SSH/GPG) and must be made by the developer. Stage changes with `git add` and leave the commit to the developer. +- **Never force push.** Do not run `git push --force` or `git push --force-with-lease` under any circumstances. Force pushing rewrites shared history and can cause data loss. +- **Never run destructive git commands** (`git reset --hard`, `git checkout .`, `git restore .`, `git clean -f`) without explicit developer instruction. +- **Staging is the limit.** Prepare and stage file changes; the developer runs `git commit` in their own environment where signing keys are available. + +## Branching Model + +- `develop` is the integration branch. Feature branches → `develop` is **squash-only**; develop is kept linear. +- `develop` → `main` is **merge-commit only** (no squash, no rebase). Merge commits preserve develop's commit list as a real second-parent reference on main, which is what makes the "release on every push" model attribute releases to the develop commits that produced them. Branch protection enforces this: the develop ruleset allows only `squash`, the main ruleset allows only `merge`. +- All commits on both branches must be cryptographically signed (SSH or GPG). Squash and merge commits created via the GitHub UI are signed by GitHub's web-flow key. + +## Pull Request Title and Commit Message Conventions + +### Format + +- Imperative subject summarizing the change, ≤72 characters, no trailing period. ("Add 24-hour PM2.5 average sensor", not "Added X" or "Adds X".) +- Optional body, blank-line separated, explaining *why* the change is being made when that's non-obvious. The diff shows *what*. + +### Rules + +- Don't write `update stuff`, `wip`, or other vague titles. (Dependabot's default `Bump X from Y to Z` titles are fine — keep them.) +- Don't add `Co-Authored-By:` lines unless the developer explicitly asks. +- Don't put release-bump magnitude in the title — no "minor", "patch", "release v0.2.0", etc. Nerdbank.GitVersioning computes the next release version from `version.json` + git history. Dependency versions in dependency-bump titles are fine and expected. +- Use US English spelling and match the existing heading style of the file you're editing: title case with lowercase short bind words (a, an, the, and, but, or, of, in, on, at, to, by, for, from); hyphenated compounds capitalize both parts unless the second is a short preposition (*Built-in*, *EPA-Corrected*, *24-Hour*). + +### Examples + +```text +Add structured logging extensions to library +Pin softprops/action-gh-release to commit SHA +Drop net8.0 multi-targeting from console project +Bump xunit.v3 from 3.2.2 to 3.3.0 +Clarify devcontainer setup steps in README +``` + +## Documentation Style Conventions + +### Markdown + +- Use reference-style links for any URL referenced more than once or appearing in lists; alphabetize the reference definitions block. +- Inline single-use relative links (e.g. `[CODESTYLE.md](./CODESTYLE.md)`) are fine. +- One logical paragraph per line; no hard-wrap line-length limit. +- Headings follow the title-case-with-short-bind-words rule from the PR-title section. + +### Quantitative Claims + +- Any quantitative claim in `README.md` (counts, sizes, version floors, supported platforms) must be verified against current code. If a doc number is derived from a code constant, mark the dependency in a source-code comment so the next editor knows to update both. + +## PR Review Etiquette + +The repo runs a review loop on every PR: local agent iteration plus remote automated review (GitHub Copilot is the configured reviewer). Treat this as a contract regardless of which local agent authored the changes. + +### Expected Review Loop + +1. Push changes to the PR branch. +2. Confirm a review was requested for the **current head SHA** (auto-trigger is unreliable; request explicitly). +3. Wait for review activity on that head. +4. Triage findings. +5. Apply fixes or write a rationale for declines. +6. Reply to each thread and resolve what was addressed. +7. Re-run the loop after every fix push until no actionable findings remain. + +`mergeStateStatus: CLEAN` only checks required statuses; it does not block on bot review comments. Merge only after review on the latest head SHA is confirmed and actionable findings are closed. + +For provider-specific mechanics (how to request review, query review state, post replies, resolve threads), see the **GitHub Copilot Review Runbook** in [.github/copilot-instructions.md](./.github/copilot-instructions.md). This file owns the contract; that file owns the mechanics. + +### Triaging Review Comments + +For each comment, classify before responding: + +- **Bug** — wrong behavior, missing test coverage, or a real divergence between code and docs. Fix it. Reply with the fixing commit SHA when done. +- **Style/convention** — the comment cites a rule from this file or a language-specific style guide. Two cases: + - The cited rule matches what the existing codebase already does → fix the offending code. + - The cited rule contradicts what's in the tree, or industry norm → **update the rule instead of the code**. The rule is wrong, not the code. Bouncing the same code across rounds is the symptom of a wrong rule. Heuristic: three rounds on the same style category means the rule needs adjusting and the user should authorize the rule change. +- **Architectural opinion** — the comment proposes a different design ("constrain this to disabled-by-default", "move it elsewhere", "add a runtime guardrail"). This is judgement, not a bug. Surface it to the user with a recommendation; don't apply unilaterally. + +### Responding and Resolution Expectations + +Reply inline with either the fixing commit SHA (for accepted issues) or a concise rationale (for declines). Resolve review threads when addressed or intentionally declined with rationale. Issue-level comments (those at `repos/.../issues//comments` rather than tied to a specific line) have no resolution action — acknowledge with a reply if needed and move on. + +After the final push on a PR, sweep older threads from earlier rounds whose code paths no longer exist; otherwise stale unresolved markers remain in the review UI. + +### Escalating to the User + +Bring the user in when: + +- **Genuine design trade-off** surfaces (fail-open vs fail-closed, narrow vs broad refactor scope, "should we add a guardrail or trust the docstring"). Triage, recommend, ask. +- **Repeated friction** across rounds without convergence — that's the rule-needs-updating signal. Stop, summarize the pattern, and let the user authorize the rule change. +- **Architectural redesign** is requested rather than a bug fix. Surface with a recommendation; never apply unilaterally. + +Anti-pattern: don't keep flipping the code on the same style point. Flip the rule once and stick to the rule. + +## Workflow YAML Conventions + +These conventions describe the target state. New and modified workflows must respect them; existing workflows are migrated opportunistically when they're being touched for other reasons. Don't open a PR purely to apply these rules across the repo — the churn isn't worth it. + +- **Action pinning**: pin third-party actions to a commit SHA with a trailing `# vX.Y.Z` comment so Renovate / Dependabot can still bump it but a tag swap can't change the executed code. First-party `actions/*` are encouraged but not required to follow the same convention. +- **Naming**: every step's `name:` ends in `step`; every job's `name:` ends in `job`. Reusable workflow filenames end in `-task.yml`. +- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. +- **Shells**: multi-line `run:` blocks with bash start with `set -euo pipefail` — fail fast, fail on undefined vars, fail on a failed pipe segment. +- **Conditionals**: multi-line `if:` uses folded scalar `if: >-` so YAML preserves whitespace correctly. Literal block (`if: |`) is wrong because it embeds newlines inside the boolean expression. +- **Boolean inputs**: workflows triggered both via `workflow_call` and `workflow_dispatch` must declare each boolean input in *both* trigger blocks — one definition does not propagate to the other. `workflow_call` delivers booleans as actual booleans; `workflow_dispatch` delivers them as the *strings* `"true"`/`"false"`. Any `if:` consuming a boolean input must compare against both forms — `if: ${{ inputs.foo == true || inputs.foo == 'true' }}`. +- **Reusable workflows**: job-level `permissions:` are validated *before* the `if:` evaluates, so even a skipped job needs valid permissions declared. A `release` job with `permissions: contents: write` and `if: ${{ inputs.publish }}` will still cause `startup_failure` on a caller that doesn't grant `contents: write`. Either declare permissions at the call site, or omit the inner block and inherit. +- **Allowlist `success` and `skipped` explicitly** when chaining jobs across optional dependencies — `!= 'failure'` lets `cancelled` through (timeout, runner failure, manual cancel). Use `(needs.X.result == 'success' || needs.X.result == 'skipped')`. +- **Tag pinning on releases**: when using `softprops/action-gh-release` (or any tag-creating action), pass `target_commitish: ${{ github.sha }}` explicitly. Without it, GitHub's REST API defaults the new tag to the repository's default branch instead of the commit that built the artifact. + +## Devcontainer + +[.devcontainer/devcontainer.json](./.devcontainer/devcontainer.json) bind-mounts the host SSH signing key's *public half* (`~/.ssh/id_ed25519.pub`), `~/.config/git/allowed_signers`, and `~/.config/gh` so commits inside the container are SSH-signed (signing happens via the forwarded `ssh-agent` socket — the private key never enters the container) and, *when the host's `gh` token is file-backed*, `gh` is pre-authenticated. On Keychain (macOS) or libsecret (Linux) hosts, `~/.config/gh/hosts.yml` carries no `oauth_token`, so container `gh` is unauthenticated until the contributor opts into `gh auth login` inside the container. See [docs/devcontainer.md](./docs/devcontainer.md) for full setup, [docs/host-setup.md](./docs/host-setup.md) for prerequisites, and [docs/ssh-signing.md](./docs/ssh-signing.md) for the SSH commit signing details. + +The unified container hosts both `.NET 10` (base image) and Python via uv (installed in `.devcontainer/post-create.sh` from a version-pinned URL). The extension list in `.devcontainer/devcontainer.json` and `recommendations` in [`ProjectTemplate.code-workspace`](./ProjectTemplate.code-workspace) are kept identical — when you add an extension to one, add it to the other. + +## Project Structure (Languages) + +- **.NET projects** (build with `dotnet build`, test with `dotnet test`): + - `NuGetLibrary/` — core reusable .NET NuGet library (published as `ptr727.ProjectTemplate.Library`) + - `Console/` — CLI app using System.CommandLine + - `Tests/` — xUnit + AwesomeAssertions + - `Benchmarks/` — BenchmarkDotNet + - `CodeGen/` — internal codegen tooling + - **Style guide: [`CODESTYLE.md`](./CODESTYLE.md)**. +- **Python project** (env/build/test with `uv` from inside `PyPiLibrary/`): + - `PyPiLibrary/` — PyPi library template, published as `ptr727-projecttemplate-library` + - **Style guide: [`PyPiLibrary/CODESTYLE.md`](./PyPiLibrary/CODESTYLE.md)**. +- **Cross-cutting**: + - `.github/` — workflows, Dependabot, Copilot instructions + - `.devcontainer/` — devcontainer config + post-create script + - `.vscode/` — debug configs and tasks (.NET-oriented) + - `Docker/` — multi-platform Linux container build for the Console app + +When you touch code in either language, also respect that language's style guide. Conventions in this file (PR titles, branching, US English, devcontainer behavior, workflow YAML) apply uniformly to both languages. + +## Quick Start for Derived Projects + +1. **Clone this template** as the baseline for your project. +2. **Decide** which language sides you need. If you need only one, delete the other folder and its references — see the relevant CODESTYLE for the deletion checklist. +3. **Read** [CODESTYLE.md](./CODESTYLE.md) (.NET) and/or [PyPiLibrary/CODESTYLE.md](./PyPiLibrary/CODESTYLE.md) (Python) for the per-language style. +4. **Update project-specific values** — `PackageId`/`RootNamespace` in `.csproj`, `name` in `pyproject.toml`, namespace conventions, `README.md`, `HISTORY.md`, `version.json`, `LICENSE`, NuGet/PyPI badge URLs. +5. **Run tools before first commit**: + - .NET: `dotnet tool restore` and `dotnet husky install`. + - Python: `cd PyPiLibrary && uv sync`. +6. **Wire up release credentials** when ready to publish — see the README's release notes section and [PyPiLibrary/README.md](./PyPiLibrary/README.md) for PyPI Trusted Publisher setup. diff --git a/CODESTYLE.md b/CODESTYLE.md index 8037f473..75371b10 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -1,4 +1,8 @@ -# Code Style and Formatting Rules +# Code Style and Formatting Rules — .NET + +This file is the style guide for the **.NET projects** in this repo: [`NuGetLibrary/`](./NuGetLibrary/), [`Console/`](./Console/), [`Tests/`](./Tests/), [`Benchmarks/`](./Benchmarks/), and [`CodeGen/`](./CodeGen/). It does NOT apply to the Python project (`PyPiLibrary/`) — see [`PyPiLibrary/CODESTYLE.md`](./PyPiLibrary/CODESTYLE.md) for that. + +Cross-cutting rules (PR titles, branching, US English, markdown style, workflow YAML, PR review etiquette) live in [AGENTS.md](./AGENTS.md) and apply to both languages. This file only documents what's specific to C# / .NET. ## Build Requirements diff --git a/PyPiLibrary/CODESTYLE.md b/PyPiLibrary/CODESTYLE.md new file mode 100644 index 00000000..34f1b098 --- /dev/null +++ b/PyPiLibrary/CODESTYLE.md @@ -0,0 +1,125 @@ +# Code Style and Formatting Rules — Python + +This file is the style guide for the **Python project** in this repo: [`PyPiLibrary/`](./). It does NOT apply to the .NET projects — see [`CODESTYLE.md`](../CODESTYLE.md) at the repo root for those. + +Cross-cutting rules (PR titles, branching, US English, markdown style, workflow YAML, PR review etiquette) live in [`AGENTS.md`](../AGENTS.md) and apply to both languages. This file only documents what's specific to Python. + +## Toolchain + +| Tool | Role | Config | +|---|---|---| +| [uv](https://docs.astral.sh/uv/) | env, deps, build, publish | `pyproject.toml` `[dependency-groups]`, `uv.lock` | +| [hatchling](https://hatch.pypa.io/latest/) | build backend | `pyproject.toml` `[build-system]` | +| [ruff](https://docs.astral.sh/ruff/) | lint + format + import sort | `pyproject.toml` `[tool.ruff]` | +| [pyright](https://microsoft.github.io/pyright/) | type checker | `pyproject.toml` `[tool.pyright]` | +| [pytest](https://docs.pytest.org/) | test runner | `pyproject.toml` `[tool.pytest.ini_options]` | + +`pyright` is consumed in two places: as a dev dependency (`uv run pyright` for CI/scripted runs) and via VS Code's **Pylance** extension (which embeds pyright). The standalone `ms-pyright.pyright` extension is in `unwantedRecommendations` because Pylance covers it. `mypy` is **not used** here — don't introduce it. + +## Local Development Loop + +From inside `PyPiLibrary/`: + +```sh +uv sync # creates .venv, installs deps + dev group +uv run ruff format # auto-format +uv run ruff check --fix # auto-fix lint +uv run ruff check # verify lint clean +uv run ruff format --check # verify format clean +uv run pyright # verify types +uv run pytest # run tests +uv build # produce wheel + sdist in ./dist +``` + +CI runs the same commands via [`.github/workflows/build-pypilibrary-task.yml`](../.github/workflows/build-pypilibrary-task.yml). Husky.Net pre-commit hooks (configured in [`.husky/task-runner.json`](../.husky/task-runner.json)) run `ruff format` and `ruff check` against staged Python files when `uv` is on PATH. + +## Layout + +`src` layout — keeps the package out of the repo root and prevents accidental imports of unbuilt code: + +```text +PyPiLibrary/ + pyproject.toml + README.md + CODESTYLE.md # this file + uv.lock # committed for reproducible CI + src/ + ptr727_projecttemplate_library/ + __init__.py + _version.py + .py + tests/ + __init__.py + test_.py +``` + +## Code Style + +### Formatting and Linting + +- **`ruff format` is authoritative.** Don't argue with the formatter; if it reformats your code, that's the final form. Configure (line length, target version) in `pyproject.toml` `[tool.ruff]`, not via inline `# fmt:` directives. +- **Run `ruff check --fix` before committing.** Most ruff lint rules have safe autofixes; let the tool handle them. The configured rule families are listed under `[tool.ruff.lint]` `select`. Add new rule families project-wide rather than scattering inline `# noqa` markers. +- **`# noqa` is a last resort.** When you must use one, scope it narrowly (`# noqa: E501`, not bare `# noqa`) and add a short comment on the same line explaining why. False-positive patterns that recur across the codebase belong in `[tool.ruff.lint]` `ignore` or per-file `[tool.ruff.lint.per-file-ignores]`, with a comment. + +### Comments + +- **Inline `#` comments**: keep tight and local. One line is preferred, but multi-line is fine when you need to document a non-obvious implementation constraint, a local trade-off, or coupling that future edits could easily break. Keep that rationale next to the affected block so the reviewer/maintainer sees it at edit-time. +- **Don't explain *what* the code does** — well-named identifiers handle that. Don't reference the current task ("added for X", "used by Y"); that belongs in the PR description. + +### Docstrings + +- Follow [PEP 257](https://peps.python.org/pep-0257/). Focus docstrings primarily on the **behavior contract** (what callers and tests can rely on), public semantics, and edge-case expectations. Implementation-local rationale belongs in inline `#` comments, not docstrings. +- A short one-liner is fine for trivial functions and tests with self-documenting names. +- For non-trivial behavior — non-obvious test scenarios, contracts a test pins, edge cases callers must know about, design trade-offs that are load-bearing for future maintainers — write a one-line summary, blank line, then a details paragraph. Multi-paragraph docstrings are fine when the contract earns it. +- Design notes belong **in the code** (docstrings or inline comments). They do NOT belong in [`HISTORY.md`](../HISTORY.md) — that file is end-user release notes, not a design log. + +### Type Hints + +- **All public APIs are typed.** Pyright runs on `src/**` in strict mode (`[tool.pyright]` `strict = ["src/**"]`); tests run in standard mode. +- **Use modern syntax**: `list[int]` not `List[int]`, `dict[str, X]` not `Dict[str, X]`, `X | None` not `Optional[X]`, `from __future__ import annotations` only when needed for forward references. +- **Don't add `# type: ignore` to silence pyright errors without a comment** explaining the constraint. If a recurring false positive needs suppression, configure it project-wide in `[tool.pyright]`. + +### Naming + +- `snake_case` for functions, methods, variables, modules, package directories. +- `PascalCase` for classes, type aliases, type vars, enum members. +- `UPPER_SNAKE_CASE` for module-level constants. +- Single leading underscore for module-private; double leading underscore for name-mangled (rare — usually means rethink the design). + +### Imports + +- **Let ruff sort imports.** `[tool.ruff.lint]` `select` includes the `I` rule family (isort-equivalent). Don't hand-sort. +- Standard library first, then third-party, then first-party (the project itself), each block separated by a blank line — ruff enforces this automatically. +- Avoid wildcard imports (`from x import *`) outside `__init__.py` re-exports. + +### Patterns to Avoid + +- **Don't add backward-compat shims, `# removed` markers, or rename-to-`_` for unused vars** — just delete. Git history is the audit trail. +- **Don't add error handling for impossible cases.** Trust internal code; only validate at boundaries (user input, parsed config, external APIs). +- **Don't use exceptions for expected control flow.** Exceptions are for *unexpected* states. +- **Don't suppress errors silently** (`except Exception: pass`). Either handle the specific exception and document why it's safe, or let it propagate. + +## Tests + +- `pytest` with the configuration in `[tool.pytest.ini_options]`. Default invocation: `uv run pytest`. +- One test file per module under test, named `test_.py`. +- Test functions named `test__` — descriptive, not numbered. +- Use fixtures (defined in `conftest.py` for shared ones, or per-test for narrowly-scoped) instead of setup/teardown methods. +- **Avoid mocking when fakes work.** Hand-rolled fakes that implement the protocol you depend on are usually clearer and break less than `unittest.mock` magic. +- **Test edge cases that the docstring promises**, not implementation details. If the test breaks when you refactor *without changing behavior*, the test is asserting on an implementation detail. + +## Versioning + +`_version.py` ships with `__version__ = "0.0.0"` as a placeholder. The publish workflow uses `skip-existing: true` so the workflow won't fail, but no new PyPI versions will land until you wire `_version.py` to something that increments. See the **Template Adoption** section of [`README.md`](./README.md) for the three usual options (`hatch-vcs`, version.json bridge, manual bumps). + +## Linter Cleanliness + +Before pushing or opening a PR: + +- VS Code's **Problems** pane should be quiet for the files you touched. The relevant linters are ruff (via the `charliermarsh.ruff` extension) and pyright (via the `ms-python.python` extension's bundled Pylance). +- The CI gate is `uv run ruff check && uv run ruff format --check && uv run pyright && uv run pytest` — same as the local commands above, run from `PyPiLibrary/`. +- For markdown files in this directory, follow the markdown style rules in [AGENTS.md](../AGENTS.md). The repo's markdownlint config applies; fix violations at the source rather than disabling rules. + +## Adopting This Template Without Python + +If your derived project does not need a Python side, delete the entire `PyPiLibrary/` folder, the `build-pypilibrary` job in `build-release-task.yml`, the `publish-pypi` job in `publish-release.yml`, the `build-pypilibrary-task.yml` workflow, the `uv` block in `.github/dependabot.yml`, the Python entries in `.husky/task-runner.json`, and the Python settings/extension recommendations in `ProjectTemplate.code-workspace` and `.devcontainer/devcontainer.json`. The .NET side stands alone. From b77a9364a57ee668036a9db5dad51afd636ae8fd Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 12:48:15 -0700 Subject: [PATCH 19/24] Address Copilot review on PR #64 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/host-setup.md: explicitly call out that the snippets below the Supported Hosts list assume a POSIX shell, with WSL2/Git Bash as the Windows path. The earlier "native Windows is supported for host-install" bullet was true for the .NET tooling but misleading next to ``mkdir -p``, ``grep -E``, and ``$(...)`` snippets that don''t work in PowerShell. - docs/ssh-signing.md: make the Verify Signing snippet explicitly sign via ``-S`` plus ``-c gpg.format=ssh``. The previous form relied on ``commit.gpgsign=true`` already being set globally, which is exactly the config the user is verifying — so the verification could create an unsigned empty commit and silently pass. --- docs/host-setup.md | 2 ++ docs/ssh-signing.md | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/host-setup.md b/docs/host-setup.md index c48f228d..0cf46fb7 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -8,6 +8,8 @@ Supported hosts: - **macOS** — both the devcontainer flow and the host-install flow. - **Windows** — the devcontainer flow requires **WSL2**; native Windows (PowerShell + winget) is supported only for the host-install flow described in `README.md`. The bind-mounts in `.devcontainer/devcontainer.json` rely on POSIX paths and only work from Linux/macOS/WSL2. +> **Shell assumptions in this doc**: every command snippet below assumes a **POSIX shell** (bash/zsh) and POSIX path conventions (`~/.ssh/...`, `mkdir -p`, `$(...)` command substitution). On Windows, run them from **WSL2** or **Git Bash** — they will not work as-is in PowerShell or `cmd.exe`. The git config and `gh` commands are portable; only the file/path manipulation differs by shell. + ## Git Identity Configure your name and email — used for commit authorship. diff --git a/docs/ssh-signing.md b/docs/ssh-signing.md index be3b9c16..1a95e06a 100644 --- a/docs/ssh-signing.md +++ b/docs/ssh-signing.md @@ -92,12 +92,14 @@ If you must work on Windows directly without a devcontainer, OpenSSH for Windows ## Verify Signing +The `-S` flag and `-c gpg.format=ssh` override are explicit so the verification works even before `commit.gpgsign` and `gpg.format` are set globally — useful when verifying a fresh setup mid-configuration. + ```shell -git commit --allow-empty -m "verify-signing" +git -c gpg.format=ssh commit -S --allow-empty -m "verify-signing" git log --show-signature -1 ``` -Expected output includes `Good "git" signature for `. If you see `error: gpg.ssh.allowedSignersFile needs to be configured` or `No signature`, walk back through the host setup — most often `allowed_signers` is missing the entry, or `commit.gpgsign` is not set. +Expected output includes `Good "git" signature for `. If you see `error: gpg.ssh.allowedSignersFile needs to be configured` or `No signature`, walk back through the host setup — most often `allowed_signers` is missing the entry, or the `user.signingkey` and `gpg.ssh.allowedSignersFile` configs aren't set yet. ## Inside the Devcontainer From 176bb4d8a7519311115ff3c23e597ce28911d7c2 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 13:00:18 -0700 Subject: [PATCH 20/24] Use official PyPI capitalization in prose; consistent plural MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review on PR #64. - README.md: "PyPI Package" -> "PyPI Packages" so the bullet text and the link text agree (both plural, matching the adjacent NuGet line). - AGENTS.md, docs/devcontainer.md, PyPiLibrary/README.md, PyPiLibrary/pyproject.toml description, and the ``__init__.py`` module docstring: prose mentions of the registry are now "PyPI" (the official capitalization, https://pypi.org), not "PyPi". - Workflow display names (Build / Publish / Download PyPI library ...) use the same casing. The folder/project identifier ``PyPiLibrary`` (and import name ``ptr727_projecttemplate_library``) is unchanged — it''s an established camelcase identifier that disambiguates from ``NuGetLibrary`` on disk, not user-facing branding. --- .github/workflows/build-pypilibrary-task.yml | 6 +++--- .github/workflows/build-release-task.yml | 4 ++-- .github/workflows/publish-release.yml | 4 ++-- AGENTS.md | 2 +- PyPiLibrary/README.md | 2 +- PyPiLibrary/pyproject.toml | 2 +- PyPiLibrary/src/ptr727_projecttemplate_library/__init__.py | 2 +- README.md | 2 +- docs/devcontainer.md | 2 +- 9 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build-pypilibrary-task.yml b/.github/workflows/build-pypilibrary-task.yml index db287a80..f7419fc7 100644 --- a/.github/workflows/build-pypilibrary-task.yml +++ b/.github/workflows/build-pypilibrary-task.yml @@ -1,6 +1,6 @@ -name: Build PyPi library task +name: Build PyPI library task -# This reusable workflow only builds the PyPi library and uploads the +# This reusable workflow only builds the PyPI library and uploads the # wheel + sdist as a workflow-run artifact. It does NOT publish to PyPI. # Publishing happens directly in `publish-release.yml` so that the # `id-token: write` permission required by Trusted Publishing is granted @@ -20,7 +20,7 @@ on: jobs: build-pypilibrary: - name: Build PyPi library project job + name: Build PyPI library project job runs-on: ubuntu-latest defaults: run: diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index fd64e83a..bf5d90c8 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -34,12 +34,12 @@ jobs: # Conditional push to NuGet.org push: ${{ inputs.nuget }} - # PyPi publishing happens in `publish-release.yml`, not here, so that + # PyPI publishing happens in `publish-release.yml`, not here, so that # `id-token: write` only needs to be granted at the entry-point job. # This reusable workflow just builds and uploads the artifact; the # publish-release workflow downloads it by name in a sibling job. build-pypilibrary: - name: Build PyPi library job + name: Build PyPI library job uses: ./.github/workflows/build-pypilibrary-task.yml secrets: inherit diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index f44c3ce2..81c2322f 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -24,7 +24,7 @@ jobs: dockerhub: true publish-pypi: - name: Publish PyPi library job + name: Publish PyPI library job needs: [create-release] runs-on: ubuntu-latest environment: @@ -47,7 +47,7 @@ jobs: steps: - - name: Download PyPi library build artifacts step + - name: Download PyPI library build artifacts step uses: actions/download-artifact@v7 with: name: pypilibrary-build diff --git a/AGENTS.md b/AGENTS.md index e2d4e657..5a117adb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,7 +133,7 @@ The unified container hosts both `.NET 10` (base image) and Python via uv (insta - `CodeGen/` — internal codegen tooling - **Style guide: [`CODESTYLE.md`](./CODESTYLE.md)**. - **Python project** (env/build/test with `uv` from inside `PyPiLibrary/`): - - `PyPiLibrary/` — PyPi library template, published as `ptr727-projecttemplate-library` + - `PyPiLibrary/` — PyPI library template, published as `ptr727-projecttemplate-library` - **Style guide: [`PyPiLibrary/CODESTYLE.md`](./PyPiLibrary/CODESTYLE.md)**. - **Cross-cutting**: - `.github/` — workflows, Dependabot, Copilot instructions diff --git a/PyPiLibrary/README.md b/PyPiLibrary/README.md index 377b72d2..d548bc41 100644 --- a/PyPiLibrary/README.md +++ b/PyPiLibrary/README.md @@ -1,6 +1,6 @@ # PyPiLibrary -Python PyPi template — companion to the .NET `NuGetLibrary` in this repo. Published to PyPI as [`ptr727-projecttemplate-library`](https://pypi.org/project/ptr727-projecttemplate-library/). +Python PyPI template — companion to the .NET `NuGetLibrary` in this repo. Published to PyPI as [`ptr727-projecttemplate-library`](https://pypi.org/project/ptr727-projecttemplate-library/). ## Stack diff --git a/PyPiLibrary/pyproject.toml b/PyPiLibrary/pyproject.toml index 332bd9e3..07164f59 100644 --- a/PyPiLibrary/pyproject.toml +++ b/PyPiLibrary/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ptr727-projecttemplate-library" -description = "Python PyPi template library — companion to the .NET NuGetLibrary in this template repo." +description = "Python PyPI template library — companion to the .NET NuGetLibrary in this template repo." readme = "README.md" license = { text = "MIT" } authors = [{ name = "Pieter Viljoen" }] diff --git a/PyPiLibrary/src/ptr727_projecttemplate_library/__init__.py b/PyPiLibrary/src/ptr727_projecttemplate_library/__init__.py index 1f0a1200..8c603871 100644 --- a/PyPiLibrary/src/ptr727_projecttemplate_library/__init__.py +++ b/PyPiLibrary/src/ptr727_projecttemplate_library/__init__.py @@ -1,4 +1,4 @@ -"""Python PyPi template library.""" +"""Python PyPI template library.""" from ptr727_projecttemplate_library._version import __version__ from ptr727_projecttemplate_library.example import greet diff --git a/README.md b/README.md index 986715ad..2b062ca5 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ C# .NET project template. - **Versioned Releases**: [GitHub Releases][releases-link] - Version tagged source code and build artifacts. - **Docker Images**: [Docker Hub][docker-link] - Container images with all tools pre-installed. - **NuGet Packages** [NuGet Packages][nuget-link] - .NET libraries published to NuGet.org. -- **PyPI Packages** [PyPI Package][pypi-link] - Python library published to PyPI.org. +- **PyPI Packages** [PyPI Packages][pypi-link] - Python library published to PyPI.org. ### Build Status diff --git a/docs/devcontainer.md b/docs/devcontainer.md index 4a7f0c07..e09e19c6 100644 --- a/docs/devcontainer.md +++ b/docs/devcontainer.md @@ -9,7 +9,7 @@ Prerequisite: complete [host setup](./host-setup.md) first — without git confi | Component | Source | Purpose | |---|---|---| | .NET 10 SDK | base image `mcr.microsoft.com/devcontainers/dotnet:1-10.0` | Build, test, pack the .NET projects | -| `uv` | `https://astral.sh/uv//install.sh` (version-pinned) downloaded by `.devcontainer/post-create.sh` | Python env, dependency, build, and publish manager for the PyPi sibling | +| `uv` | `https://astral.sh/uv//install.sh` (version-pinned) downloaded by `.devcontainer/post-create.sh` | Python env, dependency, build, and publish manager for the PyPI sibling | | `gh` CLI | `ghcr.io/devcontainers/features/github-cli:1` | Issue/PR/release management from inside the container | | Common utilities | `ghcr.io/devcontainers/features/common-utils:2` | bash, curl, wget, sudo, `vscode` user | | VS Code extensions | `customizations.vscode.extensions` in `devcontainer.json` | Mirrors `ProjectTemplate.code-workspace` recommendations so the container has the same tooling | From 89c7f0ad4b6773ba546eee03c32dc2b10dd9c7a5 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 13:02:19 -0700 Subject: [PATCH 21/24] Tighten workflow naming conventions; fix one step name deviation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of `.github/workflows/` against AGENTS.md surfaced two findings: - Filename and top-level workflow `name:` follow a clear pattern that AGENTS.md hadn''t spelled out: reusable workflows (those with `on: workflow_call`) use `-task.yml` and a `... task` display name, while entry-point workflows (push/pull_request/schedule/ workflow_dispatch) drop the `-task` suffix entirely (they end with what they DO — `-pull-request.yml`, `-release.yml`) and use a `... action` display name. The display-name suffix lets you tell orchestrators from callees at a glance in the GitHub Actions UI. AGENTS.md now documents this explicitly. - Job `name:` always ends in "job" and step `name:` always ends in "step", with one INTENTIONAL exception: a job whose name is bound to a branch-ruleset required-status-check `context:` value cannot be renamed without breaking enforcement. Currently that''s `Check pull request workflow status` in test-pull-request.yml. The AGENTS.md update calls this out explicitly so future agents don''t "fix" it. - One real step-suffix deviation remained: the `Check workflow results` step at test-pull-request.yml:28 had no ruleset binding — just an oversight when the convention was applied. Renamed to `Check workflow results step`. The two `Check` names sit on the same pair (job + step) by coincidence. The job name is ruleset-locked; the step name is not. --- .github/workflows/test-pull-request.yml | 2 +- AGENTS.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 8bbf5e06..394ebc9a 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -25,7 +25,7 @@ jobs: [ test-release ] if: always() steps: - - name: Check workflow results + - name: Check workflow results step run: | exit_on_result() { if [[ "$2" == "failure" || "$2" == "cancelled" ]]; then diff --git a/AGENTS.md b/AGENTS.md index 5a117adb..7262f2e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,7 +108,9 @@ Anti-pattern: don't keep flipping the code on the same style point. Flip the rul These conventions describe the target state. New and modified workflows must respect them; existing workflows are migrated opportunistically when they're being touched for other reasons. Don't open a PR purely to apply these rules across the repo — the churn isn't worth it. - **Action pinning**: pin third-party actions to a commit SHA with a trailing `# vX.Y.Z` comment so Renovate / Dependabot can still bump it but a tag swap can't change the executed code. First-party `actions/*` are encouraged but not required to follow the same convention. -- **Naming**: every step's `name:` ends in `step`; every job's `name:` ends in `job`. Reusable workflow filenames end in `-task.yml`. +- **Filename**: reusable workflows (those with `on: workflow_call`) end in `-task.yml`. Entry-point workflows (`on: push` / `pull_request` / `schedule` / `workflow_dispatch`) do NOT use the `-task` suffix; they end with what they do — `-pull-request.yml`, `-release.yml`, etc. The suffix carries semantic meaning: a `-task.yml` file is meant to be `uses:`-d, never triggered directly. +- **Workflow `name:`** (the top-level `name:` field): reusable workflow names end in **"task"** (e.g. `Build PyPI library task`); entry-point workflow names end in **"action"** (e.g. `Publish project release action`, `Test pull request action`). The displayed action name in the GitHub Actions UI tells you at a glance whether you're looking at an orchestrator or a callee. +- **Job and step `name:` suffixes**: every job's `name:` ends in **"job"**; every step's `name:` ends in **"step"**. **Exception**: a job whose `name:` is also referenced as a required-status-check `context:` in a branch ruleset (currently `Check pull request workflow status` in `test-pull-request.yml`) keeps the ruleset-bound name verbatim — renaming would silently break required-status-check enforcement. Do not "fix" that name; if a future job becomes ruleset-bound, mark it the same way. - **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. - **Shells**: multi-line `run:` blocks with bash start with `set -euo pipefail` — fail fast, fail on undefined vars, fail on a failed pipe segment. - **Conditionals**: multi-line `if:` uses folded scalar `if: >-` so YAML preserves whitespace correctly. Literal block (`if: |`) is wrong because it embeds newlines inside the boolean expression. From 22db3bca20e396d9cf6ba656b4081dbe9d65843f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 13:07:13 -0700 Subject: [PATCH 22/24] Fix dependabot.yml path in deletion checklists Both ``Template - TODO List`` (root README) and the PyPiLibrary/README deletion checklist referenced ``dependabot.yml``, but the Dependabot config lives at ``.github/dependabot.yml``. Adopters following either checklist would have looked for a non-existent file at the repo root. Path is now qualified. --- PyPiLibrary/README.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/PyPiLibrary/README.md b/PyPiLibrary/README.md index d548bc41..f099a2b7 100644 --- a/PyPiLibrary/README.md +++ b/PyPiLibrary/README.md @@ -65,4 +65,4 @@ When deriving a new project from this template: - **Read from `version.json`** — the .NET side uses Nerdbank.GitVersioning which reads from `version.json`. A small custom Hatchling plugin or a CI step can pull the version into `_version.py` so .NET and Python ship with matching versions. - **Manual bumps** — edit `_version.py` in each release PR. Simplest, but easy to forget. -If you don't want a Python project at all, delete the `PyPiLibrary/` folder, the `build-pypilibrary-task.yml` workflow, the `build-pypilibrary` job in `build-release-task.yml`, the `publish-pypi` job in `publish-release.yml`, and the `uv` block in `dependabot.yml`. +If you don't want a Python project at all, delete the `PyPiLibrary/` folder, the `build-pypilibrary-task.yml` workflow, the `build-pypilibrary` job in `build-release-task.yml`, the `publish-pypi` job in `publish-release.yml`, and the `uv` block in `.github/dependabot.yml`. diff --git a/README.md b/README.md index 2b062ca5..2d6f1181 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,7 @@ Licensed under the [MIT License][license-link]\ ### Template - TODO List - [ ] Configure git for SSH signing and SSH forwarding in dev containers — see [docs/host-setup.md](./docs/host-setup.md), [docs/ssh-signing.md](./docs/ssh-signing.md), and [docs/devcontainer.md](./docs/devcontainer.md). -- [ ] Decide whether your project needs the .NET (`NuGetLibrary/`) side, the Python (`PyPiLibrary/`) side, or both. Delete the unused folder and remove its references from `ProjectTemplate.slnx`, `dependabot.yml`, and the corresponding `.github/workflows/build-*-task.yml`. +- [ ] Decide whether your project needs the .NET (`NuGetLibrary/`) side, the Python (`PyPiLibrary/`) side, or both. Delete the unused folder and remove its references from `ProjectTemplate.slnx`, `.github/dependabot.yml`, and the corresponding `.github/workflows/build-*-task.yml`. - [ ] Start on Linux to avoid file permission issues when moving from Windows. - [ ] Configure the [Developer Environment](#template---developer-environment-setup). - [ ] Open the project directory (*not the workspace*) in Visual Studio Code, and rename (Ctrl-Shift-H) all instances of `ProjectTemplate` to `[NewProject]` in code. From e76aab0b91937b643986098c50ed599b75058aba Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 13:14:54 -0700 Subject: [PATCH 23/24] Bump default Python target to 3.14; fix pyright per-path strict config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes bundled because they both touch pyproject.toml. Python 3.14: - ``requires-python = ">=3.14"`` (was ``>=3.13``). - ``Programming Language :: Python :: 3.14`` classifier. - Ruff ``target-version = "py314"`` so it generates / accepts 3.14 syntax. - Pyright ``pythonVersion = "3.14"`` so type-checking matches what the package will run on. - ``uv.lock`` regenerated against ``>=3.14``. Pyright per-path strict mode: - The original ``strict = ["src/**"]`` glob form is ineffective — pyright''s ``strict`` field accepts directory paths, not glob patterns. The previous attempt with ``[[tool.pyright.executionEnvironments]]`` set to ``typeCheckingMode = "strict"`` is also wrong — that key is not recognized inside an executionEnvironment. - Correct form is ``strict = ["src"]`` at the top level. That tells pyright to apply strict type-checking to everything under ``src/`` (equivalent to placing ``# pyright: strict`` at the top of every file under it). ``tests/`` continues to use the global ``typeCheckingMode = "standard"`` so fixture / mock / parametrize typing stays loose. - PyPiLibrary/CODESTYLE.md updated to match. Local validation: ruff check / format --check / pyright / pytest / uv build all clean against Python 3.14. --- PyPiLibrary/CODESTYLE.md | 2 +- PyPiLibrary/pyproject.toml | 16 +++++++++++----- PyPiLibrary/uv.lock | 2 +- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/PyPiLibrary/CODESTYLE.md b/PyPiLibrary/CODESTYLE.md index 34f1b098..c8ae2bd2 100644 --- a/PyPiLibrary/CODESTYLE.md +++ b/PyPiLibrary/CODESTYLE.md @@ -75,7 +75,7 @@ PyPiLibrary/ ### Type Hints -- **All public APIs are typed.** Pyright runs on `src/**` in strict mode (`[tool.pyright]` `strict = ["src/**"]`); tests run in standard mode. +- **All public APIs are typed.** Pyright runs on `src/` in strict mode (`[tool.pyright]` `strict = ["src"]`); tests run in standard mode. - **Use modern syntax**: `list[int]` not `List[int]`, `dict[str, X]` not `Dict[str, X]`, `X | None` not `Optional[X]`, `from __future__ import annotations` only when needed for forward references. - **Don't add `# type: ignore` to silence pyright errors without a comment** explaining the constraint. If a recurring false positive needs suppression, configure it project-wide in `[tool.pyright]`. diff --git a/PyPiLibrary/pyproject.toml b/PyPiLibrary/pyproject.toml index 07164f59..f73f737c 100644 --- a/PyPiLibrary/pyproject.toml +++ b/PyPiLibrary/pyproject.toml @@ -8,7 +8,7 @@ description = "Python PyPI template library — companion to the .NET NuGetLibra readme = "README.md" license = { text = "MIT" } authors = [{ name = "Pieter Viljoen" }] -requires-python = ">=3.13" +requires-python = ">=3.14" keywords = ["template", "pypi", "library"] classifiers = [ "Development Status :: 4 - Beta", @@ -17,7 +17,7 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Libraries :: Python Modules", ] dynamic = ["version"] @@ -46,7 +46,7 @@ include = ["src", "tests", "README.md", "pyproject.toml"] [tool.ruff] line-length = 120 -target-version = "py313" +target-version = "py314" [tool.ruff.lint] select = [ @@ -66,9 +66,15 @@ docstring-code-format = true [tool.pyright] include = ["src", "tests"] -strict = ["src/**"] -pythonVersion = "3.13" +pythonVersion = "3.14" typeCheckingMode = "standard" +# Per-path strictness: `strict` accepts directory paths and applies +# strict-mode type checking to everything under them — equivalent to +# placing `# pyright: strict` at the top of every file in those dirs. +# Public library surface (`src/`) needs tight types; tests inherit the +# standard mode set above (fixtures, mocks, and parametrize args are +# commonly looser). +strict = ["src"] [tool.pytest.ini_options] minversion = "8.0" diff --git a/PyPiLibrary/uv.lock b/PyPiLibrary/uv.lock index 56ab5bfe..cb734f96 100644 --- a/PyPiLibrary/uv.lock +++ b/PyPiLibrary/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.13" +requires-python = ">=3.14" [[package]] name = "colorama" From ab57f01cc96212399459fe0e3a421f772193c87c Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 13:31:49 -0700 Subject: [PATCH 24/24] Add PEP 561 py.typed; alphabetize README defs; canonical PyPI URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review on PR #64. - Add empty ``py.typed`` marker under ``src/ptr727_projecttemplate_library/`` per PEP 561. Without this marker, downstream type checkers (mypy/pyright in consumer projects) ignore the inline type information shipped in the wheel — the package is treated as untyped, which negates the strict-mode types this template enforces. Hatchling auto-includes files in the package directory, so the wheel inventory now contains ``ptr727_projecttemplate_library/py.typed`` alongside the modules (verified via ``uv build`` + zip listing). - Alphabetize the README reference-definition blocks. AGENTS.md says "alphabetize the reference definitions block" but the existing blocks were grouped by topic, not sorted. The two blocks (``Shields links`` and ``3rd Party tool links``) are now each alphabetized within themselves; the ``devcontainers-link`` entry moved into the 3rd-party block where it belongs (it''s a marketplace link, not a shields URL). - Switch the ``publish-pypi`` job''s ``environment.url`` from the short form ``https://pypi.org/p/ptr727-projecttemplate-library`` to the canonical ``/project/`` form used elsewhere in the repo (README ``[pypi-link]``). The short form redirects, but consistency matters for the Actions UI and avoids a future broken link if PyPI ever changes the short-form behavior. --- .github/workflows/publish-release.yml | 2 +- .../ptr727_projecttemplate_library/py.typed | 0 README.md | 37 ++++++++----------- 3 files changed, 16 insertions(+), 23 deletions(-) create mode 100644 PyPiLibrary/src/ptr727_projecttemplate_library/py.typed diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 81c2322f..c3ba0e21 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest environment: name: pypi - url: https://pypi.org/p/ptr727-projecttemplate-library + url: https://pypi.org/project/ptr727-projecttemplate-library/ # When a `permissions:` block is present, every scope not listed # collapses to `none`. The job needs three things explicitly: # - `id-token: write` for Trusted Publishing's OIDC exchange diff --git a/PyPiLibrary/src/ptr727_projecttemplate_library/py.typed b/PyPiLibrary/src/ptr727_projecttemplate_library/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/README.md b/README.md index 2d6f1181..3b646feb 100644 --- a/README.md +++ b/README.md @@ -483,46 +483,39 @@ Licensed under the [MIT License][license-link]\ - Bot generated pull requests (codegen, dependabot) always checkout from and merge into `main` directly. - If `develop` falls behind after a bot merge, re-run codegen or rebase `develop` on `main` before merging `develop` to `main`. - + -[github-link]: https://github.com/ptr727/ProjectTemplate [actions-link]: https://github.com/ptr727/ProjectTemplate/actions -[discussions-link]: https://github.com/ptr727/ProjectTemplate/discussions [commits-link]: https://github.com/ptr727/ProjectTemplate/commits/main -[issues-link]: https://github.com/ptr727/ProjectTemplate/issues -[releases-link]: https://github.com/ptr727/ProjectTemplate/releases - -[license-link]: ./LICENSE -[license-shield]: https://img.shields.io/github/license/ptr727/ProjectTemplate?label=License - +[discussions-link]: https://github.com/ptr727/ProjectTemplate/discussions [docker-link]: https://hub.docker.com/r/ptr727/projecttemplate -[dockerlatestversion-shield]: https://img.shields.io/docker/v/ptr727/projecttemplate/latest?label=Docker%20Latest&logo=docker -[dockerdevelopversion-shield]: https://img.shields.io/docker/v/ptr727/projecttemplate/develop?label=Docker%20Develop&logo=docker&color=orange [dockerbuildstatus-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/ProjectTemplate/publish-periodic-docker-release.yml?logo=github&label=Docker%20Build - +[dockerdevelopversion-shield]: https://img.shields.io/docker/v/ptr727/projecttemplate/develop?label=Docker%20Develop&logo=docker&color=orange +[dockerlatestversion-shield]: https://img.shields.io/docker/v/ptr727/projecttemplate/latest?label=Docker%20Latest&logo=docker +[github-link]: https://github.com/ptr727/ProjectTemplate +[issues-link]: https://github.com/ptr727/ProjectTemplate/issues [lastbuild-shield]: https://byob.yarr.is/ptr727/ProjectTemplate/lastbuild [lastcommit-shield]: https://img.shields.io/github/last-commit/ptr727/ProjectTemplate?logo=github&label=Last%20Commit - -[releaseversion-shield]: https://img.shields.io/github/v/release/ptr727/ProjectTemplate?logo=github&label=GitHub%20Release -[prereleaseversion-shield]: https://img.shields.io/github/v/release/ptr727/ProjectTemplate?include_prereleases&label=GitHub%20Pre-Release&logo=github -[releasebuildstatus-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/ProjectTemplate/publish-release.yml?logo=github&label=Releases%20Build - +[license-link]: ./LICENSE +[license-shield]: https://img.shields.io/github/license/ptr727/ProjectTemplate?label=License [nuget-link]: https://www.nuget.org/packages/ptr727.ProjectTemplate.Library/ -[nugetreleaseversion-shield]: https://img.shields.io/nuget/v/ptr727.ProjectTemplate.Library?logo=nuget&label=NuGet%20Release [nugetprereleaseversion-shield]: https://img.shields.io/nuget/vpre/ptr727.ProjectTemplate.Library?logo=nuget&&label=NuGet%20Pre-Release&color=orange - +[nugetreleaseversion-shield]: https://img.shields.io/nuget/v/ptr727.ProjectTemplate.Library?logo=nuget&label=NuGet%20Release +[prereleaseversion-shield]: https://img.shields.io/github/v/release/ptr727/ProjectTemplate?include_prereleases&label=GitHub%20Pre-Release&logo=github [pypi-link]: https://pypi.org/project/ptr727-projecttemplate-library/ [pypireleaseversion-shield]: https://img.shields.io/pypi/v/ptr727-projecttemplate-library?logo=pypi&label=PyPI%20Release +[releasebuildstatus-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/ProjectTemplate/publish-release.yml?logo=github&label=Releases%20Build +[releases-link]: https://github.com/ptr727/ProjectTemplate/releases +[releaseversion-shield]: https://img.shields.io/github/v/release/ptr727/ProjectTemplate?logo=github&label=GitHub%20Release - - -[devcontainers-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers + [apininjas-link]: https://api-ninjas.com/api/quotes [awesomeassertions-link]: https://awesomeassertions.org/ [byob-link]: https://github.com/marketplace/actions/bring-your-own-badge [createpr-link]: https://github.com/marketplace/actions/create-pull-request [csharpier-link]: https://csharpier.com/ +[devcontainers-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers [ghactions-link]: https://github.com/actions [ghautocommit-link]: https://github.com/marketplace/actions/git-auto-commit [ghdependabot-link]: https://github.com/dependabot