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/dotnet/devcontainer.json b/.devcontainer/dotnet/devcontainer.json new file mode 100644 index 00000000..782f46be --- /dev/null +++ b/.devcontainer/dotnet/devcontainer.json @@ -0,0 +1,58 @@ +{ + "name": "ProjectTemplate (.NET)", + "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", + + // 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", + + // 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 DotNet.code-workspace. + "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" + ] + } + } +} 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/python/post-create.sh b/.devcontainer/python/post-create.sh new file mode 100755 index 00000000..340b7376 --- /dev/null +++ b/.devcontainer/python/post-create.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 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 +# 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 + +# 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/.github/copilot-instructions.md b/.github/copilot-instructions.md index f61f6468..b1d48d8c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,356 +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: - -- **Library**: Core library with AOT compatibility (`Library.csproj`) -- **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.Library; - ``` - -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.` - - Library: `ptr727.ProjectTemplate.Library` - - 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.Library; - - 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**: Library 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 -- `Library/` - Core reusable 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. - -## 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-library-task.yml b/.github/workflows/build-nugetlibrary-task.yml similarity index 73% rename from .github/workflows/build-library-task.yml rename to .github/workflows/build-nugetlibrary-task.yml index 6bd1279b..3bdc4f8b 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,10 @@ 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 \ + set -euo pipefail + dotnet build ./NuGetLibrary/NuGetLibrary.csproj \ -property:OutputPath=${{ runner.temp }}/publish/ \ -property:PackageOutputPath=${{ runner.temp }}/publish/ \ --configuration ${{ github.ref_name == 'main' && 'Release' || 'Debug' }} \ @@ -52,17 +53,18 @@ 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 }} \ --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-pypilibrary-task.yml b/.github/workflows/build-pypilibrary-task.yml new file mode 100644 index 00000000..71477b32 --- /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/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. + 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 20130c2c..bf5d90c8 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -26,14 +26,23 @@ 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 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,30 +60,37 @@ 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-pypilibrary, build-executable, build-docker] steps: - name: Checkout code step uses: actions/checkout@v6 - - name: Download library build artifacts job + - name: Download NuGet library build artifacts 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 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/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index ac31c745..9914784a 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@cef221092ed1bacb1cc03d23a2d87d1d172e277b # 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/run-codegen-app-pull-request-task.yml b/.github/workflows/run-codegen-app-pull-request-task.yml index 6937a6cb..31dfcb53 100644 --- a/.github/workflows/run-codegen-app-pull-request-task.yml +++ b/.github/workflows/run-codegen-app-pull-request-task.yml @@ -43,14 +43,15 @@ 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 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..77b99b52 100644 --- a/.github/workflows/run-codegen-pull-request-task.yml +++ b/.github/workflows/run-codegen-pull-request-task.yml @@ -33,14 +33,15 @@ 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 husky install dotnet csharpier format --log-level=debug . git status @@ -61,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 8bbf5e06..dd87da69 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -25,8 +25,9 @@ jobs: [ test-release ] if: always() steps: - - name: Check workflow results + - 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/.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/.gitignore b/.gitignore index 193ff244..0db2f387 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,13 @@ .artifacts .DS_Store *.user +.claude + +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +dist/ +.pytest_cache/ +.ruff_cache/ +.pyright/ 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 009e6b3a..00000000 --- a/.husky/task-runner.json +++ /dev/null @@ -1,32 +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" - ] - } - ] -} 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 ac3a0281..97a8cdc5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,79 +1,161 @@ -# 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. - -## 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 - -- **Library**: Core reusable 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 + +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. + +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) + +- **.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/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 + +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`. + - 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/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/CODESTYLE.md b/CODESTYLE.md index 8037f473..1252bc9b 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 @@ -16,8 +20,9 @@ - `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 @@ -28,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 @@ -40,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/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/ProjectTemplate.code-workspace b/DotNet.code-workspace similarity index 79% rename from ProjectTemplate.code-workspace rename to DotNet.code-workspace index 0ad06819..e5c6245f 100644 --- a/ProjectTemplate.code-workspace +++ b/DotNet.code-workspace @@ -1,92 +1,105 @@ -{ - "folders": [ - { - "path": "." - } - ], - "settings": { - "cSpell.words": [ - "accessibilities", - "Allman", - "apikey", - "autoremove", - "buildcache", - "buildtransitive", - "Buildx", - "codegen", - "contentfiles", - "csdevkit", - "datebadge", - "davidanson", - "debuglevel", - "dockerhub", - "dotnettools", - "dryrun", - "Emby", - "finalizers", - "gpgsign", - "gruntfuggly", - "Jellyfin", - "lastbuild", - "LINQ", - "logfile", - "nameof", - "nbgv", - "nektos", - "Nerdbank", - "noninteractive", - "othercommand", - "Pieter", - "ProjectTemplate", - "quoteoftheday", - "resharper", - "Rubba", - "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", - ] - } -} +{ + "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/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.slnx b/ProjectTemplate.slnx index c8e900d4..e751e769 100644 --- a/ProjectTemplate.slnx +++ b/ProjectTemplate.slnx @@ -3,7 +3,8 @@ - + + @@ -24,14 +25,14 @@ - + - + - + - + diff --git a/PyPiLibrary/CODESTYLE.md b/PyPiLibrary/CODESTYLE.md new file mode 100644 index 00000000..9e23de45 --- /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). 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 + +`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.code-workspace` file, and the `.devcontainer/python/` directory. 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/Python.code-workspace b/Python.code-workspace new file mode 100644 index 00000000..d554ecda --- /dev/null +++ b/Python.code-workspace @@ -0,0 +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" + ], + "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 ccfe3891..b732d7ae 100644 --- a/README.md +++ b/README.md @@ -9,7 +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. +- **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 @@ -211,6 +213,23 @@ Options: ## Development Environment Setup +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 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)**: + - **Install Developer Tools**: - Install [.NET SDK](https://dotnet.microsoft.com/en-us/download): @@ -248,12 +267,59 @@ Options: # 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**: @@ -267,7 +333,6 @@ Options: - [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] @@ -283,12 +348,13 @@ 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. -- [ ] 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. @@ -306,8 +372,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 @@ -321,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 @@ -341,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. @@ -469,46 +529,45 @@ 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/ +[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 -[nugetprereleaseversion-shield]: https://img.shields.io/nuget/vpre/ptr727.ProjectTemplate.Library?logo=nuget&&label=NuGet%20Pre-Release&color=orange +[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 - + [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 [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/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 @@ - + diff --git a/docs/devcontainer.md b/docs/devcontainer.md new file mode 100644 index 00000000..68336792 --- /dev/null +++ b/docs/devcontainer.md @@ -0,0 +1,111 @@ +# Devcontainer Setup + +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. + +| 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` | + +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 | +| --------- | ------ | ------- | +| `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 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. + +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 (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. | + +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 + +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`** — 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. + +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 | + +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 the language-appropriate checks. + +**Both containers** — verify SSH signing and `gh`: + +```shell +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 + +**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** (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 new file mode 100644 index 00000000..69d49a9d --- /dev/null +++ b/docs/host-setup.md @@ -0,0 +1,138 @@ +# 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/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. + +## 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 per-language .NET or 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..1a95e06a --- /dev/null +++ b/docs/ssh-signing.md @@ -0,0 +1,122 @@ +# 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 + +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 -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 the `user.signingkey` and `gpg.ssh.allowedSignersFile` configs aren't set yet. + +## 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.