From 37b10b44328436b3627c5ab8c5442470535c0cc3 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 08:22:42 -0700 Subject: [PATCH 01/10] Rename Library project to NuGetLibrary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disambiguate the .NET project name in preparation for adding a sibling Python PyPi project. The folder, csproj filename, RootNamespace, and namespace declarations move from `Library` to `NuGetLibrary`. The companion GitHub Actions reusable workflow `build-library-task.yml` is renamed to `build-nugetlibrary-task.yml` for the same reason; the artifact name and zip filename track the rename. The published NuGet package id is intentionally preserved as `ptr727.ProjectTemplate.Library` via an explicit `` element so existing consumers and the README NuGet badges continue to work without a new package or a 404 on the existing nuget.org URL. Class names `TemplateLibrary` and `StaticTemplateLibrary` are left alone — they describe the type, not the project, and are referenced by tests and benchmarks. dotnet build: 0 warnings, 0 errors. dotnet test: 15 passed, 0 failed. dotnet pack: produces ptr727.ProjectTemplate.Library.1.0.0-pre.nupkg as expected. --- .github/copilot-instructions.md | 12 +++++------ ...y-task.yml => build-nugetlibrary-task.yml} | 20 +++++++++---------- .github/workflows/build-release-task.yml | 12 +++++------ AGENTS.md | 2 +- Benchmarks/Benchmarks.csproj | 2 +- Console/Console.csproj | 2 +- Console/Program.cs | 2 +- {Library => NuGetLibrary}/.editorconfig | 0 {Library => NuGetLibrary}/Extensions.cs | 2 +- {Library => NuGetLibrary}/GlobalUsings.cs | 0 {Library => NuGetLibrary}/Library.cs | 2 +- {Library => NuGetLibrary}/LogOptions.cs | 2 +- .../NuGetLibrary.csproj | 2 +- {Library => NuGetLibrary}/Options.cs | 2 +- ProjectTemplate.code-workspace | 1 + ProjectTemplate.slnx | 10 +++++----- Tests/LoggingTests.cs | 2 +- Tests/Tests.csproj | 2 +- 18 files changed, 39 insertions(+), 38 deletions(-) rename .github/workflows/{build-library-task.yml => build-nugetlibrary-task.yml} (75%) rename {Library => NuGetLibrary}/.editorconfig (100%) rename {Library => NuGetLibrary}/Extensions.cs (92%) rename {Library => NuGetLibrary}/GlobalUsings.cs (100%) rename {Library => NuGetLibrary}/Library.cs (92%) rename {Library => NuGetLibrary}/LogOptions.cs (96%) rename Library/Library.csproj => NuGetLibrary/NuGetLibrary.csproj (94%) rename {Library => NuGetLibrary}/Options.cs (85%) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f61f6468..aa7fab8c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,7 +4,7 @@ **ProjectTemplate** is a C# .NET template project that demonstrates best practices for C# .NET development. The project includes: -- **Library**: Core library with AOT compatibility (`Library.csproj`) +- **NuGetLibrary**: Core .NET NuGet library with AOT compatibility (`NuGetLibrary.csproj`, published as `ptr727.ProjectTemplate.Library`) - **Console**: Command-line application using System.CommandLine (`Console.csproj`) - **Tests**: Unit tests using xUnit and AwesomeAssertions (`Tests.csproj`) - **Benchmarks**: Performance benchmarks using BenchmarkDotNet (`Benchmarks.csproj`) @@ -43,7 +43,7 @@ Available VS Code tasks (use via `run_task` tool): 1. **File-Scoped Namespaces**: Always use file-scoped namespaces ```csharp - namespace ptr727.ProjectTemplate.Library; + namespace ptr727.ProjectTemplate.NuGetLibrary; ``` 2. **Nullable Reference Types**: Enabled (`enable`) @@ -92,7 +92,7 @@ Available VS Code tasks (use via `run_task` tool): ``` 4. **Namespace**: Follow format `ptr727.ProjectTemplate.` - - Library: `ptr727.ProjectTemplate.Library` + - NuGetLibrary: `ptr727.ProjectTemplate.NuGetLibrary` - Console: `ptr727.ProjectTemplate.Console` - Tests: `ptr727.ProjectTemplate.Tests` @@ -110,7 +110,7 @@ Available VS Code tasks (use via `run_task` tool): ```csharp using System.CommandLine; using System.Runtime.CompilerServices; - using ptr727.ProjectTemplate.Library; + using ptr727.ProjectTemplate.NuGetLibrary; namespace ptr727.ProjectTemplate.Console; ``` @@ -211,7 +211,7 @@ Available VS Code tasks (use via `run_task` tool): 1. **Target Framework**: .NET 10.0 (`net10.0`) -2. **AOT Compatibility**: Library is AOT compatible +2. **AOT Compatibility**: NuGetLibrary is AOT compatible - `true` - `true` @@ -292,7 +292,7 @@ Available VS Code tasks (use via `run_task` tool): - `CodeGen/` - Code generation utilities (internal tooling) - `Console/` - Console/CLI application using System.CommandLine - `Docker/` - Docker build scripts and Dockerfile -- `Library/` - Core reusable library +- `NuGetLibrary/` - Core reusable .NET NuGet library (published as `ptr727.ProjectTemplate.Library`) - `Tests/` - Unit tests using xUnit and AwesomeAssertions ## Best Practices diff --git a/.github/workflows/build-library-task.yml b/.github/workflows/build-nugetlibrary-task.yml similarity index 75% rename from .github/workflows/build-library-task.yml rename to .github/workflows/build-nugetlibrary-task.yml index 6bd1279b..0a5de77f 100644 --- a/.github/workflows/build-library-task.yml +++ b/.github/workflows/build-nugetlibrary-task.yml @@ -1,9 +1,9 @@ -name: Build library task +name: Build NuGet library task on: workflow_call: inputs: - # Input to control whether to push the library to NuGet.org + # Input to control whether to push the NuGet library to NuGet.org push: required: false type: boolean @@ -11,7 +11,7 @@ on: outputs: # Output of the uploaded artifact id artifact-id: - value: ${{ jobs.build-library.outputs.artifact-id }} + value: ${{ jobs.build-nugetlibrary.outputs.artifact-id }} jobs: @@ -20,8 +20,8 @@ jobs: uses: ./.github/workflows/get-version-task.yml secrets: inherit - build-library: - name: Build library project job + build-nugetlibrary: + name: Build NuGet library project job runs-on: ubuntu-latest outputs: artifact-id: ${{ steps.artifact-upload-step.outputs.artifact-id }} @@ -37,9 +37,9 @@ jobs: - name: Checkout code step uses: actions/checkout@v6 - - name: Build library project step + - name: Build NuGet library project step run: | - dotnet build ./Library/Library.csproj \ + dotnet build ./NuGetLibrary/NuGetLibrary.csproj \ -property:OutputPath=${{ runner.temp }}/publish/ \ -property:PackageOutputPath=${{ runner.temp }}/publish/ \ --configuration ${{ github.ref_name == 'main' && 'Release' || 'Debug' }} \ @@ -58,11 +58,11 @@ jobs: --skip-duplicate - name: Zip output step - run: 7z a -t7z ${{ runner.temp }}/Library.7z ${{ runner.temp }}/publish/* + run: 7z a -t7z ${{ runner.temp }}/NuGetLibrary.7z ${{ runner.temp }}/publish/* - name: Upload build artifacts step id: artifact-upload-step uses: actions/upload-artifact@v6 with: - name: library-build - path: ${{ runner.temp }}/Library.7z + name: nugetlibrary-build + path: ${{ runner.temp }}/NuGetLibrary.7z diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 20130c2c..4bccfdff 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -26,9 +26,9 @@ jobs: uses: ./.github/workflows/get-version-task.yml secrets: inherit - build-library: - name: Build library job - uses: ./.github/workflows/build-library-task.yml + build-nugetlibrary: + name: Build NuGet library job + uses: ./.github/workflows/build-nugetlibrary-task.yml secrets: inherit with: # Conditional push to NuGet.org @@ -51,17 +51,17 @@ jobs: name: Publish GitHub release job if: ${{ inputs.github }} runs-on: ubuntu-latest - needs: [get-version, build-library, build-executable, build-docker] + needs: [get-version, build-nugetlibrary, build-executable, build-docker] steps: - name: Checkout code step uses: actions/checkout@v6 - - name: Download library build artifacts job + - name: Download NuGet library build artifacts job uses: actions/download-artifact@v7 with: - artifact-ids: ${{ needs.build-library.outputs.artifact-id }} + artifact-ids: ${{ needs.build-nugetlibrary.outputs.artifact-id }} path: ./Publish - name: Download executable build artifacts job diff --git a/AGENTS.md b/AGENTS.md index ac3a0281..62c4cc23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ For comprehensive coding standards and detailed conventions, refer to [`.github/ ### Project Structure -- **Library**: Core reusable library +- **NuGetLibrary**: Core reusable .NET NuGet library (published as `ptr727.ProjectTemplate.Library`) - **Console**: CLI application using System.CommandLine - **Tests**: xUnit with AwesomeAssertions (Arrange-Act-Assert pattern) - **Benchmarks**: BenchmarkDotNet performance measurements diff --git a/Benchmarks/Benchmarks.csproj b/Benchmarks/Benchmarks.csproj index ab6aa801..fbf90517 100644 --- a/Benchmarks/Benchmarks.csproj +++ b/Benchmarks/Benchmarks.csproj @@ -7,6 +7,6 @@ - + diff --git a/Console/Console.csproj b/Console/Console.csproj index bc4ea6ae..ae14c9a1 100644 --- a/Console/Console.csproj +++ b/Console/Console.csproj @@ -21,6 +21,6 @@ - + diff --git a/Console/Program.cs b/Console/Program.cs index 45752492..5df60863 100644 --- a/Console/Program.cs +++ b/Console/Program.cs @@ -1,4 +1,4 @@ -using ptr727.ProjectTemplate.Library; +using ptr727.ProjectTemplate.NuGetLibrary; namespace ptr727.ProjectTemplate.Console; diff --git a/Library/.editorconfig b/NuGetLibrary/.editorconfig similarity index 100% rename from Library/.editorconfig rename to NuGetLibrary/.editorconfig diff --git a/Library/Extensions.cs b/NuGetLibrary/Extensions.cs similarity index 92% rename from Library/Extensions.cs rename to NuGetLibrary/Extensions.cs index 59709850..bdf0e4cd 100644 --- a/Library/Extensions.cs +++ b/NuGetLibrary/Extensions.cs @@ -1,6 +1,6 @@ using System.Runtime.CompilerServices; -namespace ptr727.ProjectTemplate.Library; +namespace ptr727.ProjectTemplate.NuGetLibrary; internal static partial class LogExtensions { diff --git a/Library/GlobalUsings.cs b/NuGetLibrary/GlobalUsings.cs similarity index 100% rename from Library/GlobalUsings.cs rename to NuGetLibrary/GlobalUsings.cs diff --git a/Library/Library.cs b/NuGetLibrary/Library.cs similarity index 92% rename from Library/Library.cs rename to NuGetLibrary/Library.cs index 1f661bc7..b3cedf4f 100644 --- a/Library/Library.cs +++ b/NuGetLibrary/Library.cs @@ -1,4 +1,4 @@ -namespace ptr727.ProjectTemplate.Library; +namespace ptr727.ProjectTemplate.NuGetLibrary; /// /// Provides the primary library functionality. diff --git a/Library/LogOptions.cs b/NuGetLibrary/LogOptions.cs similarity index 96% rename from Library/LogOptions.cs rename to NuGetLibrary/LogOptions.cs index 9c63601c..382fc04a 100644 --- a/Library/LogOptions.cs +++ b/NuGetLibrary/LogOptions.cs @@ -1,4 +1,4 @@ -namespace ptr727.ProjectTemplate.Library; +namespace ptr727.ProjectTemplate.NuGetLibrary; /// /// Provides global logging configuration for the library. diff --git a/Library/Library.csproj b/NuGetLibrary/NuGetLibrary.csproj similarity index 94% rename from Library/Library.csproj rename to NuGetLibrary/NuGetLibrary.csproj index 6736346e..f08d6fd9 100644 --- a/Library/Library.csproj +++ b/NuGetLibrary/NuGetLibrary.csproj @@ -21,7 +21,7 @@ 1.0.0-pre true https://github.com/ptr727/ProjectTemplate - ptr727.ProjectTemplate.Library + ptr727.ProjectTemplate.NuGetLibrary snupkg 1.0.0.0 diff --git a/Library/Options.cs b/NuGetLibrary/Options.cs similarity index 85% rename from Library/Options.cs rename to NuGetLibrary/Options.cs index cef2b103..04d51a70 100644 --- a/Library/Options.cs +++ b/NuGetLibrary/Options.cs @@ -1,4 +1,4 @@ -namespace ptr727.ProjectTemplate.Library; +namespace ptr727.ProjectTemplate.NuGetLibrary; /// /// Options used to configure the library. diff --git a/ProjectTemplate.code-workspace b/ProjectTemplate.code-workspace index 0ad06819..b8714f6a 100644 --- a/ProjectTemplate.code-workspace +++ b/ProjectTemplate.code-workspace @@ -32,6 +32,7 @@ "logfile", "nameof", "nbgv", + "nugetlibrary", "nektos", "Nerdbank", "noninteractive", diff --git a/ProjectTemplate.slnx b/ProjectTemplate.slnx index c8e900d4..7e3cc9c6 100644 --- a/ProjectTemplate.slnx +++ b/ProjectTemplate.slnx @@ -3,7 +3,7 @@ - + @@ -24,14 +24,14 @@ - + - + - + - + diff --git a/Tests/LoggingTests.cs b/Tests/LoggingTests.cs index 9f43dca7..2d8f8a65 100644 --- a/Tests/LoggingTests.cs +++ b/Tests/LoggingTests.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using ptr727.ProjectTemplate.Library; +using ptr727.ProjectTemplate.NuGetLibrary; namespace ptr727.ProjectTemplate.Tests; diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 8c53010d..6cf10d59 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -14,6 +14,6 @@ - + From 5243352c8075cc7297c71f29db571629b0427c50 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 08:31:14 -0700 Subject: [PATCH 02/10] Add devcontainer + per-OS host and SSH signing docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single unified devcontainer hosts both .NET 10 and the upcoming PyPi sibling. Host SSH key, allowed_signers, and gh config are bind-mounted so commits sign correctly inside the container without the private key ever leaving the host. Lifecycle scripts install uv, restore .NET local tools, and set up Husky.Net hooks. Devcontainer extension list mirrors the workspace `recommendations` so the two stay in sync; Python tooling extensions are added now so they'll be installed when PyPiLibrary lands in PR 5. New docs decompose the verbose template setup section into focused files: - docs/host-setup.md: git identity, SSH key generation, allowed_signers, gh auth, per-OS ssh-agent / Keychain handling, verify checklist. - docs/devcontainer.md: bind-mount table, lifecycle commands, gh credential-store nuance (Keychain vs libsecret vs file), verify checklist, troubleshooting matrix. - docs/ssh-signing.md: per-OS deltas (systemd ssh-agent, Apple Keychain, WSL2 caveats), allowed_signers format, devcontainer interaction, troubleshooting matrix. README links to the new docs from the existing Development Environment Setup section; verbose host-setup snippets stay in the docs. Native Windows hosts are explicitly out-of-scope for the devcontainer — WSL2 is the supported Windows path, matching what Docker Desktop's WSL2 backend cleanly supports. --- .devcontainer/devcontainer.json | 62 +++++++++++++++ .devcontainer/post-create.sh | 20 +++++ ProjectTemplate.code-workspace | 15 ++++ README.md | 17 +++- docs/devcontainer.md | 83 ++++++++++++++++++++ docs/host-setup.md | 132 ++++++++++++++++++++++++++++++++ docs/ssh-signing.md | 120 +++++++++++++++++++++++++++++ 7 files changed, 447 insertions(+), 2 deletions(-) create mode 100644 .devcontainer/devcontainer.json create mode 100755 .devcontainer/post-create.sh create mode 100644 docs/devcontainer.md create mode 100644 docs/host-setup.md create mode 100644 docs/ssh-signing.md diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..a7f92bef --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,62 @@ +{ + "name": "ProjectTemplate", + "image": "mcr.microsoft.com/devcontainers/dotnet:1-10.0", + + "features": { + "ghcr.io/devcontainers/features/common-utils:2": {}, + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + + "mounts": [ + { + "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.ssh/id_ed25519.pub", + "target": "/home/vscode/.ssh/id_ed25519.pub", + "type": "bind", + "readonly": true + }, + { + "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.config/git/allowed_signers", + "target": "/home/vscode/.config/git/allowed_signers", + "type": "bind", + "readonly": true + }, + { + "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.config/gh", + "target": "/home/vscode/.config/gh", + "type": "bind", + "readonly": false + } + ], + + "remoteUser": "vscode", + "workspaceFolder": "/workspaces/ProjectTemplate", + + // The bind-mount on macOS hosts surfaces /home/vscode/.ssh as root-owned; + // chown it back so writes from inside the container (known_hosts updates + // by gh / git) land cleanly. Idempotent on Linux/WSL2. + "onCreateCommand": "sudo install -d -m 700 -o vscode -g vscode /home/vscode/.ssh", + + // Install uv for the Python sibling project, restore .NET local tools, + // and install the husky git hooks. uv is installed under $HOME/.local/bin + // and added to PATH by uv's install script. + "postCreateCommand": ".devcontainer/post-create.sh", + + "customizations": { + "vscode": { + "extensions": [ + "csharpier.csharpier-vscode", + "davidanson.vscode-markdownlint", + "editorconfig.editorconfig", + "github.vscode-github-actions", + "gruntfuggly.todo-tree", + "ms-azuretools.vscode-docker", + "ms-dotnettools.csdevkit", + "streetsidesoftware.code-spell-checker", + "yzhang.markdown-all-in-one", + "ms-python.python", + "charliermarsh.ruff", + "ms-pyright.pyright" + ] + } + } +} diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100755 index 00000000..cc43eafa --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Install uv (Astral) for the Python sibling project. Idempotent — re-running +# overwrites in place. Adds $HOME/.local/bin to PATH via uv's installer hook. +if ! command -v uv >/dev/null 2>&1; then + curl -LsSf https://astral.sh/uv/install.sh | sh +fi + +# Restore the .NET local-tool manifest (CSharpier, Husky.Net, dotnet-outdated). +dotnet tool restore + +# Install Husky.Net git hooks so commits run pre-commit checks. +dotnet husky install || true + +# Pre-warm uv environment for PyPiLibrary if it exists. Guarded so this script +# is safe before PyPiLibrary lands in the repo. +if [[ -f PyPiLibrary/pyproject.toml ]]; then + (cd PyPiLibrary && "$HOME/.local/bin/uv" sync) +fi diff --git a/ProjectTemplate.code-workspace b/ProjectTemplate.code-workspace index b8714f6a..12c72702 100644 --- a/ProjectTemplate.code-workspace +++ b/ProjectTemplate.code-workspace @@ -9,6 +9,7 @@ "accessibilities", "Allman", "apikey", + "astral", "autoremove", "buildcache", "buildtransitive", @@ -19,6 +20,7 @@ "datebadge", "davidanson", "debuglevel", + "devcontainer", "dockerhub", "dotnettools", "dryrun", @@ -26,8 +28,11 @@ "finalizers", "gpgsign", "gruntfuggly", + "hatchling", "Jellyfin", + "Keychain", "lastbuild", + "libsecret", "LINQ", "logfile", "nameof", @@ -36,12 +41,19 @@ "nektos", "Nerdbank", "noninteractive", + "onCreateCommand", "othercommand", "Pieter", + "postCreateCommand", "ProjectTemplate", + "pyproject", + "pypi", + "pypilibrary", + "pyright", "quoteoftheday", "resharper", "Rubba", + "ruff", "Serilog", "settingsfile", "signingkey", @@ -88,6 +100,9 @@ "ms-dotnettools.csdevkit", "streetsidesoftware.code-spell-checker", "yzhang.markdown-all-in-one", + "ms-python.python", + "charliermarsh.ruff", + "ms-pyright.pyright", ] } } diff --git a/README.md b/README.md index ccfe3891..1314dc0c 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,16 @@ Options: ## Development Environment Setup +The recommended setup is the [Dev Container](./docs/devcontainer.md) — a single image with the .NET 10 SDK, the `uv` Python toolchain, and the GitHub CLI. It bind-mounts your SSH public key, allowed-signers file, and `gh` config from the host so commits sign correctly and `gh` is pre-authenticated. + +**Recommended (devcontainer)**: + +1. Complete [host setup](./docs/host-setup.md) once per machine (git identity, SSH key, allowed_signers, `gh auth login`, [SSH commit signing](./docs/ssh-signing.md)). +2. Clone the repo, open in VS Code with the [Dev Containers extension][devcontainers-link], and run **Reopen in Container**. +3. The `postCreateCommand` runs `dotnet tool restore`, installs Husky.Net hooks, and installs `uv`. + +**Alternative (host install)**: + - **Install Developer Tools**: - Install [.NET SDK](https://dotnet.microsoft.com/en-us/download): @@ -306,8 +316,9 @@ Licensed under the [MIT License][license-link]\ #### Template - Git Setup - **⚠️ Prerequisites**: - - Configure git for SSH signing. - - Configure SSH forwarding for dev containers. + - Configure git for SSH signing — see [SSH commit signing](./docs/ssh-signing.md). + - Configure host prerequisites (SSH key, `allowed_signers`, `gh` auth) — see [host setup](./docs/host-setup.md). + - Configure SSH forwarding for dev containers — see [devcontainer setup](./docs/devcontainer.md). - Setup new project from template: ```shell @@ -499,6 +510,8 @@ Licensed under the [MIT License][license-link]\ +[devcontainers-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers + [apininjas-link]: https://api-ninjas.com/api/quotes [awesomeassertions-link]: https://awesomeassertions.org/ [byob-link]: https://github.com/marketplace/actions/bring-your-own-badge diff --git a/docs/devcontainer.md b/docs/devcontainer.md new file mode 100644 index 00000000..9252773a --- /dev/null +++ b/docs/devcontainer.md @@ -0,0 +1,83 @@ +# Devcontainer Setup + +The repo ships a single unified [Dev Container](https://containers.dev/) that hosts both the .NET 10 SDK and the Python `uv` toolchain. Open the repo in VS Code with the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) installed and pick **Reopen in Container**. + +Prerequisite: complete [host setup](./host-setup.md) first — without git config, an SSH key, and the allowed-signers file on the host, the devcontainer will not be able to sign commits. + +## What's Inside + +| Component | Source | Purpose | +|---|---|---| +| .NET 10 SDK | base image `mcr.microsoft.com/devcontainers/dotnet:1-10.0` | Build, test, pack the .NET projects | +| `uv` | `astral.sh/uv/install.sh` in `post-create.sh` | Python env, dependency, build, and publish manager for the PyPi sibling | +| `gh` CLI | `ghcr.io/devcontainers/features/github-cli:1` | Issue/PR/release management from inside the container | +| Common utilities | `ghcr.io/devcontainers/features/common-utils:2` | bash, curl, wget, sudo, `vscode` user | +| VS Code extensions | `customizations.vscode.extensions` in `devcontainer.json` | Mirrors `ProjectTemplate.code-workspace` recommendations so the container has the same tooling | + +The extension list in `.devcontainer/devcontainer.json` and the `recommendations` array in `ProjectTemplate.code-workspace` are kept identical — when you add an extension to one, add it to the other. + +## Bind Mounts + +The host SSH key, allowed-signers file, and `gh` config directory are mounted into the container so commits sign correctly and `gh` is pre-authenticated. + +| Host path | Container path | Mode | Purpose | +|---|---|---|---| +| `~/.ssh/id_ed25519.pub` | `/home/vscode/.ssh/id_ed25519.pub` | read-only | Public half of the SSH key. The private key never enters the container — SSH agent forwarding handles signing. | +| `~/.config/git/allowed_signers` | `/home/vscode/.config/git/allowed_signers` | read-only | Maps your email to your public key so `git verify-commit` and `git log --show-signature` work inside the container. | +| `~/.config/gh` | `/home/vscode/.config/gh` | read-write | `gh` CLI auth state shared with the host. See [`gh` credential store](#gh-credential-store) below. | + +VS Code Dev Containers automatically copies your host `~/.gitconfig` into the container at startup, so `user.name`, `user.email`, `user.signingkey`, `gpg.format`, and `commit.gpgsign` propagate without an explicit mount. + +The SSH agent is forwarded automatically by the Dev Containers extension via `SSH_AUTH_SOCK`, so signing works as long as the agent on the host has your key loaded. + +## Lifecycle Commands + +`devcontainer.json` runs two scripts at well-defined points: + +- **`onCreateCommand`** — `sudo install -d -m 700 -o vscode -g vscode /home/vscode/.ssh`. On macOS hosts the bind-mount surfaces `/home/vscode/.ssh` as root-owned, which would block writes from inside the container (e.g. `gh` updating `known_hosts`). This chown fixes it. Idempotent on Linux and WSL2. +- **`postCreateCommand`** — `.devcontainer/post-create.sh`, which installs `uv`, runs `dotnet tool restore`, installs Husky.Net hooks, and pre-syncs `PyPiLibrary` if it exists. Re-runs are idempotent. + +To force them to run again after editing the script: VS Code → Command Palette → **Dev Containers: Rebuild Container**. + +## `gh` Credential Store + +`gh auth login` writes its token to either a file or an OS credential store. Which one depends on your host: + +| Host | Default token storage | +|---|---| +| Linux | libsecret (gnome-keyring) when available, otherwise file | +| WSL2 | file (no native credential store) | +| macOS | macOS Keychain | + +The bind-mount of `~/.config/gh` covers the **file** case. If your host stores the token in Keychain or libsecret, the bind-mount carries the rest of `gh` config but **not the token** — the container will report "no authentication" until you either: + +1. Re-run `gh auth login` inside the container (writes a file token to the mounted directory), or +2. Skip in-container `gh` and run those commands on the host instead. + +The file-token path is slightly less secure than Keychain/libsecret because it's plaintext on disk inside `~/.config/gh/hosts.yml`. For most contributors that's an acceptable trade-off; if it isn't, use option 2. + +## Verify the Devcontainer + +After **Reopen in Container** finishes, run: + +```shell +dotnet --version # 10.x +uv --version # uv 0.x +gh auth status # logged in as you +git -c gpg.format=ssh commit -S --allow-empty -m "verify-signing" +git log --show-signature -1 # "Good 'git' signature for ..." +dotnet build # 0 warnings, 0 errors +dotnet test # tests pass +``` + +If `git -c gpg.format=ssh commit -S` errors with `signing failed: no allowed signers`, the bind-mount of `allowed_signers` is missing or the file on the host is empty — re-run the snippet in [host setup](./host-setup.md). + +## Troubleshooting + +**Permission denied writing to `~/.ssh/known_hosts` in the container** — The `onCreateCommand` should have chowned `~/.ssh` to `vscode`. Rebuild the container; if it persists, open a shell and run the same `sudo install -d -m 700 -o vscode -g vscode ~/.ssh` manually. + +**`git commit` fails with "no SSH agent socket"** — VS Code Dev Containers forwards `SSH_AUTH_SOCK` automatically, but only if the host has `ssh-agent` running with at least one key. Run `ssh-add -l` on the host first; if it says "could not open a connection to your authentication agent", start the agent (see [host setup](./host-setup.md)). + +**uv not on `PATH` after rebuild** — The post-create installer adds `~/.local/bin` to `PATH` via the user shell init scripts, which take effect on next shell. Either re-open the integrated terminal or `source ~/.bashrc`. + +**Container builds but extensions don't auto-install** — Make sure VS Code is using the Dev Containers extension (not "Remote - SSH" or "Remote - Tunnels"). The extension auto-install is keyed on `customizations.vscode.extensions` and only Dev Containers honors that. diff --git a/docs/host-setup.md b/docs/host-setup.md new file mode 100644 index 00000000..4e922730 --- /dev/null +++ b/docs/host-setup.md @@ -0,0 +1,132 @@ +# Host Setup + +Prerequisites for working with this repo locally — apply once per machine before opening the devcontainer or building outside one. + +Supported hosts: **Linux**, **WSL2 on Windows** (native Windows is not supported for the devcontainer; use WSL2), **macOS**. + +## Git Identity + +Configure your name and email — used for commit authorship. + +```shell +git config --global user.name "Your Name" +git config --global user.email "you@example.com" +``` + +## SSH Key + +Generate an Ed25519 SSH key for both authentication and commit signing. One key serves both roles. + +```shell +ssh-keygen -t ed25519 -C "you@example.com" -f ~/.ssh/id_ed25519 +``` + +Add the public key (`~/.ssh/id_ed25519.pub`) to GitHub twice: + +1. **Authentication key** — [GitHub → Settings → SSH and GPG keys → New SSH key](https://github.com/settings/keys), key type **Authentication Key**. +2. **Signing key** — same page, but **Signing Key** type. GitHub treats these independently even though it's the same public key. + +Test the auth key: + +```shell +ssh -T git@github.com +``` + +## SSH Config + +Tell SSH which key to use for `github.com`. Pick the snippet for your platform. + +### Linux / WSL2 + +```sshconfig +# ~/.ssh/config +Host github.com + HostName github.com + User git + IdentityFile ~/.ssh/id_ed25519 + IdentitiesOnly yes +``` + +Make sure ssh-agent is running and the key is loaded. On systemd-based distros: + +```shell +systemctl --user enable --now ssh-agent.socket +ssh-add ~/.ssh/id_ed25519 +``` + +For non-systemd shells, add to `~/.bashrc` or `~/.zshrc`: + +```shell +if [ -z "$SSH_AUTH_SOCK" ]; then + eval "$(ssh-agent -s)" >/dev/null + ssh-add ~/.ssh/id_ed25519 2>/dev/null +fi +``` + +### macOS + +```sshconfig +# ~/.ssh/config +Host github.com + HostName github.com + User git + IdentityFile ~/.ssh/id_ed25519 + IdentitiesOnly yes + UseKeychain yes + AddKeysToAgent yes +``` + +Load the key into the macOS Keychain so it's available without re-entering the passphrase: + +```shell +ssh-add --apple-use-keychain ~/.ssh/id_ed25519 +``` + +## Allowed Signers File + +Required for SSH signature verification by `git verify-commit` and similar tools. Without it git can sign commits but not verify them locally. + +```shell +mkdir -p ~/.config/git +echo "$(git config user.email) namespaces=\"git\" $(cat ~/.ssh/id_ed25519.pub)" \ + >> ~/.config/git/allowed_signers +git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers +``` + +## Configure Git for SSH Signing + +```shell +git config --global gpg.format ssh +git config --global user.signingkey ~/.ssh/id_ed25519.pub +git config --global commit.gpgsign true +git config --global tag.gpgsign true +``` + +See [SSH commit signing](./ssh-signing.md) for verification steps and per-OS troubleshooting. + +## GitHub CLI + +Install [`gh`](https://cli.github.com/) and authenticate. + +```shell +gh auth login --hostname github.com --git-protocol ssh +``` + +Choose the SSH key generated above when prompted. + +## Verify Host Setup + +```shell +git config --global --list | grep -E "user\.|signing|gpg\." +ssh-add -L # should list your public key +git -c gpg.format=ssh commit -S --allow-empty -m "verify-signing" +git log --show-signature -1 +gh auth status +``` + +If signing fails locally, the devcontainer will fail too — fix here first. + +## Next Steps + +- [Devcontainer setup](./devcontainer.md) — open the repo in the unified .NET + Python devcontainer. +- [SSH commit signing](./ssh-signing.md) — per-OS setup details, verification, and troubleshooting. diff --git a/docs/ssh-signing.md b/docs/ssh-signing.md new file mode 100644 index 00000000..be3b9c16 --- /dev/null +++ b/docs/ssh-signing.md @@ -0,0 +1,120 @@ +# SSH Commit Signing + +This repo enforces signed commits on `main` and `develop` via branch protection. Use SSH signing — one Ed25519 key serves both authentication (push) and signing. + +If you haven't generated a key and configured git yet, follow [host setup](./host-setup.md) first. + +## Why SSH Signing + +- **One key for everything**. Same `id_ed25519` you use for `git push` also signs commits. No GPG keyring, no expirations to chase. +- **GitHub native**. GitHub treats authentication and signing keys independently but accepts the same public key for both — register it twice on the SSH and GPG keys page. +- **Survives rotation cleanly**. When you rotate the key, update the `allowed_signers` file and old signatures still verify against the historical entry. + +## Configuration + +Per-user (host) git config — set once: + +```shell +git config --global gpg.format ssh +git config --global user.signingkey ~/.ssh/id_ed25519.pub +git config --global commit.gpgsign true +git config --global tag.gpgsign true +git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers +``` + +The `allowed_signers` file is what `git verify-commit` consults — without it, signatures sign fine but verify as "unknown signer". Format: + +```text +you@example.com namespaces="git" ssh-ed25519 AAAA... your_public_key_contents_here +``` + +Build it from your existing public key: + +```shell +mkdir -p ~/.config/git +echo "$(git config user.email) namespaces=\"git\" $(cat ~/.ssh/id_ed25519.pub)" \ + >> ~/.config/git/allowed_signers +``` + +If you collaborate with others, append their entries to the same file — each line maps an email to a public key. + +## Per-OS Setup Notes + +### Linux / WSL2 + +The SSH agent must be running for git to find the private key without prompting for the passphrase every commit. On systemd-based distros: + +```shell +systemctl --user enable --now ssh-agent.socket +ssh-add ~/.ssh/id_ed25519 +``` + +The agent socket lives at `$XDG_RUNTIME_DIR/ssh-agent.socket`. Make sure your shell exports `SSH_AUTH_SOCK` to point at it — most distros do this in `/etc/X11/Xsession.d` or systemd user environment. + +For shells without systemd integration, fall back to ad-hoc agent in `~/.bashrc` or `~/.zshrc`: + +```shell +if [ -z "$SSH_AUTH_SOCK" ] || ! ssh-add -l >/dev/null 2>&1; then + eval "$(ssh-agent -s)" >/dev/null + ssh-add ~/.ssh/id_ed25519 2>/dev/null +fi +``` + +WSL2 specifically: WSL inherits no agent from Windows. Run `ssh-agent` inside WSL; do not try to forward an agent from the Windows side. + +### macOS + +macOS has its own `ssh-agent` integrated with Keychain. To load your key once and have it persist across reboots: + +```shell +ssh-add --apple-use-keychain ~/.ssh/id_ed25519 +``` + +Add to `~/.ssh/config` so `ssh` and `git` use the Keychain-aware agent automatically: + +```sshconfig +Host github.com + HostName github.com + User git + IdentityFile ~/.ssh/id_ed25519 + IdentitiesOnly yes + UseKeychain yes + AddKeysToAgent yes +``` + +The Keychain prompt for the passphrase appears on first use after each reboot; subsequent sessions are silent. + +### Windows (without WSL) + +Native Windows is **not supported** for the devcontainer setup in this repo. Use WSL2 instead. The reason: VS Code Dev Containers needs a Linux-like file system for the bind-mounts to behave consistently, and Docker Desktop's WSL2 backend is the supported path. + +If you must work on Windows directly without a devcontainer, OpenSSH for Windows can sign with `gpg.format=ssh` — but the bind-mounted devcontainer setup expects Linux/WSL2 paths. + +## Verify Signing + +```shell +git commit --allow-empty -m "verify-signing" +git log --show-signature -1 +``` + +Expected output includes `Good "git" signature for `. If you see `error: gpg.ssh.allowedSignersFile needs to be configured` or `No signature`, walk back through the host setup — most often `allowed_signers` is missing the entry, or `commit.gpgsign` is not set. + +## Inside the Devcontainer + +The container picks up: + +- Your `~/.gitconfig` automatically (VS Code Dev Containers copies it on start). +- The `~/.ssh/id_ed25519.pub` and `~/.config/git/allowed_signers` files via bind-mount declared in `devcontainer.json`. +- The forwarded SSH agent socket from `SSH_AUTH_SOCK`, so signing happens with the host's loaded private key without the private key ever entering the container. + +If the container's `~/.ssh` directory exists with the wrong owner (root, surfaced by macOS bind-mount semantics), `gh auth login` writes to `~/.ssh/known_hosts` may fail. The `onCreateCommand` in `devcontainer.json` chowns the directory to `vscode` to fix this — see [devcontainer setup](./devcontainer.md) for the rationale. + +## Troubleshooting + +**`gpg.ssh.allowedSignersFile needs to be configured`** — Set `git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers` and ensure the file exists. + +**`signing failed: no allowed signers`** — The `allowed_signers` file exists but doesn't contain a line matching `user.email` + a key. Re-run the `echo $(git config user.email) namespaces="git" $(cat ~/.ssh/id_ed25519.pub) >> …` snippet. + +**Verifies on the host but not in the container** — The bind-mount source path differs. `${localEnv:HOME}` resolves on Linux/macOS hosts; on Windows hosts (WSL2 backend) the `${localEnv:USERPROFILE}` fallback in `devcontainer.json` handles it. Check the actual mount with `mount | grep ssh` inside the container. + +**SSH agent says "could not open a connection"** — The host's agent isn't running. Linux: `systemctl --user start ssh-agent.socket`. macOS: open a new terminal so launchd starts the agent. From 28736a9535ad6e4fc9368d50cbefe12a40a1eb04 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 08:39:06 -0700 Subject: [PATCH 03/10] Address Copilot review on PR #63 - Remove the hard-coded `workspaceFolder` from `devcontainer.json`. The default `/workspaces/${localWorkspaceFolderBasename}` tracks the host folder name automatically, so derived projects with a different repo name don''t need to edit this config. - Drop `|| true` from `dotnet husky install` in `post-create.sh`. Husky hook installation failing silently would let the container come up without pre-commit enforcement, masking a real setup problem. Let it fail loudly instead. - After installing uv, prepend `$HOME/.local/bin` to PATH for the rest of the script and call `uv sync` from PATH instead of by hard-coded path. Handles the case where uv is already installed elsewhere on PATH. --- .devcontainer/devcontainer.json | 5 ++++- .devcontainer/post-create.sh | 14 ++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a7f92bef..601202c8 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -29,7 +29,10 @@ ], "remoteUser": "vscode", - "workspaceFolder": "/workspaces/ProjectTemplate", + // workspaceFolder defaults to /workspaces/${localWorkspaceFolderBasename}, + // which makes the devcontainer config portable: when this template is + // forked into a repo with a different folder name, the mount path tracks + // the host folder name automatically. // The bind-mount on macOS hosts surfaces /home/vscode/.ssh as root-owned; // chown it back so writes from inside the container (known_hosts updates diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index cc43eafa..25b2eac3 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -2,19 +2,25 @@ set -euo pipefail # Install uv (Astral) for the Python sibling project. Idempotent — re-running -# overwrites in place. Adds $HOME/.local/bin to PATH via uv's installer hook. +# overwrites in place. The installer drops the binary in $HOME/.local/bin and +# updates user shell init to add it to PATH for new shells; we add it to the +# current PATH explicitly so the rest of this script can invoke `uv` without a +# hard-coded path. if ! command -v uv >/dev/null 2>&1; then curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" fi # Restore the .NET local-tool manifest (CSharpier, Husky.Net, dotnet-outdated). dotnet tool restore -# Install Husky.Net git hooks so commits run pre-commit checks. -dotnet husky install || true +# Install Husky.Net git hooks so commits run pre-commit checks. Failures here +# (e.g. missing .git directory, broken tool restore) should surface — the +# devcontainer setup is not "successful" if hook installation fails silently. +dotnet husky install # Pre-warm uv environment for PyPiLibrary if it exists. Guarded so this script # is safe before PyPiLibrary lands in the repo. if [[ -f PyPiLibrary/pyproject.toml ]]; then - (cd PyPiLibrary && "$HOME/.local/bin/uv" sync) + (cd PyPiLibrary && uv sync) fi From 449d4941b2a7f2a8c615ca55a204077f4e091ab2 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 10:06:38 -0700 Subject: [PATCH 04/10] Drop USERPROFILE concat from devcontainer mount sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `${localEnv:HOME}${localEnv:USERPROFILE}` produces an invalid concatenated path on hosts where both variables are set (e.g. native Windows shells). The supported devcontainer hosts are Linux, macOS, and WSL2 — all of which have HOME set unconditionally — so HOME alone covers every supported case. --- .devcontainer/devcontainer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 601202c8..c2d3a54b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -9,19 +9,19 @@ "mounts": [ { - "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.ssh/id_ed25519.pub", + "source": "${localEnv:HOME}/.ssh/id_ed25519.pub", "target": "/home/vscode/.ssh/id_ed25519.pub", "type": "bind", "readonly": true }, { - "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.config/git/allowed_signers", + "source": "${localEnv:HOME}/.config/git/allowed_signers", "target": "/home/vscode/.config/git/allowed_signers", "type": "bind", "readonly": true }, { - "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.config/gh", + "source": "${localEnv:HOME}/.config/gh", "target": "/home/vscode/.config/gh", "type": "bind", "readonly": false From eb38acec31918c0bd79ec40e74839f2a4f59539b Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 10:13:35 -0700 Subject: [PATCH 05/10] Restore HOME/USERPROFILE fallback for devcontainer mount sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts a regression introduced in 449d494. The `${localEnv:HOME}${localEnv:USERPROFILE}` pattern is the canonical devcontainer.json fallback idiom: at most one of the two is set in practice in the contexts where devcontainer.json `localEnv` is evaluated (Windows VS Code: USERPROFILE only; macOS / Linux / WSL2: HOME only). The "concatenation produces an invalid path" concern is theoretical for shells that set both, but those shells aren''t the context VS Code Dev Containers resolves variables in. Without the USERPROFILE half, Windows VS Code launching the container sees `${localEnv:HOME}` as empty and the mount source becomes `/.ssh/id_ed25519.pub` — broken signing and gh auth. --- .devcontainer/devcontainer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c2d3a54b..601202c8 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -9,19 +9,19 @@ "mounts": [ { - "source": "${localEnv:HOME}/.ssh/id_ed25519.pub", + "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.ssh/id_ed25519.pub", "target": "/home/vscode/.ssh/id_ed25519.pub", "type": "bind", "readonly": true }, { - "source": "${localEnv:HOME}/.config/git/allowed_signers", + "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.config/git/allowed_signers", "target": "/home/vscode/.config/git/allowed_signers", "type": "bind", "readonly": true }, { - "source": "${localEnv:HOME}/.config/gh", + "source": "${localEnv:HOME}${localEnv:USERPROFILE}/.config/gh", "target": "/home/vscode/.config/gh", "type": "bind", "readonly": false From 70c8e383e9773ee9716ebfe326d95c2196c08287 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 11:33:43 -0700 Subject: [PATCH 06/10] Address Copilot review on PR #63 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pin uv to a specific version in `post-create.sh` via the version-prefixed Astral install URL (https://astral.sh/uv// install.sh). The `latest` install script remains a supply-chain attack surface; pinning means a compromised `latest` cannot silently change what runs on contributors'' machines or CI. Bump `UV_VERSION` on upgrade after reviewing release notes. - Clarify in `docs/host-setup.md` that the WSL2-only constraint applies to the devcontainer flow specifically. The host-install path (`README.md` → "Alternative (host install)") supports native Windows with winget; the devcontainer flow does not because the bind-mounts rely on POSIX paths. - Strengthen the non-systemd ssh-agent snippet: probe the agent for at least one loaded key via `ssh-add -l`. The previous `[ -z "$SSH_AUTH_SOCK" ]`-only check missed the stale-socket and agent-running-but-empty cases. --- .devcontainer/post-create.sh | 8 +++++++- docs/host-setup.md | 10 +++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 25b2eac3..c01dc580 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -6,8 +6,14 @@ set -euo pipefail # updates user shell init to add it to PATH for new shells; we add it to the # current PATH explicitly so the rest of this script can invoke `uv` without a # hard-coded path. +# +# uv is pinned to a specific version (via the version-prefixed install URL, +# https://astral.sh/uv//install.sh) so a compromised or broken +# upstream `latest` script cannot silently change what runs on contributors' +# machines and CI runners. Bump UV_VERSION when you've reviewed release notes. +UV_VERSION="0.11.8" if ! command -v uv >/dev/null 2>&1; then - curl -LsSf https://astral.sh/uv/install.sh | sh + curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" | sh export PATH="$HOME/.local/bin:$PATH" fi diff --git a/docs/host-setup.md b/docs/host-setup.md index 4e922730..c48f228d 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -2,7 +2,11 @@ Prerequisites for working with this repo locally — apply once per machine before opening the devcontainer or building outside one. -Supported hosts: **Linux**, **WSL2 on Windows** (native Windows is not supported for the devcontainer; use WSL2), **macOS**. +Supported hosts: + +- **Linux** — both the devcontainer flow and the host-install flow. +- **macOS** — both the devcontainer flow and the host-install flow. +- **Windows** — the devcontainer flow requires **WSL2**; native Windows (PowerShell + winget) is supported only for the host-install flow described in `README.md`. The bind-mounts in `.devcontainer/devcontainer.json` rely on POSIX paths and only work from Linux/macOS/WSL2. ## Git Identity @@ -54,10 +58,10 @@ systemctl --user enable --now ssh-agent.socket ssh-add ~/.ssh/id_ed25519 ``` -For non-systemd shells, add to `~/.bashrc` or `~/.zshrc`: +For non-systemd shells, add to `~/.bashrc` or `~/.zshrc`. The check probes the agent for at least one loaded key — `[ -z "$SSH_AUTH_SOCK" ]` alone would miss the case where `SSH_AUTH_SOCK` is set but points at a stale socket or a keyless agent: ```shell -if [ -z "$SSH_AUTH_SOCK" ]; then +if [ -z "$SSH_AUTH_SOCK" ] || ! ssh-add -l >/dev/null 2>&1; then eval "$(ssh-agent -s)" >/dev/null ssh-add ~/.ssh/id_ed25519 2>/dev/null fi From f675bd2163c0435d9ba03a85c316170011300bc6 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 12:02:08 -0700 Subject: [PATCH 07/10] Enforce uv version pin even when uv is already installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compare the installed uv --version output to UV_VERSION; if they differ (including the case where uv was already on PATH from a prior install or a system package), re-install the pinned version. Without this check the pin only applied when uv was missing entirely, undermining the lockfile reproducibility goal — the lockfile is generated against a specific uv version, and a different installed uv could resolve different dependency graphs. --- .devcontainer/post-create.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index c01dc580..4cd30487 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -11,8 +11,17 @@ set -euo pipefail # https://astral.sh/uv//install.sh) so a compromised or broken # upstream `latest` script cannot silently change what runs on contributors' # machines and CI runners. Bump UV_VERSION when you've reviewed release notes. +# +# We re-install when uv is missing OR when the installed version doesn't +# match the pin. The latter handles the case where a contributor (or a +# previous run with a different pin) left a different uv version on PATH — +# the pin is what's reproducible and what the lockfile is generated against. UV_VERSION="0.11.8" -if ! command -v uv >/dev/null 2>&1; then +installed_uv_version="" +if command -v uv >/dev/null 2>&1; then + installed_uv_version="$(uv --version | awk '{print $2}')" +fi +if [[ "$installed_uv_version" != "$UV_VERSION" ]]; then curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" | sh export PATH="$HOME/.local/bin:$PATH" fi From 2377fe90671be7fe5dc49085073e592bd3dd448a Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 12:10:42 -0700 Subject: [PATCH 08/10] Address Copilot review on PR #63 (post-merge round) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/devcontainer.md: update the "What's Inside" table row for uv to show the version-pinned install URL and the actual script path (`.devcontainer/post-create.sh`). Earlier wording matched an older unpinned form. - docs/devcontainer.md and README.md: soften "gh is pre-authenticated" wording. The bind-mount of `~/.config/gh` only carries file-backed tokens; macOS Keychain and Linux libsecret-backed tokens require an in-container `gh auth login`. Both docs now point at the credential- store nuance section in `docs/devcontainer.md` so contributors set expectations correctly. - .devcontainer/post-create.sh: download the pinned uv installer to a tempfile, log its sha256 to stderr, then run it (instead of `curl … | sh`). The hash provides an audit trail of exactly what was executed and lets an operator pin a known-good checksum via `EXPECTED_SHA` later. Astral does not currently publish per-version installer checksums in a machine-verifiable form, so the check is opt-in for now; once they do, set `EXPECTED_SHA` and the script refuses to run on mismatch. --- .devcontainer/post-create.sh | 16 +++++++++++++++- README.md | 2 +- docs/devcontainer.md | 4 ++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 4cd30487..b67cd9ab 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -22,7 +22,21 @@ if command -v uv >/dev/null 2>&1; then installed_uv_version="$(uv --version | awk '{print $2}')" fi if [[ "$installed_uv_version" != "$UV_VERSION" ]]; then - curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" | sh + # Download the pinned installer to a temp file first instead of piping + # `curl … | sh`. This produces a logged sha256 of exactly the bytes we + # ran, so a compromised installer leaves a forensic trail; it also lets + # a future change pin a known-good checksum (set EXPECTED_SHA below). + installer=$(mktemp -t uv-install.XXXXXX.sh) + trap 'rm -f "$installer"' EXIT + curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" -o "$installer" + actual_sha=$(sha256sum "$installer" | awk '{print $1}') + echo "uv installer (v${UV_VERSION}) sha256: ${actual_sha}" >&2 + # EXPECTED_SHA="" # set to enforce + if [[ -n "${EXPECTED_SHA:-}" && "${actual_sha}" != "${EXPECTED_SHA}" ]]; then + echo "uv installer sha256 mismatch — refusing to run" >&2 + exit 1 + fi + sh "$installer" export PATH="$HOME/.local/bin:$PATH" fi diff --git a/README.md b/README.md index 1314dc0c..4fb9c8ef 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ Options: ## Development Environment Setup -The recommended setup is the [Dev Container](./docs/devcontainer.md) — a single image with the .NET 10 SDK, the `uv` Python toolchain, and the GitHub CLI. It bind-mounts your SSH public key, allowed-signers file, and `gh` config from the host so commits sign correctly and `gh` is pre-authenticated. +The recommended setup is the [Dev Container](./docs/devcontainer.md) — a single image with the .NET 10 SDK, the `uv` Python toolchain, and the GitHub CLI. It bind-mounts your SSH public key, allowed-signers file, and `gh` config from the host so commits sign correctly. `gh` is pre-authenticated when the host token is file-backed; macOS Keychain and Linux libsecret-backed tokens require an in-container `gh auth login` — see the [credential-store nuance](./docs/devcontainer.md#gh-credential-store) section. **Recommended (devcontainer)**: diff --git a/docs/devcontainer.md b/docs/devcontainer.md index 9252773a..4a7f0c07 100644 --- a/docs/devcontainer.md +++ b/docs/devcontainer.md @@ -9,7 +9,7 @@ Prerequisite: complete [host setup](./host-setup.md) first — without git confi | Component | Source | Purpose | |---|---|---| | .NET 10 SDK | base image `mcr.microsoft.com/devcontainers/dotnet:1-10.0` | Build, test, pack the .NET projects | -| `uv` | `astral.sh/uv/install.sh` in `post-create.sh` | Python env, dependency, build, and publish manager for the PyPi sibling | +| `uv` | `https://astral.sh/uv//install.sh` (version-pinned) downloaded by `.devcontainer/post-create.sh` | Python env, dependency, build, and publish manager for the PyPi sibling | | `gh` CLI | `ghcr.io/devcontainers/features/github-cli:1` | Issue/PR/release management from inside the container | | Common utilities | `ghcr.io/devcontainers/features/common-utils:2` | bash, curl, wget, sudo, `vscode` user | | VS Code extensions | `customizations.vscode.extensions` in `devcontainer.json` | Mirrors `ProjectTemplate.code-workspace` recommendations so the container has the same tooling | @@ -18,7 +18,7 @@ The extension list in `.devcontainer/devcontainer.json` and the `recommendations ## Bind Mounts -The host SSH key, allowed-signers file, and `gh` config directory are mounted into the container so commits sign correctly and `gh` is pre-authenticated. +The host SSH key, allowed-signers file, and `gh` config directory are mounted into the container so commits sign correctly and `gh` is pre-authenticated **when the host stores its `gh` token in a file** (`~/.config/gh/hosts.yml`). Hosts that store the token in macOS Keychain or Linux libsecret will need an in-container `gh auth login` instead — see [`gh` credential store](#gh-credential-store) below for the full picture. | Host path | Container path | Mode | Purpose | |---|---|---|---| From 6fec0229f6b9f3d7fabbe9779577cf73a374c4c1 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 12:13:04 -0700 Subject: [PATCH 09/10] Drop ms-pyright.pyright; mark mypy/pylint/black/etc as unwanted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove `ms-pyright.pyright` from recommendations. Microsoft now ships pyright inside Pylance, which `ms-python.python` auto-installs; having both active causes the standalone pyright extension to fight Pylance for the same files. The standalone extension is in maintenance mode per Microsoft's own guidance. - Add `unwantedRecommendations` for mypy, pylint, flake8, isort, and black. We use ruff (lint + format + import sort) and pyright (via Pylance) — every other Python linter/formatter overlaps and shows duplicate diagnostics or, worse, "could not find binary" connection errors when the venv doesn''t have it installed (which is the current symptom). If a contributor already has any of these extensions installed manually, VS Code now flags them as not recommended for this workspace and offers a one-click disable. --- ProjectTemplate.code-workspace | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ProjectTemplate.code-workspace b/ProjectTemplate.code-workspace index 12c72702..af8b355a 100644 --- a/ProjectTemplate.code-workspace +++ b/ProjectTemplate.code-workspace @@ -102,7 +102,14 @@ "yzhang.markdown-all-in-one", "ms-python.python", "charliermarsh.ruff", + ], + "unwantedRecommendations": [ "ms-pyright.pyright", + "ms-python.mypy-type-checker", + "ms-python.pylint", + "ms-python.flake8", + "ms-python.isort", + "ms-python.black-formatter" ] } } From f036424ea145d5296f502b5789292bfc2aab4920 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 3 May 2026 12:13:37 -0700 Subject: [PATCH 10/10] Drop ms-pyright.pyright from devcontainer extension list Mirrors the workspace `recommendations` change in dd7073d. Pylance (auto-installed with ms-python.python) provides pyright; the standalone extension fights it for the same files. --- .devcontainer/devcontainer.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 601202c8..e1d6597e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -46,6 +46,10 @@ "customizations": { "vscode": { + // Mirror of `recommendations` in ProjectTemplate.code-workspace. + // Pyright type checking is provided by Pylance, which the + // ms-python.python extension auto-installs — no separate pyright + // extension needed (and the standalone one is in maintenance mode). "extensions": [ "csharpier.csharpier-vscode", "davidanson.vscode-markdownlint", @@ -57,8 +61,7 @@ "streetsidesoftware.code-spell-checker", "yzhang.markdown-all-in-one", "ms-python.python", - "charliermarsh.ruff", - "ms-pyright.pyright" + "charliermarsh.ruff" ] } }