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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions .devcontainer/devcontainer.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
{
"name": "ProjectTemplate",
"image": "mcr.microsoft.com/devcontainers/dotnet:1-10.0",

"features": {
"ghcr.io/devcontainers/features/common-utils:2": {},
"ghcr.io/devcontainers/features/github-cli:1": {}
},

"mounts": [
{
"source": "${localEnv:HOME}${localEnv:USERPROFILE}/.ssh/id_ed25519.pub",
"target": "/home/vscode/.ssh/id_ed25519.pub",
"type": "bind",
"readonly": true
},
{
"source": "${localEnv:HOME}${localEnv:USERPROFILE}/.config/git/allowed_signers",
"target": "/home/vscode/.config/git/allowed_signers",
"type": "bind",
"readonly": true
},
{
"source": "${localEnv:HOME}${localEnv:USERPROFILE}/.config/gh",
"target": "/home/vscode/.config/gh",
"type": "bind",
"readonly": false
}
],

"remoteUser": "vscode",
// workspaceFolder defaults to /workspaces/${localWorkspaceFolderBasename},
// which makes the devcontainer config portable: when this template is
// forked into a repo with a different folder name, the mount path tracks
// the host folder name automatically.

// The bind-mount on macOS hosts surfaces /home/vscode/.ssh as root-owned;
// chown it back so writes from inside the container (known_hosts updates
// by gh / git) land cleanly. Idempotent on Linux/WSL2.
"onCreateCommand": "sudo install -d -m 700 -o vscode -g vscode /home/vscode/.ssh",

// Install uv for the Python sibling project, restore .NET local tools,
// and install the husky git hooks. uv is installed under $HOME/.local/bin
// and added to PATH by uv's install script.
"postCreateCommand": ".devcontainer/post-create.sh",

"customizations": {
"vscode": {
// Mirror of `recommendations` in ProjectTemplate.code-workspace.
// Pyright type checking is provided by Pylance, which the
// ms-python.python extension auto-installs — no separate pyright
// extension needed (and the standalone one is in maintenance mode).
"extensions": [
"csharpier.csharpier-vscode",
"davidanson.vscode-markdownlint",
"editorconfig.editorconfig",
"github.vscode-github-actions",
"gruntfuggly.todo-tree",
"ms-azuretools.vscode-docker",
"ms-dotnettools.csdevkit",
"streetsidesoftware.code-spell-checker",
"yzhang.markdown-all-in-one",
"ms-python.python",
"charliermarsh.ruff"
]
}
}
}
55 changes: 55 additions & 0 deletions .devcontainer/post-create.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail

# Install uv (Astral) for the Python sibling project. Idempotent — re-running
# overwrites in place. The installer drops the binary in $HOME/.local/bin and
# updates user shell init to add it to PATH for new shells; we add it to the
# current PATH explicitly so the rest of this script can invoke `uv` without a
# hard-coded path.
#
# uv is pinned to a specific version (via the version-prefixed install URL,
# https://astral.sh/uv/<version>/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="<paste-from-trusted-source>" # set to enforce
if [[ -n "${EXPECTED_SHA:-}" && "${actual_sha}" != "${EXPECTED_SHA}" ]]; then
echo "uv installer sha256 mismatch — refusing to run" >&2
exit 1
fi
sh "$installer"
export PATH="$HOME/.local/bin:$PATH"
fi

# Restore the .NET local-tool manifest (CSharpier, Husky.Net, dotnet-outdated).
dotnet tool restore

# Install Husky.Net git hooks so commits run pre-commit checks. Failures here
# (e.g. missing .git directory, broken tool restore) should surface — the
# devcontainer setup is not "successful" if hook installation fails silently.
dotnet husky install

