diff --git a/.devcontainer/dotnet/devcontainer.json b/.devcontainer/dotnet/devcontainer.json index 782f46be..0f22cff0 100644 --- a/.devcontainer/dotnet/devcontainer.json +++ b/.devcontainer/dotnet/devcontainer.json @@ -1,58 +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" - ] - } - } -} +{ + "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/python/devcontainer.json b/.devcontainer/python/devcontainer.json index b2c6d7e7..02375517 100644 --- a/.devcontainer/python/devcontainer.json +++ b/.devcontainer/python/devcontainer.json @@ -1,58 +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" - ] - } - } -} +{ + "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 index 340b7376..b18a729a 100755 --- a/.devcontainer/python/post-create.sh +++ b/.devcontainer/python/post-create.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# Install uv (Astral) for the Python project. Idempotent — re-running +# Install uv (Astral) for the Python project. Idempotent - re-running # overwrites in place. The installer drops the binary in $HOME/.local/bin and # updates user shell init to add it to PATH for new shells; we add it to the # current PATH explicitly so the rest of this script can invoke `uv` without a @@ -14,7 +14,7 @@ set -euo pipefail # # 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 — +# 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="" @@ -23,7 +23,7 @@ if command -v uv >/dev/null 2>&1; then 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 + # `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) @@ -33,7 +33,7 @@ if [[ "$installed_uv_version" != "$UV_VERSION" ]]; then 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 + echo "uv installer sha256 mismatch - refusing to run" >&2 exit 1 fi sh "$installer" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 752a7172..e90ce4f4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,169 +1,169 @@ -# 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. - -Branch protection enforces the merge method on both bases (develop allows only squash, main allows only merge). When running `gh pr merge` against either base, pick the matching flag (`--squash` for develop, `--merge` for main); a mismatch fails with "Merge method ... is not allowed on this repository". The merge-bot workflow (`.github/workflows/merge-bot-pull-request.yml`) does this dispatch automatically for Dependabot and codegen PRs via a `case` on `base.ref` — keep that pattern when adding new auto-merge jobs. - -### 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. After every push, **re-request a review programmatically** via the GraphQL `requestReviews` mutation, passing the Copilot reviewer's bot node id in `botIds`. This now works reliably (it previously did not — a maintainer had to click "re-request review" in the UI; the agent can now drive the loop end-to-end without that hand-off). - -> **The reviewer login differs by API — this is intentional, not a typo.** In **GraphQL** (`gh api graphql` and `gh pr view --json reviews`, which is GraphQL-backed) the `Bot.login` is `copilot-pull-request-reviewer` — **no `[bot]` suffix**. In the **REST** API (`gh api repos/.../issues|pulls/...`) the same account's `user.login` is `copilot-pull-request-reviewer[bot]` — **with** the suffix. Each query below uses the correct form for its API; match the API, not a single spelling, when adapting them. - -```sh -# 1. PR node id + the Copilot reviewer's bot node id (read from any existing -# Copilot review; the reviewer login is `copilot-pull-request-reviewer`). -PR_NODE=$(gh pr view --json id --jq '.id') -BOT_ID=$(gh api graphql -f query=' -{ - repository(owner: "", name: "") { - pullRequest(number: ) { - reviews(first: 50) { nodes { author { __typename login ... on Bot { id } } } } - } - } -}' --jq '[.data.repository.pullRequest.reviews.nodes[] - | select(.author.login == "copilot-pull-request-reviewer") - | .author.id] | first') - -# 2. Re-request a Copilot review on the current head. -gh api graphql -f query=' -mutation($pr: ID!, $bot: ID!) { - requestReviews(input: { pullRequestId: $pr, botIds: [$bot], union: true }) { - pullRequest { id } - } -}' -F pr="$PR_NODE" -F bot="$BOT_ID" -``` - -The bot node id is read from an existing Copilot review, so step 1 needs at least one prior review on the PR — the auto-review-on-open normally supplies the first one. If no Copilot review exists yet and auto-review didn't fire, request `Copilot` once through the GitHub PR UI to seed it, then use the mutation for every subsequent re-request. - -**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 — use the `requestReviews` mutation above instead): - -- `POST /requested_reviewers` with `reviewers=[Copilot]` can return 200 but no-op. -- `copilot-pull-request-reviewer` as a requested reviewer slug returns 422. - -### 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. This is the REST API, so the login carries the `[bot]` suffix. -gh api repos///issues//comments --jq \ - '[.[] | select(.user.login=="copilot-pull-request-reviewer[bot]")] | 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. Re-request the review via the `requestReviews` mutation (see "Triggering and Polling"); fall back to the GitHub PR UI only if the mutation no-ops. -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. +# 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. + +Branch protection enforces the merge method on both bases (develop allows only squash, main allows only merge). When running `gh pr merge` against either base, pick the matching flag (`--squash` for develop, `--merge` for main); a mismatch fails with "Merge method ... is not allowed on this repository". The merge-bot workflow (`.github/workflows/merge-bot-pull-request.yml`) does this dispatch automatically for Dependabot and codegen PRs via a `case` on `base.ref` - keep that pattern when adding new auto-merge jobs. + +### 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. After every push, **re-request a review programmatically** via the GraphQL `requestReviews` mutation, passing the Copilot reviewer's bot node id in `botIds`. This now works reliably (it previously did not - a maintainer had to click "re-request review" in the UI; the agent can now drive the loop end-to-end without that hand-off). + +> **The reviewer login differs by API - this is intentional, not a typo.** In **GraphQL** (`gh api graphql` and `gh pr view --json reviews`, which is GraphQL-backed) the `Bot.login` is `copilot-pull-request-reviewer` - **no `[bot]` suffix**. In the **REST** API (`gh api repos/.../issues|pulls/...`) the same account's `user.login` is `copilot-pull-request-reviewer[bot]` - **with** the suffix. Each query below uses the correct form for its API; match the API, not a single spelling, when adapting them. + +```sh +# 1. PR node id + the Copilot reviewer's bot node id (read from any existing +# Copilot review; the reviewer login is `copilot-pull-request-reviewer`). +PR_NODE=$(gh pr view --json id --jq '.id') +BOT_ID=$(gh api graphql -f query=' +{ + repository(owner: "", name: "") { + pullRequest(number: ) { + reviews(first: 50) { nodes { author { __typename login ... on Bot { id } } } } + } + } +}' --jq '[.data.repository.pullRequest.reviews.nodes[] + | select(.author.login == "copilot-pull-request-reviewer") + | .author.id] | first') + +# 2. Re-request a Copilot review on the current head. +gh api graphql -f query=' +mutation($pr: ID!, $bot: ID!) { + requestReviews(input: { pullRequestId: $pr, botIds: [$bot], union: true }) { + pullRequest { id } + } +}' -F pr="$PR_NODE" -F bot="$BOT_ID" +``` + +The bot node id is read from an existing Copilot review, so step 1 needs at least one prior review on the PR - the auto-review-on-open normally supplies the first one. If no Copilot review exists yet and auto-review didn't fire, request `Copilot` once through the GitHub PR UI to seed it, then use the mutation for every subsequent re-request. + +**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 - use the `requestReviews` mutation above instead): + +- `POST /requested_reviewers` with `reviewers=[Copilot]` can return 200 but no-op. +- `copilot-pull-request-reviewer` as a requested reviewer slug returns 422. + +### 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. This is the REST API, so the login carries the `[bot]` suffix. +gh api repos///issues//comments --jq \ + '[.[] | select(.user.login=="copilot-pull-request-reviewer[bot]")] | 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. Re-request the review via the `requestReviews` mutation (see "Triggering and Polling"); fall back to the GitHub PR UI only if the mutation no-ops. +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 15b75796..1657dcca 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,97 +1,97 @@ -# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file -# -# Every ecosystem appears **twice**: once with `target-branch: "main"` -# and once with `target-branch: "develop"`. Dependabot will open -# parallel PRs against each branch, so both stay current on -# dependency versions independently of the develop → main release -# cadence. -# -# Why dual-target and not develop-only: -# - `develop` is the integration branch and ships content forward to -# `main` through merge-commit releases, but the time between releases -# can be long (a feature branch may sit on develop for weeks). -# - Push-distribution channels (e.g. HACS for Home Assistant -# integrations, distros that pull from main) consume `main` directly. -# If `main` only got dependency bumps via the next develop → main -# release, those channels would ship outdated code in the interim. -# - Codegen workflows take the same dual-target shape for the same -# reason — see .github/workflows/run-codegen-pull-request-task.yml. -# -# The merge-bot's `case` statement in -# .github/workflows/merge-bot-pull-request.yml dispatches the merge -# method per base ref (squash on develop, merge on main) so both bases -# auto-merge cleanly. `develop` remains strictly forward-only: there -# are no main → develop back-merges; each branch absorbs its own -# Dependabot PRs and codegen PRs independently. -# -# Security update PRs (CVE-driven) are opened by Dependabot against -# the repo default branch (`main`) regardless of any `target-branch` -# config — the `case` statement handles them in the same code path. -version: 2 -updates: - - # ----- nuget ----- - - - package-ecosystem: "nuget" - target-branch: "main" - directory: "/" - schedule: - interval: "daily" - groups: - nuget-deps: - patterns: - - "*" - - - package-ecosystem: "nuget" - target-branch: "develop" - directory: "/" - schedule: - interval: "daily" - groups: - nuget-deps: - patterns: - - "*" - - # ----- github-actions ----- - - - package-ecosystem: "github-actions" - target-branch: "main" - directory: "/" - schedule: - interval: "daily" - groups: - actions-deps: - patterns: - - "*" - - - package-ecosystem: "github-actions" - target-branch: "develop" - directory: "/" - schedule: - interval: "daily" - groups: - actions-deps: - patterns: - - "*" - - # ----- uv (PyPiLibrary) ----- - - - package-ecosystem: "uv" - target-branch: "main" - directory: "/PyPiLibrary" - schedule: - interval: "daily" - groups: - pypi-deps: - patterns: - - "*" - - - package-ecosystem: "uv" - target-branch: "develop" - directory: "/PyPiLibrary" - schedule: - interval: "daily" - groups: - pypi-deps: - patterns: - - "*" +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +# +# Every ecosystem appears **twice**: once with `target-branch: "main"` +# and once with `target-branch: "develop"`. Dependabot will open +# parallel PRs against each branch, so both stay current on +# dependency versions independently of the develop -> main release +# cadence. +# +# Why dual-target and not develop-only: +# - `develop` is the integration branch and ships content forward to +# `main` through merge-commit releases, but the time between releases +# can be long (a feature branch may sit on develop for weeks). +# - Push-distribution channels (e.g. HACS for Home Assistant +# integrations, distros that pull from main) consume `main` directly. +# If `main` only got dependency bumps via the next develop -> main +# release, those channels would ship outdated code in the interim. +# - Codegen workflows take the same dual-target shape for the same +# reason - see .github/workflows/run-codegen-pull-request-task.yml. +# +# The merge-bot's `case` statement in +# .github/workflows/merge-bot-pull-request.yml dispatches the merge +# method per base ref (squash on develop, merge on main) so both bases +# auto-merge cleanly. `develop` remains strictly forward-only: there +# are no main -> develop back-merges; each branch absorbs its own +# Dependabot PRs and codegen PRs independently. +# +# Security update PRs (CVE-driven) are opened by Dependabot against +# the repo default branch (`main`) regardless of any `target-branch` +# config - the `case` statement handles them in the same code path. +version: 2 +updates: + + # ----- nuget ----- + + - package-ecosystem: "nuget" + target-branch: "main" + directory: "/" + schedule: + interval: "daily" + groups: + nuget-deps: + patterns: + - "*" + + - package-ecosystem: "nuget" + target-branch: "develop" + directory: "/" + schedule: + interval: "daily" + groups: + nuget-deps: + patterns: + - "*" + + # ----- github-actions ----- + + - package-ecosystem: "github-actions" + target-branch: "main" + directory: "/" + schedule: + interval: "daily" + groups: + actions-deps: + patterns: + - "*" + + - package-ecosystem: "github-actions" + target-branch: "develop" + directory: "/" + schedule: + interval: "daily" + groups: + actions-deps: + patterns: + - "*" + + # ----- uv (PyPiLibrary) ----- + + - package-ecosystem: "uv" + target-branch: "main" + directory: "/PyPiLibrary" + schedule: + interval: "daily" + groups: + pypi-deps: + patterns: + - "*" + + - package-ecosystem: "uv" + target-branch: "develop" + directory: "/PyPiLibrary" + schedule: + interval: "daily" + groups: + pypi-deps: + patterns: + - "*" diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml index fd36a51e..eb6b47fe 100644 --- a/.github/workflows/build-docker-task.yml +++ b/.github/workflows/build-docker-task.yml @@ -87,13 +87,13 @@ jobs: docker.io/ptr727/projecttemplate:${{ inputs.branch == 'main' && 'latest' || 'develop' }} docker.io/ptr727/projecttemplate:${{ needs.get-version.outputs.SemVer2 }} platforms: ${{ inputs.smoke && 'linux/amd64' || 'linux/amd64,linux/arm64' }} - # Branch-scoped registry cache. READ both branches' caches — the + # Branch-scoped registry cache. READ both branches' caches - the # layers are nearly identical (only BUILD_CONFIGURATION differs), so - # a main build can seed from develop's cache and vice versa — but + # a main build can seed from develop's cache and vice versa - but # WRITE only this branch's own tag, and only when actually pushing. # Gating the export on `inputs.push` (not just `!smoke`) means a # non-publishing build never writes the shared registry cache or - # needs Docker Hub write creds — smoke builds (always push=false) are + # needs Docker Hub write creds - smoke builds (always push=false) are # covered too. Branch-scoping is what lets the publisher's weekly # matrix build main and develop concurrently in one run without the # two legs overwriting a single shared cache (destroying hit rates). diff --git a/.github/workflows/build-executable-task.yml b/.github/workflows/build-executable-task.yml index 1353a915..f8244d41 100644 --- a/.github/workflows/build-executable-task.yml +++ b/.github/workflows/build-executable-task.yml @@ -82,7 +82,7 @@ jobs: path: ${{ runner.temp }}/publish # Smoke builds only need the per-runtime compile to succeed (fast PR - # feedback) — the zipped, downloadable artifact is a release concern, so + # feedback) - the zipped, downloadable artifact is a release concern, so # skip the aggregation entirely on smoke. The GitHub release job never runs # on smoke, so no `release-asset-*` artifact is needed then. upload-build-artifacts: @@ -105,7 +105,7 @@ jobs: # GitHub-release asset, uploaded under the `release-asset--*` # convention that the `github-release` job in build-release-task.yml - # collects by pattern (it never names this job) — so a derived project + # collects by pattern (it never names this job) - so a derived project # swaps its release contents by replacing this leaf task, not the # orchestrator. Branch-suffixed so the publisher's branch matrix can # build both branches in one run without colliding on the artifact name. diff --git a/.github/workflows/build-nugetlibrary-task.yml b/.github/workflows/build-nugetlibrary-task.yml index 21445b61..3aecec5d 100644 --- a/.github/workflows/build-nugetlibrary-task.yml +++ b/.github/workflows/build-nugetlibrary-task.yml @@ -72,7 +72,7 @@ jobs: # GitHub-release asset, uploaded under the `release-asset--*` # convention that the `github-release` job in build-release-task.yml - # collects by pattern (it never names this job) — so a derived project + # collects by pattern (it never names this job) - so a derived project # swaps its release contents by replacing this leaf task, not the # orchestrator. Branch-suffixed so the publisher's branch matrix can # build both branches in one run without colliding on the artifact name. diff --git a/.github/workflows/build-pypilibrary-task.yml b/.github/workflows/build-pypilibrary-task.yml index d4f209be..8927a45f 100644 --- a/.github/workflows/build-pypilibrary-task.yml +++ b/.github/workflows/build-pypilibrary-task.yml @@ -5,8 +5,8 @@ name: Build PyPI library task # 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 the PR smoke build in -# `test-pull-request.yml` — to also grant id-token write, even when no +# chain (which would require every caller - including the PR smoke build in +# `test-pull-request.yml` - to also grant id-token write, even when no # publishing happens). on: @@ -60,9 +60,9 @@ jobs: 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. + # (UV_VERSION) so CI and local devcontainer behavior cannot drift; + # the same uv resolves the same lockfile the same way. Bump in + # lockstep with the devcontainer pin. version: "0.11.8" enable-cache: true cache-dependency-glob: "PyPiLibrary/uv.lock" @@ -93,7 +93,7 @@ jobs: # segment so develop's release segment # (which grows past main's after every # new commit) compares higher than main's - # under PEP 440 ordering — so `pip install + # under PEP 440 ordering - so `pip install # --pre ` picks the develop dev # build, while default `pip install ` # filters the dev suffix and picks the @@ -108,7 +108,7 @@ jobs: # release merge to main and the next # commit on develop, develop's # BuildNumber equals main's (or is one - # lower) — `--pre` will still resolve to + # lower) - `--pre` will still resolve to # the main release until a new develop # commit lands. This is accepted as a # small, self-healing gap. @@ -141,7 +141,7 @@ jobs: # so the test that asserts `__version__` is a non-empty string # isn't affected. On main, the PyPI version string equals the .NET # assemblies' `FileVersion` stamp (= NBGV `AssemblyFileVersion`, - # `M.N.P.B`); on develop, the PyPI version is `M.N.P.B.dev0` — + # `M.N.P.B`); on develop, the PyPI version is `M.N.P.B.dev0` - # numerically the same `FileVersion` with a trailing `.dev0` # prerelease marker. .NET keeps the bare `FileVersion`, and # NuGet/Docker use NBGV `SemVer2`, so strings are not byte- diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 1fc6a541..fbccaf9d 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -81,7 +81,7 @@ jobs: # commit than the one the release tag (also GitCommitId) points at. ref: ${{ needs.get-version.outputs.GitCommitId }} branch: ${{ inputs.branch }} - # Conditional push to NuGet.org — never on a smoke build. + # Conditional push to NuGet.org - never on a smoke build. push: ${{ inputs.nuget && !inputs.smoke }} # PyPI publishing happens in `publish-release.yml`, not here, so that @@ -122,7 +122,7 @@ jobs: ref: ${{ needs.get-version.outputs.GitCommitId }} branch: ${{ inputs.branch }} smoke: ${{ inputs.smoke }} - # Conditional push to Docker Hub — never on a smoke build. + # Conditional push to Docker Hub - never on a smoke build. push: ${{ inputs.dockerhub && !inputs.smoke }} github-release: @@ -150,14 +150,14 @@ jobs: # target (delete its job + this job's `needs` entry, per AGENTS.md # "Per-target subsetting") just leaves one fewer `release-asset-*` to # match, and a downstream ships different files by adding/replacing a - # leaf `build-*-task.yml` that uploads under this pattern — without + # leaf `build-*-task.yml` that uploads under this pattern - without # editing this step. `merge-multiple` flattens the matched artifacts into # one `./Publish` dir, exactly as the previous per-target downloads did; # download-artifact@v7 succeeds (downloads nothing) when the pattern # matches zero artifacts, so a file-less release (tag + LICENSE/README # only) still works. # NOTE: subset releases by *deleting* the target, not by `enable_*: - # false`. `enable_*` is for smoke subsetting only — it skips a build job, + # false`. `enable_*` is for smoke subsetting only - it skips a build job, # and because `github-release` `needs` those jobs, a skipped one would # skip this release job too (GitHub Actions: a skipped `needs` job skips # its dependents). @@ -172,7 +172,7 @@ jobs: # NBGV can produce a SemVer2 that was already released. GitHub release # creation has no built-in skip-duplicate (unlike NuGet's # `--skip-duplicate` and PyPI's `skip-existing`), and re-publishing an - # unchanged version is exactly the churn the two-phase model avoids — so + # unchanged version is exactly the churn the two-phase model avoids - so # skip the release step when a release for this tag already exists. The # Docker mutable tags (`latest`/`develop`) and base-image refresh still # happen regardless, so security-only rebuilds still ship. @@ -197,7 +197,7 @@ jobs: # `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). We pin it to NBGV's - # `GitCommitId` — the exact commit the version was computed from. This + # `GitCommitId` - the exact commit the version was computed from. This # avoids two bugs: `github.sha` would be wrong (the publisher's branch # matrix builds `develop` from a run whose `github.sha` is main's tip), # and `inputs.branch` would be a moving ref (a commit landing mid-run diff --git a/.github/workflows/get-version-task.yml b/.github/workflows/get-version-task.yml index e64bd474..aa20447f 100644 --- a/.github/workflows/get-version-task.yml +++ b/.github/workflows/get-version-task.yml @@ -6,8 +6,8 @@ on: # Git ref to check out and version. Empty string falls back to the # caller's default checkout ref (`github.ref`), preserving the # original behavior. The publisher passes an explicit branch so a - # scheduled run — which always reports `github.ref` as the default - # branch — can still compute NBGV versions for `develop` too. + # scheduled run - which always reports `github.ref` as the default + # branch - can still compute NBGV versions for `develop` too. ref: required: false type: string @@ -54,11 +54,11 @@ jobs: fetch-depth: 0 # `dotnet/nbgv` is intentionally floated on `master` rather than - # pinned to a commit SHA — a deliberate deviation from the + # pinned to a commit SHA - a deliberate deviation from the # AGENTS.md "pin third-party actions to a commit SHA" rule, # documented here so the deviation isn't accidentally "fixed" by # a future reviewer. Justification: - # - The upstream tag stream is effectively dormant — the latest + # - The upstream tag stream is effectively dormant - the latest # tag `v0.5.1` lags well behind `master` and fixes accumulate # on `master` between tag bumps. # - Dependabot's GitHub Actions ecosystem tracks tagged diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index cb0ed8df..51c11732 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -4,7 +4,7 @@ name: Merge bot pull request action # 1. `merge-dependabot` / `merge-codegen` run on `opened` and `reopened` # events only. They enable auto-merge via `gh pr merge --auto` once # per PR. Restricting to open/reopen (skipping `synchronize`) is what -# makes step 3 below stick — if these jobs re-ran on every +# makes step 3 below stick - if these jobs re-ran on every # `synchronize`, they'd undo a maintainer-triggered disable. # 2. The merge method (`--squash` vs `--merge`) is dispatched by a # `case` statement on `pull_request.base.ref` so the form matches @@ -14,7 +14,7 @@ name: Merge bot pull request action # updates always target `main` and flow through the same code path. # 3. `disable-auto-merge-on-maintainer-push` runs on `synchronize` # events against bot-authored PRs when the event actor is NOT the -# same bot — i.e. a maintainer pushed commits to a bot PR. It +# same bot - i.e. a maintainer pushed commits to a bot PR. It # calls `gh pr merge --disable-auto` so the maintainer's commits # don't auto-merge along with the bot's content. The maintainer # re-enables auto-merge manually (UI or `gh pr merge --auto`) @@ -165,13 +165,13 @@ jobs: name: Disable auto-merge on maintainer push job runs-on: ubuntu-latest # Fires on `synchronize` events against bot-authored PRs (Dependabot - # or codegen) when the event actor is NOT the same bot — i.e. a + # or codegen) when the event actor is NOT the same bot - i.e. a # maintainer pushed commits to the bot's branch. Disables auto-merge # so the maintainer's commits don't auto-merge along with the bot's # content. The maintainer re-enables auto-merge manually when ready # (UI button, or `gh pr merge --auto `). # - # `gh pr merge --disable-auto` is idempotent — calling it on a PR + # `gh pr merge --disable-auto` is idempotent - calling it on a PR # that already has auto-merge disabled is a no-op. if: >- github.event.action == 'synchronize' && diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 6a3ac3da..58711226 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -1,160 +1,160 @@ -name: Publish project release action - -on: - push: - branches: [ main, develop ] - workflow_dispatch: - schedule: - # Weekly full build/publish of both branches on Mondays at 02:00 UTC. - # This is the guaranteed publisher in the default two-phase model: routine - # merges only smoke-test, and this scheduled run republishes everything - # (also refreshing the Docker base image, e.g. `ubuntu:rolling`). - - cron: '0 2 * * MON' - -# Real publishes (schedule, dispatch, or push when PUBLISH_ON_MERGE is set) -# share a single GLOBAL, ref-independent group so they serialize: they push -# both branches' shared Docker tags + caches and create GitHub releases for -# both regardless of the triggering ref, so a ref-scoped group would let a -# scheduled run (ref=main) and a manual dispatch (ref=develop) run concurrently -# and double-push. Non-publishing `push` runs (the two-phase default) get a -# unique per-run group so they don't queue behind — or delay — a real publish; -# they only execute the no-op `setup` job and skip everything else. -concurrency: - group: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || vars.PUBLISH_ON_MERGE == 'true') && github.workflow || format('{0}-noop-{1}', github.workflow, github.run_id) }} - # Documented exception to the standard `cancel-in-progress: true` (see - # AGENTS.md "Workflow YAML Conventions"): cancelling a publish mid-flight can - # leave a partially pushed multi-arch tag set or a half-created GitHub - # release. Queue instead of cancel so each publish runs to completion. - cancel-in-progress: false - -jobs: - - # Decide WHICH branches to publish and WHETHER to publish at all: - # - push -> publish only the pushed branch, and only when the - # `PUBLISH_ON_MERGE` repository variable is `true` - # (opt-in legacy continuous-release). Unset/false => the - # default two-phase model: merges don't publish. - # - schedule -> always publish BOTH branches (the weekly full build). - # - dispatch -> always publish BOTH branches (manual on-demand publish). - setup: - name: Resolve publish plan job - runs-on: ubuntu-latest - outputs: - branches: ${{ steps.plan.outputs.branches }} - publish: ${{ steps.plan.outputs.publish }} - steps: - - name: Compute publish plan step - id: plan - env: - # Repository variable (Settings -> Actions -> Variables). Unset reads - # as empty string, so the default is the two-phase model. - PUBLISH_ON_MERGE: ${{ vars.PUBLISH_ON_MERGE }} - run: | - set -euo pipefail - case "${{ github.event_name }}" in - push) - branches='["${{ github.ref_name }}"]' - if [[ "${PUBLISH_ON_MERGE:-}" == "true" ]]; then - publish=true - else - publish=false - fi - ;; - *) - # schedule / workflow_dispatch - branches='["main","develop"]' - publish=true - ;; - esac - echo "Event=${{ github.event_name }} branches=$branches publish=$publish" - echo "branches=$branches" >> "$GITHUB_OUTPUT" - echo "publish=$publish" >> "$GITHUB_OUTPUT" - - # Full build + publish of every target for each planned branch. The branch - # matrix lets a single scheduled run publish both `main` (Release/`latest`, - # non-prerelease) and `develop` (Debug/`develop`, prerelease) — each leg - # checks out and versions its own branch via the threaded `ref`/`branch`. - publish: - name: Publish project release job - needs: [setup] - if: ${{ needs.setup.outputs.publish == 'true' }} - strategy: - fail-fast: false - matrix: - branch: ${{ fromJSON(needs.setup.outputs.branches) }} - uses: ./.github/workflows/build-release-task.yml - secrets: inherit - permissions: - contents: write - with: - ref: ${{ matrix.branch }} - branch: ${{ matrix.branch }} - smoke: false - # Push to GitHub and NuGet and Docker Hub. - github: true - nuget: true - dockerhub: true - - publish-pypi: - name: Publish PyPI library job - needs: [setup, publish] - if: ${{ needs.setup.outputs.publish == 'true' }} - strategy: - fail-fast: false - matrix: - branch: ${{ fromJSON(needs.setup.outputs.branches) }} - runs-on: ubuntu-latest - # `build-pypilibrary-task.yml` picks the PEP 440 version per branch - # (`M.N.P.B` release on main, `M.N.P.B.dev0` on develop). Default - # `pip install ` filters `.dev0` and picks the main release; - # `pip install --pre ` includes dev releases. The `pypi` GitHub - # environment's Deployment branch rule restricts uploads to `main` + - # `develop` as defense in depth — see PyPiLibrary/README.md. - 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` for repo metadata reads. - # - `actions: read` so `actions/download-artifact` can fetch the artifact - # uploaded by the build job 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@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - with: - # Branch-suffixed name uploaded by build-pypilibrary-task.yml so both - # branch legs in this run stay distinct. - name: pypilibrary-build-${{ matrix.branch }} - 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 - # weekly republish re-uploads the same version when nothing changed. - skip-existing: true - - date-badge: - name: Create BYOB date badge job - needs: [setup, publish] - if: ${{ needs.setup.outputs.publish == 'true' }} - strategy: - matrix: - branch: ${{ fromJSON(needs.setup.outputs.branches) }} - uses: ./.github/workflows/build-datebadge-task.yml - secrets: inherit - permissions: - contents: write - with: - # The badge task self-gates to `main`; the develop leg is a no-op. - branch: ${{ matrix.branch }} +name: Publish project release action + +on: + push: + branches: [ main, develop ] + workflow_dispatch: + schedule: + # Weekly full build/publish of both branches on Mondays at 02:00 UTC. + # This is the guaranteed publisher in the default two-phase model: routine + # merges only smoke-test, and this scheduled run republishes everything + # (also refreshing the Docker base image, e.g. `ubuntu:rolling`). + - cron: '0 2 * * MON' + +# Real publishes (schedule, dispatch, or push when PUBLISH_ON_MERGE is set) +# share a single GLOBAL, ref-independent group so they serialize: they push +# both branches' shared Docker tags + caches and create GitHub releases for +# both regardless of the triggering ref, so a ref-scoped group would let a +# scheduled run (ref=main) and a manual dispatch (ref=develop) run concurrently +# and double-push. Non-publishing `push` runs (the two-phase default) get a +# unique per-run group so they don't queue behind - or delay - a real publish; +# they only execute the no-op `setup` job and skip everything else. +concurrency: + group: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || vars.PUBLISH_ON_MERGE == 'true') && github.workflow || format('{0}-noop-{1}', github.workflow, github.run_id) }} + # Documented exception to the standard `cancel-in-progress: true` (see + # AGENTS.md "Workflow YAML Conventions"): cancelling a publish mid-flight can + # leave a partially pushed multi-arch tag set or a half-created GitHub + # release. Queue instead of cancel so each publish runs to completion. + cancel-in-progress: false + +jobs: + + # Decide WHICH branches to publish and WHETHER to publish at all: + # - push -> publish only the pushed branch, and only when the + # `PUBLISH_ON_MERGE` repository variable is `true` + # (opt-in legacy continuous-release). Unset/false => the + # default two-phase model: merges don't publish. + # - schedule -> always publish BOTH branches (the weekly full build). + # - dispatch -> always publish BOTH branches (manual on-demand publish). + setup: + name: Resolve publish plan job + runs-on: ubuntu-latest + outputs: + branches: ${{ steps.plan.outputs.branches }} + publish: ${{ steps.plan.outputs.publish }} + steps: + - name: Compute publish plan step + id: plan + env: + # Repository variable (Settings -> Actions -> Variables). Unset reads + # as empty string, so the default is the two-phase model. + PUBLISH_ON_MERGE: ${{ vars.PUBLISH_ON_MERGE }} + run: | + set -euo pipefail + case "${{ github.event_name }}" in + push) + branches='["${{ github.ref_name }}"]' + if [[ "${PUBLISH_ON_MERGE:-}" == "true" ]]; then + publish=true + else + publish=false + fi + ;; + *) + # schedule / workflow_dispatch + branches='["main","develop"]' + publish=true + ;; + esac + echo "Event=${{ github.event_name }} branches=$branches publish=$publish" + echo "branches=$branches" >> "$GITHUB_OUTPUT" + echo "publish=$publish" >> "$GITHUB_OUTPUT" + + # Full build + publish of every target for each planned branch. The branch + # matrix lets a single scheduled run publish both `main` (Release/`latest`, + # non-prerelease) and `develop` (Debug/`develop`, prerelease) - each leg + # checks out and versions its own branch via the threaded `ref`/`branch`. + publish: + name: Publish project release job + needs: [setup] + if: ${{ needs.setup.outputs.publish == 'true' }} + strategy: + fail-fast: false + matrix: + branch: ${{ fromJSON(needs.setup.outputs.branches) }} + uses: ./.github/workflows/build-release-task.yml + secrets: inherit + permissions: + contents: write + with: + ref: ${{ matrix.branch }} + branch: ${{ matrix.branch }} + smoke: false + # Push to GitHub and NuGet and Docker Hub. + github: true + nuget: true + dockerhub: true + + publish-pypi: + name: Publish PyPI library job + needs: [setup, publish] + if: ${{ needs.setup.outputs.publish == 'true' }} + strategy: + fail-fast: false + matrix: + branch: ${{ fromJSON(needs.setup.outputs.branches) }} + runs-on: ubuntu-latest + # `build-pypilibrary-task.yml` picks the PEP 440 version per branch + # (`M.N.P.B` release on main, `M.N.P.B.dev0` on develop). Default + # `pip install ` filters `.dev0` and picks the main release; + # `pip install --pre ` includes dev releases. The `pypi` GitHub + # environment's Deployment branch rule restricts uploads to `main` + + # `develop` as defense in depth - see PyPiLibrary/README.md. + 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` for repo metadata reads. + # - `actions: read` so `actions/download-artifact` can fetch the artifact + # uploaded by the build job 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@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + # Branch-suffixed name uploaded by build-pypilibrary-task.yml so both + # branch legs in this run stay distinct. + name: pypilibrary-build-${{ matrix.branch }} + 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 + # weekly republish re-uploads the same version when nothing changed. + skip-existing: true + + date-badge: + name: Create BYOB date badge job + needs: [setup, publish] + if: ${{ needs.setup.outputs.publish == 'true' }} + strategy: + matrix: + branch: ${{ fromJSON(needs.setup.outputs.branches) }} + uses: ./.github/workflows/build-datebadge-task.yml + secrets: inherit + permissions: + contents: write + with: + # The badge task self-gates to `main`; the develop leg is a no-op. + branch: ${{ matrix.branch }} diff --git a/.github/workflows/run-codegen-pull-request-task.yml b/.github/workflows/run-codegen-pull-request-task.yml index 684fdd3a..73bfb5cf 100644 --- a/.github/workflows/run-codegen-pull-request-task.yml +++ b/.github/workflows/run-codegen-pull-request-task.yml @@ -1,11 +1,11 @@ name: Run codegen and pull request task # Runs codegen against `main` and `develop` in parallel via a matrix, -# opens a PR against each base (`codegen-main` branch → main, -# `codegen-develop` branch → develop). The merge-bot auto-merges +# opens a PR against each base (`codegen-main` branch -> main, +# `codegen-develop` branch -> develop). The merge-bot auto-merges # either PR independently. This keeps both branches current on # generated content (date stamps, API-derived data, etc.) without -# either branch falling behind the other and without main → develop +# either branch falling behind the other and without main -> develop # back-merges (see AGENTS.md "Branching Model" for the forward-only # develop invariant). @@ -28,7 +28,7 @@ jobs: # out byte-identical and `develop -> main` release merges don't conflict on # it. `github.run_started_at` was used for this but resolves to an empty # string in this reusable-workflow context, so each leg fell back to its own - # `DateTime.UtcNow` and the outputs diverged — capture the value here instead. + # `DateTime.UtcNow` and the outputs diverged - capture the value here instead. get-runtime: name: Capture shared runtime job runs-on: ubuntu-latest @@ -65,7 +65,7 @@ jobs: # The App-token-driven PR open fires `pull_request` workflow events # directly. `GITHUB_TOKEN`-driven PR opens do not (GitHub's recursion # guard), which previously required a close/reopen dance under a PAT - # to nudge the auto-merge workflow — that dance is gone. + # to nudge the auto-merge workflow - that dance is gone. id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: @@ -88,7 +88,7 @@ jobs: # (captured once in the `get-runtime` job above) to both matrix legs # (main and develop) makes them produce byte-identical CodeGen.cs, so # develop->main release merges don't conflict on this demo file every - # release. Derived projects' real codegen should not copy this pattern — + # release. Derived projects' real codegen should not copy this pattern - # if your generator's per-run state is intentional, design it not to # land on multiple release branches simultaneously, or absorb the merge # cost. diff --git a/.github/workflows/run-periodic-codegen-pull-request.yml b/.github/workflows/run-periodic-codegen-pull-request.yml index 447b4178..041f95b6 100644 --- a/.github/workflows/run-periodic-codegen-pull-request.yml +++ b/.github/workflows/run-periodic-codegen-pull-request.yml @@ -15,7 +15,7 @@ concurrency: # Standard AGENTS.md "Concurrency" convention. Scheduled runs always # have `github.ref == refs/heads/` (cron's default), # and the reusable workflow `run-codegen-pull-request-task.yml` then - # matrixes over both `main` and `develop` internally — so a single + # matrixes over both `main` and `develop` internally - so a single # group per workflow+ref serializes all matrix legs of all scheduled # / dispatched runs in practice. group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 681b00a1..5052e620 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -1,165 +1,165 @@ -name: Test pull request action - -on: - pull_request: - branches: [ main, develop ] - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - - # Detect which delivery targets a PR actually touches so we only smoke-build - # what changed. Build-workflow files are intentionally NOT in any filter: a - # path filter can't tell a logic change in a build workflow from an action- - # version bump. A workflow-only change is therefore not smoke-built — the - # reusable workflows are exercised instead by the next run that uses them (a - # later code PR's smoke build, or the scheduled/publish run); lint workflow - # edits with `actionlint` locally before pushing (there is no CI lint job). - # On `workflow_dispatch` (no PR base to diff against) every target is forced - # on so a manual run is a full smoke build. - changes: - name: Detect changed targets job - runs-on: ubuntu-latest - # `dorny/paths-filter` lists the PR's changed files via the GitHub API - # (this job does not check out the tree), which needs `pull-requests: read`. - # The repo's default GITHUB_TOKEN is restricted, so grant it explicitly. - permissions: - contents: read - pull-requests: read - outputs: - docker: ${{ github.event_name == 'pull_request' && steps.filter.outputs.docker || 'true' }} - nuget: ${{ github.event_name == 'pull_request' && steps.filter.outputs.nuget || 'true' }} - pypi: ${{ github.event_name == 'pull_request' && steps.filter.outputs.pypi || 'true' }} - executable: ${{ github.event_name == 'pull_request' && steps.filter.outputs.executable || 'true' }} - steps: - - name: Filter changed paths step - id: filter - if: ${{ github.event_name == 'pull_request' }} - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 - with: - filters: | - shared: &shared - - 'Directory.Build.props' - - 'Directory.Packages.props' - - 'version.json' - - '*.slnx' - docker: - - *shared - - 'Docker/**' - - 'Console/**' - - 'NuGetLibrary/**' - executable: - - *shared - - 'Console/**' - - 'NuGetLibrary/**' - nuget: - - *shared - - 'NuGetLibrary/**' - pypi: - - 'PyPiLibrary/**' - # The PyPI package version comes from NBGV reading version.json, - # so a version bump should rebuild it. The other shared files - # (Directory.*.props, *.slnx) are .NET-only and don't affect the - # Python build, so they're intentionally excluded here. - - 'version.json' - - # Unit tests are cheap and validate the shared C# code, so they always run - # regardless of which targets changed. - unit-test: - name: Run unit tests job - runs-on: ubuntu-latest - - steps: - - - name: Setup .NET SDK step - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 - with: - dotnet-version: 10.x - - - name: Checkout code step - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - 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 - - # Fast PR feedback: build only the changed targets, in smoke mode (Docker - # amd64-only, reduced executable matrix, no publishing). Validates the PR's - # base-branch configuration (Release for main, Debug for develop) by passing - # `branch: github.base_ref`. Skipped entirely when no target changed (e.g. a - # docs-only PR) — unit tests still run. - smoke-build: - name: Smoke build changed targets job - # Also gate on unit-test: the smoke build includes a Docker image build, so - # don't spend it when unit tests are already failing. A failed unit-test - # leaves this job skipped (needs unsatisfied) and the aggregator blocks on - # the unit-test failure directly. - needs: [changes, unit-test] - if: >- - needs.changes.outputs.docker == 'true' || - needs.changes.outputs.nuget == 'true' || - needs.changes.outputs.pypi == 'true' || - needs.changes.outputs.executable == 'true' - uses: ./.github/workflows/build-release-task.yml - secrets: inherit - with: - smoke: true - # Do not publish anything from a PR. - github: false - nuget: false - dockerhub: false - # Check out the PR head by SHA (not head_ref): the head SHA is reachable - # in the base repo via refs/pull/N/head even for fork PRs, whereas the - # head_ref branch name does not exist in the base repo for forks and - # would fail checkout. Validate it in the base branch's configuration. - # `workflow_dispatch` has no pull_request payload, so fall back to the - # triggering ref. - ref: ${{ github.event.pull_request.head.sha || github.ref_name }} - branch: ${{ github.base_ref || github.ref_name }} - enable_docker: ${{ needs.changes.outputs.docker == 'true' }} - enable_nuget: ${{ needs.changes.outputs.nuget == 'true' }} - enable_pypi: ${{ needs.changes.outputs.pypi == 'true' }} - enable_executable: ${{ needs.changes.outputs.executable == 'true' }} - - # TODO: Workaround for GitHub Actions not supporting status checks on conditional jobs - # https://github.com/orgs/community/discussions/12395#discussioncomment-12970019 - # This job's name is bound to the branch ruleset as the required status check - # context — do NOT rename it (see AGENTS.md "Workflow YAML Conventions"). - check-workflow-status: - name: Check pull request workflow status - runs-on: ubuntu-latest - needs: - [ changes, unit-test, smoke-build ] - if: always() - steps: - - name: Check workflow results step - run: | - set -euo pipefail - exit_on_result() { - if [[ "$2" == "failure" || "$2" == "cancelled" ]]; then - echo "Job '$1' failed or was cancelled." - exit 1 - fi - } - # The paths-filter job MUST succeed: if it failed we don't know which - # targets changed, so a target-changing PR could merge with its smoke - # build silently skipped. Treat anything other than success as a block. - if [[ "${{ needs.changes.result }}" != "success" ]]; then - echo "Job 'changes' did not succeed (${{ needs.changes.result }}); refusing to pass." - exit 1 - fi - # unit-test always runs; smoke-build may be legitimately skipped - # (no target changed) — `skipped` passes, only failure/cancelled blocks. - exit_on_result "unit-test" "${{ needs.unit-test.result }}" - exit_on_result "smoke-build" "${{ needs.smoke-build.result }}" +name: Test pull request action + +on: + pull_request: + branches: [ main, develop ] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + + # Detect which delivery targets a PR actually touches so we only smoke-build + # what changed. Build-workflow files are intentionally NOT in any filter: a + # path filter can't tell a logic change in a build workflow from an action- + # version bump. A workflow-only change is therefore not smoke-built - the + # reusable workflows are exercised instead by the next run that uses them (a + # later code PR's smoke build, or the scheduled/publish run); lint workflow + # edits with `actionlint` locally before pushing (there is no CI lint job). + # On `workflow_dispatch` (no PR base to diff against) every target is forced + # on so a manual run is a full smoke build. + changes: + name: Detect changed targets job + runs-on: ubuntu-latest + # `dorny/paths-filter` lists the PR's changed files via the GitHub API + # (this job does not check out the tree), which needs `pull-requests: read`. + # The repo's default GITHUB_TOKEN is restricted, so grant it explicitly. + permissions: + contents: read + pull-requests: read + outputs: + docker: ${{ github.event_name == 'pull_request' && steps.filter.outputs.docker || 'true' }} + nuget: ${{ github.event_name == 'pull_request' && steps.filter.outputs.nuget || 'true' }} + pypi: ${{ github.event_name == 'pull_request' && steps.filter.outputs.pypi || 'true' }} + executable: ${{ github.event_name == 'pull_request' && steps.filter.outputs.executable || 'true' }} + steps: + - name: Filter changed paths step + id: filter + if: ${{ github.event_name == 'pull_request' }} + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + with: + filters: | + shared: &shared + - 'Directory.Build.props' + - 'Directory.Packages.props' + - 'version.json' + - '*.slnx' + docker: + - *shared + - 'Docker/**' + - 'Console/**' + - 'NuGetLibrary/**' + executable: + - *shared + - 'Console/**' + - 'NuGetLibrary/**' + nuget: + - *shared + - 'NuGetLibrary/**' + pypi: + - 'PyPiLibrary/**' + # The PyPI package version comes from NBGV reading version.json, + # so a version bump should rebuild it. The other shared files + # (Directory.*.props, *.slnx) are .NET-only and don't affect the + # Python build, so they're intentionally excluded here. + - 'version.json' + + # Unit tests are cheap and validate the shared C# code, so they always run + # regardless of which targets changed. + unit-test: + name: Run unit tests job + runs-on: ubuntu-latest + + steps: + + - name: Setup .NET SDK step + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: + dotnet-version: 10.x + + - name: Checkout code step + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - 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 + + # Fast PR feedback: build only the changed targets, in smoke mode (Docker + # amd64-only, reduced executable matrix, no publishing). Validates the PR's + # base-branch configuration (Release for main, Debug for develop) by passing + # `branch: github.base_ref`. Skipped entirely when no target changed (e.g. a + # docs-only PR) - unit tests still run. + smoke-build: + name: Smoke build changed targets job + # Also gate on unit-test: the smoke build includes a Docker image build, so + # don't spend it when unit tests are already failing. A failed unit-test + # leaves this job skipped (needs unsatisfied) and the aggregator blocks on + # the unit-test failure directly. + needs: [changes, unit-test] + if: >- + needs.changes.outputs.docker == 'true' || + needs.changes.outputs.nuget == 'true' || + needs.changes.outputs.pypi == 'true' || + needs.changes.outputs.executable == 'true' + uses: ./.github/workflows/build-release-task.yml + secrets: inherit + with: + smoke: true + # Do not publish anything from a PR. + github: false + nuget: false + dockerhub: false + # Check out the PR head by SHA (not head_ref): the head SHA is reachable + # in the base repo via refs/pull/N/head even for fork PRs, whereas the + # head_ref branch name does not exist in the base repo for forks and + # would fail checkout. Validate it in the base branch's configuration. + # `workflow_dispatch` has no pull_request payload, so fall back to the + # triggering ref. + ref: ${{ github.event.pull_request.head.sha || github.ref_name }} + branch: ${{ github.base_ref || github.ref_name }} + enable_docker: ${{ needs.changes.outputs.docker == 'true' }} + enable_nuget: ${{ needs.changes.outputs.nuget == 'true' }} + enable_pypi: ${{ needs.changes.outputs.pypi == 'true' }} + enable_executable: ${{ needs.changes.outputs.executable == 'true' }} + + # TODO: Workaround for GitHub Actions not supporting status checks on conditional jobs + # https://github.com/orgs/community/discussions/12395#discussioncomment-12970019 + # This job's name is bound to the branch ruleset as the required status check + # context - do NOT rename it (see AGENTS.md "Workflow YAML Conventions"). + check-workflow-status: + name: Check pull request workflow status + runs-on: ubuntu-latest + needs: + [ changes, unit-test, smoke-build ] + if: always() + steps: + - name: Check workflow results step + run: | + set -euo pipefail + exit_on_result() { + if [[ "$2" == "failure" || "$2" == "cancelled" ]]; then + echo "Job '$1' failed or was cancelled." + exit 1 + fi + } + # The paths-filter job MUST succeed: if it failed we don't know which + # targets changed, so a target-changing PR could merge with its smoke + # build silently skipped. Treat anything other than success as a block. + if [[ "${{ needs.changes.result }}" != "success" ]]; then + echo "Job 'changes' did not succeed (${{ needs.changes.result }}); refusing to pass." + exit 1 + fi + # unit-test always runs; smoke-build may be legitimately skipped + # (no target changed) - `skipped` passes, only failure/cancelled blocks. + exit_on_result "unit-test" "${{ needs.unit-test.result }}" + exit_on_result "smoke-build" "${{ needs.smoke-build.result }}" diff --git a/AGENTS.md b/AGENTS.md index fbafb0a4..304b027f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,188 +1,208 @@ -# 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 - -- **Default to staging, not committing.** Stage changes with `git add` and leave `git commit` to the developer unless the developer has explicitly authorized the agent to commit for the current ask ("commit this", "open a PR", etc.). Authorization is scope-bound — it covers the commits needed for that specific task, not a blanket commit license for the rest of the session. -- **All commits must be cryptographically signed (SSH or GPG).** Branch protection enforces this on both branches; unsigned commits are rejected on push. Signing depends on environment configuration — `git config commit.gpgsign true`, a configured `user.signingkey`, and a working signing agent (loaded `ssh-agent` for SSH, or `gpg-agent` for GPG). If signing is not configured in the environment, **do not commit** — surface the missing config to the developer and stop at `git add`. Verify before any agent-authored commit (`git config --get commit.gpgsign && ssh-add -L` or the GPG equivalent). -- **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. - -## 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 lets the release model attribute releases to the develop commits that produced them (relevant both for the weekly publish and the opt-in `PUBLISH_ON_MERGE` mode — see "Release Model" below). 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. -- **`develop` is forward-only — no `main → develop` back-merges.** The develop ruleset's squash-only setting physically blocks merge commits on develop. Historical back-merge commits visible in `git log` (`b9b0447`, `410ba56`, `ffb9e64`, `5ce95cf`, etc.) predate this rule and must not be repeated. -- **Both rulesets intentionally omit "Require branches to be up to date before merging".** The flag is off on `main` and on `develop`, for related but distinct reasons. - - *Main:* the check is graph-based — it asks whether main's tip commit is reachable from develop, not whether the two branches have the same content. After any develop → main release, main's tip is a brand-new merge commit that develop's history doesn't contain. Forward-only develop never adds it (no back-merge of main into develop), so the check would fail on every subsequent release. Other technical workarounds — rebasing develop onto main, or rewriting develop's history — exist but contradict the squash-only develop ruleset and the linearity invariant. - - *Develop:* the check stalls bot auto-merge when two bot PRs against develop land within the same window. As soon as the first merges, the second flips to `mergeStateStatus: BEHIND` and GitHub's auto-merge will not fire while strict is on. The merge-bot only *enables* auto-merge on `opened`/`reopened` (see below) and never auto-updates bot branches, and Dependabot's rebase isn't real-time, so the second PR sits OPEN with all checks green indefinitely. Squash mechanics still rebase the diff onto develop's tip on merge, `required_linear_history` still enforces linearity, textual conflicts still block `mergeable: CONFLICTING`, and the required `Check pull request workflow status` still gates merges — the only thing lost is pre-merge detection of *semantic-but-not-textual* conflicts, which the post-merge develop CI run catches anyway. - - See [`README.md`](./README.md#template---github-setup) "Rules / Rulesets" for the configured state. -- **Bots (Dependabot and codegen) target both `main` and `develop` in parallel.** [`.github/dependabot.yml`](./.github/dependabot.yml) duplicates every ecosystem entry (one per branch) and [`.github/workflows/run-codegen-pull-request-task.yml`](./.github/workflows/run-codegen-pull-request-task.yml) runs as a matrix over both branches with branch names `codegen-main` and `codegen-develop`. Each branch absorbs its own bot PRs independently, so neither falls behind, and the forward-only rule still holds (nothing is back-merged from main to develop — both branches receive their updates directly). The merge-bot ([`.github/workflows/merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml)) dispatches `--squash` or `--merge` from each PR's base ref via a `case` statement so the form matches the ruleset on either base. Dependabot **security** PRs (CVE-driven) always open against the repo default branch (`main`) regardless of `target-branch` — the same `case` statement covers them. -- **Maintainer-pushed commits on a bot PR auto-disable auto-merge.** The merge-bot's `merge-dependabot` and `merge-codegen` jobs only fire on `opened` / `reopened` events (auto-merge is enabled exactly once per PR). When a maintainer pushes commits to a bot's branch (a `synchronize` event with an actor that isn't the same bot), the merge-bot's `disable-auto-merge-on-maintainer-push` job fires and calls `gh pr merge --disable-auto`. The maintainer's commits stay in the PR but won't auto-merge with the bot's content; re-enable auto-merge manually (`gh pr merge --auto ` or the GitHub UI) when ready. -- **Why parallel dual-target rather than develop-only with eventual flow-through:** push-distribution channels (HACS for Home Assistant integrations, Linux distros that vendor from `main`, etc.) consume `main` directly. A develop-only model would leave `main` running stale code during long-running develop features. Codegen content can also be production-critical (live API-derived data, language lists, build catalogs) rather than just sample/demo content, so both branches need fresh codegen on their own cadence. -- **Dual-target codegen + per-run state = merge conflicts.** If a generator embeds per-invocation state (timestamps, GUIDs, build IDs) and runs independently on `main` and `develop`, the two branches' outputs diverge and every `develop → main` release conflicts on the generated file. This template's `CodeGen/CodeGen.cs` demo embeds a timestamp; the codegen workflow captures one UTC timestamp in a `get-runtime` job and passes it via `--runtime` to both matrix legs so they produce byte-identical output. (It deliberately does *not* use `github.run_started_at`, which resolves to an empty string in the reusable-workflow context and made each leg fall back to its own `DateTime.UtcNow` — diverging the output and reintroducing the conflict.) **That `--runtime` plumbing is template hygiene only — not a codegen pattern derived projects should reproduce.** Your real generators should either be deterministic given the same inputs (preferred), or not run on both release branches simultaneously, or absorb the per-release merge cost. -- **App-token workflows use Client ID, not App ID.** `actions/create-github-app-token` deprecated the numeric `app-id` input in v3.0.0; the template uses `client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}`. When adding new App-token call sites, use the same form — do not reintroduce `app-id` / `CODEGEN_APP_ID`. See [README "Template - GitHub Setup"](./README.md#template---github-setup) for the secret-setup procedure. - -## Release Model - -The template uses a **two-phase model by default**: PRs build fast, publishing is batched. See [README "Release Distribution Model"](./README.md#template---release-distribution-model-two-phase-by-default) for the full rationale; the load-bearing rules: - -- **PRs smoke-test only.** [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml) always runs unit tests, then a `dorny/paths-filter` `changes` job gates a **reduced** build of only the changed targets (Docker `linux/amd64` only, executable on a representative runtime subset), never pushing. Build-workflow files are intentionally not in the path filters — a filter can't tell a logic change from an action-version bump — so a workflow-only change isn't smoke-built; the reusable workflows are exercised by the next run that uses them (a later code PR's smoke build, or the scheduled/publish run). There is no CI workflow-lint job; lint workflow edits with `actionlint` locally before pushing. -- **Merges don't publish by default.** [`publish-release.yml`](./.github/workflows/publish-release.yml) is the sole publisher: its **weekly schedule** (Mondays 02:00 UTC) and **manual `workflow_dispatch`** always do the full build/publish of **both** `main` and `develop` (a branch matrix). Its `push` trigger publishes only when the **`PUBLISH_ON_MERGE` repository variable** is `true` (opt-in legacy continuous-release). Unset/`false` = two-phase. -- **Required check.** The `changes` job is in the `Check pull request workflow status` aggregator's `needs` and **must succeed** (not just "not fail") — a paths-filter error must never let a target-changing PR merge with its smoke build silently skipped. Skipped smoke jobs (no matching change) pass; `failure`/`cancelled` blocks. -- **Reusable-task parameter contract.** Every `build-*-task.yml` and `build-release-task.yml` takes `ref` (git ref to check out/version), `branch` (logical branch driving config/tags/prerelease — `main` ⇒ Release/`latest`/non-prerelease, else Debug/`develop`/prerelease), and where relevant `smoke`. **Branch-derived config keys off `inputs.branch`, never `github.ref_name`** — the publisher's matrix builds `develop` from a run whose `github.ref_name` is `main`, so `ref_name` would be wrong. Artifact names are branch-suffixed so both matrix legs coexist in one run. `get-version-task.yml` takes a `ref` so NBGV versions the right branch. -- **Per-target subsetting (derived projects).** `build-release-task.yml` has per-target `enable_*` gates and self-contained leaf tasks, so a project that drops a target deletes: its `build--task.yml`, the matching job + `github-release` `needs` entry in `build-release-task.yml`, its path-filter entry in `test-pull-request.yml`, and (for PyPI) the `publish-pypi` job in `publish-release.yml`. CodeGen, versioning, badge, merge-bot, and Dependabot are target-agnostic. -- **Orchestration vs. build — the override seam.** The pipeline splits into two layers. The **orchestration** layer is generic and meant to be synced verbatim from upstream: [`publish-release.yml`](./.github/workflows/publish-release.yml) (publish plan + branch matrix), the `get-version` + `github-release` jobs inside [`build-release-task.yml`](./.github/workflows/build-release-task.yml), [`get-version-task.yml`](./.github/workflows/get-version-task.yml), [`build-datebadge-task.yml`](./.github/workflows/build-datebadge-task.yml), and the aggregator shape of [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml). The **build** layer — the `build--task.yml` leaf tasks — is what a derived project owns and replaces. The contract that keeps the seam clean: **a target contributes files to the GitHub release by uploading a workflow artifact named `release-asset--`.** The `github-release` job collects every `release-asset--*` artifact by pattern and **never names a build job**, so it (the tag-the-commit + create-the-release + attach-the-assets logic) is reusable **verbatim** — that is the part a downstream previously had to fork and rewrite, and no longer does. - - **What a downstream still curates** (this is by design, not a leak): the *list* of leaf jobs in `build-release-task.yml`. Per **Per-target subsetting** above, you delete the target jobs you don't ship and add the one(s) you do — `build-release-task.yml`'s `github-release` job is untouched, but the file is not byte-identical because its `needs`/job list reflects your targets. Making that list itself target-agnostic is a larger "factor build from orchestration" refactor that is intentionally **not** done. - - **Map your outputs to the right seam** — pick by where each artifact *goes*, not by language: - - *Files attached to the GitHub Release* (zips, binaries, packaged libraries): one leaf task per output, each uploading `release-asset--`. A data-only repo (e.g. a symbol library) has exactly one such task: validate → `zip` → upload `release-asset--library`; it deletes the nuget/pypi/executable/docker jobs and the `publish-pypi` job, keeps `github-release` as-is. This is also where the .NET `build-executable-task` lives — it is *not* a generic file step, it is specifically `dotnet publish` of the console app; replace it wholesale, don't adapt it. - - *Package-registry pushes* (NuGet.org, PyPI): the leaf task both builds **and** publishes to its registry. NuGet pushes from inside `build-nugetlibrary-task` (`dotnet nuget push --skip-duplicate`) *and* also uploads a `release-asset-*` (.7z) for the GitHub release. PyPI is split: `build-pypilibrary-task` only builds + uploads the `pypilibrary-build-` artifact, and the separate `publish-pypi` job in `publish-release.yml` does the OIDC Trusted-Publishing upload (so `id-token: write` is granted only at that one entry point) — PyPI contributes **no** `release-asset-*`. - - *Image-registry pushes* (Docker Hub): `build-docker-task` pushes multi-arch tags directly; contributes **no** `release-asset-*`. - - *Source-only / no build* (validate + tag + release): you need none of the package/image leaf tasks — only your validation in `test-pull-request.yml`, one `release-asset-*` leaf task for the artifact you attach (or zero, if the release is just a tag), and the verbatim `get-version` + `github-release` + `date-badge` orchestration. - - `get-version-task.yml` installs the .NET SDK only because NBGV needs the runtime to compute the version/tag — heavyweight but expected even for a non-.NET downstream; acceptable as-is. -- **No-op republish guarantee.** A weekly/dispatch publish where NBGV `SemVer2` is **unchanged** (no new commit since the last publish) re-pushes **nothing** to GitHub Releases (the `github-release` job's `release-exists` check skips the create step), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI (`gh-action-pypi-publish` `skip-existing: true`) — all three key on the version string. **Docker always re-pushes** by design: it picks up upstream base-image refreshes (e.g. `ubuntu:rolling`) that aren't visible in the repo. Boundary: `version.json` has **no `pathFilters`**, so *any* commit — including a CI/workflow-only or docs-only change — advances the NBGV git height and therefore `SemVer2`, and the next publish *does* create a fresh release for it even when the shipped binary is byte-identical. This is accepted NBGV behavior; `pathFilters` are intentionally not added. - -## 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. Re-request a review for the **current head SHA**. Auto-trigger is unreliable, so request it explicitly via the `requestReviews` GraphQL mutation (now reliable end-to-end — see the runbook); the UI is only a fallback. -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. Drive the loop to green — review confirmed on the latest head SHA and every actionable finding closed — and then **wait for the maintainer's explicit permission to merge**. The agent does not merge on its own (consistent with "default to staging"; merging is maintainer-authorized). - -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; the rest of the repo is expected to be brought up to the same standard. Sweep PRs that apply a rule everywhere are welcome when a rule changes. - -- **Action pinning**: pin **every** action — first-party (`actions/*`) and third-party — 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. Use `# vX` (major-only) only when the upstream's floating major tag doesn't correspond to a specific patch/minor release SHA — pinning to the floating-tag SHA still gives the SHA guarantee, the version comment just records the major line. Documented exception (no SHA pin at all): [`dotnet/nbgv`](./.github/workflows/get-version-task.yml) is consumed via `@master` because the upstream tag stream lags `master` substantially and Dependabot's tag-tracking would propose a downgrade — the rationale is documented inline in that workflow. -- **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. **Documented exceptions** (both record the rationale inline in their header comment): (1) [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) uses `cancel-in-progress: false` because its three-job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order — cancellation would leave auto-merge in an inconsistent state. (2) [`publish-release.yml`](./.github/workflows/publish-release.yml) uses both a **global, ref-independent group** (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) and `cancel-in-progress: false`. It publishes shared ref-independent artifacts (both branches' Docker tags/caches and GitHub releases) on schedule/dispatch regardless of the triggering ref, so a ref-scoped group would let a scheduled run (ref `main`) and a manual dispatch (ref `develop`) run concurrently and double-push; and cancelling a publish mid-flight can leave a partially pushed tag set or a half-created release. The global group + queueing serializes every publish run to completion. -- **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` 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. Pin it to the **exact built commit's SHA** (the publisher uses NBGV's `GitCommitId` output), not `github.sha` (wrong branch in the publisher's branch matrix — a `develop` leg runs with `github.sha` = main's tip) and not a branch name (a moving ref that a mid-run commit could advance past the built tree). - -## 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. +# 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 + +- **Default to staging, not committing.** Stage changes with `git add` and leave `git commit` to the developer unless the developer has explicitly authorized the agent to commit for the current ask ("commit this", "open a PR", etc.). Authorization is scope-bound - it covers the commits needed for that specific task, not a blanket commit license for the rest of the session. +- **All commits must be cryptographically signed (SSH or GPG).** Branch protection enforces this on both branches; unsigned commits are rejected on push. Signing depends on environment configuration - `git config commit.gpgsign true`, a configured `user.signingkey`, and a working signing agent (loaded `ssh-agent` for SSH, or `gpg-agent` for GPG). If signing is not configured in the environment, **do not commit** - surface the missing config to the developer and stop at `git add`. Verify before any agent-authored commit (`git config --get commit.gpgsign && ssh-add -L` or the GPG equivalent). **Signing must be live before the *first* commit, not retrofitted.** Turning on `Require signed commits` against a branch that already has unsigned commits forces a rewrite of that entire history to re-sign it - changing every commit SHA and making whoever does the rewrite the committer and signer of every commit (a rebase preserves the `author` field but not the original signatures; you cannot sign another contributor's commits for them). During new-repo setup, never create commits until signing is verified. +- **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. + +## 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 lets the release model attribute releases to the develop commits that produced them (relevant both for the weekly publish and the opt-in `PUBLISH_ON_MERGE` mode - see "Release Model" below). 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. +- **`develop` is forward-only - no `main -> develop` back-merges.** The develop ruleset's squash-only setting physically blocks merge commits on develop. Historical back-merge commits visible in `git log` (`b9b0447`, `410ba56`, `ffb9e64`, `5ce95cf`, etc.) predate this rule and must not be repeated. +- **Both rulesets intentionally omit "Require branches to be up to date before merging".** The flag is off on `main` and on `develop`, for related but distinct reasons. + - *Main:* the check is graph-based - it asks whether main's tip commit is reachable from develop, not whether the two branches have the same content. After any develop -> main release, main's tip is a brand-new merge commit that develop's history doesn't contain. Forward-only develop never adds it (no back-merge of main into develop), so the check would fail on every subsequent release. Other technical workarounds - rebasing develop onto main, or rewriting develop's history - exist but contradict the squash-only develop ruleset and the linearity invariant. + - *Develop:* the check stalls bot auto-merge when two bot PRs against develop land within the same window. As soon as the first merges, the second flips to `mergeStateStatus: BEHIND` and GitHub's auto-merge will not fire while strict is on. The merge-bot only *enables* auto-merge on `opened`/`reopened` (see below) and never auto-updates bot branches, and Dependabot's rebase isn't real-time, so the second PR sits OPEN with all checks green indefinitely. Squash mechanics still rebase the diff onto develop's tip on merge, `required_linear_history` still enforces linearity, textual conflicts still block `mergeable: CONFLICTING`, and the required `Check pull request workflow status` still gates merges - the only thing lost is pre-merge detection of *semantic-but-not-textual* conflicts, which the post-merge develop CI run catches anyway. + - See [`README.md`](./README.md#template---github-setup) "Rules / Rulesets" for the configured state. +- **Configuring branch protection on a derived repo: don't hand-build the rules.** Reconstructing the rules by hand is error-prone and has gone wrong on past ports. First delete any **legacy classic branch-protection** rules and any stray rulesets (this template uses rulesets *only*), then create **exactly two rulesets named `develop` and `main`** by exporting the template's two rulesets and re-importing them via `gh api -X POST .../rulesets` (`gh ruleset` is read-only). The names are load-bearing - this file and the workflows reference them. Full export/import procedure: [README "Rules / Rulesets"](./README.md#template---github-setup). +- **Bots (Dependabot and codegen) target both `main` and `develop` in parallel.** [`.github/dependabot.yml`](./.github/dependabot.yml) duplicates every ecosystem entry (one per branch) and [`.github/workflows/run-codegen-pull-request-task.yml`](./.github/workflows/run-codegen-pull-request-task.yml) runs as a matrix over both branches with branch names `codegen-main` and `codegen-develop`. Each branch absorbs its own bot PRs independently, so neither falls behind, and the forward-only rule still holds (nothing is back-merged from main to develop - both branches receive their updates directly). The merge-bot ([`.github/workflows/merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml)) dispatches `--squash` or `--merge` from each PR's base ref via a `case` statement so the form matches the ruleset on either base. Dependabot **security** PRs (CVE-driven) always open against the repo default branch (`main`) regardless of `target-branch` - the same `case` statement covers them. +- **Maintainer-pushed commits on a bot PR auto-disable auto-merge.** The merge-bot's `merge-dependabot` and `merge-codegen` jobs only fire on `opened` / `reopened` events (auto-merge is enabled exactly once per PR). When a maintainer pushes commits to a bot's branch (a `synchronize` event with an actor that isn't the same bot), the merge-bot's `disable-auto-merge-on-maintainer-push` job fires and calls `gh pr merge --disable-auto`. The maintainer's commits stay in the PR but won't auto-merge with the bot's content; re-enable auto-merge manually (`gh pr merge --auto ` or the GitHub UI) when ready. +- **Why parallel dual-target rather than develop-only with eventual flow-through:** push-distribution channels (HACS for Home Assistant integrations, Linux distros that vendor from `main`, etc.) consume `main` directly. A develop-only model would leave `main` running stale code during long-running develop features. Codegen content can also be production-critical (live API-derived data, language lists, build catalogs) rather than just sample/demo content, so both branches need fresh codegen on their own cadence. +- **Dual-target codegen + per-run state = merge conflicts.** If a generator embeds per-invocation state (timestamps, GUIDs, build IDs) and runs independently on `main` and `develop`, the two branches' outputs diverge and every `develop -> main` release conflicts on the generated file. This template's `CodeGen/CodeGen.cs` demo embeds a timestamp; the codegen workflow captures one UTC timestamp in a `get-runtime` job and passes it via `--runtime` to both matrix legs so they produce byte-identical output. (It deliberately does *not* use `github.run_started_at`, which resolves to an empty string in the reusable-workflow context and made each leg fall back to its own `DateTime.UtcNow` - diverging the output and reintroducing the conflict.) **That `--runtime` plumbing is template hygiene only - not a codegen pattern derived projects should reproduce.** Your real generators should either be deterministic given the same inputs (preferred), or not run on both release branches simultaneously, or absorb the per-release merge cost. +- **App-token workflows use Client ID, not App ID.** `actions/create-github-app-token` deprecated the numeric `app-id` input in v3.0.0; the template uses `client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}`. When adding new App-token call sites, use the same form - do not reintroduce `app-id` / `CODEGEN_APP_ID`. See [README "Template - GitHub Setup"](./README.md#template---github-setup) for the secret-setup procedure. + +## Release Model + +The template uses a **two-phase model by default**: PRs build fast, publishing is batched. See [README "Release Distribution Model"](./README.md#template---release-distribution-model-two-phase-by-default) for the full rationale; the load-bearing rules: + +- **PRs smoke-test only.** [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml) always runs unit tests, then a `dorny/paths-filter` `changes` job gates a **reduced** build of only the changed targets (Docker `linux/amd64` only, executable on a representative runtime subset), never pushing. Build-workflow files are intentionally not in the path filters - a filter can't tell a logic change from an action-version bump - so a workflow-only change isn't smoke-built; the reusable workflows are exercised by the next run that uses them (a later code PR's smoke build, or the scheduled/publish run). There is no CI workflow-lint job; lint workflow edits with `actionlint` locally before pushing. +- **Merges don't publish by default.** [`publish-release.yml`](./.github/workflows/publish-release.yml) is the sole publisher: its **weekly schedule** (Mondays 02:00 UTC) and **manual `workflow_dispatch`** always do the full build/publish of **both** `main` and `develop` (a branch matrix). Its `push` trigger publishes only when the **`PUBLISH_ON_MERGE` repository variable** is `true` (opt-in legacy continuous-release). Unset/`false` = two-phase. +- **Required check.** The `changes` job is in the `Check pull request workflow status` aggregator's `needs` and **must succeed** (not just "not fail") - a paths-filter error must never let a target-changing PR merge with its smoke build silently skipped. Skipped smoke jobs (no matching change) pass; `failure`/`cancelled` blocks. +- **Reusable-task parameter contract.** Every `build-*-task.yml` and `build-release-task.yml` takes `ref` (git ref to check out/version), `branch` (logical branch driving config/tags/prerelease - `main` => Release/`latest`/non-prerelease, else Debug/`develop`/prerelease), and where relevant `smoke`. **Branch-derived config keys off `inputs.branch`, never `github.ref_name`** - the publisher's matrix builds `develop` from a run whose `github.ref_name` is `main`, so `ref_name` would be wrong. Artifact names are branch-suffixed so both matrix legs coexist in one run. `get-version-task.yml` takes a `ref` so NBGV versions the right branch. +- **Per-target subsetting (derived projects).** `build-release-task.yml` has per-target `enable_*` gates and self-contained leaf tasks, so a project that drops a target deletes: its `build--task.yml`, the matching job + `github-release` `needs` entry in `build-release-task.yml`, its path-filter entry in `test-pull-request.yml`, and (for PyPI) the `publish-pypi` job in `publish-release.yml`. CodeGen, versioning, badge, merge-bot, and Dependabot are target-agnostic. +- **Orchestration vs. build - the override seam.** The pipeline splits into two layers. The **orchestration** layer is generic and meant to be synced verbatim from upstream: [`publish-release.yml`](./.github/workflows/publish-release.yml) (publish plan + branch matrix), the `get-version` + `github-release` jobs inside [`build-release-task.yml`](./.github/workflows/build-release-task.yml), [`get-version-task.yml`](./.github/workflows/get-version-task.yml), [`build-datebadge-task.yml`](./.github/workflows/build-datebadge-task.yml), and the aggregator shape of [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml). The **build** layer - the `build--task.yml` leaf tasks - is what a derived project owns and replaces. The contract that keeps the seam clean: **a target contributes files to the GitHub release by uploading a workflow artifact named `release-asset--`.** The `github-release` job collects every `release-asset--*` artifact by pattern and **never names a build job**, so it (the tag-the-commit + create-the-release + attach-the-assets logic) is reusable **verbatim** - that is the part a downstream previously had to fork and rewrite, and no longer does. + - **What a downstream still curates** (this is by design, not a leak): the *list* of leaf jobs in `build-release-task.yml`. Per **Per-target subsetting** above, you delete the target jobs you don't ship and add the one(s) you do - `build-release-task.yml`'s `github-release` job is untouched, but the file is not byte-identical because its `needs`/job list reflects your targets. Making that list itself target-agnostic is a larger "factor build from orchestration" refactor that is intentionally **not** done. + - **Map your outputs to the right seam** - pick by where each artifact *goes*, not by language: + - *Files attached to the GitHub Release* (zips, binaries, packaged libraries): one leaf task per output, each uploading `release-asset--`. A data-only repo (e.g. a symbol library) has exactly one such task: validate -> `zip` -> upload `release-asset--library`; it deletes the nuget/pypi/executable/docker jobs and the `publish-pypi` job, keeps `github-release` as-is. This is also where the .NET `build-executable-task` lives - it is *not* a generic file step, it is specifically `dotnet publish` of the console app; replace it wholesale, don't adapt it. + - *Package-registry pushes* (NuGet.org, PyPI): the leaf task both builds **and** publishes to its registry. NuGet pushes from inside `build-nugetlibrary-task` (`dotnet nuget push --skip-duplicate`) *and* also uploads a `release-asset-*` (.7z) for the GitHub release. PyPI is split: `build-pypilibrary-task` only builds + uploads the `pypilibrary-build-` artifact, and the separate `publish-pypi` job in `publish-release.yml` does the OIDC Trusted-Publishing upload (so `id-token: write` is granted only at that one entry point) - PyPI contributes **no** `release-asset-*`. + - *Image-registry pushes* (Docker Hub): `build-docker-task` pushes multi-arch tags directly; contributes **no** `release-asset-*`. + - *Source-only / no build* (validate + tag + release): you need none of the package/image leaf tasks - only your validation in `test-pull-request.yml`, one `release-asset-*` leaf task for the artifact you attach (or zero, if the release is just a tag), and the verbatim `get-version` + `github-release` + `date-badge` orchestration. + - `get-version-task.yml` installs the .NET SDK only because NBGV needs the runtime to compute the version/tag - heavyweight but expected even for a non-.NET downstream; acceptable as-is. +- **No-op republish guarantee.** A weekly/dispatch publish where NBGV `SemVer2` is **unchanged** (no new commit since the last publish) re-pushes **nothing** to GitHub Releases (the `github-release` job's `release-exists` check skips the create step), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI (`gh-action-pypi-publish` `skip-existing: true`) - all three key on the version string. **Docker always re-pushes** by design: it picks up upstream base-image refreshes (e.g. `ubuntu:rolling`) that aren't visible in the repo. Boundary: `version.json` has **no `pathFilters`**, so *any* commit - including a CI/workflow-only or docs-only change - advances the NBGV git height and therefore `SemVer2`, and the next publish *does* create a fresh release for it even when the shipped binary is byte-identical. This is accepted NBGV behavior; `pathFilters` are intentionally not added. + +## 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. +- **Write docs in the current state, not as a change from a prior one.** The reader has no memory of the previous behavior, so describe what *is*: "X does Y", never "X *now* does Y", "X *no longer* does Z", "changed/switched/restored to Y", or "X *still* does W". Before/after framing belongs in changelogs, commit messages, and PR descriptions - where the prior state is the point - not in `README.md` or other living docs. + +### Character Set + +- **Write ASCII in all agent-authored text** - documentation, code, comments, commit messages, and PR descriptions. The agent does not introduce non-ASCII characters. Replace typographic Unicode with its ASCII equivalent on sight: + - em dash (U+2014) and en dash (U+2013) -> hyphen `-` (use a spaced ` - ` for an em-dash-style clause break) + - right arrow (U+2192) -> `->`; double arrow (U+21D2) -> `=>` + - less-than-or-equal (U+2264) -> `<=`; greater-than-or-equal (U+2265) -> `>=` + - curly quotes (U+2018/U+2019/U+201C/U+201D) -> straight `'` and `"`; ellipsis (U+2026) -> `...` +- **Allowed non-ASCII (two narrow exceptions):** + - **Scientific or technical symbols with no clean ASCII equivalent** - e.g. ohm, micro, degree, pi. Keep the symbol; do not approximate it away. + - **Unicode the developer deliberately typed** - emoji used for emphasis or as callout markers (for example the warning/info markers a maintainer placed in `README.md`). Preserve it; never strip the developer's own characters. This carve-out is for developer-authored text, not a license for the agent to add emoji. + +### Line Endings + +- **[`.editorconfig`](./.editorconfig) defines the correct line ending per file type:** **CRLF** for `.md`, `.cs`, XML/`.csproj`/`.props`/`.targets`, `.yml`/`.yaml`, `.json`, and `.cmd`/`.bat`/`.ps1`; **LF** for `.sh`. `.gitattributes` is `* -text`, so git stores the exact bytes you commit and will **not** normalize endings for you. +- **New files:** create them with the `.editorconfig`-mandated ending. +- **Editing an existing file:** **preserve the file's current line endings** - do not reflow them as a side effect of a content change, even if the file is already non-compliant. A tool that rewrites a file in text mode (a script, a bulk find/replace) can silently flip CRLF to LF and turn a one-line change into a whole-file diff. After any programmatic edit, verify before staging: `git diff --stat` should touch only the lines you changed, and `file ` should report the file's expected ending. If a diff balloons to the whole file, you flipped the endings - restore them and re-stage. +- **Fixing a non-compliant file:** bring it to its `.editorconfig` ending as a **deliberate** change, and prefer to isolate it in its own EOL-only commit so the churn is reviewable. When a broader maintenance change has to normalize endings alongside content edits (a repo-wide cleanup sometimes does), call it out explicitly in the commit/PR description and verify the content separately with `git diff --ignore-cr-at-eol`. + +### 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. Re-request a review for the **current head SHA**. Auto-trigger is unreliable, so request it explicitly via the `requestReviews` GraphQL mutation (now reliable end-to-end - see the runbook); the UI is only a fallback. +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. Drive the loop to green - review confirmed on the latest head SHA and every actionable finding closed - and then **wait for the maintainer's explicit permission to merge**. The agent does not merge on its own (consistent with "default to staging"; merging is maintainer-authorized). + +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; the rest of the repo is expected to be brought up to the same standard. Sweep PRs that apply a rule everywhere are welcome when a rule changes. + +- **Action pinning**: pin **every** action - first-party (`actions/*`) and third-party - 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. Use `# vX` (major-only) only when the upstream's floating major tag doesn't correspond to a specific patch/minor release SHA - pinning to the floating-tag SHA still gives the SHA guarantee, the version comment just records the major line. Documented exception (no SHA pin at all): [`dotnet/nbgv`](./.github/workflows/get-version-task.yml) is consumed via `@master` because the upstream tag stream lags `master` substantially and Dependabot's tag-tracking would propose a downgrade - the rationale is documented inline in that workflow. +- **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. **Documented exceptions** (both record the rationale inline in their header comment): (1) [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) uses `cancel-in-progress: false` because its three-job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order - cancellation would leave auto-merge in an inconsistent state. (2) [`publish-release.yml`](./.github/workflows/publish-release.yml) uses both a **global, ref-independent group** (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) and `cancel-in-progress: false`. It publishes shared ref-independent artifacts (both branches' Docker tags/caches and GitHub releases) on schedule/dispatch regardless of the triggering ref, so a ref-scoped group would let a scheduled run (ref `main`) and a manual dispatch (ref `develop`) run concurrently and double-push; and cancelling a publish mid-flight can leave a partially pushed tag set or a half-created release. The global group + queueing serializes every publish run to completion. +- **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` 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. Pin it to the **exact built commit's SHA** (the publisher uses NBGV's `GitCommitId` output), not `github.sha` (wrong branch in the publisher's branch matrix - a `develop` leg runs with `github.sha` = main's tip) and not a branch name (a moving ref that a mid-run commit could advance past the built tree). + +## 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/CODESTYLE.md b/CODESTYLE.md index 1252bc9b..e831346a 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -1,6 +1,6 @@ -# Code Style and Formatting Rules — .NET +# 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. +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. @@ -11,7 +11,7 @@ Cross-cutting rules (PR titles, branching, US English, markdown style, workflow **CRITICAL**: All builds must complete without warnings. The project enforces this through: 1. **VS Code tasks** - - `CSharpier Format` → `.Net Build` → `.Net Format` + - `CSharpier Format` -> `.Net Build` -> `.Net Format` - `.Net Format` must pass with `--verify-no-changes` before commit - Command: `dotnet format style --verify-no-changes --severity=info --verbosity=detailed` @@ -22,7 +22,7 @@ Cross-cutting rules (PR titles, branching, US English, markdown style, workflow 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 + - No git hooks ship by default - see README "Optional: enable git hooks locally" to opt in ### Build Tasks @@ -48,7 +48,7 @@ Available VS Code tasks (use via `run_task` tool): - `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. +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 @@ -171,7 +171,7 @@ Note: Code snippets are illustrative examples only. Replace namespaces/types to - Linux scripts (`.sh`): LF 6. **`#region`**: Do not use regions. Prefer logical file/folder/namespace organization. -7. **Member ordering (StyleCop SA1201)**: const → static readonly → static fields → instance readonly fields → instance fields → constructors → public (events → properties → indexers → methods → operators) → non-public in same order → nested types +7. **Member ordering (StyleCop SA1201)**: const -> static readonly -> static fields -> instance readonly fields -> instance fields -> constructors -> public (events -> properties -> indexers -> methods -> operators) -> non-public in same order -> nested types ### Comments and Documentation diff --git a/CodeGen/CodeGen.cs b/CodeGen/CodeGen.cs index 55f7fbb7..95736e39 100644 --- a/CodeGen/CodeGen.cs +++ b/CodeGen/CodeGen.cs @@ -8,7 +8,7 @@ internal static class CodeGen internal static void Quote() { - const string dateTime = "2026-06-04T05:46:01Z"; + const string dateTime = "2026-06-05T05:41:45Z"; Console.WriteLine($"{dateTime} : {QuoteOfTheDay}"); Log.Logger.Information("Quote of the Day: {DateTime} : {Quote}", dateTime, QuoteOfTheDay); } diff --git a/CodeGen/CodeGenBuilder.cs b/CodeGen/CodeGenBuilder.cs index a4e19d70..1a79c4fb 100644 --- a/CodeGen/CodeGenBuilder.cs +++ b/CodeGen/CodeGenBuilder.cs @@ -9,7 +9,7 @@ internal sealed class CodeGenBuilder(string outputPath, CancellationToken cancel // produce byte-identical CodeGen.cs and develop->main merges don't // conflict on this file. Empty -> use DateTime.UtcNow.ToString("o") // (the original demo behavior, kept for local `dotnet run` use). - // This plumbing exists for template hygiene only — derived projects' + // This plumbing exists for template hygiene only - derived projects' // real codegen should not copy this pattern. internal async Task CodeGenAsync(string quote, string runtime) { diff --git a/CodeGen/CommandLine.cs b/CodeGen/CommandLine.cs index daa32d4b..df2e049d 100644 --- a/CodeGen/CommandLine.cs +++ b/CodeGen/CommandLine.cs @@ -67,7 +67,7 @@ private static Option CreateApiKeyOption() => // Template-internal: deterministic timestamp injection so the dual-target // codegen matrix produces byte-identical CodeGen.cs on main and develop // (eliminates merge conflicts on every develop->main release). Derived - // projects: do NOT replicate this plumbing for production codegen — if + // projects: do NOT replicate this plumbing for production codegen - if // your generator's per-run timestamp is intentional, accept the conflicts // or redesign the generator. See README "Template - GitHub Setup". private static Option CreateRuntimeOption() => diff --git a/HISTORY.md b/HISTORY.md index e1af32e4..04011e57 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,9 +1,9 @@ -# ProjectTemplate - -C# .NET project template. - -## Release History - -- Version 1.0: - - Something. - - And something else. +# ProjectTemplate + +C# .NET project template. + +## Release History + +- Version 1.0: + - Something. + - And something else. diff --git a/PyPiLibrary/CODESTYLE.md b/PyPiLibrary/CODESTYLE.md index 9e23de45..a281db30 100644 --- a/PyPiLibrary/CODESTYLE.md +++ b/PyPiLibrary/CODESTYLE.md @@ -1,125 +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. +# 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 index 38c5f05a..323db12b 100644 --- a/PyPiLibrary/README.md +++ b/PyPiLibrary/README.md @@ -1,94 +1,94 @@ -# 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) -- **Version** — [Nerdbank.GitVersioning](https://github.com/dotnet/Nerdbank.GitVersioning) (NBGV) shared with the .NET side. CI replaces the `__version__` line in `_version.py` (in place) before `uv build`. **Branch-aware**: on `main` the value is NBGV's `AssemblyFileVersion` (`Major.Minor.Patch.BuildNumber`, PEP 440 release); on `develop` it's `Major.Minor.Patch.BuildNumber.dev0` (PEP 440 dev release — `pip install` filters the `.dev` suffix unless `--pre` is passed; the BuildNumber stays in the release segment so develop's segment grows past main's per commit and `--pre` actually prefers develop). Matches how NuGet/Docker tag develop builds as prerelease. All four artifact families (.NET assemblies, NuGet, Docker, PyPI) derive from the same NBGV computation per commit; only the formatting differs. - -## 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. - -**Two-channel publishing**: pushes to both `main` and `develop` trigger `publish-release.yml`, and the **"Compute PyPI version step"** in `build-pypilibrary-task.yml` formats the version per branch: - -- `main` → `Major.Minor.Patch.BuildNumber` (PEP 440 release). `pip install ptr727-projecttemplate-library` picks this up by default. -- `develop` → `Major.Minor.Patch.BuildNumber.dev0` (PEP 440 dev release). The BuildNumber stays in the release segment so develop's release segment grows past main's per commit — that's what lets `pip install --pre ptr727-projecttemplate-library` actually resolve to a develop build (`--pre` would otherwise still pick the higher-on-release-segments main version). Same PyPI project; no separate "test" project required. - -Edge case worth knowing: in the window between a release merge to main and the next commit on develop, develop's BuildNumber equals main's (or is one lower), so `--pre` will still resolve to the main release until a new develop commit lands. Self-healing. - -This matches how NuGet (NBGV `SemVer2` prerelease tags), Docker (NBGV `SemVer2` image tags), and GitHub releases (softprops `prerelease: true` on develop) already mark develop builds. - -First-time setup (one-time, on PyPI): - -Prerequisite: enable **2FA** on the PyPI account (TOTP or hardware key). PyPI requires it before any trusted publisher can be registered. - -1. **PyPI** → **Account settings** → **Publishing** → **Add a new pending publisher** ([direct link](https://pypi.org/manage/account/publishing/)). If the project already exists on PyPI, go to the project page → **Manage** → **Publishing** → **Add a new publisher** instead — the "pending" form is only for projects that don't exist yet. Fields: - - **PyPI project name**: `ptr727-projecttemplate-library` - - **Owner**: `ptr727` - - **Repository name**: `ProjectTemplate` - - **Workflow filename**: `publish-release.yml` - - **Environment name**: `pypi` -2. **GitHub repo** → **Settings** → **Environments** → **New environment** → `pypi`. The environment owns deploy-time guardrails: - - **Deployment branch rule** → **Selected branches and tags** → add **both** `main` (release channel) and `develop` (prerelease channel). **This step is mandatory — Trusted Publishing without a branch restriction is a documented security anti-pattern.** Any other branch (feature branches, codegen, etc.) is blocked at the env gate even if a workflow misconfiguration ever tried to publish from it. - - (Optional) add yourself as a **required reviewer** so each publish requires a click — useful belt-and-suspenders against an accidental release. -3. The first successful release converts the pending publisher to a real publisher. After that the same OIDC exchange validates against the real publisher on every release. - -Troubleshooting: - -- `invalid-publisher: ... Publisher with matching claims was not found` — the publisher hasn't been registered yet, or one of the five claim fields (owner, repo, workflow filename, environment name, project name) doesn't match. Re-check step 1. -- `manifest unknown` from `docker:` pulling `ghcr.io/pypa/gh-action-pypi-publish` — the SHA pinned in `publish-release.yml` doesn't correspond to a release tag with a published GHCR image. Pin to the SHA that the upstream tag (`# vX.Y.Z` comment) actually points at on `pypa/gh-action-pypi-publish`. - -Fallback (API token instead of Trusted Publishing): drop the `id-token: write` permission from the `publish-pypi` job, add `password: ${{ secrets.PYPI_API_TOKEN }}` to the `pypa/gh-action-pypi-publish` step, and store the token as a repo secret. Also pass `attestations: false` since attestations require the OIDC token. The OIDC path is preferred — no long-lived secret in the repo — so use the token method only when Trusted Publishing isn't an option. - -## 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. -- **Pick a versioning scheme.** The template defaults to **NBGV-driven** versioning shared with the .NET side: `_version.py` holds `__version__ = "0.0.0"` as a local-development placeholder, and the CI steps **"Compute PyPI version step"** + **"Write version into _version.py step"** in [`build-pypilibrary-task.yml`](../.github/workflows/build-pypilibrary-task.yml) compute and rewrite the value before `uv build`. The version is **branch-aware**: `main` pushes ship `M.N.P.B` (PEP 440 release), `develop` pushes ship `M.N.P.B.dev0` (PEP 440 dev release — same release segment as main, `.dev0` marks it as prerelease so `pip install` filters it unless `--pre` is passed). The BuildNumber stays in the release segment so develop's segment grows past main's per commit, which is what lets `--pre` actually prefer develop. On `main` the PyPI version equals the .NET `FileVersion` stamp exactly; on `develop` it equals the same `FileVersion` numerically but with a trailing `.dev0`. .NET's `AssemblyVersion` (a separate NBGV output) and NuGet/Docker (NBGV `SemVer2`) carry different strings across artifact families on both channels; all four derive from the same NBGV computation against `version.json` + git history per commit. If you want a different scheme, replace both `_version.py` and the workflow steps. Two common alternatives: - - [`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"`. Drop the CI overwrite step. Pairs well with tag-driven releases and removes the NBGV dependency. - - **Manual bumps** — edit `_version.py` in each release PR. Simplest, but easy to forget. Drop the CI overwrite step. - - The publish workflow uses `skip-existing: true` so a re-upload of the same version is a no-op instead of a failure — useful when iterating on releases without bumping NBGV. - -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`. +# 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) +- **Version** - [Nerdbank.GitVersioning](https://github.com/dotnet/Nerdbank.GitVersioning) (NBGV) shared with the .NET side. CI replaces the `__version__` line in `_version.py` (in place) before `uv build`. **Branch-aware**: on `main` the value is NBGV's `AssemblyFileVersion` (`Major.Minor.Patch.BuildNumber`, PEP 440 release); on `develop` it's `Major.Minor.Patch.BuildNumber.dev0` (PEP 440 dev release - `pip install` filters the `.dev` suffix unless `--pre` is passed; the BuildNumber stays in the release segment so develop's segment grows past main's per commit and `--pre` actually prefers develop). Matches how NuGet/Docker tag develop builds as prerelease. All four artifact families (.NET assemblies, NuGet, Docker, PyPI) derive from the same NBGV computation per commit; only the formatting differs. + +## 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. + +**Two-channel publishing**: pushes to both `main` and `develop` trigger `publish-release.yml`, and the **"Compute PyPI version step"** in `build-pypilibrary-task.yml` formats the version per branch: + +- `main` -> `Major.Minor.Patch.BuildNumber` (PEP 440 release). `pip install ptr727-projecttemplate-library` picks this up by default. +- `develop` -> `Major.Minor.Patch.BuildNumber.dev0` (PEP 440 dev release). The BuildNumber stays in the release segment so develop's release segment grows past main's per commit - that's what lets `pip install --pre ptr727-projecttemplate-library` actually resolve to a develop build (`--pre` would otherwise still pick the higher-on-release-segments main version). Same PyPI project; no separate "test" project required. + +Edge case worth knowing: in the window between a release merge to main and the next commit on develop, develop's BuildNumber equals main's (or is one lower), so `--pre` will still resolve to the main release until a new develop commit lands. Self-healing. + +This matches how NuGet (NBGV `SemVer2` prerelease tags), Docker (NBGV `SemVer2` image tags), and GitHub releases (softprops `prerelease: true` on develop) already mark develop builds. + +First-time setup (one-time, on PyPI): + +Prerequisite: enable **2FA** on the PyPI account (TOTP or hardware key). PyPI requires it before any trusted publisher can be registered. + +1. **PyPI** -> **Account settings** -> **Publishing** -> **Add a new pending publisher** ([direct link](https://pypi.org/manage/account/publishing/)). If the project already exists on PyPI, go to the project page -> **Manage** -> **Publishing** -> **Add a new publisher** instead - the "pending" form is only for projects that don't exist yet. Fields: + - **PyPI project name**: `ptr727-projecttemplate-library` + - **Owner**: `ptr727` + - **Repository name**: `ProjectTemplate` + - **Workflow filename**: `publish-release.yml` + - **Environment name**: `pypi` +2. **GitHub repo** -> **Settings** -> **Environments** -> **New environment** -> `pypi`. The environment owns deploy-time guardrails: + - **Deployment branch rule** -> **Selected branches and tags** -> add **both** `main` (release channel) and `develop` (prerelease channel). **This step is mandatory - Trusted Publishing without a branch restriction is a documented security anti-pattern.** Any other branch (feature branches, codegen, etc.) is blocked at the env gate even if a workflow misconfiguration ever tried to publish from it. + - (Optional) add yourself as a **required reviewer** so each publish requires a click - useful belt-and-suspenders against an accidental release. +3. The first successful release converts the pending publisher to a real publisher. After that the same OIDC exchange validates against the real publisher on every release. + +Troubleshooting: + +- `invalid-publisher: ... Publisher with matching claims was not found` - the publisher hasn't been registered yet, or one of the five claim fields (owner, repo, workflow filename, environment name, project name) doesn't match. Re-check step 1. +- `manifest unknown` from `docker:` pulling `ghcr.io/pypa/gh-action-pypi-publish` - the SHA pinned in `publish-release.yml` doesn't correspond to a release tag with a published GHCR image. Pin to the SHA that the upstream tag (`# vX.Y.Z` comment) actually points at on `pypa/gh-action-pypi-publish`. + +Fallback (API token instead of Trusted Publishing): drop the `id-token: write` permission from the `publish-pypi` job, add `password: ${{ secrets.PYPI_API_TOKEN }}` to the `pypa/gh-action-pypi-publish` step, and store the token as a repo secret. Also pass `attestations: false` since attestations require the OIDC token. The OIDC path is preferred - no long-lived secret in the repo - so use the token method only when Trusted Publishing isn't an option. + +## 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. +- **Pick a versioning scheme.** The template defaults to **NBGV-driven** versioning shared with the .NET side: `_version.py` holds `__version__ = "0.0.0"` as a local-development placeholder, and the CI steps **"Compute PyPI version step"** + **"Write version into _version.py step"** in [`build-pypilibrary-task.yml`](../.github/workflows/build-pypilibrary-task.yml) compute and rewrite the value before `uv build`. The version is **branch-aware**: `main` pushes ship `M.N.P.B` (PEP 440 release), `develop` pushes ship `M.N.P.B.dev0` (PEP 440 dev release - same release segment as main, `.dev0` marks it as prerelease so `pip install` filters it unless `--pre` is passed). The BuildNumber stays in the release segment so develop's segment grows past main's per commit, which is what lets `--pre` actually prefer develop. On `main` the PyPI version equals the .NET `FileVersion` stamp exactly; on `develop` it equals the same `FileVersion` numerically but with a trailing `.dev0`. .NET's `AssemblyVersion` (a separate NBGV output) and NuGet/Docker (NBGV `SemVer2`) carry different strings across artifact families on both channels; all four derive from the same NBGV computation against `version.json` + git history per commit. If you want a different scheme, replace both `_version.py` and the workflow steps. Two common alternatives: + - [`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"`. Drop the CI overwrite step. Pairs well with tag-driven releases and removes the NBGV dependency. + - **Manual bumps** - edit `_version.py` in each release PR. Simplest, but easy to forget. Drop the CI overwrite step. + + The publish workflow uses `skip-existing: true` so a re-upload of the same version is a no-op instead of a failure - useful when iterating on releases without bumping NBGV. + +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 index f73f737c..9fb0321a 100644 --- a/PyPiLibrary/pyproject.toml +++ b/PyPiLibrary/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ptr727-projecttemplate-library" -description = "Python PyPI template library — companion to the .NET NuGetLibrary in this template repo." +description = "Python PyPI template library - companion to the .NET NuGetLibrary in this template repo." readme = "README.md" license = { text = "MIT" } authors = [{ name = "Pieter Viljoen" }] @@ -69,7 +69,7 @@ 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 +# 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 diff --git a/PyPiLibrary/src/ptr727_projecttemplate_library/_version.py b/PyPiLibrary/src/ptr727_projecttemplate_library/_version.py index 7ce72b48..cbecf34f 100644 --- a/PyPiLibrary/src/ptr727_projecttemplate_library/_version.py +++ b/PyPiLibrary/src/ptr727_projecttemplate_library/_version.py @@ -8,12 +8,12 @@ NBGV's ``AssemblyFileVersion`` (``Major.Minor.Patch.BuildNumber``) just before ``uv build``. The format is **branch-aware**: -- ``main`` push → ``Major.Minor.Patch.BuildNumber`` (PEP 440 release). +- ``main`` push -> ``Major.Minor.Patch.BuildNumber`` (PEP 440 release). ``pip install `` picks this up by default. Equals the .NET assemblies' ``FileVersion`` stamp. -- ``develop`` push → ``Major.Minor.Patch.BuildNumber.dev0`` (PEP 440 dev +- ``develop`` push -> ``Major.Minor.Patch.BuildNumber.dev0`` (PEP 440 dev release). The BuildNumber stays in the release segment so develop's - segment grows past main's per commit — that's what makes + segment grows past main's per commit - that's what makes ``pip install --pre `` actually prefer the develop build over the main release. Without ``--pre``, pip filters the ``.dev`` suffix and picks the main release. Matches how NuGet/Docker mark develop as @@ -23,7 +23,7 @@ until a new develop commit lands. Self-healing. .NET's ``AssemblyVersion`` (the binary-compat identity, a separate NBGV -output) and NuGet ``PackageVersion`` / Docker tags (NBGV ``SemVer2`` — +output) and NuGet ``PackageVersion`` / Docker tags (NBGV ``SemVer2`` - PEP 440 doesn't accept its prerelease / build-metadata suffixes) all carry different strings across artifact families. All four still derive from the same NBGV computation against ``version.json`` + git history diff --git a/PyPiLibrary/src/ptr727_projecttemplate_library/example.py b/PyPiLibrary/src/ptr727_projecttemplate_library/example.py index 84e2fcfb..97088328 100644 --- a/PyPiLibrary/src/ptr727_projecttemplate_library/example.py +++ b/PyPiLibrary/src/ptr727_projecttemplate_library/example.py @@ -1,4 +1,4 @@ -"""Trivial example module — replace with your library code.""" +"""Trivial example module - replace with your library code.""" def greet(name: str) -> str: diff --git a/README.md b/README.md index 462c8499..c73b2160 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ See [Installation](#installation) for detailed setup instructions. - [Template - Developer Environment Setup](#template---developer-environment-setup) - [Template - GitHub Setup](#template---github-setup) - [Template - Branching Workflow](#template---branching-workflow) + - [Template - Release Distribution Model: Two-Phase by Default](#template---release-distribution-model-two-phase-by-default) ## Use Cases @@ -214,18 +215,18 @@ Options: 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`. +- **`.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. +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. +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)**: @@ -273,9 +274,9 @@ Each container bind-mounts your SSH public key, allowed-signers file, and `gh` c **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. +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]: +- **For .NET work** - install [Husky.Net][huskynet-link]: ```shell dotnet new tool-manifest # if no tool manifest exists yet @@ -284,14 +285,14 @@ Hooks are not shipped with the template — CI is the lint backstop. Opt in per 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]: +- **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): + 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: @@ -347,7 +348,7 @@ Licensed under the [MIT License][license-link]\ ### Template - TODO List -- [ ] Configure git for SSH signing and SSH forwarding in dev containers — see [docs/host-setup.md](./docs/host-setup.md), [docs/ssh-signing.md](./docs/ssh-signing.md), and [docs/devcontainer.md](./docs/devcontainer.md). +- [ ] Configure git for SSH signing **before making any commits** (the signed-commits ruleset rejects unsigned history, and retrofitting it forces a full re-sign - see [GitHub Setup](#template---github-setup)), plus 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). @@ -362,7 +363,7 @@ Licensed under the [MIT License][license-link]\ - [ ] Commit and push the `first-branch`. - [ ] Edit and iterate only in `first-branch` until ready to start with git history. - [ ] Setup `main` as the first permanent branch when ready. -- [ ] Configure [GitHub](#template---github-setup) for the new repository. +- [ ] Configure [GitHub](#template---github-setup) for the new repository - including deleting any classic branch protection and creating the two `develop`/`main` rulesets by exporting/importing the template's (see [Rules / Rulesets](#template---github-setup)). - [ ] Follow the [Branching Workflow](#template---branching-workflow). - [ ] Delete the `Project Template Setup` section from `README.md`. @@ -371,9 +372,9 @@ Licensed under the [MIT License][license-link]\ #### Template - Git Setup - **⚠️ Prerequisites**: - - 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). + - 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 @@ -412,6 +413,8 @@ Licensed under the [MIT License][license-link]\ - Use `first-branch` for all the initial project setup and testing. - When ready, *only when ready*, create `main` branch from `first-branch` with no history: + > **Warning - sign from the very first commit.** The `Initial import (squashed)` commit below - and every commit after it - must be **cryptographically signed**. Configure SSH signing (the Prerequisites above) *before* running these commands, and verify it is live: `git config --get commit.gpgsign` is `true` and a signer is loaded (`ssh-add -L` for SSH). Do **not** enable the `Require signed commits` ruleset (below) until the branch's history is fully signed. Enabling it on a branch that already contains unsigned commits forces you to **rewrite the entire history to re-sign it** - which changes every commit SHA and makes whoever does the rewrite the committer and signer of every commit (you cannot sign another contributor's commits for them; a rebase preserves the `author` field but not the original signatures). Order: signing first -> clean signed history -> then the ruleset. + ```shell # Create main branch with no history git checkout --orphan main @@ -434,60 +437,94 @@ Licensed under the [MIT License][license-link]\ - Create a [NuGet API Key](https://www.nuget.org/account/apikeys). - Save the Key as `NUGET_API_KEY` in: - - GitHub project security Settings / Secrets / Actions. - - GitHub project security Settings / Secrets / Dependabot. + - GitHub project Settings / Secrets / Actions. + - GitHub project Settings / Secrets / Dependabot. - GitHub Local Actions Settings / Secrets. - Create a [Docker Hub Personal Access Token](https://app.docker.com/accounts/ptr727/settings/personal-access-tokens). - Save the PAT as `DOCKER_HUB_ACCESS_TOKEN` and `DOCKER_HUB_USERNAME` in: - - GitHub project security Settings / Secrets / Actions. - - GitHub project security Settings / Secrets / Dependabot. + - GitHub project Settings / Secrets / Actions. + - GitHub project Settings / Secrets / Dependabot. - Create a [GitHub App](https://github.com/settings/apps) for the codegen and merge-bot workflows. - - App name: `ptr727-codegen`. Bot user: `ptr727-codegen[bot]`. + - App name: `ptr727-codegen`. + - Bot user: `ptr727-codegen[bot]`. - Permissions required (repository scope): - - Contents: Read & write — push commits to the `codegen` branch and merge bot PRs. - - Pull requests: Read & write — open, update, and merge pull requests. + - Contents: Read & write - push commits to the `codegen` branch and merge bot PRs. + - Pull requests: Read & write - open, update, and merge pull requests. - Metadata: Read-only (auto-required). - - Note the **Client ID** from the app's settings page (labeled "Client ID" directly under the App name on the General tab — it looks like `Iv23li...`; **not** the numeric App ID shown above it). `actions/create-github-app-token` deprecated the numeric `app-id` input in v3.0.0 in favor of `client-id`. Also generate a private key (downloads a `.pem` file). - - [Install the app](https://github.com/settings/apps) on your account and grant it access to the repository. The app must be both created **and** installed — creating it alone is not sufficient (`actions/create-github-app-token` fails with `Not Found` if the app isn't installed on the repository). + - Note the client id, and download and secure the private key `.pem` file. + - Grant the app access to the repository, or all repositories. + - GitHub Settings / Developer settings / GitHub Apps / ptr727-codegen / Configure + - The app must be both created **and** installed - creating it alone is not sufficient (`actions/create-github-app-token` fails with `Not Found` if the app isn't installed on the repository). - Save the Client ID as `CODEGEN_APP_CLIENT_ID` and the private key contents as `CODEGEN_APP_PRIVATE_KEY` in **both** of: - - GitHub project security Settings / Secrets / Actions — for the codegen workflow and the codegen merge job. - - GitHub project security Settings / Secrets / Dependabot — **required** because Dependabot-triggered `pull_request` workflow runs use a separate, restricted secret context that doesn't see Actions secrets. Without the App secrets in the Dependabot store, the `merge-dependabot` job in `merge-bot-pull-request.yml` can't mint an App token and the PR will never auto-merge. + - GitHub project Settings / Secrets / Actions - for the codegen workflow and the codegen merge job. + - GitHub project Settings / Secrets / Dependabot - **required** because Dependabot-triggered `pull_request` workflow runs use a separate, restricted secret context that doesn't see Actions secrets. Without the App secrets in the Dependabot store, the `merge-dependabot` job in `merge-bot-pull-request.yml` can't mint an App token and the PR will never auto-merge. - If the codegen workflows require additional secrets (e.g. third-party API keys), register them in the Actions store; if a Dependabot-triggered workflow ever needs them, register them in the Dependabot store too. - - The App token is used by **both** the codegen workflow (`run-codegen-pull-request-task.yml`) **and** every job in `merge-bot-pull-request.yml`. App-authored pushes/PRs trigger downstream `pull_request` and `push` workflow events directly — unlike `GITHUB_TOKEN`-authored events, which are blocked by GitHub's recursion guard. This matters for two reasons: bot-opened PRs trigger the `test-pull-request.yml` smoke build (so they can't auto-merge unvalidated), and — when `PUBLISH_ON_MERGE` is enabled — the merge commit triggers `publish-release.yml`. It also means the codegen workflow no longer needs the legacy close/reopen dance to trigger auto-merge. + - The App token is used by **both** the codegen workflow (`run-codegen-pull-request-task.yml`) **and** every job in `merge-bot-pull-request.yml`. App-authored pushes/PRs trigger downstream `pull_request` and `push` workflow events directly - unlike `GITHUB_TOKEN`-authored events, which are blocked by GitHub's recursion guard. This matters for two reasons: bot-opened PRs trigger the `test-pull-request.yml` smoke build (so they can't auto-merge unvalidated), and - when `PUBLISH_ON_MERGE` is enabled - the merge commit triggers `publish-release.yml`. App-authored events also let the codegen workflow's auto-merge fire directly. - The codegen auto-merge condition in `merge-bot-pull-request.yml` (`merge-codegen` job) requires: - - **Event is `opened` or `reopened`** — auto-merge is enabled once per PR at open time; subsequent `synchronize` events do not re-enable. This is what lets the `disable-auto-merge-on-maintainer-push` safeguard (below) stick. - - `github.event.pull_request.user.login == 'ptr727-codegen[bot]'` — PR was opened by the App. - - `github.event.pull_request.head.repo.full_name == github.repository` — PR is from this repo (not a fork). - - **Strict head/base pairing** — `(head.ref == 'codegen-main' && base.ref == 'main') || (head.ref == 'codegen-develop' && base.ref == 'develop')`. Codegen runs as a matrix opening one PR per branch; this pairing prevents a misconfigured workflow from sneaking a `codegen-develop` branch into `main` or vice versa. - - The `disable-auto-merge-on-maintainer-push` job in `merge-bot-pull-request.yml` runs on `synchronize` events against bot-authored PRs (Dependabot or codegen) when the event actor is NOT the same bot — i.e. a maintainer pushed commits. It calls `gh pr merge --disable-auto` so the maintainer's commits don't auto-merge along with the bot's content. Re-enable auto-merge manually (`gh pr merge --auto ` or the GitHub UI) when ready. + - **Event is `opened` or `reopened`** - auto-merge is enabled once per PR at open time; subsequent `synchronize` events do not re-enable. This is what lets the `disable-auto-merge-on-maintainer-push` safeguard (below) stick. + - `github.event.pull_request.user.login == 'ptr727-codegen[bot]'` - PR was opened by the App. + - `github.event.pull_request.head.repo.full_name == github.repository` - PR is from this repo (not a fork). + - **Strict head/base pairing** - `(head.ref == 'codegen-main' && base.ref == 'main') || (head.ref == 'codegen-develop' && base.ref == 'develop')`. Codegen runs as a matrix opening one PR per branch; this pairing prevents a misconfigured workflow from sneaking a `codegen-develop` branch into `main` or vice versa. + - The `disable-auto-merge-on-maintainer-push` job in `merge-bot-pull-request.yml` runs on `synchronize` events against bot-authored PRs (Dependabot or codegen) when the event actor is NOT the same bot - i.e. a maintainer pushed commits. It calls `gh pr merge --disable-auto` so the maintainer's commits don't auto-merge along with the bot's content. Re-enable auto-merge manually (`gh pr merge --auto ` or the GitHub UI) when ready. Codegen targets `main` AND `develop` in parallel (matrix in `run-codegen-pull-request-task.yml`), so generated content lands on both branches independently without any back-merging. See [AGENTS.md "Branching Model"](./AGENTS.md#branching-model) for why this dual-target pattern beats develop-only-with-flow-through. **Codegen workflow schedule**: -- `run-periodic-codegen-pull-request.yml` runs **daily** at 04:00 UTC (staggered two hours after the weekly publish), plus on-demand via `workflow_dispatch`. It uses the App token (`CODEGEN_APP_CLIENT_ID` + `CODEGEN_APP_PRIVATE_KEY`) to commit, open the PR as `ptr727-codegen[bot]`, and let the merge-bot auto-merge once CI passes. No PAT, no close/reopen dance. Daily is cheap in the default two-phase model — codegen merges only smoke-test; the weekly publish batches the actual release. +- `run-periodic-codegen-pull-request.yml` runs **daily** at 04:00 UTC (staggered two hours after the weekly publish), plus on-demand via `workflow_dispatch`. It uses the App token (`CODEGEN_APP_CLIENT_ID` + `CODEGEN_APP_PRIVATE_KEY`) to commit, open the PR as `ptr727-codegen[bot]`, and let the merge-bot auto-merge once CI passes. No PAT, no close/reopen dance. Daily is cheap in the default two-phase model - codegen merges only smoke-test; the weekly publish batches the actual release. **GitHub project settings**: - General: - Default branch: `main` - - Pull requests — **both** merge methods enabled at the repo level so each branch ruleset can pick the right one (develop = `Squash`, main = `Merge`): - - `Allow merge commits` ✓ (required for develop → main releases) - - `Allow squash merging` ✓ (required for feature → develop merges) - - `Allow rebase merging` — disabled (no flow uses it; the develop ruleset forbids it anyway) + - Pull requests - **both** merge methods enabled at the repo level so each branch ruleset can pick the right one (develop = `Squash`, main = `Merge`): + - `Allow merge commits` ✓ (required for develop -> main releases) + - `Allow squash merging` ✓ (required for feature -> develop merges) + - `Allow rebase merging` - disabled (no flow uses it; the develop ruleset forbids it anyway) - `Always suggest updating pull request branches` - `Allow auto-merge` -- Rules / Rulesets — **separate rulesets per branch**. Develop and main intentionally diverge on two rules — allowed merge methods and `Require linear history`. `Require branches to be up to date before merging` is **off on both** for related-but-distinct reasons (below); everything else is shared. +- Rules / Rulesets - **separate rulesets per branch**. Develop and main intentionally diverge on two rules - allowed merge methods and `Require linear history`. `Require branches to be up to date before merging` is **off on both** for related-but-distinct reasons (below); everything else is shared. + - **Configure these by exporting the template's rulesets and re-importing them - do not hand-build the rules.** The result must be **exactly two rulesets named `develop` and `main`** (the names are load-bearing: `AGENTS.md` and these docs reference them). Reconstructing each rule by hand is the step that has gone wrong on past ports. + - **Step 0 - remove classic protection first.** Delete any **classic branch-protection rules** (Settings -> Branches) and any pre-existing/stray **rulesets** (Settings -> Rules -> Rulesets) so enforcement isn't doubled or contradicted - this template uses rulesets *only*. Equivalent API: + + ```sh + # Delete classic branch protection if present (404 = none, which is fine) + for b in main develop; do gh api -X DELETE "repos///branches/$b/protection" 2>/dev/null || true; done + # List existing rulesets; delete any that are not the two created below + gh api "repos///rulesets" --jq '.[] | "\(.id)\t\(.name)"' + # gh api -X DELETE "repos///rulesets/" + ``` + + - **Step 1 - export the template's two rulesets**, keeping only the re-importable fields (the GET response also carries `id`, timestamps, `_links`, `source`, etc. that a create call rejects): + + ```sh + for name in develop main; do + id=$(gh api repos/ptr727/ProjectTemplate/rulesets --jq ".[] | select(.name==\"$name\") | .id") + gh api "repos/ptr727/ProjectTemplate/rulesets/$id" \ + --jq '{name, target, enforcement, bypass_actors, conditions, rules}' > "$name-ruleset.json" + done + ``` + + - **Step 2 - import into the new repo:** + + ```sh + for name in develop main; do + gh api -X POST "repos///rulesets" --input "$name-ruleset.json" + done + ``` + + - Caveats: `bypass_actors` uses the **Admin** repository role (`actor_id: 5`), a global GitHub id that ports across repos as-is. The required status-check context (`Check pull request workflow status`) is matched by **name** and only turns green after `test-pull-request.yml` has run at least once. `gh ruleset` is read-only (list/view) - creation must go through `gh api -X POST` as above. If a field is rejected, edit the JSON and re-run the import. + - The per-branch settings below are the **reference for what each ruleset contains and why** (and the manual fallback if you configure via the UI): - "Develop": - Target branches: `develop`. - Allowed merge methods: `Squash` - `Require linear history` (develop is kept linear; main carries merge commits by design, so this setting belongs to develop only) - - `Require status checks to pass` → `Require branches to be up to date before merging` **intentionally OFF**. Leaving it on stalls bot auto-merge when two bot PRs against develop land within the same window — the first merge flips the second to `mergeStateStatus: BEHIND`, and GitHub's auto-merge will not fire while strict is on. The merge-bot in [`.github/workflows/merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) only enables auto-merge on `opened`/`reopened` and never auto-updates bot branches; Dependabot's rebase isn't real-time. With strict off, squash mechanics still rebase the diff onto develop's tip on merge, `Require linear history` still enforces linearity, textual conflicts still block `mergeable: CONFLICTING`, and the required `Check pull request workflow status` still gates merges. See [AGENTS.md "Branching Model"](./AGENTS.md#branching-model) for the full reasoning. + - `Require status checks to pass` -> `Require branches to be up to date before merging` **intentionally OFF**. Leaving it on stalls bot auto-merge when two bot PRs against develop land within the same window - the first merge flips the second to `mergeStateStatus: BEHIND`, and GitHub's auto-merge will not fire while strict is on. The merge-bot in [`.github/workflows/merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) only enables auto-merge on `opened`/`reopened` and never auto-updates bot branches; Dependabot's rebase isn't real-time. With strict off, squash mechanics still rebase the diff onto develop's tip on merge, `Require linear history` still enforces linearity, textual conflicts still block `mergeable: CONFLICTING`, and the required `Check pull request workflow status` still gates merges. See [AGENTS.md "Branching Model"](./AGENTS.md#branching-model) for the full reasoning. - Plus shared settings (below). - "Main": - Target branches: `main`. - Allowed merge methods: `Merge` - - `Require status checks to pass` → `Require branches to be up to date before merging` **intentionally OFF**. This rule is incompatible with the forward-only develop model. GitHub's "up to date" check is graph-based: it asks whether main's tip commit is reachable from develop. After any develop → main release, main's new tip is a brand-new merge commit that develop's history doesn't contain. Forward-only develop never adds it (no back-merge of main into develop, no rebase of develop onto main), so the check fails permanently on every subsequent release. Leaving the rule on would force every release through an admin bypass. See [AGENTS.md "Branching Model"](./AGENTS.md#branching-model) for the full reasoning. + - `Require status checks to pass` -> `Require branches to be up to date before merging` **intentionally OFF**. This rule is incompatible with the forward-only develop model. GitHub's "up to date" check is graph-based: it asks whether main's tip commit is reachable from develop. After any develop -> main release, main's new tip is a brand-new merge commit that develop's history doesn't contain. Forward-only develop never adds it (no back-merge of main into develop, no rebase of develop onto main), so the check fails permanently on every subsequent release. Leaving the rule on would force every release through an admin bypass. See [AGENTS.md "Branching Model"](./AGENTS.md#branching-model) for the full reasoning. - Plus shared settings (below). - Shared settings (apply to both rulesets): - `Restrict deletions` @@ -510,11 +547,11 @@ See [AGENTS.md "Branching Model"](./AGENTS.md#branching-model) for the authorita - Persistent `main` and `develop` branches, each with its own ruleset (above). Both must always be building error free. - Feature branches off `develop`. Only commit on feature branches, never directly to `develop` or `main`. -- Feature → `develop`: **squash-merge** (develop ruleset enforces this; develop is kept linear). -- `develop` → `main`: **merge-commit** (preserves develop's commit list as a real second-parent reference on main; main ruleset enforces this). -- **`develop` is forward-only.** No `main → develop` back-merges. The develop squash-only ruleset physically blocks merge commits. -- **Bots open parallel PRs against both branches.** [`.github/dependabot.yml`](./.github/dependabot.yml) duplicates each ecosystem entry per branch, and [`.github/workflows/run-codegen-pull-request-task.yml`](./.github/workflows/run-codegen-pull-request-task.yml) runs as a matrix (branch names `codegen-main` and `codegen-develop`). Each branch absorbs its own bot PRs independently — neither falls behind, no back-merges needed. -- **Review-then-merge loop.** Every PR is reviewed by GitHub Copilot. The agent pushes, re-requests a review on the new head (now reliable via the `requestReviews` GraphQL mutation), addresses and resolves each finding, repeats until green, and then **waits for the maintainer's explicit permission to merge** — it does not self-merge. See [AGENTS.md "PR Review Etiquette"](./AGENTS.md#pr-review-etiquette) and the [Copilot Review Runbook](./.github/copilot-instructions.md#github-copilot-review-runbook) for the mechanics. +- Feature -> `develop`: **squash-merge** (develop ruleset enforces this; develop is kept linear). +- `develop` -> `main`: **merge-commit** (preserves develop's commit list as a real second-parent reference on main; main ruleset enforces this). +- **`develop` is forward-only.** No `main -> develop` back-merges. The develop squash-only ruleset physically blocks merge commits. +- **Bots open parallel PRs against both branches.** [`.github/dependabot.yml`](./.github/dependabot.yml) duplicates each ecosystem entry per branch, and [`.github/workflows/run-codegen-pull-request-task.yml`](./.github/workflows/run-codegen-pull-request-task.yml) runs as a matrix (branch names `codegen-main` and `codegen-develop`). Each branch absorbs its own bot PRs independently - neither falls behind, no back-merges needed. +- **Review-then-merge loop.** Every PR is reviewed by GitHub Copilot. The agent pushes, re-requests a review on the new head (via the `requestReviews` GraphQL mutation), addresses and resolves each finding, repeats until green, and then **waits for the maintainer's explicit permission to merge** - it does not self-merge. See [AGENTS.md "PR Review Etiquette"](./AGENTS.md#pr-review-etiquette) and the [Copilot Review Runbook](./.github/copilot-instructions.md#github-copilot-review-runbook) for the mechanics. ### Template - Release Distribution Model: Two-Phase by Default @@ -522,33 +559,33 @@ This template ships with a **two-phase model** that decouples merging from publi - **Pull requests smoke-test only.** [`.github/workflows/test-pull-request.yml`](./.github/workflows/test-pull-request.yml) always runs unit tests, then path-gates a **reduced** build of only the targets a PR touches (`dorny/paths-filter`): Docker as `linux/amd64` only (no QEMU/arm64), the executable as a representative runtime subset, and nothing is pushed. A docs-only PR runs unit tests alone; a Dependabot github-actions bump is unit-tests-only. This is fast feedback, not a release. - **Merges to `main`/`develop` do not publish.** A push only smoke-tested the PR; merging it republishes nothing. -- **The weekly schedule + manual dispatch are the sole publishers.** [`.github/workflows/publish-release.yml`](./.github/workflows/publish-release.yml) runs every **Monday 02:00 UTC** and on-demand via `workflow_dispatch`, and on either trigger does the **full** build/publish of **both** `main` (Release / `latest` / non-prerelease) and `develop` (Debug / `develop` / prerelease) — GitHub release, NuGet/PyPI uploads, multi-arch Docker tags, platform executables, and a refreshed Docker base image. Trigger a release on demand from the Actions UI when you want one between weekly runs. +- **The weekly schedule + manual dispatch are the sole publishers.** [`.github/workflows/publish-release.yml`](./.github/workflows/publish-release.yml) runs every **Monday 02:00 UTC** and on-demand via `workflow_dispatch`, and on either trigger does the **full** build/publish of **both** `main` (Release / `latest` / non-prerelease) and `develop` (Debug / `develop` / prerelease) - GitHub release, NuGet/PyPI uploads, multi-arch Docker tags, platform executables, and a refreshed Docker base image. Trigger a release on demand from the Actions UI when you want one between weekly runs. -This batches cheap bot churn (Dependabot/codegen merge daily, validated by smoke builds) into one periodic publish instead of one release per merge, and keeps PR feedback fast by deferring the slow `arm64`/full-matrix builds to the publisher. A no-op weekly run (no new commit, so an unchanged `SemVer2`) re-pushes nothing to GitHub Releases / NuGet / PyPI — only Docker re-pushes, to pick up upstream base-image refreshes. +This batches cheap bot churn (Dependabot/codegen merge daily, validated by smoke builds) into one periodic publish instead of one release per merge, and keeps PR feedback fast by deferring the slow `arm64`/full-matrix builds to the publisher. A no-op weekly run (no new commit, so an unchanged `SemVer2`) re-pushes nothing to GitHub Releases / NuGet / PyPI - only Docker re-pushes, to pick up upstream base-image refreshes. #### Reusing the Release Pipeline in a Derived Project (Any Language) The pipeline is built in two layers so you only customize one of them: -- **Orchestration (sync verbatim — don't rewrite):** the publish plan and branch matrix in `publish-release.yml`, the version step (`get-version-task.yml`), the date badge (`build-datebadge-task.yml`), and — the key part — the **`github-release` job** that tags the built commit, creates the GitHub Release, and attaches assets. It collects assets by the pattern `release-asset--*` and never names a build job, so it works unchanged no matter what you ship. +- **Orchestration (sync verbatim - don't rewrite):** the publish plan and branch matrix in `publish-release.yml`, the version step (`get-version-task.yml`), the date badge (`build-datebadge-task.yml`), and - the key part - the **`github-release` job** that tags the built commit, creates the GitHub Release, and attaches assets. It collects assets by the pattern `release-asset--*` and never names a build job, so it works unchanged no matter what you ship. - **Build (you own these):** the `build--task.yml` leaf tasks. Each one builds an output and either pushes it to a registry, uploads a `release-asset--` artifact for the GitHub Release, or both. -**The one rule:** to put a file on the GitHub Release, upload it as an artifact named `release-asset--`. That's the seam — implement it in a leaf task and the rest of the release just works. +**The one rule:** to put a file on the GitHub Release, upload it as an artifact named `release-asset--`. That's the seam - implement it in a leaf task and the rest of the release just works. Customize by **where your outputs go**, not by language: | What you ship | What to do | Goes to | | --- | --- | --- | -| **A zip / packaged files / a binary** on the GitHub Release (e.g. a data or asset library) | One leaf task: validate → `zip` → upload `release-asset--library` | GitHub Release asset | +| **A zip / packaged files / a binary** on the GitHub Release (e.g. a data or asset library) | One leaf task: validate -> `zip` -> upload `release-asset--library` | GitHub Release asset | | **A NuGet package** | Keep/adapt `build-nugetlibrary-task` (it `dotnet nuget push`es *and* uploads a `release-asset-*`) | NuGet.org **+** GitHub Release asset | | **A PyPI package** | Keep/adapt `build-pypilibrary-task` (build + artifact) and the `publish-pypi` job in `publish-release.yml` (OIDC upload) | PyPI only | | **A Docker image** | Keep/adapt `build-docker-task` (pushes multi-arch tags) | Docker Hub only | -| **A compiled app/CLI** | Keep/adapt `build-executable-task` — note it is specifically `dotnet publish`; replace it wholesale for another toolchain | GitHub Release asset | +| **A compiled app/CLI** | Keep/adapt `build-executable-task` - note it is specifically `dotnet publish`; replace it wholesale for another toolchain | GitHub Release asset | | **Just validate + tag a release** (no build output) | Put your checks in `test-pull-request.yml`; attach a `release-asset-*` only if you have a file | GitHub Release (tag, optionally an asset) | -For each output you **don't** ship, delete its `build--task.yml`, its job + `needs` entry in `build-release-task.yml`, its `test-pull-request.yml` path filter, and (PyPI) the `publish-pypi` job. So `build-release-task.yml` is edited to reflect *your* set of leaf jobs, but its `github-release` logic stays as-is — that's the part you reuse rather than fork. `get-version-task.yml` still installs the .NET SDK to run NBGV (which produces the version/tag) even in a non-.NET repo. See [`AGENTS.md`](./AGENTS.md) "Release Model" for the full seam contract and the no-op-republish guarantee. +For each output you **don't** ship, delete its `build--task.yml`, its job + `needs` entry in `build-release-task.yml`, its `test-pull-request.yml` path filter, and (PyPI) the `publish-pypi` job. So `build-release-task.yml` is edited to reflect *your* set of leaf jobs, but its `github-release` logic stays as-is - that's the part you reuse rather than fork. `get-version-task.yml` installs the .NET SDK to run NBGV (which produces the version/tag) even in a non-.NET repo. See [`AGENTS.md`](./AGENTS.md) "Release Model" for the full seam contract and the no-op-republish guarantee. -**Opt in to publish-on-merge.** Set the repository variable `PUBLISH_ON_MERGE` to `true` (Settings → Secrets and variables → Actions → Variables) to restore the legacy **continuous-release** model: every push/merge to `main` publishes `main` and every push to `develop` publishes `develop`, immediately. The weekly + manual publishers still run. Leave the variable unset (or `false`) for the two-phase default. It's a repository variable, not a workflow edit, so pulling template updates never conflicts with your choice. +**Opt in to publish-on-merge.** Set the repository variable `PUBLISH_ON_MERGE` to `true` (Settings -> Secrets and variables -> Actions -> Variables) to switch to the **continuous-release** model: every push/merge to `main` publishes `main` and every push to `develop` publishes `develop`, immediately. The weekly and manual publishers also run. Leave the variable unset (or `false`) for the two-phase default. It's a repository variable, not a workflow edit, so pulling template updates never conflicts with your choice. Which to pick: two-phase suits projects whose consumers are **pushed** updates (HACS for Home Assistant, package managers that auto-update, Linux distros that vendor from `main`) where every release is a forced update and frequent bot-driven releases are noise. `PUBLISH_ON_MERGE=true` suits projects whose consumers **pull** at their own cadence (Docker pulls, NuGet/PyPI installs, manual downloads) and want every merged change available immediately. For an example of a push-distribution project, see [homeassistant-purpleair](https://github.com/ptr727/homeassistant-purpleair) (ships through HACS). diff --git a/docs/devcontainer.md b/docs/devcontainer.md index 68336792..e838f0e1 100644 --- a/docs/devcontainer.md +++ b/docs/devcontainer.md @@ -1,111 +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. +# 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 index 69d49a9d..8c1ddae0 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -1,138 +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. +# 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 index 1a95e06a..52f62e3e 100644 --- a/docs/ssh-signing.md +++ b/docs/ssh-signing.md @@ -1,122 +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. +# 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.