From 55082f77550f0367543e2283259c66de4b030555 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 11:02:21 -0700 Subject: [PATCH 1/8] Pin release action SHA, target_commitish, agent conventions (#61) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Three independent hardenings ported from the `homeassistant-purpleair` sibling repo, plus convention docs for future PRs: - **Tag pinning on releases** — `softprops/action-gh-release` is pinned to commit SHA `3bb12739` (v2.6.2) with a trailing version comment, and `target_commitish: ${{ github.sha }}` is set explicitly. Without it, GitHub's REST API silently retargets the new tag to the repository default branch (`main`) — so prerelease tags created on `develop` pushes were attaching to `main`'s tip instead of the develop commit that built the artifact, leaving "Browse files" and `git checkout ` pointing at unrelated code. - **Merge-bot token comment** — documents the `GITHUB_TOKEN` vs App-token recursion-guard tradeoff. No behavior change. The codegen-app job already side-steps the recursion guard via the App identity; dependabot and PAT-codegen targets feed `main` where releases are dispatched manually, so the missing trigger is fine there. - **Conventions documented** — `AGENTS.md` and `.github/copilot-instructions.md` now codify PR title rules (≤72 chars, imperative, no `Co-Authored-By` unless asked, no release-bump magnitude in title), markdown style (reference-style links, alphabetized), workflow YAML conventions (action SHA pinning + version comment, naming, concurrency, `set -euo pipefail`, `if: >-` over `if: |`, boolean input mirroring), and the develop=squash / main=merge branching model. ## Test plan - [ ] CI green on the PR (test-pull-request workflow) - [ ] After merge to develop, observe the next prerelease tag on the Releases page — the tag's "Browse files" link should resolve to the same commit SHA that ran `publish-release.yml`, not `main`'s tip - [ ] Markdown renders correctly on the PR page (no MD025/MD031/MD032 warnings in editor preview) - [ ] Subsequent PRs in this refactor series follow the new title/body/workflow conventions --- .github/copilot-instructions.md | 24 +++++++++ .github/workflows/build-release-task.yml | 15 ++++-- .github/workflows/merge-bot-pull-request.yml | 14 +++++ AGENTS.md | 56 ++++++++++++++++++++ 4 files changed, 105 insertions(+), 4 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f61f6468..4632c89a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -330,6 +330,30 @@ The project includes comprehensive `.editorconfig` settings that enforce: - **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 diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 20130c2c..79ac2db7 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -58,23 +58,30 @@ jobs: - name: Checkout code step uses: actions/checkout@v6 - - name: Download library build artifacts job + - name: Download library build artifacts step uses: actions/download-artifact@v7 with: artifact-ids: ${{ needs.build-library.outputs.artifact-id }} path: ./Publish - - name: Download executable build artifacts job + - name: Download executable build artifacts step uses: actions/download-artifact@v7 with: artifact-ids: ${{ needs.build-executable.outputs.artifact-id }} path: ./Publish - - name: Create GitHub release job - uses: softprops/action-gh-release@v2 + # `target_commitish` MUST be set explicitly: softprops doesn't pass a + # default through, and GitHub's REST API then defaults the new tag to + # the repository's default branch (main). On `push: develop` runs the + # tag would land on main's tip instead of the develop commit that + # built the artifact, leaving "Browse files" and `git checkout ` + # pointing at unrelated code. + - name: Create GitHub release step + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: generate_release_notes: true tag_name: ${{ needs.get-version.outputs.SemVer2 }} + target_commitish: ${{ github.sha }} prerelease: ${{ github.ref_name != 'main' }} files: | LICENSE diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index 4e27cfd6..c35b1576 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -1,5 +1,19 @@ name: Merge bot pull request action +# Token strategy: +# GitHub's recursion guard blocks pushes authored by `GITHUB_TOKEN` from +# triggering further workflow runs. When `gh pr merge --auto --squash` runs +# under `secrets.GITHUB_TOKEN`, the resulting squash-merge push therefore +# does NOT fire `publish-release.yml`. +# +# All three jobs below merge bot PRs targeting `main` (per the per-job `if:` +# conditions). Releases on `main` are dispatched manually via +# `workflow_dispatch`, so the missing trigger is acceptable for all three +# paths. If a future bot PR targets `develop` (where releases auto-fire on +# push), this merge action would need to switch to an App token so the +# resulting push is authored by an App identity not blocked by the +# recursion guard. + on: pull_request: types: [opened, reopened, synchronize] diff --git a/AGENTS.md b/AGENTS.md index ac3a0281..8a49fcea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,62 @@ For comprehensive coding standards and detailed conventions, refer to [`.github/ - **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 From 934265bd12f242ef10e9ebe3e2d957c615b2beed Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 11:55:44 -0700 Subject: [PATCH 2/8] Rename Library project to NuGetLibrary (#62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Disambiguates the .NET project name from the upcoming Python `PyPiLibrary` sibling. 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`; the produced artifact name and 7z filename track the rename. ## Preserved on purpose - `ptr727.ProjectTemplate.Library` is **kept** so the published nupkg keeps its identity. No orphaned package, no 404 on existing consumers, no new badge URL needed. - README NuGet badges (`nuget-link`, `nugetreleaseversion-shield`, `nugetprereleaseversion-shield`) still point at `ptr727.ProjectTemplate.Library` and continue to work. - Class names `TemplateLibrary` and `StaticTemplateLibrary` are unchanged — they describe types, not the project. - `InternalsVisibleTo` declarations stay (`Console`, `Tests`, `Benchmarks` assembly names are unchanged). ## Files touched - `git mv Library/ NuGetLibrary/` (folder) - `git mv NuGetLibrary/Library.csproj NuGetLibrary/NuGetLibrary.csproj` - `git mv .github/workflows/build-library-task.yml .github/workflows/build-nugetlibrary-task.yml` — paths, job key `build-nugetlibrary`, artifact name `nugetlibrary-build`, zip `NuGetLibrary.7z` - `.github/workflows/build-release-task.yml` — caller updated: job key, `uses:`, `needs:` array, artifact-id reference - `Console/Console.csproj`, `Tests/Tests.csproj`, `Benchmarks/Benchmarks.csproj` — `` paths - `Console/Program.cs`, `Tests/LoggingTests.cs` — `using` statement - `NuGetLibrary/{Library,Options,LogOptions,Extensions}.cs` — namespace declaration - `NuGetLibrary/NuGetLibrary.csproj` — `RootNamespace` - `ProjectTemplate.slnx` — 4 project path entries + 1 GitHub Actions folder entry - `ProjectTemplate.code-workspace` — `cSpell.words` add `nugetlibrary` - `AGENTS.md`, `.github/copilot-instructions.md` — project list, namespace examples, structure section ## Test plan - [x] `dotnet build` — 0 warnings, 0 errors locally - [x] `dotnet test` — 15 passed, 0 failed - [x] `dotnet pack ./NuGetLibrary/NuGetLibrary.csproj` — produces `ptr727.ProjectTemplate.Library.1.0.0-pre.nupkg` (PackageId preserved) - [x] Repo-wide grep for `Library/Library.csproj`, `build-library-task` — zero remaining hits - [ ] CI green on the PR (test-pull-request workflow invokes the renamed reusable workflow) - [ ] After merge, next prerelease produces `NuGetLibrary.7z` artifact attached to the GitHub release --- .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 4632c89a..082a7b06 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 79ac2db7..27a0f6b8 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 step + - name: Download NuGet library build artifacts step 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 step diff --git a/AGENTS.md b/AGENTS.md index 8a49fcea..7028ac11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,7 @@ These conventions describe the target state. New and modified workflows must res ### 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 d313d3efbe2e50158cf9e5842bd011954a34e915 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 12:46:29 -0700 Subject: [PATCH 3/8] Add devcontainer + per-OS host and SSH signing docs (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a single unified [Dev Container](https://containers.dev/) hosting both the .NET 10 SDK and the upcoming Python `uv` toolchain, plus three focused docs files that decompose host setup, devcontainer setup, and SSH commit signing per-OS. On Windows, the **devcontainer flow** requires WSL2 (the bind-mounts use POSIX paths). The **host-install flow** in `README.md` supports native Windows via winget — see `docs/host-setup.md` for the per-flow scope. > **Stacked on [#62](https://github.com/ptr727/ProjectTemplate/pull/62) (NuGetLibrary rename)**. The diff against `develop` will show PR #62's changes until that PR merges; once it does, the diff here cleans up to just the devcontainer + docs work. ## Changes **New files**: - `.devcontainer/devcontainer.json` — base `mcr.microsoft.com/devcontainers/dotnet:1-10.0`, `gh` and `common-utils` features. Bind-mounts `~/.ssh/id_ed25519.pub` (read-only), `~/.config/git/allowed_signers` (read-only), `~/.config/gh` (read-write). `${localEnv:HOME}${localEnv:USERPROFILE}` form covers Linux/macOS and WSL2 hosts. Extension list mirrors `ProjectTemplate.code-workspace` `recommendations`. - `.devcontainer/post-create.sh` (executable, mode 100755) — installs `uv` from `astral.sh` (pinned to a specific version via the version-prefixed install URL; re-installs on version mismatch so the pin holds even when uv is already on PATH), runs `dotnet tool restore`, installs Husky.Net hooks, pre-syncs `PyPiLibrary` if it exists (guarded so this script works before PR 5 lands). - `docs/host-setup.md` — git identity, Ed25519 SSH key, `allowed_signers`, `gh auth login`, per-OS ssh-agent setup, verify checklist. - `docs/devcontainer.md` — bind-mount table, lifecycle commands, `gh` credential-store nuance (Keychain vs libsecret vs file), verify checklist, troubleshooting. - `docs/ssh-signing.md` — per-OS deltas (systemd ssh-agent on Linux, Apple Keychain on macOS, WSL2 caveats), `allowed_signers` format, devcontainer interaction, troubleshooting. **Modified**: - `README.md` — adds a "Recommended (devcontainer)" path to the Development Environment Setup section linking to the new docs; the existing host-install path stays. Template Project Setup section now points at the docs files for prerequisites instead of inlining them. - `ProjectTemplate.code-workspace` — adds `ms-python.python` and `charliermarsh.ruff` to `recommendations` (mirrors the devcontainer list); adds `unwantedRecommendations` for `ms-pyright.pyright` (deprecated; pyright is provided by Pylance which `ms-python.python` auto-installs), `ms-python.mypy-type-checker`, `ms-python.pylint`, `ms-python.flake8`, `ms-python.isort`, and `ms-python.black-formatter` so contributors aren't prompted to install tools that overlap with ruff + Pylance and would surface "could not find binary" connection errors against the venv. Also adds `astral`, `devcontainer`, `hatchling`, `Keychain`, `libsecret`, `onCreateCommand`, `postCreateCommand`, `pyproject`, `pypi`, `pypilibrary`, `pyright`, `ruff` to `cSpell.words`. ## Why a single unified container VS Code Dev Containers does not support per-folder containers in the same multi-root window — only a picker per session. A single image with both .NET and `uv` is the simplest mental model and lets downstream users delete the language they don't need by removing a feature line and a postCreateCommand step. See [VS Code Dev Containers docs](https://code.visualstudio.com/remote/advancedcontainers/connect-multiple-containers) for the limitation. ## Why bind-mount the public key, not the private key The private key never enters the container. Signing happens via the SSH agent socket forwarded by VS Code Dev Containers (`SSH_AUTH_SOCK`). The public key plus `allowed_signers` is enough for git to know which key to delegate signing to and to verify signatures in `git log --show-signature`. ## Why an `onCreateCommand` chown On macOS hosts the bind-mount surfaces `/home/vscode/.ssh` as root-owned inside the container, which would block `gh` from updating `known_hosts`. The chown is idempotent on Linux and WSL2 so it stays unconditional rather than gated on host detection. ## Test plan - [x] `gh pr create --base develop --head devcontainer-docs` succeeded - [ ] CI green on the PR (test-pull-request workflow) - [ ] Build the devcontainer on Linux, run `git -c gpg.format=ssh commit -S --allow-empty -m verify` inside, verify it signs - [ ] Build the devcontainer on macOS host, verify the `onCreateCommand` chown lets `gh auth status` work - [ ] Build on WSL2 host, verify behavior matches Linux - [ ] Confirm `recommendations` in `code-workspace` and `customizations.vscode.extensions` in `devcontainer.json` are identical - [ ] Markdown lint passes on the new docs files --- .devcontainer/devcontainer.json | 68 ++++++++++++++++ .devcontainer/post-create.sh | 55 +++++++++++++ ProjectTemplate.code-workspace | 22 ++++++ README.md | 17 +++- docs/devcontainer.md | 83 +++++++++++++++++++ docs/host-setup.md | 136 ++++++++++++++++++++++++++++++++ docs/ssh-signing.md | 120 ++++++++++++++++++++++++++++ 7 files changed, 499 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..e1d6597e --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,68 @@ +{ + "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 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 + // 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": { + // 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", + "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" + ] + } + } +} diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100755 index 00000000..b67cd9ab --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Install uv (Astral) for the Python sibling project. Idempotent — re-running +# 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. +# +# 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. +# +# 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" +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 + # 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 + +# 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. 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 && uv sync) +fi diff --git a/ProjectTemplate.code-workspace b/ProjectTemplate.code-workspace index b8714f6a..af8b355a 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,16 @@ "ms-dotnettools.csdevkit", "streetsidesoftware.code-spell-checker", "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" ] } } diff --git a/README.md b/README.md index ccfe3891..4fb9c8ef 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. `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)**: + +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..4a7f0c07 --- /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` | `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 | + +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 **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 | +|---|---|---|---| +| `~/.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..c48f228d --- /dev/null +++ b/docs/host-setup.md @@ -0,0 +1,136 @@ +# Host Setup + +Prerequisites for working with this repo locally — apply once per machine before opening the devcontainer or building outside one. + +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 + +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`. 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" ] || ! ssh-add -l >/dev/null 2>&1; 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 94a2d164e44a1eecefdcdcb939a6b4705c006d25 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 14:36:22 -0700 Subject: [PATCH 4/8] Add PyPiLibrary Python sibling project (#64) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a Python PyPi template project alongside the .NET `NuGetLibrary`, completing the polyglot template. Modern 2026 stack: hatchling backend, `uv` for env/deps/publish, ruff for lint+format, pyright for typing, pytest for tests, PyPI Trusted Publishing via OIDC. > **Stacked on [#62](https://github.com/ptr727/ProjectTemplate/pull/62) (NuGetLibrary rename, merged) and [#63](https://github.com/ptr727/ProjectTemplate/pull/63) (devcontainer + docs)**. Once #63 merges this PR's diff cleans up to just the PyPiLibrary work. ## Naming - Folder: `PyPiLibrary/` — qualifier on disk to disambiguate from `NuGetLibrary/` - Published PyPI name: `ptr727-projecttemplate-library` — **no `pypi` qualifier**, mirrors the NuGet identity - Python import name: `ptr727_projecttemplate_library` ## New tree ```text PyPiLibrary/ pyproject.toml # hatchling backend, ruff/pyright/pytest config, PEP 735 [dependency-groups] README.md # what this PyPi template is + uv quickstart + Trusted Publisher setup uv.lock # committed for reproducible CI src/ ptr727_projecttemplate_library/ __init__.py _version.py # __version__ = "0.0.0" placeholder; see README.md "Template Adoption" for version-scheme options example.py # trivial greet() function tests/ test_example.py # 3 tests ``` ## Workflow plumbing The split-by-purpose layout was chosen so `id-token: write` (required by Trusted Publishing) only has to be granted on the entry-point job, not propagated through reusable-workflow chains: - **New** `.github/workflows/build-pypilibrary-task.yml` — reusable workflow that **only builds**: setup uv (pinned to `0.11.8` to match the devcontainer), sync, ruff check, ruff format --check, pyright, pytest, `uv build`, upload artifact. **No publish job here**, no `id-token: write`. - **Modified** `.github/workflows/build-release-task.yml` — adds a `build-pypilibrary` job calling the new reusable workflow, includes it in the `github-release` `needs:` list. Build runs unconditionally (matches the always-validate-on-PR semantic of the rest of the workflow). **No `pypi: bool` input** — would require id-token propagation through the test-pull-request chain (and triggered `startup_failure`, fixed in 4c939f6). - **Modified** `.github/workflows/publish-release.yml` — adds 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 only here**, alongside the explicit `contents: read` and `actions: read` needed for `actions/download-artifact`. Uses `skip-existing: true` so the placeholder `0.0.0` version doesn't fail the workflow on repeated pushes. - **Modified** `.github/workflows/test-release-task.yml` — no PyPi-specific input needed; the build runs as part of the existing chain. ## Other plumbing - **`.github/dependabot.yml`** — adds `package-ecosystem: "uv"` targeting `/PyPiLibrary` with the `pypi-deps` group label. Existing `nuget` and `github-actions` blocks normalized to standard two-space indentation under `updates:`. - **`.husky/task-runner.json`** — adds `Ruff Format` and `Ruff Check` tasks scoped to `PyPiLibrary/**/*.py`. Both pass `${staged}` as positional args via `bash -c "..." -- ${staged}` so paths with spaces survive; both gate on `command -v uv` so a `.cs`-only commit on a contributor without uv installed doesn't fail. - **`ProjectTemplate.code-workspace`** — adds Python format-on-save with ruff, the `[python]` formatter binding, `python.terminal.activateEnvironment: false`. No hard-coded venv paths (those caused "could not find ruff binary" popups before `uv sync` ran). Adds `unwantedRecommendations` for mypy / pylint / flake8 / isort / black / standalone pyright so contributors aren't prompted to install tools that overlap with ruff and Pylance. - **`ProjectTemplate.slnx`** — adds `build-pypilibrary-task.yml` to the GitHub Actions folder. - **`.gitignore`** — adds `.venv/`, `dist/`, `__pycache__/`, `*.py[cod]`, `*.egg-info/`, `.pytest_cache/`, `.ruff_cache/`, `.pyright/`. - **`README.md`** — PyPI badge + link in the build/distribution and releases sections; template TODO list reminds the deriver to delete the unused language side. `gh` "pre-authenticated" wording softened to call out the Keychain/libsecret credential-store limitation. ## Trusted Publisher setup (one-time, on PyPI side) 1. PyPI → **Account settings** → **Publishing** → **Add a new pending publisher** - Project name: `ptr727-projecttemplate-library` - Owner: `ptr727` - Repo: `ProjectTemplate` - Workflow: `publish-release.yml` - Environment: `pypi` 2. GitHub repo → **Settings** → **Environments** → create `pypi` environment (optionally with required reviewers). The first successful release converts the pending publisher to a real publisher. ## Versioning gap `_version.py` ships with `__version__ = "0.0.0"`. Trusted Publishing with `skip-existing: true` means the workflow won't fail, but no new PyPI versions land until you wire `_version.py` to something that increments — see `PyPiLibrary/README.md` "Template Adoption" for the three usual options (hatch-vcs / version.json bridge / manual bumps). ## Test plan - [x] `uv sync` clean (host: uv 0.11.8) - [x] `uv run ruff check` — All checks passed - [x] `uv run ruff format --check` — clean - [x] `uv run pyright` — 0 errors, 0 warnings, 0 informations - [x] `uv run pytest` — 3 passed - [x] `uv build` — produces `ptr727_projecttemplate_library-0.0.0.tar.gz` and wheel - [x] `dotnet build` — 0 warnings, 0 errors - [x] `dotnet test` — 15 passed (no .NET regression) - [ ] CI green on the PR (test-release-task exercises ruff, pyright, pytest, uv build via the same reusable workflow that publish uses) - [ ] After Trusted Publisher is configured on PyPI and a real version scheme is wired in `_version.py`, next merge to `main` smoke-tests the publish path --- .github/copilot-instructions.md | 519 +++++------------- .github/dependabot.yml | 56 +- .github/workflows/build-pypilibrary-task.yml | 71 +++ .github/workflows/build-release-task.yml | 11 +- .github/workflows/publish-release.yml | 41 ++ .github/workflows/test-pull-request.yml | 2 +- .gitignore | 10 + .husky/task-runner.json | 26 + AGENTS.md | 292 +++++----- CODESTYLE.md | 6 +- ProjectTemplate.code-workspace | 8 + ProjectTemplate.slnx | 1 + PyPiLibrary/CODESTYLE.md | 125 +++++ PyPiLibrary/README.md | 68 +++ PyPiLibrary/pyproject.toml | 82 +++ .../__init__.py | 6 + .../_version.py | 8 + .../ptr727_projecttemplate_library/example.py | 6 + .../ptr727_projecttemplate_library/py.typed | 0 PyPiLibrary/tests/__init__.py | 0 PyPiLibrary/tests/test_example.py | 16 + PyPiLibrary/uv.lock | 140 +++++ README.md | 45 +- docs/devcontainer.md | 2 +- docs/host-setup.md | 2 + docs/ssh-signing.md | 6 +- 26 files changed, 982 insertions(+), 567 deletions(-) create mode 100644 .github/workflows/build-pypilibrary-task.yml create mode 100644 PyPiLibrary/CODESTYLE.md 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/src/ptr727_projecttemplate_library/py.typed 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/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/.github/dependabot.yml b/.github/dependabot.yml index 21030f41..66d6bd2b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,23 +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: - - "*" +# 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/.github/workflows/build-pypilibrary-task.yml b/.github/workflows/build-pypilibrary-task.yml new file mode 100644 index 00000000..f7419fc7 --- /dev/null +++ b/.github/workflows/build-pypilibrary-task.yml @@ -0,0 +1,71 @@ +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: + outputs: + artifact-name: + value: ${{ jobs.build-pypilibrary.outputs.artifact-name }} + 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-name: pypilibrary-build + 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: + # 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" + + - 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/* diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 27a0f6b8..bf5d90c8 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -34,6 +34,15 @@ 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 + build-executable: name: Build executable job uses: ./.github/workflows/build-executable-task.yml @@ -51,7 +60,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..c3ba0e21 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -23,6 +23,47 @@ jobs: nuget: true dockerhub: true + publish-pypi: + name: Publish PyPI library job + needs: [create-release] + runs-on: ubuntu-latest + environment: + name: pypi + 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 + # (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: + + - 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 + # 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 needs: [create-release] 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/.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..c974f397 100644 --- a/.husky/task-runner.json +++ b/.husky/task-runner.json @@ -27,6 +27,32 @@ "include": [ "**/*.cs" ] + }, + { + "name": "Ruff Format", + "command": "bash", + "args": [ + "-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" + ] + }, + { + "name": "Ruff Check", + "command": "bash", + "args": [ + "-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/AGENTS.md b/AGENTS.md index 7028ac11..7262f2e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,135 +1,157 @@ -# 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. +- **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. +- **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/ProjectTemplate.code-workspace b/ProjectTemplate.code-workspace index af8b355a..cfc1aa56 100644 --- a/ProjectTemplate.code-workspace +++ b/ProjectTemplate.code-workspace @@ -86,6 +86,14 @@ "editor.formatOnSave": true, "editor.defaultFormatter": "csharpier.csharpier-vscode" }, + "[python]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + } + }, + "python.terminal.activateEnvironment": false, "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/CODESTYLE.md b/PyPiLibrary/CODESTYLE.md new file mode 100644 index 00000000..c8ae2bd2 --- /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. diff --git a/PyPiLibrary/README.md b/PyPiLibrary/README.md new file mode 100644 index 00000000..f099a2b7 --- /dev/null +++ b/PyPiLibrary/README.md @@ -0,0 +1,68 @@ +# 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` 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): + +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. +- **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 `.github/dependabot.yml`. diff --git a/PyPiLibrary/pyproject.toml b/PyPiLibrary/pyproject.toml new file mode 100644 index 00000000..f73f737c --- /dev/null +++ b/PyPiLibrary/pyproject.toml @@ -0,0 +1,82 @@ +[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.14" +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.14", + "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 = "py314" + +[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"] +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" +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..8c603871 --- /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/src/ptr727_projecttemplate_library/py.typed b/PyPiLibrary/src/ptr727_projecttemplate_library/py.typed new file mode 100644 index 00000000..e69de29b 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..cb734f96 --- /dev/null +++ b/PyPiLibrary/uv.lock @@ -0,0 +1,140 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[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 4fb9c8ef..3b646feb 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 Packages][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`, `.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. @@ -480,43 +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 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 | 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 7f404437ff0f074ce5cc92280db5709770f195b3 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 19:31:06 -0700 Subject: [PATCH 5/8] Split Devcontainer and Workspace per Language and Drop Husky (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Replace the single `.devcontainer/` + `ProjectTemplate.code-workspace` with per-language pairs: `.devcontainer/dotnet/` + `DotNet.code-workspace` and `.devcontainer/python/` + `Python.code-workspace`. Each container ships only one toolchain so editor surface, recommended extensions, and `postCreateCommand` match the language being worked on. - Drop the Husky.Net hooks framework. Hooks are repo-global, so any framework forces its runtime (`dotnet` for Husky, Python for pre-commit) into the wrong-language container. CI already runs `dotnet csharpier check`, `dotnet format --verify-no-changes`, `ruff check`, `ruff format --check`, and `pyright` on every PR — that is the lint backstop. - Document opt-in hooks per language in README under "Optional: enable git hooks locally" so downstream forks can wire up Husky.Net (.NET) or pre-commit (Python) if they want pre-commit checks locally. ## Notes - Windows host Python work is intentionally unsupported: the Python extension caches `.venv/bin/python` (Linux layout) against a venv whose actual Windows path is `.venv\Scripts\python.exe`, breaking Ruff. The python devcontainer is the supported path. - Git rename detection labels `ProjectTemplate.code-workspace -> Python.code-workspace` because `Python.code-workspace` happened to be the closer content match. Conceptually `DotNet.code-workspace` is the descendant; the actual file contents are correct in both. ## Test plan - [ ] `git diff main...HEAD` review. - [ ] Open `DotNet.code-workspace` on Windows host -> `dotnet build` + `dotnet test` succeed without Husky. - [ ] Open `DotNet.code-workspace` -> Reopen in Container -> "dotnet" -> `dotnet build` succeeds; `which uv` returns nothing; `ms-python.python` not installed. - [ ] Open `Python.code-workspace` -> Reopen in Container -> "python" -> `cd PyPiLibrary && uv sync && uv run pytest` succeeds; `which dotnet` returns nothing; Ruff extension log shows interpreter at the container venv path. - [ ] Confirm CI green on this PR (csharpier check + dotnet format --verify-no-changes; ruff check + ruff format --check + pyright). - [ ] Push a deliberately mis-formatted `.cs` file on a throwaway branch -> .NET pipeline fails on csharpier; same with a `.py` file -> Python pipeline fails on ruff. (Verifies the lint backstop without hooks.) - [ ] Confirm `.git/hooks/pre-commit` does not exist after a fresh clone + devcontainer rebuild. --- .config/dotnet-tools.json | 7 - .devcontainer/{ => dotnet}/devcontainer.json | 22 +- .devcontainer/dotnet/post-create.sh | 5 + .devcontainer/python/devcontainer.json | 58 +++++ .devcontainer/{ => python}/post-create.sh | 10 +- .../run-codegen-app-pull-request-task.yml | 1 - .../run-codegen-pull-request-task.yml | 1 - .github/workflows/test-release-task.yml | 13 +- .husky/pre-commit | 4 - .husky/task-runner.json | 58 ----- .vscode/tasks.json | 16 -- AGENTS.md | 12 +- CODESTYLE.md | 15 +- DotNet.code-workspace | 105 ++++++++ PyPiLibrary/CODESTYLE.md | 4 +- ...te.code-workspace => Python.code-workspace | 236 +++++++++--------- README.md | 77 ++++-- docs/devcontainer.md | 64 +++-- docs/host-setup.md | 4 +- 19 files changed, 422 insertions(+), 290 deletions(-) rename .devcontainer/{ => dotnet}/devcontainer.json (63%) create mode 100755 .devcontainer/dotnet/post-create.sh create mode 100644 .devcontainer/python/devcontainer.json rename .devcontainer/{ => python}/post-create.sh (83%) delete mode 100755 .husky/pre-commit delete mode 100644 .husky/task-runner.json create mode 100644 DotNet.code-workspace rename ProjectTemplate.code-workspace => Python.code-workspace (82%) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 2b10359b..fd29f5f6 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -9,13 +9,6 @@ ], "rollForward": false }, - "husky": { - "version": "0.9.1", - "commands": [ - "husky" - ], - "rollForward": false - }, "dotnet-outdated-tool": { "version": "4.7.1", "commands": [ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/dotnet/devcontainer.json similarity index 63% rename from .devcontainer/devcontainer.json rename to .devcontainer/dotnet/devcontainer.json index e1d6597e..782f46be 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/dotnet/devcontainer.json @@ -1,5 +1,5 @@ { - "name": "ProjectTemplate", + "name": "ProjectTemplate (.NET)", "image": "mcr.microsoft.com/devcontainers/dotnet:1-10.0", "features": { @@ -29,27 +29,19 @@ ], "remoteUser": "vscode", - // 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 // 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", + // Restore .NET local tools (csharpier, dotnet-outdated). No git hooks are + // installed by default — see README "Optional: enable git hooks locally". + "postCreateCommand": ".devcontainer/dotnet/post-create.sh", "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). + // Mirror of `recommendations` in DotNet.code-workspace. "extensions": [ "csharpier.csharpier-vscode", "davidanson.vscode-markdownlint", @@ -59,9 +51,7 @@ "ms-azuretools.vscode-docker", "ms-dotnettools.csdevkit", "streetsidesoftware.code-spell-checker", - "yzhang.markdown-all-in-one", - "ms-python.python", - "charliermarsh.ruff" + "yzhang.markdown-all-in-one" ] } } diff --git a/.devcontainer/dotnet/post-create.sh b/.devcontainer/dotnet/post-create.sh new file mode 100755 index 00000000..fcb6609a --- /dev/null +++ b/.devcontainer/dotnet/post-create.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Restore the .NET local-tool manifest (csharpier, dotnet-outdated). +dotnet tool restore diff --git a/.devcontainer/python/devcontainer.json b/.devcontainer/python/devcontainer.json new file mode 100644 index 00000000..b2c6d7e7 --- /dev/null +++ b/.devcontainer/python/devcontainer.json @@ -0,0 +1,58 @@ +{ + "name": "ProjectTemplate (Python)", + "image": "mcr.microsoft.com/devcontainers/python:1-3.14-bookworm", + + "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", + + // 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 pinned uv and pre-warm the PyPiLibrary venv. No git hooks are + // installed by default — see README "Optional: enable git hooks locally". + "postCreateCommand": ".devcontainer/python/post-create.sh", + + "customizations": { + "vscode": { + // Mirror of `recommendations` in Python.code-workspace. + "extensions": [ + "charliermarsh.ruff", + "davidanson.vscode-markdownlint", + "editorconfig.editorconfig", + "github.vscode-github-actions", + "gruntfuggly.todo-tree", + "ms-azuretools.vscode-docker", + "ms-python.python", + "streetsidesoftware.code-spell-checker", + "yzhang.markdown-all-in-one" + ] + } + } +} diff --git a/.devcontainer/post-create.sh b/.devcontainer/python/post-create.sh similarity index 83% rename from .devcontainer/post-create.sh rename to .devcontainer/python/post-create.sh index b67cd9ab..340b7376 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/python/post-create.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# Install uv (Astral) for the Python sibling project. Idempotent — re-running +# Install uv (Astral) for the Python project. Idempotent — re-running # 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 @@ -40,14 +40,6 @@ if [[ "$installed_uv_version" != "$UV_VERSION" ]]; then 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. 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 diff --git a/.github/workflows/run-codegen-app-pull-request-task.yml b/.github/workflows/run-codegen-app-pull-request-task.yml index 6937a6cb..5557f5eb 100644 --- a/.github/workflows/run-codegen-app-pull-request-task.yml +++ b/.github/workflows/run-codegen-app-pull-request-task.yml @@ -50,7 +50,6 @@ jobs: - name: Format code step run: | dotnet tool restore - dotnet husky install dotnet csharpier format --log-level=debug . git status diff --git a/.github/workflows/run-codegen-pull-request-task.yml b/.github/workflows/run-codegen-pull-request-task.yml index 7fce22d4..88dfc3b2 100644 --- a/.github/workflows/run-codegen-pull-request-task.yml +++ b/.github/workflows/run-codegen-pull-request-task.yml @@ -40,7 +40,6 @@ jobs: - name: Format code step run: | dotnet tool restore - dotnet husky install dotnet csharpier format --log-level=debug . git status diff --git a/.github/workflows/test-release-task.yml b/.github/workflows/test-release-task.yml index bb9ce6a5..d58bbb58 100644 --- a/.github/workflows/test-release-task.yml +++ b/.github/workflows/test-release-task.yml @@ -20,11 +20,14 @@ jobs: - name: Checkout code step uses: actions/checkout@v6 - - name: Check code style step - run: | - dotnet tool restore - dotnet husky install - dotnet husky run + - name: Restore .NET local tools step + run: dotnet tool restore + + - name: Check formatting with CSharpier step + run: dotnet csharpier check . + + - name: Verify .NET style with dotnet format step + run: dotnet format style --verify-no-changes --severity=info --verbosity=detailed - name: Run unit tests step run: dotnet test diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index 818853f5..00000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -dotnet husky run diff --git a/.husky/task-runner.json b/.husky/task-runner.json deleted file mode 100644 index c974f397..00000000 --- a/.husky/task-runner.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "$schema": "https://alirezanet.github.io/Husky.Net/schema.json", - "tasks": [ - { - "name": "CSharpier Format", - "command": "dotnet", - "args": [ - "csharpier", - "format", - "--log-level=debug", - "${staged}" - ], - "include": [ - "**/*.cs" - ] - }, - { - "name": ".Net Format", - "command": "dotnet", - "args": [ - "format", - "style", - "--verify-no-changes", - "--severity=info", - "--verbosity=detailed" - ], - "include": [ - "**/*.cs" - ] - }, - { - "name": "Ruff Format", - "command": "bash", - "args": [ - "-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" - ] - }, - { - "name": "Ruff Check", - "command": "bash", - "args": [ - "-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/.vscode/tasks.json b/.vscode/tasks.json index 15724458..8ef976e5 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -77,22 +77,6 @@ "clear": false } }, - { - "label": "Husky.Net Run", - "type": "process", - "command": "dotnet", - "args": [ - "husky", - "run" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, { "label": ".Net Benchmark", "type": "process", diff --git a/AGENTS.md b/AGENTS.md index 7262f2e0..97a8cdc5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,9 +121,11 @@ These conventions describe the target state. New and modified workflows must res ## 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 repo ships **two per-language devcontainers** so each container carries only one toolchain (and the matching VS Code extensions): [`.devcontainer/dotnet/devcontainer.json`](./.devcontainer/dotnet/devcontainer.json) (.NET 10 SDK) and [`.devcontainer/python/devcontainer.json`](./.devcontainer/python/devcontainer.json) (Python 3.14 + version-pinned `uv`). Open [`DotNet.code-workspace`](./DotNet.code-workspace) or [`Python.code-workspace`](./Python.code-workspace) and pick **Reopen in Container** to land in the matching one. -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. +Both containers bind-mount 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. + +Each devcontainer's `customizations.vscode.extensions` mirrors the `recommendations` array in its matching workspace file — when you add an extension to one, add it to the other. ## Project Structure (Languages) @@ -139,7 +141,8 @@ The unified container hosts both `.NET 10` (base image) and Python via uv (insta - **Style guide: [`PyPiLibrary/CODESTYLE.md`](./PyPiLibrary/CODESTYLE.md)**. - **Cross-cutting**: - `.github/` — workflows, Dependabot, Copilot instructions - - `.devcontainer/` — devcontainer config + post-create script + - `.devcontainer/dotnet/` and `.devcontainer/python/` — per-language devcontainer configs + post-create scripts + - `DotNet.code-workspace`, `Python.code-workspace` — per-language VS Code workspace files (each pairs with its devcontainer) - `.vscode/` — debug configs and tasks (.NET-oriented) - `Docker/` — multi-platform Linux container build for the Console app @@ -152,6 +155,7 @@ When you touch code in either language, also respect that language's style guide 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`. + - .NET: `dotnet tool restore`. - Python: `cd PyPiLibrary && uv sync`. + - Optional pre-commit hooks (off by default) — see README "Optional: enable git hooks locally". 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 75371b10..1252bc9b 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -20,8 +20,9 @@ Cross-cutting rules (PR titles, branching, US English, markdown style, workflow - `true` - Analyzer severity is `suggestion`, but all warnings must be addressed -3. **Husky.Net pre-commit hooks** - - Automated checks run before commits +3. **CI lint backstop** + - `dotnet csharpier check` and `dotnet format style --verify-no-changes` run on every PR + - No git hooks ship by default — see README "Optional: enable git hooks locally" to opt in ### Build Tasks @@ -32,7 +33,6 @@ Available VS Code tasks (use via `run_task` tool): - `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 ## Tooling and Editor @@ -44,15 +44,12 @@ Available VS Code tasks (use via `run_task` tool): 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 as a local dotnet tool (via `dotnet tool restore`) - - Install Git hooks locally with `dotnet husky install` - - Pre-commit hooks run formatting and style checks - -4. **Other tools** +3. **Other tools** - `dotnet-outdated-tool`: Dependency update checks - Nerdbank.GitVersioning: Version management +Pre-commit git hooks are not installed by default — CI is the lint backstop. See README "Optional: enable git hooks locally" if you want Husky.Net (or another runner) wired up locally. + ### Editor Baseline 1. **Required VS Code extensions**: CSharpier, markdownlint, CSpell diff --git a/DotNet.code-workspace b/DotNet.code-workspace new file mode 100644 index 00000000..77e15dea --- /dev/null +++ b/DotNet.code-workspace @@ -0,0 +1,105 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": { + "cSpell.words": [ + "accessibilities", + "Allman", + "apikey", + "astral", + "autoremove", + "buildcache", + "buildtransitive", + "Buildx", + "codegen", + "contentfiles", + "csdevkit", + "datebadge", + "davidanson", + "debuglevel", + "devcontainer", + "dockerhub", + "dotnettools", + "dryrun", + "Emby", + "finalizers", + "gpgsign", + "gruntfuggly", + "hatchling", + "Jellyfin", + "Keychain", + "lastbuild", + "libsecret", + "LINQ", + "logfile", + "nameof", + "nbgv", + "nugetlibrary", + "nektos", + "Nerdbank", + "noninteractive", + "onCreateCommand", + "othercommand", + "Pieter", + "postCreateCommand", + "ProjectTemplate", + "pyproject", + "pypi", + "pypilibrary", + "pyright", + "quoteoftheday", + "resharper", + "Rubba", + "ruff", + "Serilog", + "settingsfile", + "signingkey", + "slnx", + "snupkg", + "softprops", + "somecommand", + "tzdata", + "Viljoen", + "winget", + "xunit", + "yzhang" + ], + "dotnet.defaultSolution": "ProjectTemplate.slnx", + "files.trimTrailingWhitespace": true, + "files.trimTrailingWhitespaceInRegexAndStrings": false, + "diffEditor.ignoreTrimWhitespace": false, + "editor.renderWhitespace": "boundary", + "dotnet.formatting.organizeImportsOnFormat": true, + "csharp.debug.symbolOptions.searchNuGetOrgSymbolServer": true, + "csharp.debug.symbolOptions.searchMicrosoftSymbolServer": true, + "files.encoding": "utf8", + "[markdown]": { + "files.trimTrailingWhitespace": false, + }, + "[plaintext]": { + "files.trimTrailingWhitespace": false, + }, + "[csharp]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "csharpier.csharpier-vscode" + }, + "git.alwaysSignOff": true, + "markdown.extension.toc.levels": "2..3" + }, + "extensions": { + "recommendations": [ + "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" + ] + } +} diff --git a/PyPiLibrary/CODESTYLE.md b/PyPiLibrary/CODESTYLE.md index c8ae2bd2..9e23de45 100644 --- a/PyPiLibrary/CODESTYLE.md +++ b/PyPiLibrary/CODESTYLE.md @@ -31,7 +31,7 @@ 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. +CI runs the same commands via [`.github/workflows/build-pypilibrary-task.yml`](../.github/workflows/build-pypilibrary-task.yml). No git hooks ship by default — see the root README's "Optional: enable git hooks locally" section to wire up `pre-commit` for `ruff` and `pyright` if you want pre-commit checks locally. ## Layout @@ -122,4 +122,4 @@ Before pushing or opening a PR: ## 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. +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.code-workspace` file, and the `.devcontainer/python/` directory. The .NET side stands alone. diff --git a/ProjectTemplate.code-workspace b/Python.code-workspace similarity index 82% rename from ProjectTemplate.code-workspace rename to Python.code-workspace index cfc1aa56..eaf115fc 100644 --- a/ProjectTemplate.code-workspace +++ b/Python.code-workspace @@ -1,123 +1,113 @@ -{ - "folders": [ - { - "path": "." - } - ], - "settings": { - "cSpell.words": [ - "accessibilities", - "Allman", - "apikey", - "astral", - "autoremove", - "buildcache", - "buildtransitive", - "Buildx", - "codegen", - "contentfiles", - "csdevkit", - "datebadge", - "davidanson", - "debuglevel", - "devcontainer", - "dockerhub", - "dotnettools", - "dryrun", - "Emby", - "finalizers", - "gpgsign", - "gruntfuggly", - "hatchling", - "Jellyfin", - "Keychain", - "lastbuild", - "libsecret", - "LINQ", - "logfile", - "nameof", - "nbgv", - "nugetlibrary", - "nektos", - "Nerdbank", - "noninteractive", - "onCreateCommand", - "othercommand", - "Pieter", - "postCreateCommand", - "ProjectTemplate", - "pyproject", - "pypi", - "pypilibrary", - "pyright", - "quoteoftheday", - "resharper", - "Rubba", - "ruff", - "Serilog", - "settingsfile", - "signingkey", - "slnx", - "snupkg", - "softprops", - "somecommand", - "tzdata", - "Viljoen", - "winget", - "xunit", - "yzhang" - ], - "dotnet.defaultSolution": "ProjectTemplate.slnx", - "files.trimTrailingWhitespace": true, - "files.trimTrailingWhitespaceInRegexAndStrings": false, - "diffEditor.ignoreTrimWhitespace": false, - "editor.renderWhitespace": "boundary", - "dotnet.formatting.organizeImportsOnFormat": true, - "csharp.debug.symbolOptions.searchNuGetOrgSymbolServer": true, - "csharp.debug.symbolOptions.searchMicrosoftSymbolServer": true, - "files.encoding": "utf8", - "[markdown]": { - "files.trimTrailingWhitespace": false, - }, - "[plaintext]": { - "files.trimTrailingWhitespace": false, - }, - "[csharp]": { - "editor.formatOnSave": true, - "editor.defaultFormatter": "csharpier.csharpier-vscode" - }, - "[python]": { - "editor.formatOnSave": true, - "editor.defaultFormatter": "charliermarsh.ruff", - "editor.codeActionsOnSave": { - "source.organizeImports": "explicit" - } - }, - "python.terminal.activateEnvironment": false, - "git.alwaysSignOff": true, - "markdown.extension.toc.levels": "2..3" - }, - "extensions": { - "recommendations": [ - "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", - ], - "unwantedRecommendations": [ - "ms-pyright.pyright", - "ms-python.mypy-type-checker", - "ms-python.pylint", - "ms-python.flake8", - "ms-python.isort", - "ms-python.black-formatter" - ] - } -} +{ + "folders": [ + { + "path": "." + } + ], + "settings": { + "cSpell.words": [ + "accessibilities", + "Allman", + "apikey", + "astral", + "autoremove", + "buildcache", + "buildtransitive", + "Buildx", + "codegen", + "contentfiles", + "csdevkit", + "datebadge", + "davidanson", + "debuglevel", + "devcontainer", + "dockerhub", + "dotnettools", + "dryrun", + "Emby", + "finalizers", + "gpgsign", + "gruntfuggly", + "hatchling", + "Jellyfin", + "Keychain", + "lastbuild", + "libsecret", + "LINQ", + "logfile", + "nameof", + "nbgv", + "nugetlibrary", + "nektos", + "Nerdbank", + "noninteractive", + "onCreateCommand", + "othercommand", + "Pieter", + "postCreateCommand", + "ProjectTemplate", + "pyproject", + "pypi", + "pypilibrary", + "pyright", + "quoteoftheday", + "resharper", + "Rubba", + "ruff", + "Serilog", + "settingsfile", + "signingkey", + "slnx", + "snupkg", + "softprops", + "somecommand", + "tzdata", + "Viljoen", + "winget", + "xunit", + "yzhang" + ], + "files.trimTrailingWhitespace": true, + "files.trimTrailingWhitespaceInRegexAndStrings": false, + "diffEditor.ignoreTrimWhitespace": false, + "editor.renderWhitespace": "boundary", + "files.encoding": "utf8", + "[markdown]": { + "files.trimTrailingWhitespace": false, + }, + "[plaintext]": { + "files.trimTrailingWhitespace": false, + }, + "[python]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + } + }, + "python.terminal.activateEnvironment": false, + "git.alwaysSignOff": true, + "markdown.extension.toc.levels": "2..3" + }, + "extensions": { + "recommendations": [ + "charliermarsh.ruff", + "davidanson.vscode-markdownlint", + "editorconfig.editorconfig", + "github.vscode-github-actions", + "gruntfuggly.todo-tree", + "ms-azuretools.vscode-docker", + "ms-python.python", + "streetsidesoftware.code-spell-checker", + "yzhang.markdown-all-in-one" + ], + "unwantedRecommendations": [ + "ms-pyright.pyright", + "ms-python.mypy-type-checker", + "ms-python.pylint", + "ms-python.flake8", + "ms-python.isort", + "ms-python.black-formatter" + ] + } +} diff --git a/README.md b/README.md index 3b646feb..fbd9acd2 100644 --- a/README.md +++ b/README.md @@ -213,13 +213,20 @@ 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. `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. +The recommended setup is one of the per-language [Dev Containers](./docs/devcontainer.md) under `.devcontainer/`: + +- **`.devcontainer/dotnet/`** — .NET 10 SDK + GitHub CLI. Pair with `DotNet.code-workspace`. +- **`.devcontainer/python/`** — Python 3.14 + `uv` + GitHub CLI. Pair with `Python.code-workspace`. + +Each container 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. + +> **Windows note**: Python work is intentionally not supported on the Windows host. The Python extension caches the Linux-layout `PyPiLibrary/.venv/bin/python` against a venv whose actual Windows path is `PyPiLibrary\.venv\Scripts\python.exe`, breaking Ruff. Use the python devcontainer. **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`. +2. Clone the repo, open the matching workspace (`DotNet.code-workspace` or `Python.code-workspace`) in VS Code with the [Dev Containers extension][devcontainers-link], and run **Reopen in Container** — pick the language flavor. +3. The `postCreateCommand` runs `dotnet tool restore` (.NET container) or installs `uv` and runs `uv sync` (Python container). No git hooks are installed by default — see "Optional: enable git hooks locally" below. **Alternative (host install)**: @@ -260,12 +267,59 @@ The recommended setup is the [Dev Container](./docs/devcontainer.md) — a singl # Initialize dotnet tools cd ./[Project] dotnet tool restore - dotnet husky install ``` - - Open `[Project].code-workspace` in Visual Studio Code. + - Open `DotNet.code-workspace` (or `Python.code-workspace`) in Visual Studio Code. - Open `[Project].slnx` in Visual Studio. +**Optional: enable git hooks locally**: + +Hooks are not shipped with the template — CI is the lint backstop. Opt in per language if you want pre-commit checks locally. + +- **For .NET work** — install [Husky.Net][huskynet-link]: + + ```shell + dotnet new tool-manifest # if no tool manifest exists yet + dotnet tool install Husky + dotnet husky install + dotnet husky add pre-commit -c "dotnet csharpier check . && dotnet format style --verify-no-changes --severity=info" + ``` + +- **For Python work** — install [pre-commit][precommit-link]: + + ```shell + uv tool install pre-commit + pre-commit install + ``` + + Sample `.pre-commit-config.yaml` (the hooks shell into `PyPiLibrary/` because the uv project — and therefore ruff/pyright and their configs — lives there, not at the repo root): + + ```yaml + repos: + - repo: local + hooks: + - id: ruff-check + name: ruff check + entry: uv run --directory PyPiLibrary ruff check + language: system + files: ^PyPiLibrary/.*\.py$ + pass_filenames: false + - id: ruff-format + name: ruff format + entry: uv run --directory PyPiLibrary ruff format --check + language: system + files: ^PyPiLibrary/.*\.py$ + pass_filenames: false + - id: pyright + name: pyright + entry: uv run --directory PyPiLibrary pyright + language: system + files: ^PyPiLibrary/.*\.py$ + pass_filenames: false + ``` + +CI runs these same checks on every PR, so hooks are purely a local convenience. + ## 3rd Party Tools **3rd Party tools used in this project**: @@ -279,7 +333,6 @@ The recommended setup is the [Dev Container](./docs/devcontainer.md) — a singl - [Git Auto Commit][ghautocommit-link] - [GitHub Actions][ghactions-link] - [GitHub Dependabot][ghdependabot-link] -- [Husky.Net][huskynet-link] - [Nerdbank.GitVersioning][nerbankgitversion-link] - [Serilog][serilog-link] - [xUnit.Net][xunit-link] @@ -300,8 +353,8 @@ Licensed under the [MIT License][license-link]\ - [ ] 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. -- [ ] Rename `ProjectTemplate.code-workspace` to `[NewProject].code-workspace` and `ProjectTemplate.slnx` to `[NewProject].slnx`. -- [ ] Open `[NewProject].code-workspace` workspace in Visual Studio Code. +- [ ] Rename `DotNet.code-workspace` to `[NewProject].code-workspace` and `Python.code-workspace` to `[NewProject]-Python.code-workspace`, or delete the workspace for the language you don't need. Rename `ProjectTemplate.slnx` to `[NewProject].slnx`. +- [ ] Open the workspace file for the language you kept (`[NewProject].code-workspace` and/or `[NewProject]-Python.code-workspace`) in Visual Studio Code. - [ ] Delete any projects and associated actions that will not be used, update dependencies in actions to remove deleted actions. - [ ] Rename projects to match the naming, update `.slnx` and `.csproj` files, and update actions to match the naming. - [ ] Update the `namespace` in `.cs` and `.csproj` files to match the naming. @@ -335,7 +388,6 @@ Licensed under the [MIT License][license-link]\ # Init dotnet tools dotnet tool restore - dotnet husky install # Update dotnet tools dotnet tool update --all @@ -355,13 +407,7 @@ Licensed under the [MIT License][license-link]\ # Init dotnet tools dotnet new tool-manifest dotnet tool install csharpier - dotnet tool install husky dotnet tool install dotnet-outdated-tool - dotnet husky install - dotnet husky add pre-commit -c "dotnet husky run" - - # Make sure pre-commit is executable on Linux - chmod +x ./.husky/pre-commit ``` - Use `first-branch` for all the initial project setup and testing. @@ -522,5 +568,6 @@ Licensed under the [MIT License][license-link]\ [ghrelease-link]: https://github.com/marketplace/actions/gh-release [huskynet-link]: https://alirezanet.github.io/Husky.Net/ [nerbankgitversion-link]: https://github.com/marketplace/actions/nerdbank-gitversioning +[precommit-link]: https://pre-commit.com/ [serilog-link]: https://serilog.net/ [xunit-link]: https://xunit.net/ diff --git a/docs/devcontainer.md b/docs/devcontainer.md index e09e19c6..68336792 100644 --- a/docs/devcontainer.md +++ b/docs/devcontainer.md @@ -1,27 +1,34 @@ # 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**. +The repo ships **two per-language [Dev Containers](https://containers.dev/)** so each container carries only one toolchain, one extension surface, and one `postCreateCommand` — matching the language you'll actually edit. -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. +| Workspace | Devcontainer | Image | Toolchain | +| --------- | ------------ | ----- | --------- | +| [`DotNet.code-workspace`](../DotNet.code-workspace) | [`.devcontainer/dotnet/devcontainer.json`](../.devcontainer/dotnet/devcontainer.json) | `mcr.microsoft.com/devcontainers/dotnet:1-10.0` | .NET 10 SDK | +| [`Python.code-workspace`](../Python.code-workspace) | [`.devcontainer/python/devcontainer.json`](../.devcontainer/python/devcontainer.json) | `mcr.microsoft.com/devcontainers/python:1-3.14-bookworm` | Python 3.14 + version-pinned `uv` | -## What's Inside +Open the workspace file matching the language you want, install the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers), 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, neither devcontainer will be able to sign commits. + +## What's Inside (Both Containers) | 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 | +| --------- | ------ | ------- | | `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 | +| VS Code extensions | `customizations.vscode.extensions` in each `devcontainer.json` | Mirrors the matching workspace's `recommendations` so the container has the same tooling | + +The .NET container additionally ships the `csharpier`/`dotnet-outdated` local tools (restored by `.devcontainer/dotnet/post-create.sh`). The Python container additionally ships `uv` (installed by `.devcontainer/python/post-create.sh` from a version-pinned URL) and pre-syncs the `PyPiLibrary` venv. -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. +Each devcontainer's extension list and the matching workspace's `recommendations` are kept identical — when you add an extension to one, add it to the other. -## Bind Mounts +## Bind Mounts (Both Containers) 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 | -|---|---|---|---| +| --------- | -------------- | ---- | ------- | | `~/.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. | @@ -32,19 +39,23 @@ The SSH agent is forwarded automatically by the Dev Containers extension via `SS ## Lifecycle Commands -`devcontainer.json` runs two scripts at well-defined points: +Both `devcontainer.json` files run 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. +- **`postCreateCommand`** — language-specific: + - .NET: `.devcontainer/dotnet/post-create.sh` — runs `dotnet tool restore` (csharpier, dotnet-outdated). + - Python: `.devcontainer/python/post-create.sh` — installs the pinned `uv` and pre-syncs `PyPiLibrary` if it exists. -To force them to run again after editing the script: VS Code → Command Palette → **Dev Containers: Rebuild Container**. +Re-runs of either are idempotent. No git hooks are installed by default — see the README's **Optional: enable git hooks locally** section if you want pre-commit checks. + +To force them to run again after editing a 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 | @@ -58,18 +69,33 @@ The file-token path is slightly less secure than Keychain/libsecret because it's ## Verify the Devcontainer -After **Reopen in Container** finishes, run: +After **Reopen in Container** finishes, run the language-appropriate checks. + +**Both containers** — verify SSH signing and `gh`: ```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 ..." +``` + +**.NET container** (`DotNet.code-workspace` → Reopen in Container → "dotnet"): + +```shell +dotnet --version # 10.x +which uv # nothing — uv intentionally absent dotnet build # 0 warnings, 0 errors dotnet test # tests pass ``` +**Python container** (`Python.code-workspace` → Reopen in Container → "python"): + +```shell +uv --version # uv 0.x +which dotnet # nothing — dotnet intentionally absent +cd PyPiLibrary && uv sync && uv run pytest # 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 @@ -78,6 +104,8 @@ If `git -c gpg.format=ssh commit -S` errors with `signing failed: no allowed sig **`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`. +**uv not on `PATH` after rebuild** (Python container) — 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. + +**Wrong-language work in the wrong container** — The `.NET` container has no `uv` and no Python extensions; the Python container has no `dotnet` SDK and no C# extensions. This is intentional — open the matching workspace and rebuild rather than installing the missing toolchain ad hoc. diff --git a/docs/host-setup.md b/docs/host-setup.md index 0cf46fb7..69d49a9d 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -6,7 +6,7 @@ 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. +- **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/dotnet/devcontainer.json` and `.devcontainer/python/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. @@ -134,5 +134,5 @@ 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. +- [Devcontainer setup](./devcontainer.md) — open the repo in the per-language .NET or Python devcontainer. - [SSH commit signing](./ssh-signing.md) — per-OS setup details, verification, and troubleshooting. From 25c338b9a612fc39be34874f1e01101dbbbe53af Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 20:34:03 -0700 Subject: [PATCH 6/8] Address Copilot review feedback on PR #66 - Python.code-workspace and DotNet.code-workspace: convert tab indentation to 4 spaces to match .editorconfig (DotNet bundled for consistency, same issue) - build-pypilibrary-task.yml: correct uv pin reference path to .devcontainer/python/post-create.sh - README.md: fix double-ampersand in nugetprereleaseversion-shield URL --- .github/workflows/build-pypilibrary-task.yml | 2 +- DotNet.code-workspace | 26 +++++++++--------- Python.code-workspace | 28 ++++++++++---------- README.md | 2 +- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/.github/workflows/build-pypilibrary-task.yml b/.github/workflows/build-pypilibrary-task.yml index f7419fc7..71477b32 100644 --- a/.github/workflows/build-pypilibrary-task.yml +++ b/.github/workflows/build-pypilibrary-task.yml @@ -37,7 +37,7 @@ 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` + # Pin uv to the same version as `.devcontainer/python/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. diff --git a/DotNet.code-workspace b/DotNet.code-workspace index 77e15dea..e5c6245f 100644 --- a/DotNet.code-workspace +++ b/DotNet.code-workspace @@ -1,11 +1,11 @@ { - "folders": [ - { - "path": "." - } - ], - "settings": { - "cSpell.words": [ + "folders": [ + { + "path": "." + } + ], + "settings": { + "cSpell.words": [ "accessibilities", "Allman", "apikey", @@ -67,7 +67,7 @@ "xunit", "yzhang" ], - "dotnet.defaultSolution": "ProjectTemplate.slnx", + "dotnet.defaultSolution": "ProjectTemplate.slnx", "files.trimTrailingWhitespace": true, "files.trimTrailingWhitespaceInRegexAndStrings": false, "diffEditor.ignoreTrimWhitespace": false, @@ -88,9 +88,9 @@ }, "git.alwaysSignOff": true, "markdown.extension.toc.levels": "2..3" - }, - "extensions": { - "recommendations": [ + }, + "extensions": { + "recommendations": [ "csharpier.csharpier-vscode", "davidanson.vscode-markdownlint", "editorconfig.editorconfig", @@ -100,6 +100,6 @@ "ms-dotnettools.csdevkit", "streetsidesoftware.code-spell-checker", "yzhang.markdown-all-in-one" - ] - } + ] + } } diff --git a/Python.code-workspace b/Python.code-workspace index eaf115fc..d554ecda 100644 --- a/Python.code-workspace +++ b/Python.code-workspace @@ -1,11 +1,11 @@ { - "folders": [ - { - "path": "." - } - ], - "settings": { - "cSpell.words": [ + "folders": [ + { + "path": "." + } + ], + "settings": { + "cSpell.words": [ "accessibilities", "Allman", "apikey", @@ -88,9 +88,9 @@ "python.terminal.activateEnvironment": false, "git.alwaysSignOff": true, "markdown.extension.toc.levels": "2..3" - }, - "extensions": { - "recommendations": [ + }, + "extensions": { + "recommendations": [ "charliermarsh.ruff", "davidanson.vscode-markdownlint", "editorconfig.editorconfig", @@ -100,14 +100,14 @@ "ms-python.python", "streetsidesoftware.code-spell-checker", "yzhang.markdown-all-in-one" - ], - "unwantedRecommendations": [ + ], + "unwantedRecommendations": [ "ms-pyright.pyright", "ms-python.mypy-type-checker", "ms-python.pylint", "ms-python.flake8", "ms-python.isort", "ms-python.black-formatter" - ] - } + ] + } } diff --git a/README.md b/README.md index fbd9acd2..3c857af2 100644 --- a/README.md +++ b/README.md @@ -545,7 +545,7 @@ Licensed under the [MIT License][license-link]\ [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/ -[nugetprereleaseversion-shield]: https://img.shields.io/nuget/vpre/ptr727.ProjectTemplate.Library?logo=nuget&&label=NuGet%20Pre-Release&color=orange +[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/ From 0da21b2704933fcccc3fd96518440e608587681b Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 11 May 2026 08:12:43 -0700 Subject: [PATCH 7/8] Add set -euo pipefail to multi-line workflow run blocks (#68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Addresses unresolved Copilot review threads on [PR #66](https://github.com/ptr727/ProjectTemplate/pull/66) (the active develop→main release PR) plus the same-class items flagged in Copilot's review-summary "low-confidence" comments. Per [AGENTS.md workflow conventions](https://github.com/ptr727/ProjectTemplate/blob/develop/AGENTS.md), multi-line bash `run:` blocks must start with `set -euo pipefail` so failures and undefined variables surface reliably. ### Workflows hardened - [`.github/workflows/run-codegen-pull-request-task.yml`](.github/workflows/run-codegen-pull-request-task.yml) — codegen, format, trigger-PR steps (3 blocks). _Inline Copilot threads on PR #66._ - [`.github/workflows/run-codegen-app-pull-request-task.yml`](.github/workflows/run-codegen-app-pull-request-task.yml) — codegen, format steps (2 blocks). _Inline Copilot threads on PR #66._ - [`.github/workflows/test-pull-request.yml`](.github/workflows/test-pull-request.yml) — Check-workflow-results step that defines `exit_on_result` (1 block). _Review-summary item._ - [`.github/workflows/build-nugetlibrary-task.yml`](.github/workflows/build-nugetlibrary-task.yml) — dotnet-build and dotnet-nuget-push steps (2 blocks). _Review-summary item._ ### .gitignore housekeeping - Adds `.claude` so the local Claude harness state directory does not appear in `git status`. - Drops the now-redundant `# Python / uv` section comment. ### Why a new PR (not a fresh commit on PR #66's branch) Standing project rule: no direct commits to `develop`. Once this PR merges to `develop`, PR #66's diff will absorb the same fixes automatically (since #66 is `develop` → `main`), and the threads there can be marked resolved. ## Test plan - [ ] CI passes on this PR. - [ ] After merge, confirm PR #66's diff now includes the four workflow fixes and the `.gitignore` entry, and Copilot threads on #66 can be resolved. --- .github/workflows/build-nugetlibrary-task.yml | 2 ++ .github/workflows/run-codegen-app-pull-request-task.yml | 2 ++ .github/workflows/run-codegen-pull-request-task.yml | 3 +++ .github/workflows/test-pull-request.yml | 1 + .gitignore | 2 +- 5 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-nugetlibrary-task.yml b/.github/workflows/build-nugetlibrary-task.yml index 0a5de77f..3bdc4f8b 100644 --- a/.github/workflows/build-nugetlibrary-task.yml +++ b/.github/workflows/build-nugetlibrary-task.yml @@ -39,6 +39,7 @@ jobs: - name: Build NuGet library project step run: | + set -euo pipefail dotnet build ./NuGetLibrary/NuGetLibrary.csproj \ -property:OutputPath=${{ runner.temp }}/publish/ \ -property:PackageOutputPath=${{ runner.temp }}/publish/ \ @@ -52,6 +53,7 @@ jobs: - name: Publish to NuGet.org step if: ${{ inputs.push }} run: | + set -euo pipefail dotnet nuget push ${{ runner.temp }}/publish/*.nupkg \ --source https://api.nuget.org/v3/index.json \ --api-key ${{ secrets.NUGET_API_KEY }} \ diff --git a/.github/workflows/run-codegen-app-pull-request-task.yml b/.github/workflows/run-codegen-app-pull-request-task.yml index 5557f5eb..31dfcb53 100644 --- a/.github/workflows/run-codegen-app-pull-request-task.yml +++ b/.github/workflows/run-codegen-app-pull-request-task.yml @@ -43,12 +43,14 @@ jobs: - name: Run codegen step run: | + set -euo pipefail dotnet run --project ./CodeGen/CodeGen.csproj -- \ --codepath ./CodeGen \ --apikey "${{ secrets.NINJA_API_KEY }}" - name: Format code step run: | + set -euo pipefail dotnet tool restore dotnet csharpier format --log-level=debug . git status diff --git a/.github/workflows/run-codegen-pull-request-task.yml b/.github/workflows/run-codegen-pull-request-task.yml index 88dfc3b2..77b99b52 100644 --- a/.github/workflows/run-codegen-pull-request-task.yml +++ b/.github/workflows/run-codegen-pull-request-task.yml @@ -33,12 +33,14 @@ jobs: - name: Run codegen step run: | + set -euo pipefail dotnet run --project ./CodeGen/CodeGen.csproj -- \ --codepath ./CodeGen \ --apikey "${{ secrets.NINJA_API_KEY }}" - name: Format code step run: | + set -euo pipefail dotnet tool restore dotnet csharpier format --log-level=debug . git status @@ -60,6 +62,7 @@ jobs: - name: Trigger PR workflows step if: steps.cpr.outputs.pull-request-number != '' run: | + set -euo pipefail PR="${{ steps.cpr.outputs.pull-request-number }}" gh pr close "$PR" gh pr reopen "$PR" diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 394ebc9a..dd87da69 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -27,6 +27,7 @@ jobs: steps: - name: Check workflow results step run: | + set -euo pipefail exit_on_result() { if [[ "$2" == "failure" || "$2" == "cancelled" ]]; then echo "Job '$1' failed or was cancelled." diff --git a/.gitignore b/.gitignore index 8e1e603e..0db2f387 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,8 @@ .artifacts .DS_Store *.user +.claude -# Python / uv __pycache__/ *.py[cod] *.egg-info/ From 79b34e01489a879d5e130cc16d46bf07109f920a Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 11 May 2026 11:23:13 -0700 Subject: [PATCH 8/8] Fix README bullet formatting and correct PyPI publish action SHA (#69) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Two follow-ups for [PR #66](https://github.com/ptr727/ProjectTemplate/pull/66) (the active `develop` → `main` release PR): ### 1. README.md — missing colons on NuGet/PyPI bullets Copilot review thread on PR #66 flagged that the NuGet and PyPI bullets in the **Build and Distribution** list are missing the colon after the bold label that every other bullet in the list uses. ```diff - - **NuGet Packages** [NuGet Packages][nuget-link] - .NET libraries published to NuGet.org. - - **PyPI Packages** [PyPI Packages][pypi-link] - Python library published to PyPI.org. + - **NuGet Packages**: [NuGet Packages][nuget-link] - .NET libraries published to NuGet.org. + - **PyPI Packages**: [PyPI Packages][pypi-link] - Python library published to PyPI.org. ``` ### 2. publish-release.yml — wrong SHA for `pypa/gh-action-pypi-publish@v1.14.0` The action was pinned to SHA `6733eb7d741f0b11ec6a39b58540dab7590f9b7d` with a `# v1.14.0` comment, but the upstream `v1.14.0` tag actually points at `cef221092ed1bacb1cc03d23a2d87d1d172e277b`. Because `ghcr.io/pypa/gh-action-pypi-publish` is tagged by release SHAs, no GHCR image existed at the wrong SHA — Docker bailed out with `manifest unknown`. This has caused **`Publish PyPI library job` to fail on every push to `develop`** since PR #64 added the action. CI evidence: - Run on `25c338b9` (May 4) — failed at the same step. - Run on `0da21b2` (today, the PR #68 merge) — failed at the same step. Fix: use the actual upstream `v1.14.0` SHA, keep the `# v1.14.0` comment. ## Why a new PR (not committed onto PR #66's branch) Standing project rule: no direct commits to `develop`. Once this PR merges to `develop`, PR #66's diff absorbs both fixes automatically (since #66 is `develop` → `main`), and the README Copilot thread on #66 can be resolved. ## Test plan - [ ] CI passes on this PR (in particular, the publish job won't run on a non-release push — but the resolution will only be observable on the next release push to `develop`). - [ ] After merge, PR #66's CI re-runs with both fixes and `Publish PyPI library job` succeeds. - [ ] PR #66 README Copilot thread can be replied/resolved citing this merge commit. Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/publish-release.yml | 2 +- README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index c3ba0e21..9914784a 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -54,7 +54,7 @@ jobs: path: ./dist - name: Publish to PyPI step - uses: pypa/gh-action-pypi-publish@6733eb7d741f0b11ec6a39b58540dab7590f9b7d # v1.14.0 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: packages-dir: ./dist # Skip rather than fail when the version already exists on PyPI. diff --git a/README.md b/README.md index 3c857af2..b732d7ae 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ C# .NET project template. - **Source Code**: [GitHub][github-link] - Source code, issues, discussions, and CI/CD pipelines. - **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 Packages][pypi-link] - Python library published to PyPI.org. +- **NuGet Packages**: [NuGet Packages][nuget-link] - .NET libraries published to NuGet.org. +- **PyPI Packages**: [PyPI Packages][pypi-link] - Python library published to PyPI.org. ### Build Status