# Pre-warm uv environment for PyPiLibrary if it exists. Guarded so this script
# is safe before PyPiLibrary lands in the repo.
if [[ -f PyPiLibrary/pyproject.toml ]]; then
(cd PyPiLibrary && uv sync)
fi
Comment thread
ptr727 marked this conversation as resolved.
22 changes: 22 additions & 0 deletions ProjectTemplate.code-workspace
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@
"accessibilities",
"Allman",
"apikey",
"astral",
"autoremove",
"buildcache",
"buildtransitive",
Expand All@@ -19,15 +20,19 @@
"datebadge",
"davidanson",
"debuglevel",
"devcontainer",
"dockerhub",
"dotnettools",
"dryrun",
"Emby",
"finalizers",
"gpgsign",
"gruntfuggly",
"hatchling",
"Jellyfin",
"Keychain",
"lastbuild",
"libsecret",
"LINQ",
"logfile",
"nameof",
Expand All@@ -36,12 +41,19 @@
"nektos",
"Nerdbank",
"noninteractive",
"onCreateCommand",
"othercommand",
"Pieter",
"postCreateCommand",
"ProjectTemplate",
"pyproject",
"pypi",
"pypilibrary",
"pyright",
"quoteoftheday",
"resharper",
"Rubba",
"ruff",
"Serilog",
"settingsfile",
"signingkey",
Expand DownExpand Up@@ -88,6 +100,16 @@
"ms-dotnettools.csdevkit",
"streetsidesoftware.code-spell-checker",
"yzhang.markdown-all-in-one",
"ms-python.python",
"charliermarsh.ruff",
],
"unwantedRecommendations": [
"ms-pyright.pyright",
"ms-python.mypy-type-checker",
"ms-python.pylint",
Comment thread
ptr727 marked this conversation as resolved.
"ms-python.flake8",
"ms-python.isort",
"ms-python.black-formatter"
]
}
}
17 changes: 15 additions & 2 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -211,6 +211,16 @@ Options:

## Development Environment Setup

