diff --git a/.editorconfig b/.editorconfig index 4366e7de..23c9b7c1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -48,9 +48,15 @@ end_of_line = lf [*.{cmd,bat,ps1}] end_of_line = crlf +# --- .NET-only below: C# and ReSharper style. Everything above is the line-ending +# governance every derived repo carries; a non-.NET repo may drop from here down. --- + # C# files [*.cs] end_of_line = crlf +# Suppressions follow CODESTYLE.md "Analyzer Diagnostics and Suppressions": prefer a +# [SuppressMessage] attribute or the owning project's .editorconfig; relax a rule +# repo-wide here only when it applies to every project (never a brownfield batch). dotnet_diagnostic.IDE0055.severity = none dotnet_analyzer_diagnostic.severity = suggestion csharp_indent_block_contents = true diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c969281a..d7940af3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,58 +2,35 @@ 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. +The **canonical guide is [AGENTS.md](../AGENTS.md)** at the repo root - read it first, including the [PR Review Etiquette](../AGENTS.md#pr-review-etiquette) review-loop contract this file's runbook implements. This file is intentionally narrow: commit/PR-title conventions (summarized inline so VS Code's commit-message and PR-title generators have them) plus the GitHub Copilot Review Runbook. -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 code-style rules, see [`CODESTYLE.md`](../CODESTYLE.md) at the repo root - one guide with a General section plus per-language sections (.NET, Python). -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. +Do not duplicate language-specific rules here. **Project-specific conventions and API/behavioral contracts also belong in [AGENTS.md](../AGENTS.md), not here** - this file is intentionally limited to the inline commit/PR-title summary and the GitHub Copilot Review Runbook. Non-Copilot agents (Claude Code, Codex, Cursor, ...) are not directed to this file and don't read it by default, so any rule a reviewer must honor has to live in `AGENTS.md` to be provider-independent. ## 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. - -`develop` leads `main` by a minor. After a `develop -> main` release lands and main's publish completes, bump the minor in [version.json](../version.json) on `develop` via an isolated `bump-version-X.Y` PR, so develop's prereleases sort above main's last stable. A `develop -> main` promotion that carries only maintenance (not a release) holds main's version instead - `git checkout main -- version.json` on the promotion branch. See [AGENTS.md "Release Model"](../AGENTS.md#release-model). - -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 +Summarized for VS Code's generators; the full rules, rationale, and examples are in [AGENTS.md "Pull Request Title and Commit Message Conventions"](../AGENTS.md#pull-request-title-and-commit-message-conventions). -- 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 -``` +- Imperative subject, <= 72 characters, no trailing period; optional blank-line-separated body for the non-obvious *why*. +- US English, title case with lowercase short bind words; no vague titles, no `Co-Authored-By:` unless asked, no release-bump magnitude (NBGV handles versioning). Dependabot's `Bump X from Y to Z` titles are fine. +- develop PRs squash-merge (`gh pr merge --squash`), main PRs merge-commit (`--merge`); a mismatched flag is rejected by branch protection. ## GitHub Copilot Review Runbook -> **Carry this runbook verbatim into every derived repo.** This whole file is a drop-in: copy it as-is and change only the `` / `` / `` placeholders in the snippets below (and drop the language-specific style pointers that don't apply). It pairs with the [AGENTS.md "PR Review Etiquette"](../AGENTS.md#pr-review-etiquette) contract, which is also a mandatory verbatim carry - see [AGENTS.md "Files and Sections Derived Repos Must Carry Verbatim"](../AGENTS.md#files-and-sections-derived-repos-must-carry-verbatim). A derived repo without this runbook in-repo has no pointer to the reliable Copilot mechanics and falls back to known-broken paths (the no-op `POST /requested_reviewers`, the wrong bot-login filter). +> This runbook implements the [AGENTS.md "PR Review Etiquette"](../AGENTS.md#pr-review-etiquette) review-loop contract for GitHub Copilot. Without it in-repo, an agent has no pointer to the reliable Copilot mechanics and falls back to known-broken paths (the no-op `POST /requested_reviewers`, the wrong bot-login filter). In the API snippets below, fill the `` / `` / `` placeholders. 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). +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 drives the loop end-to-end without a UI 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. +**A review with no inline comments is still a completed review - not a failure, and not a reason to ask the maintainer to re-trigger.** Copilot very often posts a single formal review (GraphQL `state: COMMENTED`) whose body ends with "...reviewed N of N changed files ... and generated no comments" and adds **zero** inline threads. That review carries the head `commit.oid` and fully satisfies the loop - it is the clean-pass success case. Never read "no inline comments" as "the review didn't run," and never re-request or escalate to the maintainer because comments are absent. + +**Round 1 is normally auto-seeded - poll for it before trying to self-trigger.** Auto-review-on-open supplies the first review with no `botIds` call needed, but it can lag one to three minutes. After opening a PR (or the first push), **poll** for a Copilot review on the head SHA (see [Verify Review Covered Current Head](#verify-review-covered-current-head)) before concluding none ran. The `requestReviews` mutation below is for **re-requesting on later pushes** (a new head SHA); by then a prior review exists, so its bot node id is readable. A missing bot node id on round 1 therefore means "the auto-review has not landed yet - wait and poll," **not** "ask the maintainer to kick it off." + +> **The reviewer login differs by API.** 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 @@ -79,7 +56,7 @@ mutation($pr: ID!, $bot: 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. +The bot node id is read from an existing Copilot **formal** review (`pullRequest.reviews`), so step 1 needs at least one prior formal review on the PR - the auto-review-on-open normally supplies the first one (it may have **no inline comments**; that still counts, and its bot node id is still readable). Poll for it (give auto-review-on-open a few minutes) before deciding it is missing. If Copilot posted **only an issue comment** and no formal review, the head is covered but `reviews` yields no bot node id - read the id from the Copilot issue comment's author by querying the PR's issue comments in GraphQL (`pullRequest.comments` -> author `... on Bot { id }`), or request `Copilot` once through the GitHub PR UI to produce a formal review. Manual UI seeding is the fallback specifically when no formal review exists to read the id from; 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. @@ -106,10 +83,12 @@ 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. +Coverage is confirmed when (1) exits 0 - **a formal review with no inline comments still satisfies path (1)**, because coverage is about the head SHA, not the comment count. 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 +This path is only for a **genuinely missing** review - no Copilot review (formal *or* issue comment) covers the current head SHA after polling. A review that covered the head but produced no comments is a clean pass, not a missing review; do not enter this retry path for it. + If a review did not run on the current head, retry: 1. Wait briefly and check head-SHA coverage (see above). @@ -163,13 +142,13 @@ Issue-level Copilot comments (those in `issues//comments`) have no resolution 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 style comment: cite the rule (AGENTS.md or the CODESTYLE.md language section) 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. +Read [AGENTS.md](../AGENTS.md) for this repo's conventions. For code-style rules, [`CODESTYLE.md`](../CODESTYLE.md) (its General section plus the relevant language section) is authoritative. Don't restate any of these files' rules in commit bodies or PR descriptions - keep those focused on the change itself. -**In a derived repo:** if you find a discrepancy that should be fixed in the template itself (this file or AGENTS.md is out of date, a rule is missing, something bit this repo and would bite the next), open an issue upstream in [`ptr727/ProjectTemplate`](https://github.com/ptr727/ProjectTemplate) rather than only fixing it locally - see [AGENTS.md "Staying in Sync and Reporting Drift Upstream"](../AGENTS.md#staying-in-sync-and-reporting-drift-upstream). +**In a derived repo:** if you find a discrepancy that should be fixed in the template itself (this file or AGENTS.md is out of date, a rule is missing, something bit this repo and would bite the next), open an issue upstream in [`ptr727/ProjectTemplate`](https://github.com/ptr727/ProjectTemplate) rather than only fixing it locally - see the template's [AGENTS.md "Staying in Sync and Reporting Drift Upstream"](https://github.com/ptr727/ProjectTemplate/blob/main/AGENTS.md#staying-in-sync-and-reporting-drift-upstream). diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml index eb6b47fe..33c659d3 100644 --- a/.github/workflows/build-docker-task.yml +++ b/.github/workflows/build-docker-task.yml @@ -13,17 +13,13 @@ on: required: false type: string default: '' - # Logical branch driving config and tags (`main` => Release/`latest`, - # anything else => Debug/`develop`). Required (no `github.ref_name` - # fallback): the publisher builds develop from a run whose - # `github.ref_name` is `main`, so a silent fallback would mistag it. - # The orchestrator always passes it explicitly. + # Logical branch driving config and tags (main => Release/latest, otherwise Debug/develop). Required (no + # github.ref_name fallback): the publisher builds develop from a main-ref run, so a fallback would mistag it. branch: required: true type: string - # Smoke mode: build `linux/amd64` only (no QEMU/arm64), never push, and - # skip the shared registry `cache-to` so PR builds don't pollute the - # release buildcache. Used for fast PR feedback. + # Smoke mode: build linux/amd64 only (no QEMU/arm64), never push, and skip the shared registry cache-to so PR + # builds don't pollute the release buildcache. Used for fast PR feedback. smoke: required: false type: boolean @@ -50,8 +46,7 @@ jobs: with: ref: ${{ inputs.ref }} - # QEMU only exists to emulate arm64. Smoke builds are amd64-only, so - # skip it entirely to save the emulation setup cost. + # QEMU only emulates arm64; smoke builds are amd64-only, so skip it to save setup cost. - name: Setup QEMU step if: ${{ !inputs.smoke }} uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 @@ -63,14 +58,8 @@ jobs: with: platforms: ${{ inputs.smoke && 'linux/amd64' || 'linux/amd64,linux/arm64' }} - # Always login to Docker Hub, not just on push, to benefit from higher - # rate limits with a Docker subscription for pulls and cache reads on - # every build (including smoke). This is a CONSCIOUS choice over gating - # login on `inputs.push`: the trade-off is that fork PRs without access - # to the Docker Hub secrets cannot run the Docker smoke build. - # Acceptable here because the repo is private and PRs are same-repo; a - # public derived project that accepts fork PRs may prefer to gate this - # step on `inputs.push`. + # Always login (even for smoke) to get the higher Docker subscription rate limits for pulls and cache reads. The + # trade-off: fork PRs without the Docker Hub secrets cannot run the Docker smoke build; acceptable for same-repo PRs. - name: Login to Docker Hub step uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: @@ -87,16 +76,9 @@ 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 - # layers are nearly identical (only BUILD_CONFIGURATION differs), so - # 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 - # 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). + # Branch-scoped registry cache: read both branches' caches (layers are near-identical) but write only this branch's + # tag, and only when pushing. Branch-scoping lets the weekly matrix build main and develop concurrently without the + # two legs overwriting one shared cache. cache-from: | type=registry,ref=docker.io/ptr727/projecttemplate:buildcache-main type=registry,ref=docker.io/ptr727/projecttemplate:buildcache-develop diff --git a/.github/workflows/build-executable-task.yml b/.github/workflows/build-executable-task.yml index f8244d41..f8651d7c 100644 --- a/.github/workflows/build-executable-task.yml +++ b/.github/workflows/build-executable-task.yml @@ -8,15 +8,13 @@ on: required: false type: string default: '' - # Logical branch driving build configuration (`main` => Release, else - # Debug). Required (no `github.ref_name` fallback, which would mislabel - # the develop leg of the publisher's matrix); the orchestrator passes it. + # Logical branch driving build configuration (`main` => Release, else Debug). Required (no fallback) so the + # develop leg of the publisher's matrix isn't mislabeled. branch: required: true type: string - # Smoke mode: build a representative runtime subset (linux-x64 + - # win-x64) instead of the full 7-runtime matrix, and skip the zip / - # artifact aggregation. Used for fast PR feedback. + # Smoke mode: build a runtime subset (linux-x64 + win-x64) instead of the full matrix and skip the zip / + # artifact aggregation, for fast PR feedback. smoke: required: false type: boolean @@ -41,14 +39,8 @@ jobs: steps: - # NOTE: NuGet restore caching is intentionally NOT enabled on the .NET - # jobs (this matrix, build-nugetlibrary-task, and the unit-test job). The - # restore is low-overhead for this template's small dependency set, and - # `setup-dotnet`'s built-in cache requires a `packages.lock.json` that - # Central Package Management (Directory.Packages.props) does not produce - # by default. The Docker layer cache and uv's cache (which carry the - # expensive work) are enabled; revisit .NET restore caching only if the - # dependency graph grows enough to make it worthwhile. + # NuGet restore caching is not enabled: restore is cheap for this small dependency set, and `setup-dotnet`'s + # cache requires a `packages.lock.json` that Central Package Management does not produce by default. - name: Setup .NET SDK step uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 with: @@ -72,19 +64,20 @@ jobs: -property:InformationalVersion=${{ needs.get-version.outputs.AssemblyInformationalVersion }} \ -property:PackageVersion=${{ needs.get-version.outputs.SemVer2 }} - # Artifact names are suffixed with the branch so the publisher can build - # `main` and `develop` in the same workflow run (a branch matrix) without - # two legs colliding on an identical artifact name. + # Branch-suffixed so the publisher can build `main` and `develop` in one run without colliding on the name. + # Skipped on smoke: the aggregation job below is `!smoke`, so the per-runtime output has no consumer and would + # only burn artifact storage; smoke just needs the compile to succeed. - name: Upload matrix build artifacts step + if: ${{ !inputs.smoke }} uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: publish-${{ inputs.branch }}-${{ matrix.runtime }} path: ${{ runner.temp }}/publish + # Consumed within this run by the aggregation job; minimize artifact storage. + retention-days: 1 - # Smoke builds only need the per-runtime compile to succeed (fast PR - # 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. + # Smoke builds only need the per-runtime compile to succeed, so skip the zip aggregation; the release job never + # runs on smoke, so no `release-asset-*` artifact is needed. upload-build-artifacts: name: Upload matrix build artifacts job if: ${{ !inputs.smoke }} @@ -103,14 +96,12 @@ jobs: - name: Zip build output step run: 7z a -t7z ${{ runner.temp }}/Console.7z ${{ runner.temp }}/publish/* - # 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 - # 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. + # GitHub-release asset, uploaded under the `release-asset--*` pattern that the `github-release` job + # collects. Branch-suffixed so the publisher can build both branches in one run without colliding on the name. - name: Upload build artifacts step uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: release-asset-${{ inputs.branch }}-executable path: ${{ runner.temp }}/Console.7z + # Consumed within this run by the github-release job; minimize artifact storage. + retention-days: 1 diff --git a/.github/workflows/build-nugetlibrary-task.yml b/.github/workflows/build-nugetlibrary-task.yml index 3aecec5d..7933c121 100644 --- a/.github/workflows/build-nugetlibrary-task.yml +++ b/.github/workflows/build-nugetlibrary-task.yml @@ -3,7 +3,7 @@ name: Build NuGet library task on: workflow_call: inputs: - # Input to control whether to push the NuGet library to NuGet.org + # Whether to push the NuGet library to NuGet.org. push: required: false type: boolean @@ -13,12 +13,17 @@ on: required: false type: string default: '' - # Logical branch driving build configuration (`main` => Release, else - # Debug). Required (no `github.ref_name` fallback, which would mislabel - # the develop leg of the publisher's matrix); the orchestrator passes it. + # Logical branch driving build configuration (`main` => Release, else Debug). Required (no fallback) so the + # develop leg of the publisher's matrix isn't mislabeled. branch: required: true type: string + # Smoke mode: build for validation only and skip the release-asset zip/upload. A PR smoke run has no consumer + # for the artifact (the github-release job is gated `!smoke`), so uploading it just burns artifact storage. + smoke: + required: false + type: boolean + default: false jobs: @@ -68,16 +73,17 @@ jobs: --skip-duplicate - name: Zip output step + if: ${{ !inputs.smoke }} run: 7z a -t7z ${{ runner.temp }}/NuGetLibrary.7z ${{ runner.temp }}/publish/* - # 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 - # 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. + # GitHub-release asset, uploaded under the `release-asset--*` pattern that the `github-release` job + # collects. Branch-suffixed so the publisher can build both branches in one run without colliding on the name. + # Skipped on smoke: the github-release job is `!smoke`, so nothing would consume it. - name: Upload build artifacts step + if: ${{ !inputs.smoke }} uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: release-asset-${{ inputs.branch }}-nugetlibrary path: ${{ runner.temp }}/NuGetLibrary.7z + # Consumed within this run by the github-release job; minimize artifact storage. + retention-days: 1 diff --git a/.github/workflows/build-pypilibrary-task.yml b/.github/workflows/build-pypilibrary-task.yml index 8927a45f..c780c30c 100644 --- a/.github/workflows/build-pypilibrary-task.yml +++ b/.github/workflows/build-pypilibrary-task.yml @@ -1,13 +1,8 @@ name: Build PyPI library task -# This reusable workflow only builds the PyPI library and uploads the -# wheel + sdist as a workflow-run artifact. It does NOT publish to PyPI. -# Publishing happens directly in `publish-release.yml` so that the -# `id-token: write` permission required by Trusted Publishing is granted -# at the entry-point job, not propagated through a reusable-workflow -# chain (which would require every caller - including the PR smoke build in -# `test-pull-request.yml` - to also grant id-token write, even when no -# publishing happens). +# Reusable workflow that only builds the PyPI wheel + sdist and uploads them as a run artifact; it does not +# publish. Publishing lives in publish-release.yml so id-token:write is granted only at that entry point and not +# propagated to every caller (e.g. the PR smoke build). on: workflow_call: @@ -17,13 +12,17 @@ on: required: false type: string default: '' - # Logical branch driving the PEP 440 version (`develop` => `.dev0` - # prerelease suffix, anything else => plain release). Required (no - # `github.ref_name` fallback, which would mislabel the develop leg of - # the publisher's matrix); the orchestrator passes it. + # Logical branch driving the PEP 440 version (develop => .dev0 prerelease, otherwise plain release). Required; the + # orchestrator passes it explicitly. branch: required: true type: string + # Smoke mode: build for validation only and skip the artifact upload. A PR smoke run has no consumer for the + # wheel/sdist (the publish-pypi job runs only on a real publish), so uploading it just burns artifact storage. + smoke: + required: false + type: boolean + default: false outputs: artifact-name: value: ${{ jobs.build-pypilibrary.outputs.artifact-name }} @@ -59,10 +58,8 @@ jobs: - name: Setup uv step uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: - # Pin uv to the same version as `.devcontainer/python/post-create.sh` - # (UV_VERSION) so CI and local devcontainer behavior cannot drift; - # the same uv resolves the same lockfile the same way. Bump in - # lockstep with the devcontainer pin. + # Pin uv to the same version as the devcontainer (UV_VERSION) so CI and local resolves cannot drift; bump in + # lockstep. version: "0.11.8" enable-cache: true cache-dependency-glob: "PyPiLibrary/uv.lock" @@ -82,40 +79,9 @@ jobs: - name: Run pytest step run: uv run pytest - # Compute the PEP 440 version string for this build: - # refs/heads/main -> AssemblyFileVersion as-is - # (`Major.Minor.Patch.BuildNumber`). PEP 440 - # treats this 4-segment numeric form as a - # release. - # refs/heads/develop -> `${AssemblyFileVersion}.dev0` - # (`Major.Minor.Patch.BuildNumber.dev0`). - # The BuildNumber stays in the release - # 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 - # --pre ` picks the develop dev - # build, while default `pip install ` - # filters the dev suffix and picks the - # main release. The `.dev0` literal is a - # constant because BuildNumber alone - # already differentiates each develop - # push (NBGV BuildNumber increments per - # commit), so we don't need a second - # counter in the dev segment. - # - # Edge case: 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) - `--pre` will still resolve to - # the main release until a new develop - # commit lands. This is accepted as a - # small, self-healing gap. - # other branches (PR smoke build via test-pull-request, feature - # branches) -> AssemblyFileVersion as-is. These - # never publish; we just need a PEP 440 valid - # string for `uv build`. + # Compute the PEP 440 version string: main -> M.N.P.B (release), develop -> M.N.P.B.dev0. BuildNumber stays in the + # release segment so develop sorts above main and `pip install --pre` picks the dev build while plain `pip install` + # picks the main release. Other branches (PR smoke, feature) use M.N.P.B as-is and never publish. - name: Compute PyPI version step id: pypiver run: | @@ -131,24 +97,8 @@ jobs: AFV: ${{ needs.get-version.outputs.AssemblyFileVersion }} BRANCH: ${{ inputs.branch }} - # Replace the `__version__` line in `_version.py` (which ships - # hardcoded "0.0.0" so local `uv build` works without CI) with the - # branch-aware PEP 440 version computed above. `sed -i` replaces - # the line in place so the module docstring and any future metadata - # in the file survive into the published wheel / sdist. - # `_version.py` is the single source `hatchling` reads via the - # `[tool.hatch.version]` path in pyproject.toml. Done AFTER tests - # 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` - - # 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- - # identical across artifacts on either channel. All four still - # derive from the same NBGV computation per commit (main pushes - # publish release versions; develop pushes publish PEP 440 dev - # releases / NBGV prereleases). + # Rewrite the hardcoded __version__ in _version.py (the single source hatchling reads) with the computed version. + # Done after tests so the test asserting __version__ is non-empty isn't affected. - name: Write version into _version.py step run: | set -euo pipefail @@ -159,12 +109,14 @@ jobs: - name: Build sdist and wheel step run: uv build - # Branch-suffixed so the publisher's branch matrix can build both - # branches in one run without colliding on the artifact name. The - # publish-pypi job downloads `pypilibrary-build-`. + # Branch-suffixed so the publisher's branch matrix can build both branches in one run without colliding. + # Skipped on smoke: the publish-pypi job runs only on a real publish, so nothing would consume it. - name: Upload build artifacts step id: artifact-upload-step + if: ${{ !inputs.smoke }} uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: pypilibrary-build-${{ inputs.branch }} path: PyPiLibrary/dist/* + # Consumed within this run by the publish-pypi job; minimize artifact storage. + retention-days: 1 diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index fbccaf9d..9f9924f8 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -3,17 +3,17 @@ name: Build project release task on: workflow_call: inputs: - # Input to control whether to create a GitHub release + # Whether to create a GitHub release. github: required: false type: boolean default: false - # Input to control whether to push the library to NuGet.org + # Whether to push the library to NuGet.org. nuget: required: false type: boolean default: false - # Input to control whether to push the docker image to Docker Hub + # Whether to push the docker image to Docker Hub. dockerhub: required: false type: boolean @@ -23,25 +23,19 @@ on: required: false type: string default: '' - # Logical branch driving config / tags / prerelease for every target. - # Required (no `github.ref_name` fallback): the publisher builds both - # `main` and `develop` from one run whose `github.ref_name` is `main`, - # so a silent fallback would mislabel the develop leg. Every caller - # passes it explicitly; a missing value should fail loudly. + # Logical branch driving config / tags / prerelease for every target. Required (no fallback) because the + # publisher builds both `main` and `develop` in one run, so a silent fallback would mislabel the develop leg. branch: required: true type: string - # Smoke mode: reduced, never-published build for fast PR feedback. - # Forwarded to every target; also hard-disables every push below so a - # smoke run can never publish regardless of the publish flags. + # Smoke mode: reduced, never-published build for fast PR feedback. Forwarded to every target and hard-disables + # every push below, so a smoke run can never publish regardless of the publish flags. smoke: required: false type: boolean default: false - # Per-target presence gates. Default true (build everything). A derived - # project that drops a target deletes its job below and removes it from - # `github-release`'s `needs`; a PR smoke run sets these from the - # paths-filter so only changed targets build. + # Per-target presence gates. Default true (build everything); a PR smoke run sets these from the paths-filter + # so only changed targets build. enable_docker: required: false type: boolean @@ -75,19 +69,16 @@ jobs: uses: ./.github/workflows/build-nugetlibrary-task.yml secrets: inherit with: - # Pin to the exact commit get-version resolved (immutable), not the - # possibly-moving branch ref: the publisher passes a branch name, and a - # commit landing mid-run could otherwise build artifacts from a different - # commit than the one the release tag (also GitCommitId) points at. + # Pin to the resolved commit so the artifacts match the release tag even if the branch advances mid-run. ref: ${{ needs.get-version.outputs.GitCommitId }} branch: ${{ inputs.branch }} - # Conditional push to NuGet.org - never on a smoke build. + # Push to NuGet.org, never on a smoke build. push: ${{ inputs.nuget && !inputs.smoke }} + # Skip the release-asset upload on smoke (nothing consumes it on a PR). + smoke: ${{ inputs.smoke }} - # PyPI publishing happens in `publish-release.yml`, not here, so that - # `id-token: write` only needs to be granted at the entry-point job. - # This reusable workflow just builds and uploads the artifact; the - # publish-release workflow downloads it by name in a sibling job. + # PyPI publishing happens in `publish-release.yml`, not here, so `id-token: write` is only granted at the + # entry-point job; this workflow just builds and uploads the artifact for that job to download. build-pypilibrary: name: Build PyPI library job if: ${{ inputs.enable_pypi }} @@ -95,9 +86,11 @@ jobs: uses: ./.github/workflows/build-pypilibrary-task.yml secrets: inherit with: - # Pin to the resolved commit (see build-nugetlibrary). + # Pin to the resolved commit so the artifacts match the release tag. ref: ${{ needs.get-version.outputs.GitCommitId }} branch: ${{ inputs.branch }} + # Skip the wheel/sdist upload on smoke (the publish-pypi job runs only on a real publish). + smoke: ${{ inputs.smoke }} build-executable: name: Build executable job @@ -106,7 +99,7 @@ jobs: uses: ./.github/workflows/build-executable-task.yml secrets: inherit with: - # Pin to the resolved commit (see build-nugetlibrary). + # Pin to the resolved commit so the artifacts match the release tag. ref: ${{ needs.get-version.outputs.GitCommitId }} branch: ${{ inputs.branch }} smoke: ${{ inputs.smoke }} @@ -118,49 +111,34 @@ jobs: uses: ./.github/workflows/build-docker-task.yml secrets: inherit with: - # Pin to the resolved commit (see build-nugetlibrary). + # Pin to the resolved commit so the artifacts match the release tag. ref: ${{ needs.get-version.outputs.GitCommitId }} branch: ${{ inputs.branch }} smoke: ${{ inputs.smoke }} - # Conditional push to Docker Hub - never on a smoke build. + # Push to Docker Hub, never on a smoke build. push: ${{ inputs.dockerhub && !inputs.smoke }} github-release: name: Publish GitHub release job - # `&& !inputs.smoke` enforces the "smoke never publishes" guarantee at the - # job level too (matching the `&& !inputs.smoke` push gates above), so a - # smoke caller that also set `github: true` still can't create a release. + # `!inputs.smoke` enforces "smoke never publishes" at the job level, so a smoke caller that also set + # `github: true` still can't create a release. if: ${{ inputs.github && !inputs.smoke }} runs-on: ubuntu-latest needs: [get-version, build-nugetlibrary, build-pypilibrary, build-executable, build-docker] steps: - # Check out the exact built commit (NBGV `GitCommitId`), not the - # possibly-moving `inputs.ref` branch, so the uploaded release files - # match the tag even if the branch advances mid-run. + # Check out the exact built commit so the uploaded release files match the tag even if the branch advances mid-run. - name: Checkout code step uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ needs.get-version.outputs.GitCommitId }} - # Collect every release asset by the `release-asset--*` artifact - # convention rather than naming individual build jobs, so this download - # step never changes when a derived project subsets targets: dropping a - # 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 - # 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, - # 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). + # Collect every release asset by the `release-asset--*` pattern rather than naming build jobs, so this + # step is unaffected when targets are added or removed. `merge-multiple` flattens the matches into one `./Publish` + # dir, and the download succeeds with zero matches, so a file-less release (tag + LICENSE/README only) still works. + # Subset releases by deleting the target, not via `enable_*: false`: `enable_*` skips a build job, and a skipped + # `needs` job would skip this release job too. - name: Download release asset artifacts step uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: @@ -168,14 +146,8 @@ jobs: merge-multiple: true path: ./Publish - # The weekly publisher re-runs even when a branch has no new commits, so - # 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 - # 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. + # The weekly publisher re-runs even with no new commits, so the version may already be released. Skip the release + # step when a release for this tag already exists to avoid a no-op republish. - name: Check for existing release step id: release-exists env: @@ -194,19 +166,9 @@ jobs: echo "exists=false" >> "$GITHUB_OUTPUT" fi - # `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 - # 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 - # could tag the release on a newer commit than the one that was built). - # The exact SHA is immutable, on the right branch, and consistent with - # both the SemVer2 tag and the uploaded artifacts. - # Skip the no-op weekly republish when the tag already exists, but always - # allow a manual `workflow_dispatch` through so it can repair/refresh a - # partially-created release for the same tag. + # `target_commitish` must be set explicitly: otherwise GitHub's REST API tags the release on the default branch. + # Pin it to `GitCommitId` so the tag is on the exact built commit, consistent with the SemVer2 tag and artifacts. + # Skip when the release already exists, but always let a manual `workflow_dispatch` through to refresh it. - name: Create GitHub release step if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }} uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 diff --git a/.github/workflows/check-upstream-version-task.yml b/.github/workflows/check-upstream-version-task.yml new file mode 100644 index 00000000..d505df9b --- /dev/null +++ b/.github/workflows/check-upstream-version-task.yml @@ -0,0 +1,143 @@ +name: Check upstream version task + +# Skeleton for a wrapper repo that tracks an upstream release. A resolver command +# computes the upstream version(s) as a JSON object of name -> version, written to +# a committed state file at the repo root (a build-input version source, beside +# version.json), and opens a rolling, App-signed bump PR per branch that the +# merge-bot auto-merges. The JSON object carries one key for the common +# single-version case or N keys for a wrapper that pins several upstream +# components (e.g. an image plus a companion tool); the build reads each component +# by key. A merged bump ships on the next publish, not immediately. Call this from +# a scheduled entry-point workflow; matrix only the branches that ship the version +# (a CI-only version uses ["develop"]). + +on: + workflow_call: + inputs: + resolver-command: + # Single-version wrappers print {"version":"X"}; multi-component wrappers print one + # key per pinned upstream component, e.g. {"esphome":"2026.6.2","device_builder":"1.0.12"}. + description: Shell command that prints the resolved upstream version(s) as a JSON object of name -> version to stdout. + required: true + type: string + state-file: + description: Committed version-state file (a JSON object of name -> version), at the repo root beside version.json. + required: false + type: string + default: upstream-version.json + branches: + description: JSON array of base branches to open bump PRs against. + required: false + type: string + default: '["main", "develop"]' + bump-branch-prefix: + # merge-bot-pull-request.yml hard-codes the head refs upstream-version-main/-develop; keep this + # default unless you also update the merge-upstream-version job, or auto-merge will not fire. + description: Head-branch prefix; must match the merge-bot's upstream-version- head refs. + required: false + type: string + default: upstream-version + +jobs: + + check-upstream-version: + name: Check upstream version job + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + branch: ${{ fromJSON(inputs.branches) }} + permissions: + contents: write + pull-requests: write + + steps: + + # App token so the merge commit fires downstream workflows and the PR is signed. + - name: Generate GitHub App token step + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} + private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + + - name: Checkout code step + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ matrix.branch }} + token: ${{ steps.app-token.outputs.token }} + + # The resolver prints a JSON object of name -> version. Normalize it (sorted keys, pretty + # print) so the committed state file and its diff are stable, then write it. Comparing the + # new object against the old state yields the changed keys that drive the bump PR's title + # and body (only the components that actually moved are named). + - name: Resolve upstream version step + id: resolve + env: + RESOLVER_COMMAND: ${{ inputs.resolver-command }} + STATE_FILE: ${{ inputs.state-file }} + run: | + set -euo pipefail + + # Require a non-empty JSON object of single-line name -> version strings, so a malformed + # resolver (non-object, numeric/nested values, no keys, or a key/value carrying a CR/LF + # that would corrupt the single-line `title=`/`body` GITHUB_OUTPUT) fails fast here + # instead of committing state that downstream by-key build logic cannot consume. + raw="$(bash -c "$RESOLVER_COMMAND")" + if ! new="$(printf '%s' "$raw" | jq -S '.' 2>/dev/null)" \ + || [ "$(printf '%s' "$new" | jq -r 'type == "object" and length > 0 and all(.[]; type == "string" and (test("[\r\n]") | not)) and (keys | all(test("[\r\n]") | not))')" != "true" ]; then + echo "Resolver must print a non-empty JSON object of single-line name -> version strings, no CR/LF (single-version wrappers print {\"version\":\"X\"}); got: $raw" >&2 + exit 1 + fi + + # Missing, non-JSON, or non-object state => empty object, so the first run and any + # unusable prior file both diff cleanly against the resolved object instead of failing + # (a valid-JSON-but-non-object file would otherwise break the `$old + $new` union below). + if [ -f "$STATE_FILE" ] && old="$(jq -S 'if type == "object" then . else empty end' "$STATE_FILE" 2>/dev/null)" && [ -n "$old" ]; then :; else old='{}'; fi + + # Write the canonical state file as CRLF (jq emits LF) to match .editorconfig's .json + # rule. Unchanged content => no diff => no PR. + printf '%s\n' "$new" | sed 's/$/\r/' > "$STATE_FILE" + + # Diff across the union of old+new keys so an added, moved, or removed key is all caught; + # removals carry a null .new. These drive the PR title/body (only keys that moved). + changed="$(jq -n --argjson old "$old" --argjson new "$new" ' + [ (($old + $new) | keys[]) | { key: ., new: $new[.] } | select($old[.key] != .new) ]')" + summary="$(printf '%s' "$changed" | jq -r ' + map(if .new == null then "\(.key) removed" else "\(.key) to \(.new)" end) | join(", ")')" + + # Title: a canonicalization-only change (state reserialized, no key moved) when the prior + # file was valid-but-differently-formatted; the trivial single-version case renders bare; + # otherwise name each moved component. + if [ "$(printf '%s' "$changed" | jq 'length == 0')" = "true" ]; then + title="Canonicalize upstream version state file" + elif [ "$(printf '%s' "$new" | jq -r 'keys == ["version"]')" = "true" ]; then + title="Update upstream version to $(printf '%s' "$new" | jq -r '.version')" + else + title="Update upstream versions: $summary" + fi + { + echo "title=$title" + echo "body<> "$GITHUB_OUTPUT" + + # Rolling PR: signed by the API (satisfies Require signed commits), auto-merged by the merge-bot. + - name: Open bump pull request step + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ steps.app-token.outputs.token }} + base: ${{ matrix.branch }} + branch: ${{ inputs.bump-branch-prefix }}-${{ matrix.branch }} + title: ${{ steps.resolve.outputs.title }} + commit-message: ${{ steps.resolve.outputs.title }} + body: ${{ steps.resolve.outputs.body }} + sign-commits: true + delete-branch: true diff --git a/.github/workflows/get-version-task.yml b/.github/workflows/get-version-task.yml index aa20447f..38cf4535 100644 --- a/.github/workflows/get-version-task.yml +++ b/.github/workflows/get-version-task.yml @@ -3,17 +3,13 @@ name: Get version information task on: workflow_call: inputs: - # 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. + # Git ref to check out and version. Empty falls back to the caller's default checkout ref (`github.ref`); the + # publisher passes an explicit branch so a scheduled run can still compute versions for `develop`. ref: required: false type: string default: '' outputs: - # Version information outputs SemVer2: value: ${{ jobs.get-version.outputs.SemVer2 }} AssemblyVersion: @@ -22,9 +18,7 @@ on: value: ${{ jobs.get-version.outputs.AssemblyFileVersion }} AssemblyInformationalVersion: value: ${{ jobs.get-version.outputs.AssemblyInformationalVersion }} - # Full SHA of the commit NBGV computed the version from. Used to pin the - # GitHub release tag to the exact built commit (immutable), rather than a - # moving branch ref. + # Full SHA of the commit the version was computed from, used to pin the release tag to the exact built commit. GitCommitId: value: ${{ jobs.get-version.outputs.GitCommitId }} @@ -53,23 +47,8 @@ jobs: ref: ${{ inputs.ref }} fetch-depth: 0 - # `dotnet/nbgv` is intentionally floated on `master` rather than - # 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 - # tag `v0.5.1` lags well behind `master` and fixes accumulate - # on `master` between tag bumps. - # - Dependabot's GitHub Actions ecosystem tracks tagged - # releases. A SHA pinned to a post-`v0.5.1` `master` commit - # would either receive no Dependabot updates (silently stale) - # or get an attempted downgrade PR to `v0.5.1`'s SHA. Neither - # beats just floating on `master`. - # - Upstream owner is Microsoft (`dotnet/`), so the - # "tag-/branch-retargeting risk" the AGENTS.md rule guards - # against is materially lower than for a random author. - # Revisit if `dotnet/nbgv` resumes regular tagged releases. + # nbgv is floated on @master: its tag stream lags master, so Dependabot tag-tracking would propose a downgrade. + # Revisit if dotnet/nbgv resumes regular tagged releases. - name: Run Nerdbank.GitVersioning tool step id: nbgv uses: dotnet/nbgv@master diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index 51c11732..73a2cc70 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -1,55 +1,17 @@ name: Merge bot pull request action -# Three-job model: -# 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 -# `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 -# each branch's ruleset (develop = squash-only, main = merge-only, -# see AGENTS.md "Branching Model"). Both Dependabot and codegen -# open parallel PRs against both branches; Dependabot security -# 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 -# 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`) -# when ready. -# -# Token strategy: -# Every job uses an App token (`actions/create-github-app-token`). -# The resulting push is committed by the App, which fires downstream -# workflows on develop and main. `GITHUB_TOKEN`-authored pushes are -# blocked from triggering further workflow runs by GitHub's recursion -# guard, which would silently skip `publish-release.yml` on the merge -# commit. The App-token path also removes the close/reopen dance -# previously used by codegen PRs created under `GITHUB_TOKEN` to nudge -# the auto-merge workflow. The disable job needs an App token too: -# even though the event actor is a maintainer, the workflow context -# on a Dependabot PR runs with Dependabot's restricted secrets -# regardless of actor, so plain `GITHUB_TOKEN` would be read-only. +# The merge jobs enable auto-merge once per PR on opened/reopened; the disable job turns it off when a +# maintainer pushes to a bot branch. Merge method is dispatched by base branch (develop = squash, main = merge). +# All jobs use an App token so the merge commit fires downstream workflows (GITHUB_TOKEN pushes don't, due to +# GitHub's recursion guard) and so the disable job has write access on Dependabot PRs, which otherwise run with +# read-only restricted secrets regardless of who triggered the event. on: pull_request: types: [opened, reopened, synchronize] -# `cancel-in-progress: false` is load-bearing. The three-job model -# (enable on opened/reopened, disable on maintainer-triggered -# synchronize) relies on those events running to completion in arrival -# order. With cancel-in-progress: true, a fast follow-up synchronize -# (e.g. a Dependabot rebase right after PR open) would cancel the -# in-flight `opened` run before it reached `gh pr merge --auto`, and -# the new synchronize run skips the enable jobs (opened/reopened -# filter), leaving auto-merge never enabled. Queueing instead of -# cancelling makes the final state deterministic: opened enables, -# then any subsequent synchronize disables (if maintainer) or no-ops -# (if bot). Action-aware grouping has its own race (opened finishing -# after a maintainer synchronize would re-enable auto-merge), so we -# keep a single group and just disable cancellation. +# `cancel-in-progress: false` is required so events process to completion in arrival order: a follow-up +# synchronize must not cancel an in-flight `opened` run before it enables auto-merge. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false @@ -59,12 +21,7 @@ jobs: merge-dependabot: name: Merge dependabot pull request job runs-on: ubuntu-latest - # Restrict to Dependabot PRs that originate from this repository, not - # a fork. Only runs on `opened` / `reopened` events so the auto-merge - # enable happens once per PR; the `disable-auto-merge-on-maintainer-push` - # job below is what disables auto-merge when a maintainer pushes to a - # Dependabot branch. Skipping `synchronize` here is what keeps that - # disable sticky. + # Dependabot PRs from this repo (not forks). Only on opened/reopened so the disable job stays sticky. if: >- (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login == 'dependabot[bot]' && @@ -88,9 +45,7 @@ jobs: with: github-token: "${{ secrets.GITHUB_TOKEN }}" - # Skip semver-major NuGet bumps: majors can build cleanly but break - # runtime behavior, so they should land via human review. Other - # ecosystems' majors (github-actions, uv) are usually safe and merge. + # Skip semver-major NuGet bumps so they land via human review; other ecosystems' majors auto-merge. - name: Merge pull request step if: >- (steps.metadata.outputs.package-ecosystem != 'nuget') || @@ -113,17 +68,8 @@ jobs: merge-codegen: name: Merge codegen pull request job runs-on: ubuntu-latest - # Restrict to codegen PRs that originate from the App in this - # repository. Codegen runs in a matrix over `main` and `develop`, - # so two head refs are valid: `codegen-main` (always targets `main`) - # and `codegen-develop` (always targets `develop`). The head/base - # pairing is enforced strictly so a misconfigured workflow can't, - # for example, sneak a `codegen-develop` branch into `main`. - # Only runs on `opened` / `reopened` events so the auto-merge enable - # happens once per PR; the `disable-auto-merge-on-maintainer-push` - # job below is what disables auto-merge when a maintainer pushes to a - # codegen branch. Skipping `synchronize` here is what keeps that - # disable sticky. + # Codegen PRs from this repo. Head/base pairing is enforced strictly (codegen-main->main, codegen-develop-> + # develop). Only on opened/reopened so the disable job stays sticky. if: >- (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login == 'ptr727-codegen[bot]' && @@ -161,18 +107,53 @@ jobs: PR_URL: ${{ github.event.pull_request.html_url }} GH_TOKEN: ${{ steps.app-token.outputs.token }} + merge-upstream-version: + name: Merge upstream version pull request job + runs-on: ubuntu-latest + # Upstream-version bump PRs from the App. Head/base pairing is enforced (upstream-version-main->main, + # upstream-version-develop->develop). Only on opened/reopened so the disable job stays sticky. + if: >- + (github.event.action == 'opened' || github.event.action == 'reopened') && + github.event.pull_request.user.login == 'ptr727-codegen[bot]' && + github.event.pull_request.head.repo.full_name == github.repository && + ( + (github.event.pull_request.head.ref == 'upstream-version-main' && github.event.pull_request.base.ref == 'main') || + (github.event.pull_request.head.ref == 'upstream-version-develop' && github.event.pull_request.base.ref == 'develop') + ) + permissions: + contents: write + pull-requests: write + + steps: + + - name: Generate GitHub App token step + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} + private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + + - name: Merge pull request step + run: | + set -euo pipefail + case "${{ github.event.pull_request.base.ref }}" in + develop) method=--squash ;; + main) method=--merge ;; + *) + echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}" + exit 1 + ;; + esac + gh pr merge --auto "$method" "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + disable-auto-merge-on-maintainer-push: 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 - # 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 - # that already has auto-merge disabled is a no-op. + # Fires when a maintainer pushes to a bot's branch (synchronize, actor != bot). Disables auto-merge so the + # maintainer's commits don't merge with the bot's; they re-enable it manually. The disable call is idempotent. if: >- github.event.action == 'synchronize' && github.event.pull_request.head.repo.full_name == github.repository && @@ -187,11 +168,7 @@ jobs: steps: - name: Generate GitHub App token step - # App token rather than GITHUB_TOKEN: on a Dependabot PR the - # workflow context runs with Dependabot's restricted secrets - # regardless of who triggered the event (GitHub gates by PR - # origin, not by event actor), and the restricted GITHUB_TOKEN - # is read-only. Same App token pattern as the other merge jobs. + # App token because a Dependabot PR's GITHUB_TOKEN is read-only regardless of who triggered the event. id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: diff --git a/.github/workflows/publish-docker-readme-task.yml b/.github/workflows/publish-docker-readme-task.yml new file mode 100644 index 00000000..4b04ad64 --- /dev/null +++ b/.github/workflows/publish-docker-readme-task.yml @@ -0,0 +1,36 @@ +name: Publish Docker Hub readme task + +on: + workflow_call: + inputs: + # Logical branch this run is for. The overview only updates on `main`; + # the publisher passes the branch so a develop leg is a no-op. + branch: + required: true + type: string + +jobs: + + publish-docker-readme: + name: Publish Docker Hub readme job + runs-on: ubuntu-latest + + steps: + + - name: Checkout code step + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Check out the branch being published so the main leg pushes main's readme regardless of the triggering ref. + ref: ${{ inputs.branch }} + + # Push Docker/README.md as the Docker Hub repository overview. For a + # multi-image repo, add a matrix over { repository, readme } and run + # this step per image. + - name: Publish Docker Hub readme step + if: ${{ inputs.branch == 'main' }} + uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + repository: ptr727/projecttemplate + readme-filepath: ./Docker/README.md diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 58711226..4c867513 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -5,37 +5,25 @@ on: 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`). + # Weekly full build/publish of both branches. Routine merges only smoke-test; this scheduled run republishes + # everything (also refreshing the Docker base image). - 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. +# Real publishes (schedule, dispatch, or push with PUBLISH_ON_MERGE) share one global group so they serialize: they +# push both branches' shared Docker tags/caches and create releases for both regardless of the triggering ref, so a +# ref-scoped group could let two real publishes run concurrently and double-push. Non-publishing push runs get a +# unique per-run group so they don't queue behind a real publish; they only run the no-op setup job. 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. + # Queue instead of cancel: cancelling a publish mid-flight can leave a partially pushed multi-arch tag set or a + # half-created GitHub release. 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). + # Decide which branches to publish and whether to publish at all: push -> only the pushed branch, and only when the + # PUBLISH_ON_MERGE variable is true (default two-phase model: merges don't publish); schedule/dispatch -> both + # branches. setup: name: Resolve publish plan job runs-on: ubuntu-latest @@ -46,8 +34,7 @@ jobs: - 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. + # Repository variable; unset reads as empty string, so the default is the two-phase model. PUBLISH_ON_MERGE: ${{ vars.PUBLISH_ON_MERGE }} run: | set -euo pipefail @@ -70,10 +57,8 @@ jobs: 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`. + # Full build + publish of every target per planned branch. The matrix lets one run publish main (Release/latest) + # and develop (Debug/develop) - each leg checks out and versions its own branch via the threaded ref/branch. publish: name: Publish project release job needs: [setup] @@ -104,23 +89,14 @@ jobs: 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. + # build-pypilibrary-task.yml picks the PEP 440 version per branch (M.N.P.B on main, M.N.P.B.dev0 on develop). The + # pypi environment's deployment branch rule restricts uploads to main + develop as defense in depth. 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. + # A permissions block collapses every unlisted scope to none. This job needs id-token:write for Trusted Publishing's + # OIDC token exchange, contents:read for repo metadata, and actions:read so download-artifact can fetch the build + # artifact from earlier in this run. permissions: id-token: write contents: read @@ -131,8 +107,7 @@ jobs: - 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. + # Branch-suffixed name so both branch legs in this run stay distinct. name: pypilibrary-build-${{ matrix.branch }} path: ./dist @@ -140,8 +115,7 @@ jobs: 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 rather than fail when the version already exists; the weekly republish re-uploads unchanged versions. skip-existing: true date-badge: @@ -156,5 +130,20 @@ jobs: permissions: contents: write with: - # The badge task self-gates to `main`; the develop leg is a no-op. + # The badge task self-gates to main; the develop leg is a no-op. + branch: ${{ matrix.branch }} + + docker-readme: + name: Publish Docker Hub readme job + needs: [setup, publish] + if: ${{ needs.setup.outputs.publish == 'true' }} + strategy: + matrix: + branch: ${{ fromJSON(needs.setup.outputs.branches) }} + uses: ./.github/workflows/publish-docker-readme-task.yml + secrets: inherit + permissions: + contents: read + with: + # The 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 73bfb5cf..3c39b5b9 100644 --- a/.github/workflows/run-codegen-pull-request-task.yml +++ b/.github/workflows/run-codegen-pull-request-task.yml @@ -1,34 +1,22 @@ 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 -# 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 -# back-merges (see AGENTS.md "Branching Model" for the forward-only -# develop invariant). +# Runs codegen against `main` and `develop` in parallel via a matrix and opens a PR against each base +# (codegen-main -> main, codegen-develop -> develop), which the merge-bot auto-merges independently. on: workflow_call: secrets: - # GitHub App credentials to generate an installation token CODEGEN_APP_CLIENT_ID: required: true CODEGEN_APP_PRIVATE_KEY: required: true - # API Ninjas API key NINJA_API_KEY: required: true jobs: - # Capture ONE UTC timestamp shared by both matrix legs below. `--runtime` - # needs the same value on the `main` and `develop` legs so CodeGen.cs comes - # 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. + # Capture one UTC timestamp shared by both matrix legs so CodeGen.cs comes out byte-identical and + # develop -> main release merges don't conflict on it. (`github.run_started_at` is empty in this context.) get-runtime: name: Capture shared runtime job runs-on: ubuntu-latest @@ -49,8 +37,7 @@ jobs: contents: write pull-requests: write strategy: - # Each branch gets its own parallel codegen run + PR. If one - # branch's PR fails (CI, conflicts, etc.) the other is unaffected. + # Each branch gets its own parallel codegen run + PR; one branch's failure doesn't affect the other. fail-fast: false matrix: target: @@ -62,10 +49,7 @@ jobs: steps: - name: Generate GitHub App token step - # 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. + # App token so the PR open fires `pull_request` workflow events (GITHUB_TOKEN opens don't). id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: @@ -84,14 +68,8 @@ jobs: token: ${{ steps.app-token.outputs.token }} - name: Run codegen step - # `--runtime` is template-internal hygiene: passing the SAME timestamp - # (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 - - # if your generator's per-run state is intentional, design it not to - # land on multiple release branches simultaneously, or absorb the merge - # cost. + # Pass the shared `--runtime` to both legs so they produce byte-identical CodeGen.cs (avoids merge + # conflicts on develop -> main). run: | set -euo pipefail dotnet run --project ./CodeGen/CodeGen.csproj -- \ @@ -110,7 +88,7 @@ jobs: uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 id: cpr with: - # App token: triggers pull_request workflow events directly, creates verified commits as the app + # App token: triggers pull_request workflow events and creates verified commits as the app. token: ${{ steps.app-token.outputs.token }} base: ${{ matrix.target.ref }} branch: ${{ matrix.target.branch }} diff --git a/.github/workflows/run-periodic-codegen-pull-request.yml b/.github/workflows/run-periodic-codegen-pull-request.yml index 041f95b6..90b5adcc 100644 --- a/.github/workflows/run-periodic-codegen-pull-request.yml +++ b/.github/workflows/run-periodic-codegen-pull-request.yml @@ -3,21 +3,11 @@ name: Run daily CodeGen and Pull Request action on: workflow_dispatch: schedule: - # Run daily at 04:00 UTC. Staggered two hours after the weekly publish - # (`publish-release.yml`, Mondays 02:00) so the two don't start together - # on Mondays. Codegen merges are cheap in the default two-phase model - # (they only smoke-test, the weekly publish batches the actual release), - # so running daily keeps both branches' generated content fresh without - # triggering a build per merge. + # Daily at 04:00 UTC, staggered after the weekly publish so the two don't start together on Mondays. - cron: '0 4 * * *' 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 - # group per workflow+ref serializes all matrix legs of all scheduled - # / dispatched runs in practice. + # A single group per workflow+ref serializes all scheduled/dispatched runs and their internal matrix legs. group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 5052e620..e2fba4b0 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -11,21 +11,14 @@ concurrency: 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. + # Detect which delivery targets a PR touches so we smoke-build only what changed. + # Build-workflow files are deliberately excluded - a path filter can't tell a logic + # change from an action-version bump - so workflow-only changes aren't smoke-built; + # lint them with `actionlint` locally. workflow_dispatch forces every target on. 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. + # dorny/paths-filter reads changed files via the API (no checkout), so grant pull-requests: read. permissions: contents: read pull-requests: read @@ -60,14 +53,11 @@ jobs: - '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 drives the NBGV-computed PyPI version; the other shared files are .NET-only. - 'version.json' - # Unit tests are cheap and validate the shared C# code, so they always run - # regardless of which targets changed. + # Unit tests are cheap and validate the shared C# code, so they always run. + # This job is .NET-owned/per-target - a non-.NET repo replaces or removes it. unit-test: name: Run unit tests job runs-on: ubuntu-latest @@ -94,17 +84,11 @@ jobs: - 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. + # Build only the changed targets in smoke mode (Docker amd64-only, reduced matrix, + # no publishing), in the PR base-branch configuration. Skipped when no target changed. 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. + # Gate on unit-test too: the smoke build runs a Docker build, so skip it when unit tests fail. needs: [changes, unit-test] if: >- needs.changes.outputs.docker == 'true' || @@ -119,12 +103,8 @@ jobs: 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. + # Check out the PR head by SHA: refs/pull/N/head is reachable in the base repo even for + # forks, unlike the head_ref branch name. workflow_dispatch falls 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' }} @@ -132,10 +112,10 @@ jobs: 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 + # TODO: GitHub Actions does not support required 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"). + # This aggregator and its job name are verbatim orchestration: the name is the + # ruleset-bound required status-check context - do NOT rename it. check-workflow-status: name: Check pull request workflow status runs-on: ubuntu-latest @@ -152,14 +132,12 @@ jobs: 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. + # The changes job MUST succeed - a paths-filter error must not let a + # target-changing PR merge with its smoke build silently skipped. 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. + # smoke-build may be legitimately skipped (no target changed); 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/.vscode/launch.json b/.vscode/launch.json index c3cd9340..123220f2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,7 +5,7 @@ "name": "Console Root", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/Console/debug/Console.dll", "args": [ "--loglevel=Debug", @@ -20,7 +20,7 @@ "name": "Console Test", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/Console/debug/Console.dll", "args": [ "--loglevel=Debug", @@ -37,7 +37,7 @@ "name": "CodeGen", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/CodeGen/debug/CodeGen.dll", "args": [ "--codepath", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 8ef976e5..233ce214 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,8 +1,11 @@ { "version": "2.0.0", "tasks": [ + // .NET language group. A non-.NET repo drops this group and adds its own + // language's tasks. The first three tasks are the .NET clean-compile set + // (CODESTYLE.md) carried verbatim; the rest are convenience/project-specific. { - "label": ".Net Build", + "label": ".NET Build", "type": "process", "command": "dotnet", "args": [ @@ -20,7 +23,7 @@ } }, { - "label": ".Net Format", + "label": ".NET Format", "type": "process", "command": "dotnet", "args": [ @@ -37,9 +40,10 @@ "showReuseMessage": false, "clear": false }, + "dependsOrder": "sequence", "dependsOn": [ "CSharpier Format", - ".Net Build" + ".NET Build" ] }, { @@ -60,8 +64,9 @@ "clear": false } }, + // Convenience / project-specific tasks (adapt or drop per repo). { - "label": ".Net Tool Update", + "label": ".NET Tool Update", "type": "process", "command": "dotnet", "args": [ @@ -78,7 +83,7 @@ } }, { - "label": ".Net Benchmark", + "label": ".NET Benchmark", "type": "process", "command": "dotnet", "args": [ @@ -100,7 +105,7 @@ } }, { - "label": ".Net Outdated Upgrade", + "label": ".NET Outdated Upgrade", "type": "process", "command": "dotnet", "args": [ diff --git a/AGENTS.md b/AGENTS.md index 409be1fe..22df9033 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,8 @@ # 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: +**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. Code style lives in [`CODESTYLE.md`](./CODESTYLE.md) at the repo root - one guide with a General section that applies to every language plus droppable per-language sections (.NET, Python). -- .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. +Treat this file as authoritative for everything else; don't restate its rules elsewhere. A derived repo's **project-specific conventions and public-API/behavioral contracts** (e.g. a "Library API Conventions" section) also live here, **not** in [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) - that file targets GitHub Copilot / VS Code specifically, while this file is the agent-agnostic one every coding agent is directed to read, so any rule a reviewer must honor has to live here to be provider-independent. ## Git and Commit Rules @@ -40,18 +37,19 @@ The template uses a **two-phase model by default**: PRs build fast, publishing i - **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. +- **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). Within `test-pull-request.yml`, only the `changes -> smoke-build -> check-workflow-status` aggregator wiring and the ruleset-bound job name are verbatim orchestration; the `unit-test` job and the `dorny/paths-filter` entries are owned/per-target. 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-*`. + - *Image-registry pushes* (Docker Hub): `build-docker-task` pushes multi-arch tags directly; contributes **no** `release-asset-*`. The image tag is build-layer-owned - drive it from whatever version source fits (NBGV `SemVer2`, an upstream-release pin, or a per-image matrix). To publish the Docker Hub repository overview, [`publish-docker-readme-task.yml`](./.github/workflows/publish-docker-readme-task.yml) pushes `Docker/README.md` via `peter-evans/dockerhub-description` (single-repo by default; matrix per image for multi-image repos), wired into `publish-release.yml` and gated to `main`. - *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. - **Versioning is semantic and maintainer-controlled.** The `version` (major.minor) in [`version.json`](./version.json) is the version floor; NBGV appends the git height (the SemVer patch position) for the build version. `main` (the public release ref) builds a stable `X.Y.`; `develop` builds a prerelease `X.Y.-g`. The maintainer edits `version.json`; dependency bumps, CI/workflow fixes, doc edits, and template re-syncs leave it untouched. - - **`develop` leads `main` by a minor.** After a `develop -> main` release lands and main's publish completes, bump the minor in `version.json` on `develop` in an isolated `bump-version-X.Y` PR (squash to develop). This keeps every develop prerelease numerically above main's last stable: at a shared `X.Y`, develop's prerelease `X.Y.-g` sorts *below* main's stable `X.Y.` in `--version`, `HISTORY.md`, and consumer update prompts, and the lead avoids that. - - **A maintenance `develop -> main` promotion holds main's version.** Catch-up work - dependency bumps, CI/doc fixes, template re-syncs - reaches `main` without releasing develop's leading minor: run `git checkout main -- version.json` on the promotion branch before opening the PR, so `main`'s stable advances only its NBGV height (the patch position), not its minor, and `develop` keeps its lead. A release promotion instead carries develop's version to `main`, and `develop` then bumps again to restore the lead. + - **Bump `version.json` only for functional changes, by maintainer instruction.** Raise the major/minor when the work being introduced warrants a new semantic version - a new feature, a behavior or API change, a breaking change - and do it in the PR that introduces that work (typically on `develop`). Do **not** bump on a fixed cadence or mechanically after a release. NBGV advances the patch (git height) on every commit automatically, so a release always gets a fresh build version without any `version.json` edit. + - **No post-release bump; no develop-ahead requirement.** NBGV advances the patch (git height) on every commit, so a release always gets a fresh build version with no `version.json` edit and there is no `bump-version-X.Y` PR after a release. A `develop -> main` promotion carries whatever `version.json` is current: a promotion with a functional bump releases that new version on `main`; a maintenance-only promotion carries the unchanged `version.json` and `main` advances only its NBGV height. +- **Wrapper repos that track an upstream release.** A repo wrapping an upstream release uses [`check-upstream-version-task.yml`](./.github/workflows/check-upstream-version-task.yml): a resolver command prints the upstream version(s) as a **JSON object of `name -> version`**, written to a committed state file at the **repo root beside `version.json`** (default `upstream-version.json` - it is a build-input version source, not GitHub-platform config, so it does not belong under `.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges (`merge-upstream-version`). The object carries one key for the common single-version case (`{"version":"X"}`) or N keys for a wrapper that pins several upstream components (e.g. an image plus a companion tool), and the build reads each component by key; the bump PR's title/body name only the keys that actually moved. Call it from a scheduled entry-point workflow and matrix only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump ships on the **next publish**, not immediately - the two-phase latency tradeoff. ## Pull Request Title and Commit Message Conventions @@ -83,10 +81,18 @@ Clarify devcontainer setup steps in README - 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. +- One logical paragraph per line; no hard-wrap line-length limit. For an intentional hard line break within a block - stacked badges, status, or license lines - end the line with a trailing backslash (`\`); this explicit form is preferred over trailing whitespace and is not treated as a paragraph split. - 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. +### Comments + +Applies to code and workflow (`#`) comments alike. + +- Comment only when the code does not explain itself or the logic is genuinely complex. Self-evident code needs no comment. +- Write for the human reading *this* project's code now: state what the code does and only the non-obvious *why*. No cross-project references (do not name other repos), no historic or design narrative, no rule citations - governance lives in this file, not echoed inline. +- Match the surrounding code's line length (typically ~120), not an 80-column wrap. + ### 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: @@ -104,7 +110,7 @@ Clarify devcontainer setup steps in README - **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`. -- **Derived repos must carry both files.** [`.editorconfig`](./.editorconfig) **and** [`.gitattributes`](./.gitattributes) are mandatory verbatim carries (see [Files and Sections Derived Repos Must Carry Verbatim](#files-and-sections-derived-repos-must-carry-verbatim)). A derived repo missing either file, or one whose `.editorconfig` sets `end_of_line` only under `[*.md]` instead of carrying the full per-extension rules, will accumulate files mixed between LF and CRLF - the exact failure these two files prevent. +- **Derived repos must carry both files.** [`.editorconfig`](./.editorconfig) **and** [`.gitattributes`](./.gitattributes) are mandatory carries (see [Files and Sections Derived Repos Must Carry Verbatim](#files-and-sections-derived-repos-must-carry-verbatim)). A derived repo missing either file, or one whose `.editorconfig` sets `end_of_line` only under `[*.md]` instead of carrying the full per-extension rules, will accumulate files mixed between LF and CRLF - the exact failure these two files prevent. The EOL/per-extension block is always-verbatim; the `[*.cs]` style block is .NET-only. Adopting `.gitattributes` for the first time requires a one-time normalization pass - see the verbatim-carry entry. ### Quantitative Claims @@ -120,7 +126,7 @@ The repo runs a review loop on every PR: local agent iteration plus remote autom 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. +3. Wait for review activity on that head. A completed review that raises **no findings** is a valid terminal outcome for that head - proceed; do not re-trigger it or treat the absence of comments as a missing review. 4. Triage findings. 5. Apply fixes or write a rationale for declines. 6. Reply to each thread and resolve what was addressed. @@ -160,7 +166,7 @@ Anti-pattern: don't keep flipping the code on the same style point. Flip the rul 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. +- **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. - **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. @@ -170,6 +176,8 @@ These conventions describe the target state. New and modified workflows must res - **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')`. +- **Artifact retention**: intermediate build artifacts (`actions/upload-artifact`) are consumed by a later job in the same run, so set `retention-days: 1` - the default 90-day retention otherwise piles up against the account-wide artifact-storage quota. The durable copies live on the GitHub release, not in workflow artifacts. +- **Docker layer cache**: cache to/from a registry tag (`type=registry`, e.g. `buildcache-` on Docker Hub), not the GitHub Actions cache (`type=gha`), to keep large image layers off the 10 GB Actions cache. - **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). ### Running the Linters Locally (Known-Working Invocations) @@ -210,24 +218,24 @@ Each devcontainer's `customizations.vscode.extensions` mirrors the `recommendati - `Tests/` - xUnit + AwesomeAssertions - `Benchmarks/` - BenchmarkDotNet - `CodeGen/` - internal codegen tooling - - **Style guide: [`CODESTYLE.md`](./CODESTYLE.md)**. + - **Style guide: [`CODESTYLE.md`](./CODESTYLE.md) ".NET" section**. - **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)**. + - **Style guide: [`CODESTYLE.md`](./CODESTYLE.md) "Python" section**. - **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) + - `.vscode/` - debug configs and tasks, grouped by language (the template ships the .NET group); carry your language's named clean-compile tasks verbatim (see [`CODESTYLE.md`](./CODESTYLE.md)) - `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. +When you touch code in either language, also respect that language's style guide. After editing, that language's **clean-compile** must pass before commit, and new-port/brownfield status never licenses relaxing analyzer/linter severities or silencing newly surfaced diagnostics - both rules live in [`CODESTYLE.md`](./CODESTYLE.md) "General". 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. +2. **Decide** which language sides you need. If you need only one, delete the other folder and its references - drop that language's section in [CODESTYLE.md](./CODESTYLE.md) and follow its "Adopting Without ..." deletion checklist. +3. **Read** [CODESTYLE.md](./CODESTYLE.md) - the General section plus the section(s) for the language(s) you keep (.NET, Python). 4. **Carry the mandatory shared files and sections verbatim** - do not re-invent them per repo. See [Files and Sections Derived Repos Must Carry Verbatim](#files-and-sections-derived-repos-must-carry-verbatim) for the exact list (review-loop contract + runbook, lint config, line-ending governance) and what to adapt. 5. **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. 6. **Run tools before first commit**: @@ -235,20 +243,41 @@ When you touch code in either language, also respect that language's style guide - Python: `cd PyPiLibrary && uv sync`. - Optional pre-commit hooks (off by default) - see README "Optional: enable git hooks locally". 7. **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. +8. **Maintain release notes** - every derived repo keeps a short release-notes summary in [`README.md`](./README.md) and the full history in [`HISTORY.md`](./HISTORY.md); update both when cutting a release. ### Files and Sections Derived Repos Must Carry Verbatim These artifacts are the template's cross-cutting contract. A derived repo must carry **each** of them; copy the file/section as-is and change only the noted placeholders. Re-inventing or omitting any of these is the drift the template exists to prevent. - **[`AGENTS.md`](./AGENTS.md) "PR Review Etiquette" section** - the provider-agnostic review-loop contract. Copy verbatim. No placeholders to change (it names no owner/repo). -- **[`.github/copilot-instructions.md`](./.github/copilot-instructions.md)** - the whole file is a drop-in; its "GitHub Copilot Review Runbook" carries the provider mechanics. Copy verbatim and change only the `` / `` / `` placeholders in the API snippets; drop language-specific style pointers that don't apply. +- **[`.github/copilot-instructions.md`](./.github/copilot-instructions.md)** - the whole file is a drop-in; its "GitHub Copilot Review Runbook" carries the provider mechanics. Copy verbatim and change only the `` / `` / `` placeholders in the API snippets; drop language-specific style pointers that don't apply. Keep this file **narrow** - provider-specific mechanics (the Copilot review runbook) plus the inline commit/PR-title summary. **Project-specific conventions and API/behavioral contracts do not belong here**; put them in [`AGENTS.md`](./AGENTS.md), the agent-agnostic file every coding agent reads. Non-Copilot agents (Claude Code, Codex, Cursor, ...) are not directed to this file and don't read it by default, so any rule a reviewer must honor has to live in `AGENTS.md` to be provider-independent. - **[`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc)** - the shared lint config read by both the davidanson `markdownlint` IDE extension and CLI/CI `markdownlint-cli2`, so the IDE and command line stay in lock-step. Copy verbatim (it is repo-agnostic). **On first adoption**, a repo's existing docs often carry structural debt this config surfaces (MD022/MD031/MD032 blank lines around headings/fences/lists, MD040 unlabeled fences). Clear it in one pass by running the markdownlint-cli2 Docker command from [Running the Linters Locally](#running-the-linters-locally-known-working-invocations) with `--fix` added (`docker run --rm -v "$PWD":/workdir davidanson/markdownlint-cli2:latest --fix "**/*.md"`), then hand-label any remaining unlabeled fences (MD040 - usually `text` for format/example blocks) and **re-verify the line endings of touched `.md` files** (`--fix` can rewrite a CRLF file as LF). -- **[`.editorconfig`](./.editorconfig) and [`.gitattributes`](./.gitattributes)** - line-ending governance (see [Line Endings](#line-endings)). Copy **both** verbatim. `.editorconfig` sets `end_of_line` per file type and `.gitattributes` (`* -text`) stops git from normalizing; a repo missing either, or one that only sets `end_of_line` for `[*.md]` instead of carrying the full per-extension rules, drifts between LF and CRLF. +- **[`.editorconfig`](./.editorconfig) and [`.gitattributes`](./.gitattributes)** - line-ending governance (see [Line Endings](#line-endings)). `.editorconfig` sets `end_of_line` per file type and `.gitattributes` (`* -text`) stops git from normalizing; a repo missing either, or one that only sets `end_of_line` for `[*.md]` instead of carrying the full per-extension rules, drifts between LF and CRLF. The **defaults + per-extension EOL block is always-verbatim**; the `[*.cs]` + ReSharper style block at the end is **.NET-only** and may be dropped in a non-.NET repo (the file marks the boundary). A repo adopting `.gitattributes` for the **first time** must do a one-time explicit line-ending normalization: `* -text` tells git to stop normalizing, so pre-existing files keep whatever (possibly mixed) endings they have - convert each to its `.editorconfig` ending and commit that as a deliberate one-time pass, best isolated in its own commit. +- **[`CODESTYLE.md`](./CODESTYLE.md)** - the single code-style guide. Its **General** section is always carried; each **language section** (.NET, Python) is droppable, exactly like the `.editorconfig` `[*.cs]` boundary - keep the section(s) for the language(s) you ship and drop the rest. **Repo-root placement is load-bearing**: `AGENTS.md` links it as `./CODESTYLE.md` and `.github/copilot-instructions.md` as `../CODESTYLE.md`, so moving it breaks those links. Adapt the in-section repo-specific bits - the .NET project-folder list, the `InternalsVisibleTo` project names, and the VS Code task labels - to your repo. +- **[`.vscode/tasks.json`](./.vscode/tasks.json)** - carry your language's **named clean-compile definitions verbatim**: as VS Code tasks where the template ships them that way (the .NET group - `.NET Build` / `CSharpier Format` / `.NET Format`), or as the documented commands where it doesn't (Python's `ruff` / `pyright`, in `CODESTYLE.md`). Their names are owned by the matching `CODESTYLE.md` language section and their command sequence + arguments are the canonical clean-compile spec. Convenience tasks (`.NET Tool Update`, `.NET Outdated Upgrade`) and project-specific tasks (`.NET Benchmark`) are the adapt zone; a non-.NET repo drops the .NET task group and carries its own language's definitions. When the template changes one of these, re-sync the derived repo from the new version (see below). ### Staying in Sync and Reporting Drift Upstream -A derived repo is expected to **re-sync against the template periodically**, not just at creation: pull the current version of each verbatim-carry artifact above and re-apply it (adapting only the noted placeholders). +A derived repo is expected to **re-sync against the template periodically**, not just at creation: pull the current version of each verbatim-carry artifact above and re-apply it (adapting only the noted placeholders). For [`CODESTYLE.md`](./CODESTYLE.md), re-sync the whole file from the template and then drop the language section(s) you don't ship (always keeping the General section) - replacing the file wholesale and trimming whole sections is simpler to keep current than hand-editing per-language snippets. **Drift flows back upstream as an issue, not a private fix.** When porting or re-syncing, if you find a discrepancy that should be fixed in the **template itself** - a gap, an outdated instruction, a missing rule, something that bit this repo and would bite the next derived repo too - **open an issue in [`ptr727/ProjectTemplate`](https://github.com/ptr727/ProjectTemplate)** describing it, rather than only patching it locally. A local fix realigns *this* repo; an upstream issue (then fix) corrects it *for every future derived repo* and keeps the template the single source of truth. This is exactly how the current review-loop / lint-config / brownfield-migration gaps were surfaced. + +#### Known Downstream Projects + +Sync is **bidirectional**. The flow above is the downstream-to-upstream direction (derived repos report drift up). The reverse direction is the maintainer's: **when changing a verbatim-carry artifact or another cross-cutting contract in this template, file a heads-up issue in each affected downstream repo below** so it can re-sync, rather than letting the change be discovered only on the next ad-hoc port. Keep this table current as projects are derived from or retired from the template. + +This registry and the maintainer fan-out duty live in the **template hub only**. A derived repo does **not** carry this table and never names sibling repos in its own docs, comments, or AGENTS - a reader or agent there cares only about that project. Its sole cross-repo obligation is the upstream-issue rule above. + +| Repo | Ships | Consumer model | +| --- | --- | --- | +| [`ptr727/NxWitness`](https://github.com/ptr727/NxWitness) | Network Optix VMS Docker images | pull (Docker Hub) | +| [`ptr727/PlexCleaner`](https://github.com/ptr727/PlexCleaner) | Media-optimization tool (Docker + release binaries) | pull (Docker Hub / GitHub releases) | +| [`ptr727/Utilities`](https://github.com/ptr727/Utilities) | C# .NET utility library | pull (NuGet) | +| [`ptr727/LanguageTags`](https://github.com/ptr727/LanguageTags) | ISO 639 / BCP 47 language-tag library | pull (NuGet) | +| [`ptr727/ESPHome-NonRoot`](https://github.com/ptr727/ESPHome-NonRoot) | Non-root ESPHome Docker image | pull (Docker Hub) | +| [`ptr727/VSCode-Server-DotNetCore`](https://github.com/ptr727/VSCode-Server-DotNetCore) | VS Code Server + .NET SDK Docker image | pull (Docker Hub) | +| [`ptr727/homeassistant-purpleair`](https://github.com/ptr727/homeassistant-purpleair) | PurpleAir Home Assistant integration | push (HACS) | + +The *consumer model* column (push = forced update on every release, pull = consumer updates on its own cadence) is the signal that drives a downstream's two-phase vs `PUBLISH_ON_MERGE` choice - see [README "Release Distribution Model"](./README.md#template---release-distribution-model-two-phase-by-default). diff --git a/CODESTYLE.md b/CODESTYLE.md index e9d6ff75..7963e48e 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -1,77 +1,100 @@ -# Code Style and Formatting Rules - .NET +# Code Style and Formatting Rules -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 is the single code-style guide for the repo. The **General** section applies to every language and is always carried. Each **language section** (.NET, Python) is self-contained and **droppable**: a repo with no .NET side drops the .NET section, a repo with no Python side drops the Python section - the same per-language model as [`.editorconfig`](./.editorconfig), whose `[*.cs]` block a non-.NET repo drops. -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. +Cross-cutting *process* rules (PR titles, branching, US English, markdown style, comments philosophy, workflow YAML, PR review etiquette) live in [AGENTS.md](./AGENTS.md) and are not repeated here. -## Build Requirements +## General -### Zero Warnings Policy +These rules apply to every language in the repo. + +### Tooling Names and Casing + +Use each tool's official casing in task labels, docs, and prose - `.NET` (not `.Net`), `CSharpier`, `ruff`, `pyright`, `uv`. Don't invent personal variants. + +### Clean-Compile Verification + +Each language defines a **clean-compile** verification - the combination of build, formatter, linter, and code-analysis tools that must report clean before a commit. It is exposed as one or more **named** VS Code tasks (or, where a language ships no tasks, documented commands), and those definitions are **carried verbatim** across derived repos. The concrete names live in each language section below. + +- **Run it after every code change.** The relevant language's clean-compile must pass before you commit; CI runs the same checks as a backstop. +- **The named task definition is the canonical spec** - its exact command sequence, arguments, and strictness. You may run it through the VS Code task **or** by invoking the equivalent native commands directly; either is fine **only if the sequence, arguments, and strictness match exactly**. No shortcuts and no more-lenient options (for example, never drop `--verify-no-changes` or loosen a `--severity`). + +### Analyzer Diagnostics and Suppressions + +- **A new port is not a license to silence diagnostics.** Brownfield / just-ported status never justifies relaxing analyzer or linter severities or muting newly surfaced warnings - fix them. (The only brownfield allowance in this template is the one-time git-signing / line-ending migration described in [AGENTS.md](./AGENTS.md) and [README.md](./README.md), which has nothing to do with code analysis.) +- **Suppress only genuine false-positives or deliberate, documented exceptions**, always at the **narrowest scope that fits**, in this order of preference: + 1. An **in-code annotation on the specific symbol**, with a justification - the language's attribute/comment form, never a blanket pragma spanning a region. + 2. The **owning project's local config** when the exception is project-wide for one project (e.g. a test project's own `.editorconfig` / `pyproject.toml`). + 3. The **root / shared config** only when the suppression is genuinely applicable to **every** project in the repo. +- **Never blanket-relax a batch of rules project-wide** to get a port to build. The per-language mechanics (which attribute, which config key) are in each language section. + +### Markdown and Spelling + +These apply repo-wide, in every directory: + +1. **Markdown linting**: All `.md` files must be lint-clean (error and warning free) via the VS Code `markdownlint` extension. [`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc) at the repo root is the single source of truth - the davidanson `markdownlint` extension and a command-line `markdownlint-cli2` run both read it, so the IDE and CLI stay in lock-step. Rules it deliberately disables (e.g. `MD013` line-length, `MD033` inline HTML) are **intentional** - do not "fix" them. This file is carried verbatim by every derived repo (see [AGENTS.md "Files and Sections Derived Repos Must Carry Verbatim"](./AGENTS.md#files-and-sections-derived-repos-must-carry-verbatim)). Fix violations at the source rather than disabling rules. +2. **Spelling**: All spelling must be clean via the CSpell VS Code integration; words must be correctly spelled in **US English** (the repo-wide convention - see [AGENTS.md](./AGENTS.md)). Project-specific terms go in the workspace CSpell config. + +## .NET + +*This section applies only to the .NET side. A repo with no .NET projects drops the whole section - see [Adopting Without .NET](#adopting-without-net) at its end.* + +This is the style guide for the **.NET projects** in this repo. **Adapt the project list to your repo**: this template ships [`NuGetLibrary/`](./NuGetLibrary/), [`Console/`](./Console/), [`Tests/`](./Tests/), [`Benchmarks/`](./Benchmarks/), and [`CodeGen/`](./CodeGen/); a derived repo names its own projects. + +### Build Requirements + +#### Zero Warnings Policy **CRITICAL**: All builds must complete without warnings. The project enforces this through: -1. **VS Code tasks** - - `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` +1. **The `.NET Format` clean-compile task** (see [Clean-Compile Verification](#clean-compile-verification)) + - The .NET clean-compile is the **`.NET Format`** VS Code task, which chains `CSharpier Format` -> `.NET Build` -> `dotnet format style --verify-no-changes`. These three task definitions are carried verbatim in [`.vscode/tasks.json`](./.vscode/tasks.json). + - After any code change it must pass before commit. Run the `.NET Format` task. To run it natively instead, reproduce that task chain from [`.vscode/tasks.json`](./.vscode/tasks.json) exactly - `CSharpier Format`, then `.NET Build`, then the `dotnet format style --verify-no-changes --severity=info ...` verify - without dropping or loosening any argument (tasks.json is the canonical command spec). Bare `dotnet format` alone, skipping CSharpier or the build, is not sufficient. 2. **Analyzer configuration** - `latest-all` - `true` - - Analyzer severity is `suggestion`, but all warnings must be addressed + - Analyzer severity is `suggestion`, but all warnings must be addressed - see [Analyzer Diagnostics and Suppressions](#analyzer-diagnostics-and-suppressions); do not relax rules to dodge them. 3. **CI lint backstop** - `dotnet csharpier check` and `dotnet format style --verify-no-changes` run on every PR - No git hooks ship by default - see README "Optional: enable git hooks locally" to opt in -### Build Tasks +#### Build Tasks -Available VS Code tasks (use via `run_task` tool): +Available VS Code tasks (run them from VS Code's task runner - **Terminal -> Run Task** - or an agent's task-running tool). The first three are the clean-compile set, carried verbatim; the rest are convenience tasks a derived repo adapts or drops: -- `.Net Build`: Build with diagnostic verbosity -- `.Net Format`: Verify formatting and style (must pass) -- `CSharpier Format`: Auto-format code with CSharpier -- `.Net Tool Update`: Update dotnet tools -- `.Net Outdated Upgrade`: Upgrade outdated NuGet dependencies (interactive prompt) +- `.NET Build`: Build with diagnostic verbosity *(clean-compile)* +- `CSharpier Format`: Auto-format code with CSharpier *(clean-compile)* +- `.NET Format`: Run CSharpier and build, then verify formatting and style with `--verify-no-changes` *(clean-compile; the task to run after edits)* +- `.NET Tool Update`: Update dotnet tools *(convenience)* +- `.NET Outdated Upgrade`: Upgrade outdated NuGet dependencies, interactive prompt *(convenience)* +- `.NET Benchmark`: Run BenchmarkDotNet *(project-specific; present only if a Benchmarks project exists)* -## Tooling and Editor +### Tooling and Editor -### Code Formatting and Tooling +#### Code Formatting and Tooling 1. **CSharpier**: Primary code formatter - - Run before committing: `dotnet csharpier format --log-level=debug .` - + - Invoked by the `CSharpier Format` task / `dotnet csharpier format --log-level=debug .` 2. **dotnet format**: Style verification - Verify no changes: `dotnet format style --verify-no-changes --severity=info --verbosity=detailed` - 3. **Other tools** - `dotnet-outdated-tool`: Dependency update checks - Nerdbank.GitVersioning: Version management Pre-commit git hooks are not installed by default - CI is the lint backstop. See README "Optional: enable git hooks locally" if you want Husky.Net (or another runner) wired up locally. -### Editor Baseline +#### Editor Baseline 1. **Required VS Code extensions**: CSharpier, markdownlint, CSpell 2. **VS Code settings**: Use the workspace settings without overrides -### Markdown Files - -1. **Linting**: All `.md` files must be linted with the VS Code `markdownlint` extension (local only; no CI) -2. **Zero warnings**: Markdown linting must be error and warning free -3. **Authoritative config**: [`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc) at the repo root is the single source of truth - the davidanson `markdownlint` extension and a command-line `markdownlint-cli2` run both read it, so the IDE and CLI stay in lock-step. Rules the config deliberately disables (e.g. `MD013` line-length, `MD033` inline HTML) are **intentional** - do not "fix" them. This file is one of the artifacts every derived repo must carry verbatim (see [AGENTS.md "Quick Start for Derived Projects"](./AGENTS.md#quick-start-for-derived-projects)). - -### Spelling - -1. **CSpell**: All spelling checks must be error free using the CSpell VS Code integration -2. **Accepted spellings**: Words must be correctly spelled in US or UK English -3. **Allowed exceptions**: Project-specific terms must be added to the workspace CSpell config - -## Coding Standards and Conventions +### Coding Standards and Conventions Note: Code snippets are illustrative examples only. Replace namespaces/types to match your project. -### C# Language Features +#### C# Language Features 1. **File-scoped namespaces** @@ -107,7 +130,7 @@ Note: Code snippets are illustrative examples only. Replace namespaces/types to var name = "test"; ``` -### Naming Conventions +#### Naming Conventions 1. **Private fields**: underscore prefix with camelCase @@ -128,7 +151,7 @@ Note: Code snippets are illustrative examples only. Replace namespaces/types to private const int MaxRetries = 3; ``` -### Code Structure +#### Code Structure 1. **Global usings**: Use `GlobalUsings.cs` for common namespaces @@ -174,7 +197,7 @@ Note: Code snippets are illustrative examples only. Replace namespaces/types to 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 -### Comments and Documentation +#### Comments and Documentation 1. **XML documentation** - `true` @@ -205,20 +228,25 @@ Note: Code snippets are illustrative examples only. Replace namespaces/types to public async Task GetQuoteOfTheDayAsync(string category, CancellationToken cancellationToken) {} ``` -2. **Code analysis suppressions** - - Do not use `#pragma` sections to disable analyzers - - For one-off cases, use suppression attributes with justifications - - For project-wide suppressions, add rules to `.editorconfig` +#### Analyzer Suppressions (.NET) - ```csharp - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Design", - "CA1034:Nested types should not be visible", - Justification = "https://github.com/dotnet/sdk/issues/51681" - )] - ``` +Follow the scope hierarchy in [Analyzer Diagnostics and Suppressions](#analyzer-diagnostics-and-suppressions). .NET mechanics, narrowest first: + +- **Never use `#pragma warning disable`** to silence an analyzer. +- **Symbol-scoped**: a `[System.Diagnostics.CodeAnalysis.SuppressMessage(...)]` attribute with a `Justification`, on the specific member or type: + + ```csharp + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1034:Nested types should not be visible", + Justification = "https://github.com/dotnet/sdk/issues/51681" + )] + ``` -### Error Handling and Logging +- **Project-scoped** (e.g. a test project): a `dotnet_diagnostic..severity` entry in *that project's own* `.editorconfig`, with a comment explaining why. +- **Repo-wide**: a `dotnet_diagnostic..severity` entry in the root `.editorconfig`, only when the rule is genuinely not applicable to any project. Relaxing a batch of `CA*` rules (or `dotnet_analyzer_diagnostic.severity`) to push a brownfield port through the build is exactly what this forbids. + +#### Error Handling and Logging 1. **Serilog logging**: Use structured logging @@ -250,7 +278,7 @@ Note: Code snippets are illustrative examples only. Replace namespaces/types to 5. **Exceptions**: Do not swallow exceptions; log and rethrow or translate to a domain-specific exception -### Code Patterns +#### Code Patterns 1. **Guard clauses**: Prefer early returns for validation and error handling 2. **Async all the way**: Avoid blocking calls (`.Result`, `.Wait()`); use `async`/`await` @@ -267,7 +295,7 @@ Note: Code snippets are illustrative examples only. Replace namespaces/types to 12. **Read-only data**: Use immutable or frozen collections for read-only data sets 13. **Lazy initialization**: Use `Lazy` for static, thread-safe instantiation (e.g., logger factory, HTTP factory) -### Testing Conventions +#### Testing Conventions 1. **Framework**: xUnit with AwesomeAssertions @@ -290,7 +318,7 @@ Note: Code snippets are illustrative examples only. Replace namespaces/types to 3. **Naming**: Descriptive names with underscores 4. **Theory tests**: Use `[Theory]` with `[InlineData]` -## Project Configuration +### Project Configuration 1. **Target framework**: .NET 10.0 (`net10.0`) @@ -303,7 +331,7 @@ Note: Code snippets are illustrative examples only. Replace namespaces/types to - Include SourceLink: `true` - Embed untracked sources: `true` -4. **Internal visibility**: Use `InternalsVisibleTo` for test and benchmark access +4. **Internal visibility**: Use `InternalsVisibleTo` for test and benchmark access (adapt the project names to your repo's test/benchmark projects) ```xml @@ -312,6 +340,135 @@ Note: Code snippets are illustrative examples only. Replace namespaces/types to ``` -## Best Practices +### Best Practices 1. **Code reviews**: All changes go through pull requests + +### Adopting Without .NET + +If your derived project has no .NET side, drop this entire `.NET` section and delete the .NET projects and their build/release wiring: the NuGet build/publish jobs, the `[*.cs]` / ReSharper block in `.editorconfig`, the `.NET` task group in `.vscode/tasks.json`, and the `nuget` entries in `.github/dependabot.yml`. See [README.md](./README.md) Template Adoption for the full checklist. The Python side stands alone. + +## Python + +*This section applies only to the Python side. A repo with no Python projects drops the whole section - see [Adopting Without Python](#adopting-without-python) at its end.* + +This is the style guide for the **Python project** in this repo ([`PyPiLibrary/`](./PyPiLibrary/)). + +### 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 +``` + +The Python clean-compile (see [Clean-Compile Verification](#clean-compile-verification)) is `uv run ruff format` + `uv run ruff check` + `uv run pyright`; run it (plus `uv run pytest`) before committing. The template ships these as documented commands, not VS Code tasks. 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 + 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. Porting an existing codebase is not a license to add `ignore` / `per-file-ignores` blocks to mute newly surfaced lint - fix it (see [Analyzer Diagnostics and Suppressions](#analyzer-diagnostics-and-suppressions)). + +#### 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]`. A new port doesn't change this - fix freshly surfaced type errors rather than muting them (see [Analyzer Diagnostics and Suppressions](#analyzer-diagnostics-and-suppressions)). + +#### 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`](./PyPiLibrary/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/`. +- Markdown in this directory follows the repo-wide [Markdown and Spelling](#markdown-and-spelling) rules. + +### Adopting 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/CodeGen/CodeGen.cs b/CodeGen/CodeGen.cs index b0a4e3b5..89aaa922 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-08T05:45:52Z"; + const string dateTime = "2026-06-22T06:01:35Z"; Console.WriteLine($"{dateTime} : {QuoteOfTheDay}"); Log.Logger.Information("Quote of the Day: {DateTime} : {Quote}", dateTime, QuoteOfTheDay); } diff --git a/Docker/README.md b/Docker/README.md new file mode 100644 index 00000000..7f1b1b42 --- /dev/null +++ b/Docker/README.md @@ -0,0 +1,21 @@ +# ProjectTemplate + +Container image for the ProjectTemplate Console app. + +## Image Tags + +- `latest` - the current stable release, built from `main`. +- `develop` - the current prerelease, built from `develop`. +- Immutable version tags (NBGV SemVer2): `X.Y.Z` for stable releases, `X.Y.Z-g` for develop prereleases. + +## Usage + +```sh +docker run --rm docker.io/ptr727/projecttemplate:latest +``` + +## Links + +- Source, issues, and CI/CD: [GitHub](https://github.com/ptr727/ProjectTemplate) +- Versioned releases: [GitHub Releases](https://github.com/ptr727/ProjectTemplate/releases) +- License: [MIT](https://github.com/ptr727/ProjectTemplate/blob/main/LICENSE) diff --git a/DotNet.code-workspace b/DotNet.code-workspace index e5c6245f..74cd478e 100644 --- a/DotNet.code-workspace +++ b/DotNet.code-workspace @@ -21,14 +21,19 @@ "davidanson", "debuglevel", "devcontainer", + "distros", "dockerhub", + "dorny", "dotnettools", "dryrun", "Emby", + "envsubst", "finalizers", "gpgsign", "gruntfuggly", + "HACS", "hatchling", + "homeassistant", "Jellyfin", "Keychain", "lastbuild", @@ -37,18 +42,19 @@ "logfile", "nameof", "nbgv", - "nugetlibrary", "nektos", "Nerdbank", "noninteractive", + "nugetlibrary", "onCreateCommand", "othercommand", "Pieter", "postCreateCommand", "ProjectTemplate", - "pyproject", + "purpleair", "pypi", "pypilibrary", + "pyproject", "pyright", "quoteoftheday", "resharper", @@ -62,6 +68,8 @@ "softprops", "somecommand", "tzdata", + "unvalidated", + "venv", "Viljoen", "winget", "xunit", @@ -95,11 +103,11 @@ "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" + "yzhang.markdown-all-in-one", + "fanaticpythoner.better-todo-tree" ] } } diff --git a/PyPiLibrary/CODESTYLE.md b/PyPiLibrary/CODESTYLE.md deleted file mode 100644 index a281db30..00000000 --- a/PyPiLibrary/CODESTYLE.md +++ /dev/null @@ -1,125 +0,0 @@ -# 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/README.md b/README.md index 01a0317e..eeee3773 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ See [Installation](#installation) for detailed setup instructions. - [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) + - [Template - Deferred Patterns](#template---deferred-patterns) ## Use Cases @@ -359,7 +360,7 @@ Licensed under the [MIT License][license-link]\ - [ ] Rename projects to match the naming, update `.slnx` and `.csproj` files, and update actions to match the naming. - [ ] Update the `namespace` in `.cs` and `.csproj` files to match the naming. - [ ] Update all ref-links in `README.md` to point to the naming. -- [ ] Keep the template's mandatory shared files and sections - do **not** re-invent them per repo. Carry **verbatim** the `AGENTS.md` "PR Review Etiquette" section, `.github/copilot-instructions.md` (the Copilot review runbook), `.markdownlint-cli2.jsonc`, `.editorconfig`, and `.gitattributes`, adapting only ``/`` placeholders. See [AGENTS.md "Files and Sections Derived Repos Must Carry Verbatim"](./AGENTS.md#files-and-sections-derived-repos-must-carry-verbatim), and re-sync them from the template periodically - filing an upstream issue when you spot a template gap. +- [ ] Keep the template's mandatory shared files and sections - do **not** re-invent them per repo. Carry **verbatim** the `AGENTS.md` "PR Review Etiquette" section, `.github/copilot-instructions.md` (the Copilot review runbook), `.markdownlint-cli2.jsonc`, `.editorconfig`, `.gitattributes`, `CODESTYLE.md` (its General section plus the section(s) for the language(s) you ship, kept at the repo root), and your language's named clean-compile definitions (the .NET tasks in `.vscode/tasks.json`, or the documented commands for a language the template ships that way, per `CODESTYLE.md`), adapting only the ``/``/`` placeholders in its API snippets and the noted per-repo zones. See [AGENTS.md "Files and Sections Derived Repos Must Carry Verbatim"](./AGENTS.md#files-and-sections-derived-repos-must-carry-verbatim), and re-sync them from the template periodically - filing an upstream issue in [`ptr727/ProjectTemplate`](https://github.com/ptr727/ProjectTemplate) when you spot a template gap or hit a problem adopting the template. Sync is bidirectional: add your repo to [AGENTS.md "Known Downstream Projects"](./AGENTS.md#known-downstream-projects) (via an upstream PR) so template-side contract changes reach you as heads-up issues. A repo adopting `.gitattributes` (`* -text`) for the first time must do a one-time explicit line-ending normalization - `* -text` stops git normalizing, so convert each existing file to its `.editorconfig` ending and commit that as a deliberate one-time pass. - [ ] Publish to GitHub from VSCode to create a new empty GitHub repository. - [ ] Commit and push the `first-branch`. - [ ] Edit and iterate only in `first-branch` until ready to start with git history. @@ -518,21 +519,34 @@ Licensed under the [MIT License][license-link]\ - **Renaming or updating an existing ruleset needs a FULL-payload PUT, not a partial one.** `gh api -X PUT "repos///rulesets/" -f name=develop` (name only) fails with `422 Unexpected parameter 'allowed_dismissal_actors'`: GitHub re-validates the stored `pull_request` rule on a partial update, and that rule carries fields the GET response does not return. To rename (e.g. legacy `Develop`/`Main` -> `develop`/`main`) or otherwise edit a ruleset, **GET it, change the field, and PUT the whole `{name, target, enforcement, bypass_actors, conditions, rules}` back** (the same writable-field subset used for export above). Back up the GET first and verify afterward that the rule types, `required_signatures`, `non_fast_forward`, and the required status-check context are all still present. Renaming is safe for *enforcement* - the required status-check binds by check name, not ruleset name, so a rename won't break CI - but the template still expects the exact `develop`/`main` names that `AGENTS.md` and these docs reference (which is precisely what a legacy `Develop`/`Main` repo is renaming *to*); the rename removes inconsistency, it isn't a license for arbitrary names. - **Migrating a brownfield repo with unsigned history.** The shared `Require signed commits` rule (below) rejects any commit made before signing was enabled, so on a pre-existing repo the first `develop -> main` release is blocked the moment it tries to introduce that legacy history. The fix is to re-sign the legacy commits, but that rewrite is a non-fast-forward and the `Block force pushes` rule rejects it - **and the ruleset's admin bypass does not cover `git push --force` (GitHub honors ruleset bypass for UI/API operations, not git force-push).** So even the owner cannot complete the re-sign without temporarily relaxing the ruleset. This is a **one-time, maintainer-performed manual migration** - it deliberately uses the force-push that [AGENTS.md "Git and Commit Rules"](./AGENTS.md#git-and-commit-rules) forbids agents from running, so an AI agent must **never** execute this procedure; surface it to the maintainer instead. Procedure: - 1. Re-sign the divergent history, preserving merge topology. Prefer a rebase, which re-signs each commit with your current key (`commit.gpgsign` / `-S`): + Recommended order: **re-sign while the repo's rulesets are still permissive, then import the strict rulesets** (Steps 1-2 above). Re-signing before the strict rules exist avoids the disable/re-enable dance entirely; if the strict rulesets are already in place, disable enforcement per affected branch first (step 2). + + 1. **Re-sign the divergent history with a committer rewrite.** For merge-heavy history, prefer `git filter-branch` with a `--commit-filter`: it rewrites every commit object in place, preserving the exact DAG with zero conflicts, and rewriting both refs at once keeps shared ancestry consistent (same new SHA on both branches). It **must rewrite the committer** to the signing identity - `filter-branch` otherwise preserves the original committer, so the commit ends up `committer != signer`, GitHub does **not** mark it Verified, and `Require signed commits` still rejects it: + + ```sh + FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch --commit-filter ' + GIT_COMMITTER_NAME="You" GIT_COMMITTER_EMAIL="you@users.noreply.github.com" git commit-tree -S "$@" + ' -- develop main + ``` + + Pass `-- develop main` (or `--all`) to re-sign all the way to the root; add `--tag-name-filter cat` only if you want existing tags to follow the rewrite. On some hosts `filter-branch` prints a benign `envsubst: not found` warning from git's i18n setup - ignore it. + + A rebase is an alternative for simple, mostly-linear history - it re-signs each commit and sets the committer to the rewriter automatically (which is *why* it verifies), but its merge-rewriting todo is fragile when there are many merge commits: ```sh git rebase --rebase-merges --exec 'git commit --amend --no-edit -S' ``` - `git filter-branch` also works but is **deprecated** upstream (it prints a warning; suppress with `FILTER_BRANCH_SQUELCH_WARNING=1`, or use `git filter-repo` if installed) - keep it only as a fallback: + 2. **If the strict rulesets are already imported, disable enforcement on each affected branch.** The push rejection is per-branch and depends on which rules that branch carries: `Block force pushes` **or** `Require a pull request` rejects the direct force-push, and admin bypass covers neither for git force-push. A branch with only a deletion rule may accept the push; a branch with a `pull_request` rule rejects it with `GH013 ... Changes must be made through a pull request` (which doesn't read like a force-push error). Set each affected ruleset's **Enforcement** to **Disabled** (Settings -> Rules -> Rulesets). + 3. **(Maintainer only)** Force-push the re-signed branches. Force-push is restricted to the maintainer because it rewrites shared history and can lose commits if done wrong - a destructive operation, not a signing concern (signing comes from local git config and is unaffected by who runs the push). It is the single manual force-push the template sanctions; agents must never run it (see [AGENTS.md "Git and Commit Rules"](./AGENTS.md#git-and-commit-rules)). + 4. Re-enable **Enforcement** (or import the strict rulesets now, if you deferred them per the recommended order). + 5. **Verify via the API, not local `%G?`.** `git log --pretty=%G?` can show `U` (good signature, untrusted) on a fresh machine even when the signature is fine, or `G` only because `allowed_signers` happens to be configured - neither reflects what GitHub accepts. The authoritative check is: ```sh - git filter-branch -f --commit-filter 'git commit-tree -S "$@"' -- ..HEAD + gh api repos///commits/ --jq '.commit.verification' # expect verified: true, reason: valid ``` - 2. Temporarily set the `develop` (and `main` if it diverged) ruleset **Enforcement** to **Disabled** (Settings -> Rules -> Rulesets), since the admin bypass won't permit the force-push. - 3. **(Maintainer only)** Force-push the re-signed branch. This is the single manual force-push the template sanctions; agents must never run it (see [AGENTS.md "Git and Commit Rules"](./AGENTS.md#git-and-commit-rules)). - 4. Re-enable **Enforcement**. + 6. **Clean up.** A root rewrite orphans existing tags (they keep pointing at the pre-rewrite commits; `Require signed commits` applies to branches not tags, so leaving them is fine). Open bot PRs/branches based on the old history go stale after the force-push - let Dependabot recreate them (or rebase) so reviewers aren't confused. Alternatively, enable `Require signed commits` only on a repo whose **full history is already signed** - greenfield repos created from this template (where signing is live before the first commit, per [AGENTS.md "Git and Commit Rules"](./AGENTS.md#git-and-commit-rules)) never hit this. - 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): @@ -571,7 +585,7 @@ See [AGENTS.md "Branching Model"](./AGENTS.md#branching-model) for the authorita - 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. -- **A `develop -> main` release requires a develop version bump right after.** Once the merge lands and main's publish completes, raise the `version` minor in [`version.json`](./version.json) on `develop` via an isolated `bump-version-X.Y` PR, so develop's prereleases stay numerically above main's last stable. A `develop -> main` promotion that carries only maintenance (not a release) holds main's version instead (`git checkout main -- version.json` on the promotion branch). See [AGENTS.md "Release Model"](./AGENTS.md#release-model). +- **Versioning is semantic and maintainer-driven.** Raise the `version` major/minor in [`version.json`](./version.json) only when the change being introduced warrants a new semantic version (a feature, a behavior/API change, a breaking change), in the PR that introduces it - not on a cadence and not mechanically after a release. NBGV advances the patch (git height) on every commit automatically. `develop` builds are always prereleases, so they need no artificial lead over `main` and there is no post-release `version.json` bump; a `develop -> main` promotion carries whatever `version.json` is current. See [AGENTS.md "Release Model"](./AGENTS.md#release-model). - **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. @@ -611,6 +625,13 @@ For each output you **don't** ship, delete its `build--task.yml`, its jo 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). +### Template - Deferred Patterns + +Template improvements identified but deferred until a real project needs them, so they aren't lost and aren't force-fit prematurely. Implement an entry (and remove it here) when a new or existing derived project first hits the use case. + +- **Factor the unit-test job out of `test-pull-request.yml` into a `test-*-task.yml`** so the entry-point file is target-agnostic. Trigger: a non-.NET repo that wants the aggregator without hand-deleting the `unit-test` job. +- **Per-language / per-project-type test scaffolds** (a Python test task, a Docker smoke/health-check test, etc.), added as each language or project type is actually exercised downstream. Trigger: the first repo that ships that language/type and needs CI coverage for it. + [actions-link]: https://github.com/ptr727/ProjectTemplate/actions