From a73f0035693aef4532160d3d2f2c6c31112c52a7 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 21 Jun 2026 08:11:46 -0700 Subject: [PATCH 1/2] Resummarize template docs and trim workflow comments Act on the issues surfaced porting ESPHome-NonRoot to the template, plus the comment/doc cleanup feedback. Docs: - Add a Comments house-rule (concise, current-state, no cross-project references, no rule citations, match ~120-col) and apply it. - Trim ~300 comment lines across .github/workflows/*; only comments changed (no logic), CRLF and SHA-pins preserved. - Resummarize copilot-instructions.md (drop duplicated PR-title block, historic narrative) keeping the Copilot runbook mechanics. - Bless trailing-backslash hard line breaks; clarify the cross-repo boundary (hub keeps the registry/fan-out rules, derived repos never name siblings); simplify the version.json rule; document HISTORY.md + CODESTYLE aggregate patterns. Workflows and CI: - Mark the .editorconfig C# block .NET-only; note first-time .gitattributes normalization. - Rewrite the brownfield re-sign procedure (filter-branch + committer rewrite, ruleset ordering, API verification, cleanup); clarify the maintainer-only force-push is restricted as a destructive operation, not a signing concern. - Ship publish-docker-readme-task.yml + Docker/README.md, wired into publish-release.yml. - Ship check-upstream-version-task.yml + merge-bot wiring for wrapper repos that track an upstream release. - Set retention-days: 1 on intermediate build artifacts and document the artifact-retention and registry-cache storage conventions. Add a Deferred Patterns backlog to the README. --- .editorconfig | 3 + .github/copilot-instructions.md | 36 +---- .github/workflows/build-docker-task.yml | 38 ++--- .github/workflows/build-executable-task.yml | 42 ++---- .github/workflows/build-nugetlibrary-task.yml | 17 +-- .github/workflows/build-pypilibrary-task.yml | 86 ++--------- .github/workflows/build-release-task.yml | 102 ++++--------- .../workflows/check-upstream-version-task.yml | 91 ++++++++++++ .github/workflows/get-version-task.yml | 31 +--- .github/workflows/merge-bot-pull-request.yml | 135 ++++++++---------- .../workflows/publish-docker-readme-task.yml | 33 +++++ .github/workflows/publish-release.yml | 85 +++++------ .../run-codegen-pull-request-task.yml | 40 ++---- .../run-periodic-codegen-pull-request.yml | 14 +- .github/workflows/test-pull-request.yml | 60 +++----- AGENTS.md | 30 ++-- Docker/README.md | 21 +++ DotNet.code-workspace | 12 +- README.md | 35 ++++- 19 files changed, 420 insertions(+), 491 deletions(-) create mode 100644 .github/workflows/check-upstream-version-task.yml create mode 100644 .github/workflows/publish-docker-readme-task.yml create mode 100644 Docker/README.md diff --git a/.editorconfig b/.editorconfig index 4366e7de..907ec112 100644 --- a/.editorconfig +++ b/.editorconfig @@ -48,6 +48,9 @@ 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 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ab8c5b37..61c8a8c0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,9 +2,7 @@ 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 for this repo's conventions, including the [PR Review Etiquette](../AGENTS.md#pr-review-etiquette) review-loop contract that this file's runbook implements. - -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. +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. For language-specific style rules, see: @@ -15,31 +13,11 @@ Do not duplicate language-specific rules here. ## Commit Messages and Pull Request Titles -Feature -> develop PRs squash-merge - the PR title becomes the single commit on develop. Develop -> main PRs merge-commit - main's history shows one merge commit per release with develop's tip as the second parent. Titles are descriptive and have no versioning effect - versioning is handled by [Nerdbank.GitVersioning](https://github.com/dotnet/Nerdbank.GitVersioning) reading [version.json](../version.json) and git history, not by parsing commit messages. - -Branch protection enforces the merge method on both bases (develop allows only squash, main allows only merge). When running `gh pr merge` against either base, pick the matching flag (`--squash` for develop, `--merge` for main); a mismatch fails with "Merge method ... is not allowed on this repository". The merge-bot workflow (`.github/workflows/merge-bot-pull-request.yml`) does this dispatch automatically for Dependabot and codegen PRs via a `case` on `base.ref` - keep that pattern when adding new auto-merge jobs. - -### Format - -- Imperative subject summarizing the change, <= 72 characters, no trailing period. ("Add 24-hour PM2.5 average sensor", not "Added X" or "Adds X".) -- Optional body, blank-line separated, explaining *why* the change is being made when that's non-obvious. The diff shows *what*. +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). -### Rules - -- Don't write `update stuff`, `wip`, or other vague titles. (Dependabot's default `Bump X from Y to Z` titles are fine - keep them.) -- Don't add `Co-Authored-By:` lines unless the user explicitly asks. -- Don't put release-bump magnitude in the title - no "minor", "patch", "release v0.2.0", etc. NBGV computes the next release version from `version.json` + git history. Dependency versions in dependency-bump titles are fine and expected. -- Use US English spelling and match the existing heading style of the file you're editing: title case with lowercase short bind words (a, an, the, and, but, or, of, in, on, at, to, by, for, from); hyphenated compounds capitalize both parts unless the second is a short preposition (*Built-in*, *EPA-Corrected*, *24-Hour*). - -### Examples - -```text -Add structured logging extensions to library -Pin softprops/action-gh-release to commit SHA -Drop net8.0 multi-targeting from console project -Bump xunit.v3 from 3.2.2 to 3.3.0 -Clarify devcontainer setup steps in README -``` +- 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 @@ -49,9 +27,9 @@ Use this section for provider-specific mechanics. The expected review loop *cont ### 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. +> **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 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..2c38f29f 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,17 @@ 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. - name: Upload matrix build artifacts step 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 +93,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..9b121d2a 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,9 +13,8 @@ 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 @@ -70,14 +69,12 @@ jobs: - name: Zip output step 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. - name: Upload build artifacts step 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..3c5493de 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,10 +12,8 @@ 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 @@ -59,10 +52,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 +73,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 +91,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 +103,12 @@ 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. - name: Upload build artifacts step id: artifact-upload-step 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..af3d782c 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,14 @@ 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 }} - # 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,7 +84,7 @@ 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 }} @@ -106,7 +95,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 +107,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 +142,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 +162,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..5d13a5bc --- /dev/null +++ b/.github/workflows/check-upstream-version-task.yml @@ -0,0 +1,91 @@ +name: Check upstream version task + +# Skeleton for a wrapper repo that tracks an upstream release. A resolver command +# computes the upstream version, writes it 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. 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: + description: Shell command that prints the resolved upstream version to stdout. + required: true + type: string + state-file: + description: Committed version-state file, 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: + description: Head-branch prefix; the merge-bot matches "-". + 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 }} + + - name: Resolve upstream version step + id: resolve + env: + RESOLVER_COMMAND: ${{ inputs.resolver-command }} + run: | + set -euo pipefail + version="$(bash -c "$RESOLVER_COMMAND")" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Write version state file step + env: + VERSION: ${{ steps.resolve.outputs.version }} + STATE_FILE: ${{ inputs.state-file }} + run: | + set -euo pipefail + printf '%s\n' "$VERSION" > "$STATE_FILE" + + # 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: Update upstream version to ${{ steps.resolve.outputs.version }} + commit-message: Update upstream version to ${{ steps.resolve.outputs.version }} + body: Rolling upstream-version bump opened by the version tracker. + 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..8c21a4c8 --- /dev/null +++ b/.github/workflows/publish-docker-readme-task.yml @@ -0,0 +1,33 @@ +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 + + # 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/AGENTS.md b/AGENTS.md index a4d2195f..a83be4b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,18 +40,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. - **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. - - **`develop` need not lead `main`; no post-release bump.** Because `develop` builds are always prereleases (`X.Y.-g`), they are ordered below same-`X.Y` stable releases *by design* - that is what "prerelease" means - so there is no need to keep `develop` a minor ahead, and no `bump-version-X.Y` PR after a release. `develop` *may* sit at a higher major/minor than `main` whenever functional work in flight has bumped it, but that is incidental, not a requirement. A `develop -> main` promotion simply carries whatever `version.json` is current: a promotion that introduced a functional bump releases that new version on `main`; a maintenance-only promotion (dependency bumps, CI/doc fixes, template re-syncs) carries the unchanged `version.json` and `main` advances only its NBGV height. + - **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 computes the upstream version, writes it 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`). 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 +84,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 +113,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 @@ -160,7 +169,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 +179,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) @@ -235,6 +246,7 @@ 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 @@ -243,13 +255,13 @@ These artifacts are the template's cross-cutting contract. A derived repo must c - **[`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. - **[`.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. 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), prefer to keep it as the full multi-language aggregate the template ships and re-sync the whole file, even if the repo uses only one language - replacing the entire file is simpler to keep current than maintaining hand-trimmed 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. @@ -257,6 +269,8 @@ A derived repo is expected to **re-sync against the template periodically**, not 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) | diff --git a/Docker/README.md b/Docker/README.md new file mode 100644 index 00000000..ea509873 --- /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`. +- `X.Y.Z` - immutable per-release tags. + +## 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..99dc3273 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", diff --git a/README.md b/README.md index 5a0dd4d1..9057f06d 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 the ``/``/`` placeholders in its API snippets. 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. +- [ ] 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 the ``/``/`` placeholders in its API snippets. 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): @@ -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 From f88f7c8199ac2bc8a650e59eedaa67dd7dc03241 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 21 Jun 2026 08:18:55 -0700 Subject: [PATCH 2/2] Address Copilot review on docs and tracker tasks - Pin the docker-readme checkout to inputs.branch so the main leg always publishes main's readme regardless of the triggering ref. - Clarify that bump-branch-prefix must match the merge-bot's hard-coded upstream-version- head refs or auto-merge won't fire. - Document that Docker immutable tags are NBGV SemVer2, including develop prerelease tags, not only X.Y.Z. --- .github/workflows/check-upstream-version-task.yml | 4 +++- .github/workflows/publish-docker-readme-task.yml | 3 +++ Docker/README.md | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check-upstream-version-task.yml b/.github/workflows/check-upstream-version-task.yml index 5d13a5bc..35d7b163 100644 --- a/.github/workflows/check-upstream-version-task.yml +++ b/.github/workflows/check-upstream-version-task.yml @@ -26,7 +26,9 @@ on: type: string default: '["main", "develop"]' bump-branch-prefix: - description: Head-branch prefix; the merge-bot matches "-". + # 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 diff --git a/.github/workflows/publish-docker-readme-task.yml b/.github/workflows/publish-docker-readme-task.yml index 8c21a4c8..4b04ad64 100644 --- a/.github/workflows/publish-docker-readme-task.yml +++ b/.github/workflows/publish-docker-readme-task.yml @@ -19,6 +19,9 @@ jobs: - 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 diff --git a/Docker/README.md b/Docker/README.md index ea509873..7f1b1b42 100644 --- a/Docker/README.md +++ b/Docker/README.md @@ -6,7 +6,7 @@ Container image for the ProjectTemplate Console app. - `latest` - the current stable release, built from `main`. - `develop` - the current prerelease, built from `develop`. -- `X.Y.Z` - immutable per-release tags. +- Immutable version tags (NBGV SemVer2): `X.Y.Z` for stable releases, `X.Y.Z-g` for develop prereleases. ## Usage