The recommended setup is the [Dev Container](./docs/devcontainer.md) — a single image with the .NET 10 SDK, the `uv` Python toolchain, and the GitHub CLI. It bind-mounts your SSH public key, allowed-signers file, and `gh` config from the host so commits sign correctly. `gh` is pre-authenticated when the host token is file-backed; macOS Keychain and Linux libsecret-backed tokens require an in-container `gh auth login` — see the [credential-store nuance](./docs/devcontainer.md#gh-credential-store) section.

**Recommended (devcontainer)**:

1. Complete [host setup](./docs/host-setup.md) once per machine (git identity, SSH key, allowed_signers, `gh auth login`, [SSH commit signing](./docs/ssh-signing.md)).
2. Clone the repo, open in VS Code with the [Dev Containers extension][devcontainers-link], and run **Reopen in Container**.
3. The `postCreateCommand` runs `dotnet tool restore`, installs Husky.Net hooks, and installs `uv`.

**Alternative (host install)**:

- **Install Developer Tools**:

- Install [.NET SDK](https://dotnet.microsoft.com/en-us/download):
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -499,6 +510,8 @@ Licensed under the [MIT License][license-link]\

<!-- 3rd Party tool links -->

[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
Expand Down
83 changes: 83 additions & 0 deletions docs/devcontainer.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
# Devcontainer Setup

The repo ships a single unified [Dev Container](https://containers.dev/) that hosts both the .NET 10 SDK and the Python `uv` toolchain. Open the repo in VS Code with the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) installed and pick **Reopen in Container**.

Prerequisite: complete [host setup](./host-setup.md) first — without git config, an SSH key, and the allowed-signers file on the host, the devcontainer will not be able to sign commits.

## What's Inside

| Component | Source | Purpose |
|---|---|---|
| .NET 10 SDK | base image `mcr.microsoft.com/devcontainers/dotnet:1-10.0` | Build, test, pack the .NET projects |
| `uv` | `https://astral.sh/uv/<UV_VERSION>/install.sh` (version-pinned) downloaded by `.devcontainer/post-create.sh` | Python env, dependency, build, and publish manager for the PyPi sibling |
| `gh` CLI | `ghcr.io/devcontainers/features/github-cli:1` | Issue/PR/release management from inside the container |
| Common utilities | `ghcr.io/devcontainers/features/common-utils:2` | bash, curl, wget, sudo, `vscode` user |
| VS Code extensions | `customizations.vscode.extensions` in `devcontainer.json` | Mirrors `ProjectTemplate.code-workspace` recommendations so the container has the same tooling |

The extension list in `.devcontainer/devcontainer.json` and the `recommendations` array in `ProjectTemplate.code-workspace` are kept identical — when you add an extension to one, add it to the other.

## Bind Mounts

The host SSH key, allowed-signers file, and `gh` config directory are mounted into the container so commits sign correctly and `gh` is pre-authenticated **when the host stores its `gh` token in a file** (`~/.config/gh/hosts.yml`). Hosts that store the token in macOS Keychain or Linux libsecret will need an in-container `gh auth login` instead — see [`gh` credential store](#gh-credential-store) below for the full picture.

| Host path | Container path | Mode | Purpose |
|---|---|---|---|
| `~/.ssh/id_ed25519.pub` | `/home/vscode/.ssh/id_ed25519.pub` | read-only | Public half of the SSH key. The private key never enters the container — SSH agent forwarding handles signing. |
| `~/.config/git/allowed_signers` | `/home/vscode/.config/git/allowed_signers` | read-only | Maps your email to your public key so `git verify-commit` and `git log --show-signature` work inside the container. |
| `~/.config/gh` | `/home/vscode/.config/gh` | read-write | `gh` CLI auth state shared with the host. See [`gh` credential store](#gh-credential-store) below. |

VS Code Dev Containers automatically copies your host `~/.gitconfig` into the container at startup, so `user.name`, `user.email`, `user.signingkey`, `gpg.format`, and `commit.gpgsign` propagate without an explicit mount.

The SSH agent is forwarded automatically by the Dev Containers extension via `SSH_AUTH_SOCK`, so signing works as long as the agent on the host has your key loaded.

## Lifecycle Commands

`devcontainer.json` runs two scripts at well-defined points:

- **`onCreateCommand`** — `sudo install -d -m 700 -o vscode -g vscode /home/vscode/.ssh`. On macOS hosts the bind-mount surfaces `/home/vscode/.ssh` as root-owned, which would block writes from inside the container (e.g. `gh` updating `known_hosts`). This chown fixes it. Idempotent on Linux and WSL2.
- **`postCreateCommand`** — `.devcontainer/post-create.sh`, which installs `uv`, runs `dotnet tool restore`, installs Husky.Net hooks, and pre-syncs `PyPiLibrary` if it exists. Re-runs are idempotent.

To force them to run again after editing the script: VS Code → Command Palette → **Dev Containers: Rebuild Container**.

## `gh` Credential Store

`gh auth login` writes its token to either a file or an OS credential store. Which one depends on your host:

| Host | Default token storage |
|---|---|
| Linux | libsecret (gnome-keyring) when available, otherwise file |
| WSL2 | file (no native credential store) |
| macOS | macOS Keychain |

The bind-mount of `~/.config/gh` covers the **file** case. If your host stores the token in Keychain or libsecret, the bind-mount carries the rest of `gh` config but **not the token** — the container will report "no authentication" until you either:

1. Re-run `gh auth login` inside the container (writes a file token to the mounted directory), or
2. Skip in-container `gh` and run those commands on the host instead.

The file-token path is slightly less secure than Keychain/libsecret because it's plaintext on disk inside `~/.config/gh/hosts.yml`. For most contributors that's an acceptable trade-off; if it isn't, use option 2.

## Verify the Devcontainer

After **Reopen in Container** finishes, run:

```shell
dotnet --version # 10.x
uv --version # uv 0.x
gh auth status # logged in as you
git -c gpg.format=ssh commit -S --allow-empty -m "verify-signing"
git log --show-signature -1 # "Good 'git' signature for ..."
dotnet build # 0 warnings, 0 errors
dotnet test # tests pass
```

If `git -c gpg.format=ssh commit -S` errors with `signing failed: no allowed signers`, the bind-mount of `allowed_signers` is missing or the file on the host is empty — re-run the snippet in [host setup](./host-setup.md).

## Troubleshooting

**Permission denied writing to `~/.ssh/known_hosts` in the container** — The `onCreateCommand` should have chowned `~/.ssh` to `vscode`. Rebuild the container; if it persists, open a shell and run the same `sudo install -d -m 700 -o vscode -g vscode ~/.ssh` manually.

**`git commit` fails with "no SSH agent socket"** — VS Code Dev Containers forwards `SSH_AUTH_SOCK` automatically, but only if the host has `ssh-agent` running with at least one key. Run `ssh-add -l` on the host first; if it says "could not open a connection to your authentication agent", start the agent (see [host setup](./host-setup.md)).

**uv not on `PATH` after rebuild** — The post-create installer adds `~/.local/bin` to `PATH` via the user shell init scripts, which take effect on next shell. Either re-open the integrated terminal or `source ~/.bashrc`.

**Container builds but extensions don't auto-install** — Make sure VS Code is using the Dev Containers extension (not "Remote - SSH" or "Remote - Tunnels"). The extension auto-install is keyed on `customizations.vscode.extensions` and only Dev Containers honors that.
Loading
Loading