diff --git a/.devcontainer/dotnet/devcontainer.json b/.devcontainer/dotnet/devcontainer.json index 0f22cff0..dc5e36bc 100644 --- a/.devcontainer/dotnet/devcontainer.json +++ b/.devcontainer/dotnet/devcontainer.json @@ -41,8 +41,9 @@ "customizations": { "vscode": { - // Mirror of `recommendations` in DotNet.code-workspace. + // Mirror of `recommendations` in ProjectTemplate.code-workspace. "extensions": [ + "arahata.linter-actionlint", "csharpier.csharpier-vscode", "davidanson.vscode-markdownlint", "editorconfig.editorconfig", @@ -51,6 +52,7 @@ "ms-azuretools.vscode-docker", "ms-dotnettools.csdevkit", "streetsidesoftware.code-spell-checker", + "timonwong.shellcheck", "yzhang.markdown-all-in-one" ] } diff --git a/.devcontainer/python/devcontainer.json b/.devcontainer/python/devcontainer.json index 02375517..a36be913 100644 --- a/.devcontainer/python/devcontainer.json +++ b/.devcontainer/python/devcontainer.json @@ -41,8 +41,9 @@ "customizations": { "vscode": { - // Mirror of `recommendations` in Python.code-workspace. + // Mirror of `recommendations` in ProjectTemplate.code-workspace. "extensions": [ + "arahata.linter-actionlint", "charliermarsh.ruff", "davidanson.vscode-markdownlint", "editorconfig.editorconfig", @@ -51,6 +52,7 @@ "ms-azuretools.vscode-docker", "ms-python.python", "streetsidesoftware.code-spell-checker", + "timonwong.shellcheck", "yzhang.markdown-all-in-one" ] } diff --git a/.editorconfig b/.editorconfig index e95c05b8..22e58dde 100644 --- a/.editorconfig +++ b/.editorconfig @@ -44,6 +44,10 @@ end_of_line = crlf [*.sh] end_of_line = lf +# Dockerfiles - CRLF breaks RUN heredocs and line continuations +[{Dockerfile,*.Dockerfile}] +end_of_line = lf + # Windows scripts [*.{cmd,bat,ps1}] end_of_line = crlf diff --git a/.gitattributes b/.gitattributes index d4823c75..cae17288 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,16 @@ -# Leave line endings alone -# git config --global core.autocrlf false -# git add --renormalize . -# git ls-files --eol -* -text +# Default: do not normalize line endings (`* -text`); .editorconfig end_of_line rules guide what the editor writes. +# The exception pins below are git's own enforcement - they force LF for execution-sensitive classes regardless of editor. +# git config --global core.autocrlf false +# git add --renormalize . +# git ls-files --eol +* -text + +# Exception: scripts must stay LF regardless of the `* -text` default - a CRLF shebang breaks execution. `.editorconfig` +# covers `*.sh`, but extensionless executables match no extension rule, so pin them here so git enforces LF on checkout +# and `--renormalize`. Any repo whose tooling ships extensionless scripts adds the matching path pin, e.g. s6-overlay +# init `Docker/s6-overlay/** text eol=lf` or husky/git hooks `.husky/pre-commit text eol=lf`. +*.sh text eol=lf + +# Dockerfiles must be LF - a CRLF breaks RUN heredocs and line continuations. +Dockerfile text eol=lf +*.Dockerfile text eol=lf diff --git a/.github/rulesets/develop.json b/.github/rulesets/develop.json new file mode 100644 index 00000000..59c94d49 --- /dev/null +++ b/.github/rulesets/develop.json @@ -0,0 +1,68 @@ +{ + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "always" + } + ], + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/develop" + ] + } + }, + "enforcement": "active", + "name": "develop", + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_linear_history" + }, + { + "type": "required_signatures" + }, + { + "parameters": { + "allowed_merge_methods": [ + "squash" + ], + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_approving_review_count": 0, + "required_review_thread_resolution": true, + "required_reviewers": [] + }, + "type": "pull_request" + }, + { + "parameters": { + "do_not_enforce_on_create": false, + "required_status_checks": [ + { + "context": "Check pull request workflow status", + "integration_id": 15368 + } + ], + "strict_required_status_checks_policy": false + }, + "type": "required_status_checks" + }, + { + "parameters": { + "review_draft_pull_requests": true, + "review_on_push": true + }, + "type": "copilot_code_review" + } + ], + "target": "branch" +} diff --git a/.github/rulesets/main.json b/.github/rulesets/main.json new file mode 100644 index 00000000..f95b66e4 --- /dev/null +++ b/.github/rulesets/main.json @@ -0,0 +1,65 @@ +{ + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "always" + } + ], + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/main" + ] + } + }, + "enforcement": "active", + "name": "main", + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_signatures" + }, + { + "parameters": { + "allowed_merge_methods": [ + "merge" + ], + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_approving_review_count": 0, + "required_review_thread_resolution": true, + "required_reviewers": [] + }, + "type": "pull_request" + }, + { + "parameters": { + "do_not_enforce_on_create": false, + "required_status_checks": [ + { + "context": "Check pull request workflow status", + "integration_id": 15368 + } + ], + "strict_required_status_checks_policy": false + }, + "type": "required_status_checks" + }, + { + "parameters": { + "review_draft_pull_requests": true, + "review_on_push": true + }, + "type": "copilot_code_review" + } + ], + "target": "branch" +} diff --git a/.github/workflows/build-datebadge-task.yml b/.github/workflows/build-datebadge-task.yml index 8e0818f3..0624ebfe 100644 --- a/.github/workflows/build-datebadge-task.yml +++ b/.github/workflows/build-datebadge-task.yml @@ -1,13 +1,10 @@ name: Build BYOB date badge task +# Caller-gated: the publisher invokes this only when main is published - the badge has no per-branch context, it tracks +# the last main build. + on: workflow_call: - inputs: - # Branch this badge run is for. Required (no github.ref_name fallback) so the main-only gate can't misfire - # when the publisher builds develop from a main-ref run. - branch: - required: true - type: string jobs: @@ -22,8 +19,7 @@ jobs: run: echo "date=$(date)" >> "$GITHUB_OUTPUT" - name: Build BYOB date badge step - if: ${{ inputs.branch == 'main' }} - uses: RubbaBoy/BYOB@a4919104bc0ec7cfd7f113e42c405cc45246f2a4 # v1 + uses: RubbaBoy/BYOB@24f464284c1fd32028524b59607d417a2e36fee7 # v1.3.0 with: name: lastbuild label: "Last Build" diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml index 25fc27bb..d2e22676 100644 --- a/.github/workflows/build-docker-task.yml +++ b/.github/workflows/build-docker-task.yml @@ -42,7 +42,7 @@ jobs: steps: - name: Checkout step - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.ref }} diff --git a/.github/workflows/build-executable-task.yml b/.github/workflows/build-executable-task.yml index a4a821c5..ab5add7e 100644 --- a/.github/workflows/build-executable-task.yml +++ b/.github/workflows/build-executable-task.yml @@ -42,12 +42,12 @@ jobs: # No NuGet restore caching: restore is cheap here, and setup-dotnet's cache needs a packages.lock.json that # Central Package Management doesn't produce by default. - name: Setup .NET SDK step - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 with: dotnet-version: 10.x - name: Checkout code step - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.ref }} @@ -68,7 +68,7 @@ jobs: # job is `!smoke`, so the per-runtime output would have no consumer. - name: Upload matrix build artifacts step if: ${{ !inputs.smoke }} - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: publish-${{ inputs.branch }}-${{ matrix.runtime }} path: ${{ runner.temp }}/publish @@ -86,7 +86,7 @@ jobs: steps: - name: Download matrix build artifacts step - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: publish-${{ inputs.branch }}-* merge-multiple: true @@ -98,7 +98,7 @@ jobs: # 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 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-asset-${{ inputs.branch }}-executable path: ${{ runner.temp }}/Console.7z diff --git a/.github/workflows/build-nugetlibrary-task.yml b/.github/workflows/build-nugetlibrary-task.yml index 7933c121..a0311beb 100644 --- a/.github/workflows/build-nugetlibrary-task.yml +++ b/.github/workflows/build-nugetlibrary-task.yml @@ -41,12 +41,12 @@ jobs: steps: - name: Setup .NET SDK step - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 with: dotnet-version: 10.x - name: Checkout code step - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.ref }} @@ -81,7 +81,7 @@ jobs: # Skipped on smoke: the github-release job is `!smoke`, so nothing would consume it. - name: Upload build artifacts step if: ${{ !inputs.smoke }} - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-asset-${{ inputs.branch }}-nugetlibrary path: ${{ runner.temp }}/NuGetLibrary.7z diff --git a/.github/workflows/build-pypilibrary-task.yml b/.github/workflows/build-pypilibrary-task.yml index faebbce2..61885a54 100644 --- a/.github/workflows/build-pypilibrary-task.yml +++ b/.github/workflows/build-pypilibrary-task.yml @@ -51,7 +51,7 @@ jobs: steps: - name: Checkout code step - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.ref }} @@ -113,7 +113,7 @@ jobs: - name: Upload build artifacts step id: artifact-upload-step if: ${{ !inputs.smoke }} - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: pypilibrary-build-${{ inputs.branch }} path: PyPiLibrary/dist/* diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index bfb10f99..4333a42d 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -52,6 +52,12 @@ on: required: false type: boolean default: true + # Set false for a repo that produces no release-asset-* files (e.g. Docker-only): the release is then just the + # tag + source zip + README + LICENSE; the artifact download is skipped and the unmatched-files guard relaxes. + expect_release_assets: + required: false + type: boolean + default: true jobs: @@ -62,10 +68,42 @@ jobs: with: ref: ${{ inputs.ref }} + # Entry gate: validate branch<->version consistency once, before the build jobs, so an NBGV mis-classification fails + # fast instead of after building and publishing. main must be a public release (no prerelease '-'); every other branch + # must carry a prerelease '-' (guards a develop leg being classified public and published as stable). Strip + # '+buildmetadata' first; a '-' there is legitimate, only a '-' in the core/prerelease segment marks a prerelease. + validate-release: + name: Validate release version job + needs: [get-version] + runs-on: ubuntu-latest + steps: + - name: Validate branch and version consistency step + env: + SEMVER2: ${{ needs.get-version.outputs.SemVer2 }} + BRANCH: ${{ inputs.branch }} + SMOKE: ${{ inputs.smoke }} + run: | + set -euo pipefail + # Smoke builds never publish and always version as prerelease (detached PR HEAD), which would trip the main arm. + if [[ "$SMOKE" == "true" ]]; then + echo "Smoke build; skipping release version validation." + exit 0 + fi + CORE_AND_PRE="${SEMVER2%%+*}" + if [[ "$BRANCH" == "main" ]]; then + if [[ "$CORE_AND_PRE" == *-* ]]; then + echo "::error::Public (main) release version '$SEMVER2' carries a prerelease suffix; refusing to publish." + exit 1 + fi + elif [[ "$CORE_AND_PRE" != *-* ]]; then + echo "::error::Prerelease ($BRANCH) version '$SEMVER2' has no prerelease suffix (NBGV classified it public); refusing to publish." + exit 1 + fi + build-nugetlibrary: name: Build NuGet library job if: ${{ inputs.enable_nuget }} - needs: [get-version] + needs: [get-version, validate-release] uses: ./.github/workflows/build-nugetlibrary-task.yml secrets: inherit with: @@ -82,7 +120,7 @@ jobs: build-pypilibrary: name: Build PyPI library job if: ${{ inputs.enable_pypi }} - needs: [get-version] + needs: [get-version, validate-release] uses: ./.github/workflows/build-pypilibrary-task.yml secrets: inherit with: @@ -95,7 +133,7 @@ jobs: build-executable: name: Build executable job if: ${{ inputs.enable_executable }} - needs: [get-version] + needs: [get-version, validate-release] uses: ./.github/workflows/build-executable-task.yml secrets: inherit with: @@ -107,7 +145,7 @@ jobs: build-docker: name: Build Docker job if: ${{ inputs.enable_docker }} - needs: [get-version] + needs: [get-version, validate-release] uses: ./.github/workflows/build-docker-task.yml secrets: inherit with: @@ -124,36 +162,24 @@ jobs: # `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] + needs: [get-version, validate-release, build-nugetlibrary, build-pypilibrary, build-executable, build-docker] steps: # 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 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ needs.get-version.outputs.GitCommitId }} - # Backstop (main only): a public release must not carry a prerelease '-', guarding against NBGV mis-versioning the - # public ref (e.g. a dispatch on a non-default ref) into a malformed "Latest" release. Strip '+buildmetadata' - # first - a '-' there is legitimate; only a '-' in the core/prerelease segment marks a prerelease. - - name: Verify public release version step - if: ${{ inputs.branch == 'main' }} - env: - SEMVER2: ${{ needs.get-version.outputs.SemVer2 }} - run: | - set -euo pipefail - CORE_AND_PRE="${SEMVER2%%+*}" # drop +buildmetadata; a '-' here is the genuine prerelease separator - if [[ "$CORE_AND_PRE" == *-* ]]; then - echo "::error::Public (main) release version '$SEMVER2' carries a prerelease suffix; refusing to publish." - exit 1 - fi - - # Collect assets by the `release-asset--*` pattern so this step is target-agnostic; zero matches still - # yields a valid file-less release. Subset releases by deleting the target, not `enable_*: false` (a skipped - # `needs` job would skip this release job too). + # Collect assets by the `release-asset--*` pattern so this step is target-agnostic: subset releases by + # deleting the target, not `enable_*: false` (a skipped `needs` job would skip this release job too). The release + # step guards `fail_on_unmatched_files: true`, so at least one `release-asset-*` must match; a repo that drops + # every file-producing target (e.g. a Docker-only repo, whose release carries only source zip + README + LICENSE) + # relaxes that guard. - name: Download release asset artifacts step - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + if: ${{ inputs.expect_release_assets }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: release-asset-${{ inputs.branch }}-* merge-multiple: true @@ -182,15 +208,45 @@ jobs: # `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. + # Every release (any branch, any target) is a tag on the built commit plus the auto-attached source zip, README, + # and LICENSE; targets amend it by uploading `release-asset-*` files (binaries/packages) or pushing elsewhere + # (image/registry). `fail_on_unmatched_files: true` fails loudly if a promised `release-asset-*` is missing or + # misnamed; a no-file-target repo relaxes it (see download step). - 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 + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 with: generate_release_notes: true tag_name: ${{ needs.get-version.outputs.SemVer2 }} target_commitish: ${{ needs.get-version.outputs.GitCommitId }} prerelease: ${{ inputs.branch != 'main' }} + fail_on_unmatched_files: ${{ inputs.expect_release_assets }} files: | LICENSE README.md ./Publish/* + + # Surgical cleanup at the point of consumption: the release-asset--* transfer artifacts now have durable + # copies on the release, so delete them by exact pattern to free the storage quota - scoped to this branch's + # assets, leaving diagnostics and any other artifacts. Gated to the same condition as the create step so it only + # deletes when a release was actually created/refreshed this run; on a skipped create (existing tag, no new + # commits) the fresh artifacts stay for the run, reaped by the retention-days: 1 backstop. Needs the caller to + # grant `actions: write` (publish-release's publish job does). + - name: Delete consumed release asset artifacts step + if: ${{ inputs.expect_release_assets && (steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch') }} + # Best-effort: the release is already published, so a listing/delete hiccup must never red the job; the + # retention-days: 1 backstop reaps anything missed. Deletes every matching id (a rerun can upload duplicates). + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if ! ids=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/${{ github.run_id }}/artifacts" --paginate \ + --jq ".artifacts[] | select(.name | startswith(\"release-asset-${{ inputs.branch }}-\")) | .id"); then + echo "::warning::Could not list run artifacts; retention-days backstop will reap them." + ids="" + fi + for id in $ids; do + gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/artifacts/$id" \ + || echo "::warning::Failed to delete artifact $id; retention-days backstop will reap it." + done diff --git a/.github/workflows/check-upstream-version-task.yml b/.github/workflows/check-upstream-version-task.yml index 6dcbc94a..72fab1e6 100644 --- a/.github/workflows/check-upstream-version-task.yml +++ b/.github/workflows/check-upstream-version-task.yml @@ -56,7 +56,7 @@ jobs: private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} - name: Checkout code step - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ matrix.branch }} token: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/get-version-task.yml b/.github/workflows/get-version-task.yml index 38cf4535..d795213d 100644 --- a/.github/workflows/get-version-task.yml +++ b/.github/workflows/get-version-task.yml @@ -37,12 +37,12 @@ jobs: steps: - name: Setup .NET SDK step - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 with: dotnet-version: 10.x - name: Checkout code step - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.ref }} fetch-depth: 0 @@ -52,3 +52,9 @@ jobs: - name: Run Nerdbank.GitVersioning tool step id: nbgv uses: dotnet/nbgv@master + env: + # Version from the checked-out branch, not the CI ref. GITHUB_REF is reserved and a step env can't reliably + # override it (the runner re-injects the dispatch ref), so on a publish dispatched from the default branch NBGV + # would classify every leg as the public ref. IGNORE_GITHUB_REF makes NBGV ignore GITHUB_REF and use the + # checked-out branch, which each matrix leg already is. The validate-release gate backstops any misclassification. + IGNORE_GITHUB_REF: "true" diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index 87952083..f09e3049 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -11,10 +11,11 @@ on: pull_request_target: types: [opened, reopened, synchronize] -# `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. +# Per-PR group: under `pull_request_target` `github.ref` is the base branch, which would serialize every bot PR +# against that base; key on the PR number so each PR's events queue independently. `cancel-in-progress: false` so a +# follow-up synchronize doesn't cancel an in-flight `opened` run before it enables auto-merge. concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: false jobs: @@ -42,7 +43,7 @@ jobs: - name: Get dependabot metadata step id: metadata - uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0 + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 with: github-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/publish-docker-readme-task.yml b/.github/workflows/publish-docker-readme-task.yml index 4b04ad64..785a2026 100644 --- a/.github/workflows/publish-docker-readme-task.yml +++ b/.github/workflows/publish-docker-readme-task.yml @@ -1,36 +1,146 @@ name: Publish Docker Hub readme task +# Pushes the Docker Hub repository overview (Docker/README.md). Caller-gated: the publisher invokes this only when main +# is published - the overview has no per-branch context. The repository list is either passed directly (`repositories`) +# or derived from a manifest (`manifest` + `manifest-jq`), covering single- and multi-image repos without per-repo glue. +# An optional transform step (e.g. m4) renders the readme before pushing, optionally after downloading a build artifact. + 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 + # Ref whose readme (and any transform sources) to publish; empty uses the caller's ref. The publisher passes + # `main` so the overview tracks the main release even when dispatched from another ref. + ref: + required: false + type: string + default: '' + # JSON array of Docker Hub repositories to update, e.g. '["owner/image"]'. Leave empty to derive the list from a + # manifest instead (see `manifest`). + repositories: + required: false + type: string + default: '' + # Optional: derive the repository list from a checked-out manifest file (used when `repositories` is empty), e.g. + # './Make/Matrix.json'. `manifest-jq` is the jq program that turns it into the JSON array of repo names. + manifest: + required: false + type: string + default: '' + manifest-jq: + required: false + type: string + default: '' + # Optional command that renders the readme before pushing (e.g. an m4 step that writes Docker/README.md). Empty + # pushes the committed file as-is. + transform-run: + required: false type: string + default: '' + # Optional same-run workflow artifact to download before the transform (e.g. version includes the m4 render needs). + # Using this requires the caller to grant `permissions: actions: read` so download-artifact can read the run's artifacts. + transform-artifact: + required: false + type: string + default: '' + # Readme file to push (the transform's output, or the committed file). Single-image repos with a root README pass + # './README.md'. + readme-filepath: + required: false + type: string + default: ./Docker/README.md jobs: - publish-docker-readme: + # Resolve the repository list once - either the static `repositories` input or a jq program over a manifest - so the + # publish matrix is the same shape for single- and multi-image repos and no caller hand-rolls its own derivation. + get-repos: + name: Get repository list job + runs-on: ubuntu-latest + outputs: + repositories: ${{ steps.list.outputs.repositories }} + + steps: + + # Enforce the input contract once so the downstream steps can trust it: the list comes from exactly one source - + # `repositories`, the `manifest` + `manifest-jq` pair, or neither (default to this repo). Silent fall-through to the + # default would otherwise mask caller mistakes (a half-filled manifest pair, or both sources passed at once). + - name: Validate inputs step + env: + REPOSITORIES: ${{ inputs.repositories }} + MANIFEST: ${{ inputs.manifest }} + MANIFEST_JQ: ${{ inputs.manifest-jq }} + run: | + set -euo pipefail + if [ -n "$REPOSITORIES" ] && [ -n "$MANIFEST" ]; then + echo "::error::Pass either 'repositories' or 'manifest', not both." >&2 + exit 1 + fi + if { [ -n "$MANIFEST" ] && [ -z "$MANIFEST_JQ" ]; } || { [ -z "$MANIFEST" ] && [ -n "$MANIFEST_JQ" ]; }; then + echo "::error::'manifest' and 'manifest-jq' must be set together." >&2 + exit 1 + fi + + - name: Checkout code step + # Only the manifest path needs the tree; the static + default paths don't check out. + if: ${{ inputs.repositories == '' && inputs.manifest != '' }} + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Resolve repository list step + id: list + env: + REPOSITORIES: ${{ inputs.repositories }} + MANIFEST: ${{ inputs.manifest }} + MANIFEST_JQ: ${{ inputs.manifest-jq }} + run: | + set -euo pipefail + # Inputs validated above: at most one of repositories / manifest is set, and manifest implies manifest-jq. + if [ -n "$REPOSITORIES" ]; then + echo "repositories=$REPOSITORIES" >> "$GITHUB_OUTPUT" + elif [ -n "$MANIFEST" ]; then + echo "repositories=$(jq --compact-output "$MANIFEST_JQ" "$MANIFEST")" >> "$GITHUB_OUTPUT" + else + # Default to this repo's own Docker Hub repository (lowercased owner/name) so a single-image caller + # carries the orchestration verbatim with no repo-specific value. + echo "repositories=[\"$(echo "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]')\"]" >> "$GITHUB_OUTPUT" + fi + + publish-readme: name: Publish Docker Hub readme job runs-on: ubuntu-latest + needs: get-repos + strategy: + matrix: + repository: ${{ fromJSON(needs.get-repos.outputs.repositories) }} steps: - name: Checkout code step - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ inputs.ref || github.ref }} + + # Optional: pull a same-run artifact the transform depends on (e.g. version includes for an m4 render). + - name: Download transform artifact step + if: ${{ inputs.transform-artifact != '' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - # Check out the branch being published so the main leg pushes main's readme regardless of the triggering ref. - ref: ${{ inputs.branch }} + name: ${{ inputs.transform-artifact }} + path: ${{ runner.temp }}/transform + + # Optional readme render (e.g. m4 with version includes) before pushing. + - name: Generate readme step + if: ${{ inputs.transform-run != '' }} + run: | + set -euo pipefail + ${{ inputs.transform-run }} - # 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 + repository: ${{ matrix.repository }} + short-description: ${{ github.event.repository.description }} + readme-filepath: ${{ inputs.readme-filepath }} diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index b1656674..272070d5 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -77,6 +77,8 @@ jobs: secrets: inherit permissions: contents: write + # actions:write lets the github-release job delete the release-asset-* artifacts it consumes (surgical cleanup). + actions: write with: ref: ${{ matrix.branch }} branch: ${{ matrix.branch }} @@ -100,17 +102,17 @@ jobs: environment: name: pypi url: https://pypi.org/project/ptr727-projecttemplate-library/ - # id-token:write for Trusted Publishing's OIDC exchange, contents:read for repo metadata, actions:read so - # download-artifact can fetch this run's build artifact. + # id-token:write for Trusted Publishing's OIDC exchange, contents:read for repo metadata, actions:write so + # download-artifact can fetch this run's build artifact and the surgical cleanup step can delete it afterwards. permissions: id-token: write contents: read - actions: read + actions: write steps: - name: Download PyPI library build artifacts step - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: # Branch-suffixed name so both branch legs in this run stay distinct. name: pypilibrary-build-${{ matrix.branch }} @@ -123,59 +125,45 @@ jobs: # Skip rather than fail when the version already exists; the weekly republish re-uploads unchanged versions. skip-existing: true + # Surgical cleanup at the point of consumption: the pypilibrary-build- artifact has been published, so + # delete it by exact name to free the storage quota. A failure before this leaves it for the retention-days: 1 + # backstop. + - name: Delete consumed PyPI build artifact step + # Best-effort: PyPI is already published, so a listing/delete hiccup must never red the job; the + # retention-days: 1 backstop reaps anything missed. Deletes every matching id (a rerun can upload duplicates). + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if ! ids=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/${{ github.run_id }}/artifacts" --paginate \ + --jq ".artifacts[] | select(.name == \"pypilibrary-build-${{ matrix.branch }}\") | .id"); then + echo "::warning::Could not list run artifacts; retention-days backstop will reap them." + ids="" + fi + for id in $ids; do + gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/artifacts/$id" \ + || echo "::warning::Failed to delete artifact $id; retention-days backstop will reap it." + done + + # Caller-gated to main: the badge and Docker Hub overview have no per-branch context, so they update only when main + # is among the published branches (a develop-only push skips them). One invocation, not a per-branch matrix leg. date-badge: name: Create BYOB date badge job needs: [setup, publish] - if: ${{ needs.setup.outputs.publish == 'true' }} - strategy: - matrix: - branch: ${{ fromJSON(needs.setup.outputs.branches) }} + if: ${{ needs.setup.outputs.publish == 'true' && contains(fromJSON(needs.setup.outputs.branches), 'main') }} uses: ./.github/workflows/build-datebadge-task.yml secrets: inherit permissions: contents: write - with: - # The badge task self-gates to main; the develop leg is a no-op. - branch: ${{ matrix.branch }} 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) }} + if: ${{ needs.setup.outputs.publish == 'true' && contains(fromJSON(needs.setup.outputs.branches), 'main') }} 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 }} - - # Release artifacts are an intra-run handoff (durable copies live on the GitHub release), so leaving them accumulates - # against the small account-wide storage quota; delete them once every consumer has read them. - cleanup-artifacts: - name: Delete workflow artifacts job - needs: [setup, publish, publish-pypi, date-badge, docker-readme] - if: ${{ always() && needs.setup.outputs.publish == 'true' }} - runs-on: ubuntu-latest - permissions: - actions: write - steps: - - name: Delete workflow artifacts step - # continue-on-error: best-effort housekeeping must never red the run, even on an unexpected failure. - continue-on-error: true - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - if ! ids=$(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts --paginate \ - --jq '.artifacts[].id'); then - echo "::warning::Could not list run artifacts; skipping cleanup (storage may not be freed)." - ids="" - fi - for artifact_id in $ids; do - gh api --method DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id" \ - || echo "::warning::Failed to delete artifact $artifact_id; continuing." - done + ref: main diff --git a/.github/workflows/run-codegen-pull-request-task.yml b/.github/workflows/run-codegen-pull-request-task.yml index 3d802810..34a56640 100644 --- a/.github/workflows/run-codegen-pull-request-task.yml +++ b/.github/workflows/run-codegen-pull-request-task.yml @@ -42,12 +42,12 @@ jobs: private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} - name: Setup .NET SDK step - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 with: dotnet-version: 10.x - name: Checkout code step - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ matrix.target.ref }} token: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 45774261..7d0fcb74 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -63,12 +63,12 @@ jobs: steps: - name: Setup .NET SDK step - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 with: dotnet-version: 10.x - name: Checkout code step - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore .NET local tools step run: dotnet tool restore @@ -139,31 +139,3 @@ jobs: # 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 }}" - - # Smoke builds gate every release-asset upload on `!smoke`, but actions like docker/build-push-action can still emit - # a build-record artifact, so this terminal cleanup deletes the run's artifacts to keep them off the storage quota. - # Independent of `check-workflow-status` so housekeeping never gates the required merge check. - cleanup-artifacts: - name: Delete workflow artifacts job - needs: [smoke-build] - if: always() - runs-on: ubuntu-latest - permissions: - actions: write - steps: - - name: Delete workflow artifacts step - # continue-on-error: best-effort housekeeping must never red the run, even on an unexpected failure. - continue-on-error: true - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - if ! ids=$(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts --paginate \ - --jq '.artifacts[].id'); then - echo "::warning::Could not list run artifacts; skipping cleanup (storage may not be freed)." - ids="" - fi - for artifact_id in $ids; do - gh api --method DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id" \ - || echo "::warning::Failed to delete artifact $artifact_id; continuing." - done diff --git a/AGENTS.md b/AGENTS.md index 689e1c3a..e73f36e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Instructions for AI Coding Agents -**ProjectTemplate** is a polyglot template repo. The .NET side ships under [`NuGetLibrary/`](./NuGetLibrary/) (plus `Console/`, `Tests/`, `Benchmarks/`, `CodeGen/`); the Python side ships under [`PyPiLibrary/`](./PyPiLibrary/). This file is the single source of truth for cross-cutting rules. Code style lives in [`CODESTYLE.md`](./CODESTYLE.md) at the repo root - one guide with a General section that applies to every language plus droppable per-language sections (.NET, Python). +**ProjectTemplate** is a polyglot template repo. The .NET side ships under [`NuGetLibrary/`](./NuGetLibrary/) (plus `Console/`, `Tests/`, `Benchmarks/`, `CodeGen/`); the Python side ships under [`PyPiLibrary/`](./PyPiLibrary/). This file is the single source of truth for cross-cutting rules. Code style lives in [`CODESTYLE.md`](./CODESTYLE.md) at the repo root - one guide with a General section that applies to every language plus per-language sections (.NET, Python); the file is carried whole and a repo reads only the sections for the languages it ships. Treat this file as authoritative for everything else; don't restate its rules elsewhere. A derived repo's **project-specific conventions and public-API/behavioral contracts** (e.g. a "Library API Conventions" section) also live here, **not** in [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) - that file targets GitHub Copilot / VS Code specifically, while this file is the agent-agnostic one every coding agent is directed to read, so any rule a reviewer must honor has to live here to be provider-independent. @@ -38,7 +38,7 @@ 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). 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. +- **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 - its `download-artifact` step uses `pattern:`/`merge-multiple:`, **never an `artifact-ids:` that names a build job's output** (the producing build jobs still appear in `needs` for sequencing) - 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. **This name-pattern handoff is canonical for every repo, single-target included** - name your one asset `release-asset--` and the verbatim `github-release` globs it; do not switch a single-target repo to an `artifact-id` output plus `download-artifact` `artifact-ids:`, which looks tidier for 1:1 but forks the `github-release` download (`pattern:`/`merge-multiple:`) and breaks its verbatim carry. - **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. @@ -110,10 +110,12 @@ Applies to code and workflow (`#`) comments alike. ### Line Endings - **[`.editorconfig`](./.editorconfig) defines the correct line ending per file type:** **CRLF** for `.md`, `.cs`, XML/`.csproj`/`.props`/`.targets`, `.yml`/`.yaml`, `.json`, and `.cmd`/`.bat`/`.ps1`; **LF** for `.sh`. `.gitattributes` is `* -text`, so git stores the exact bytes you commit and will **not** normalize endings for you. +- **Choosing an ending for a new file type:** CRLF is the **default** - cross-platform editors on Windows produce it, and it is harmless on Linux for everything except shell. Use LF only when the type **requires** it or CRLF **breaks how it is consumed**: executable scripts/shebangs (`*.sh`, s6, husky), Dockerfiles (CRLF breaks `RUN` heredocs/continuations), and tool-owned formats with a native LF ending (KiCad). **YAML stays CRLF** - GitHub Actions' parser tolerates it and these repos run it without breakage (a repo that also runs yamllint sets `new-lines: disable` to defer to `.editorconfig`). Distinguish where a file is *consumed* from where it is *edited*: consumption on Linux alone does not force LF. +- **Scripts and extensionless executables must be LF - and pinned in `.gitattributes`, not just configured.** A CRLF shebang (`#!/usr/bin/env bash\r`) breaks execution. `.editorconfig` sets `[*.sh] = lf`, but that extension-based rule does not match **extensionless** executables (s6 service scripts `run`/`up`/`finish`, husky/git hook scripts like `.husky/pre-commit`), and `* -text` enforces nothing - so a broad normalization pass or an editor can silently flip them to CRLF (it has). `.gitattributes` is the enforcement layer: it carries `*.sh text eol=lf`, and any repo whose tooling ships extensionless scripts **adds the matching path pin** - e.g. `Docker/s6-overlay/** text eol=lf` for s6 init, `.husky/pre-commit text eol=lf` for husky hooks - so git holds them at LF on checkout and `--renormalize`. This pin is mandatory for any repo that overrides s6 init, uses husky/git hooks, or otherwise ships executable scripts. The same explicit-pin rule extends to **tool-owned file formats the base config doesn't key on**: pin them to whatever ending the tool reads and writes so a normalization sweep can't churn them - e.g. KiCad project/footprint/3D files (`*.kicad_mod`, `*.kicad_sym`, `*.step`), which KiCad writes LF (`*.kicad_mod text eol=lf`, ...). The principle is general: a file class the `.editorconfig` extension rules and `* -text` don't cover needs an explicit `.gitattributes` pin matching its tool's native ending. - **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 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. +- **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. Carry both files **whole** (the `[*.cs]` block is inert without `.cs` files), including the `*.sh text eol=lf` pin and any extensionless-script path pins. Adopting `.gitattributes` for the first time requires a one-time normalization pass - see the verbatim-carry entry. ### Quantitative Claims @@ -136,7 +138,7 @@ The repo runs a review loop on every PR: local agent iteration plus remote autom `mergeStateStatus: CLEAN` reflects **only** required statuses - it never reflects open bot review comments, so `CLEAN` alone is **never** sufficient to merge. A green/`CLEAN` PR with an unresolved Copilot finding fails this gate; treat it as "not mergeable" no matter what the merge-state field says. The agent never merges on its own (consistent with "default to staging"; merging is maintainer-authorized). -**Merging is not releasing.** A merge to a release branch does **not** by itself publish; publishing is a separate step in the repo's release pipeline (a scheduled run or a manual dispatch), not an automatic consequence of merging. Never describe a merge as cutting a release, and never trigger a publish without explicit maintainer instruction. +**Merging is not releasing.** A merge to a release branch does **not** by itself publish; publishing is a separate, explicitly configured step in the repo's release pipeline (e.g. a scheduled run, a manual dispatch, or an opted-in publish-on-merge trigger), not an automatic consequence of merging. Never describe a merge as cutting a release, and never trigger a publish without explicit maintainer instruction. ### Expected Review Loop @@ -182,7 +184,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. +- **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. **This applies to repo-owned build-layer leaves too** - a leaf owning its build specifics is not a reason to use floating tags; Dependabot still bumps SHA pins (updating the SHA + version comment). - **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. @@ -190,10 +192,11 @@ These conventions describe the target state. New and modified workflows must res - **Shells**: multi-line `run:` blocks with bash start with `set -euo pipefail` - fail fast, fail on undefined vars, fail on a failed pipe segment. - **Conditionals**: multi-line `if:` uses folded scalar `if: >-` so YAML preserves whitespace correctly. Literal block (`if: |`) is wrong because it embeds newlines inside the boolean expression. - **Boolean inputs**: workflows triggered both via `workflow_call` and `workflow_dispatch` must declare each boolean input in *both* trigger blocks - one definition does not propagate to the other. `workflow_call` delivers booleans as actual booleans; `workflow_dispatch` delivers them as the *strings* `"true"`/`"false"`. Any `if:` consuming a boolean input must compare against both forms - `if: ${{ inputs.foo == true || inputs.foo == 'true' }}`. +- **Validate input/state consistency at entry, fail fast**: when a workflow's inputs must satisfy a cross-input or input-versus-derived-state invariant (e.g. the release branch must match the computed version's prerelease status, or two inputs are mutually exclusive), assert it **once** in a dedicated entry validation step/job that the downstream jobs `needs:`, before any expensive build or publish work - not as partial checks scattered deep in later jobs. One gate that fails fast with a clear `::error::` beats a late or one-directional check. Examples: [`build-release-task.yml`](./.github/workflows/build-release-task.yml)'s `validate-release` job (branch-versus-prerelease, both directions) and [`publish-docker-readme-task.yml`](./.github/workflows/publish-docker-readme-task.yml)'s "Validate inputs step". - **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**: workflow artifacts are an intra-run handoff only - durable copies live on the GitHub release, not in workflow artifacts - so they must not survive the run and accumulate against the small account-wide artifact-storage quota. **Every workflow that can produce artifacts ends with a terminal `cleanup-artifacts` job** that deletes the run's artifacts via the REST API: `permissions: actions: write`, `needs` the artifact producers, an `if:` that **includes** `always()` (so a failed run still cleans up) plus any workflow-specific gate (e.g. `publish-release.yml` adds `&& needs.setup.outputs.publish == 'true'` to run only on real publishes), independent of any required status check so housekeeping never gates a merge, `continue-on-error: true` on the delete step so even an unexpected failure never reds the run, and tolerant of individual list/delete failures (warn and continue). This covers not just `actions/upload-artifact` but build-records that actions emit automatically (e.g. `docker/build-push-action`'s `.dockerbuild`). Both `publish-release.yml` and `test-pull-request.yml` carry one; add one to any new artifact-producing entry workflow. Set `retention-days: 1` on explicit uploads as a backstop. -- **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. +- **Artifact retention**: workflow artifacts are an intra-run handoff only - durable copies live on the GitHub release, not in workflow artifacts - so they must not survive the run and accumulate against the small account-wide artifact-storage quota. **Clean up each transfer artifact surgically at its point of consumption**: the job that downloads it deletes it by exact name/pattern right after consuming it (the `github-release` job deletes `release-asset--*` after attaching them to the release; `publish-release.yml`'s `publish-pypi` deletes `pypilibrary-build-` after publishing). Deletion needs `actions: write` granted on that job - for a reusable callee (e.g. `github-release` inside `build-release-task.yml`) the **caller** grants it (`publish-release.yml`'s `publish` job does). **Never blanket-delete the run's artifacts** (`gh api .../artifacts --jq '.artifacts[].id'`) - that also destroys diagnostic/log artifacts and the build-records actions emit automatically (`docker/build-push-action`'s `.dockerbuild`), which are exactly what you need to debug a failed run. Set `retention-days: 1` on **every** explicit `upload-artifact`: it is the failure-path backstop - a job that dies before its consumer runs leaves its artifact, reaped within a day - so no separate terminal cleanup job is needed. A derived repo customizing these jobs must preserve the consume-then-delete shape. +- **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. A **multi-image** repo uses a **per-image** buildcache tag (`:buildcache-` for each image, plus the base image's own tag and inline cache); it does not fall back to `type=gha` for the extra images. - **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) @@ -250,7 +253,7 @@ When you touch code in either language, also respect that language's style guide ## Quick Start for Derived Projects 1. **Clone this template** as the baseline for your project. -2. **Decide** which language sides you need. If you need only one, delete the other folder and its references - drop that language's section in [CODESTYLE.md](./CODESTYLE.md) and follow its "Adopting Without ..." deletion checklist. +2. **Decide** which language sides you need. If you need only one, delete the other language's folder and its build/release wiring; keep [CODESTYLE.md](./CODESTYLE.md) **whole** (carry the file in full per [Files and Sections Derived Repos Must Carry Verbatim](#files-and-sections-derived-repos-must-carry-verbatim)) and simply ignore the unused language's section. 3. **Read** [CODESTYLE.md](./CODESTYLE.md) - the General section plus the section(s) for the language(s) you keep (.NET, Python). 4. **Carry the mandatory shared files and sections verbatim** - do not re-invent them per repo. See [Files and Sections Derived Repos Must Carry Verbatim](#files-and-sections-derived-repos-must-carry-verbatim) for the exact list (review-loop contract + runbook, lint config, line-ending governance) and what to adapt. 5. **Update project-specific values** - `PackageId`/`RootNamespace` in `.csproj`, `name` in `pyproject.toml`, namespace conventions, `README.md`, `HISTORY.md`, `version.json`, `LICENSE`, NuGet/PyPI badge URLs. @@ -265,22 +268,51 @@ When you touch code in either language, also respect that language's style guide These artifacts are the template's cross-cutting contract. A derived repo must carry **each** of them; copy the file/section as-is and change only the noted placeholders. Re-inventing or omitting any of these is the drift the template exists to prevent. +**Carry each shared *file* in full** - do not trim sections that don't currently apply. An inert `[*.cs]` block in a non-.NET repo or an unused-language section in `CODESTYLE.md` costs nothing, and keeping it makes every re-sync a clean wholesale overwrite instead of an error-prone partial merge. Only genuinely per-language *task definitions* in [`.vscode/tasks.json`](./.vscode/tasks.json) track the repo's own language, because the template ships only the task groups for the languages it uses. + +**A carried file must be self-contained - no template, demo, or cross-project references.** Because the file is copied verbatim into every derived repo, it has to read as if it belongs to *that* repo: a developer there has only their project's context, no knowledge of this template or its example projects. So a carried file names **no** template demo project (e.g. an example `NuGetLibrary/`, `PyPiLibrary/`), **no** sibling repo, and **no** "this template ships X / a derived repo adapts Y" meta-instruction. The one sanctioned exception is the upstream-drift-report pointer to this template (the [Staying in Sync](#staying-in-sync-and-reporting-drift-upstream) rule, mirrored in [`.github/copilot-instructions.md`](./.github/copilot-instructions.md)) - a derived repo's sole legitimate cross-repo mention. Write rules generically (or with neutral placeholders); keep template-onboarding (how to strip a demo, which folders to delete on adoption) in this template's `README.md`, never in a carried style/config file. Genericize on the way *into* the carry set, not per-repo on the way out. + - **[`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). - **[`AGENTS.md`](./AGENTS.md) "Git and Commit Rules" and "Pull Request Title and Commit Message Conventions" sections** - the commit/PR-title contract that [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) summarizes inline and links to. Copy verbatim (no owner/repo to change). Without them in the derived repo's `AGENTS.md`, the runbook's deferral link (`AGENTS.md#pull-request-title-and-commit-message-conventions`) is broken and the full rules are missing. - **[`.github/copilot-instructions.md`](./.github/copilot-instructions.md)** - the whole file is a drop-in; its "GitHub Copilot Review Runbook" carries the provider mechanics. Copy verbatim and change only the `` / `` / `` placeholders in the API snippets; drop language-specific style pointers that don't apply. Keep this file **narrow** - provider-specific mechanics (the Copilot review runbook) plus the inline commit/PR-title summary. **Project-specific conventions and API/behavioral contracts do not belong here**; put them in [`AGENTS.md`](./AGENTS.md), the agent-agnostic file every coding agent reads. Non-Copilot agents (Claude Code, Codex, Cursor, ...) are not directed to this file and don't read it by default, so any rule a reviewer must honor has to live in `AGENTS.md` to be provider-independent. - **[`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc)** - the shared lint config read by both the davidanson `markdownlint` IDE extension and CLI/CI `markdownlint-cli2`, so the IDE and command line stay in lock-step. Copy verbatim (it is repo-agnostic). **On first adoption**, a repo's existing docs often carry structural debt this config surfaces (MD022/MD031/MD032 blank lines around headings/fences/lists, MD040 unlabeled fences). Clear it in one pass by running the markdownlint-cli2 Docker command from [Running the Linters Locally](#running-the-linters-locally-known-working-invocations) with `--fix` added (`docker run --rm -v "$PWD":/workdir davidanson/markdownlint-cli2:latest --fix "**/*.md"`), then hand-label any remaining unlabeled fences (MD040 - usually `text` for format/example blocks) and **re-verify the line endings of touched `.md` files** (`--fix` can rewrite a CRLF file as LF). -- **[`.editorconfig`](./.editorconfig) and [`.gitattributes`](./.gitattributes)** - line-ending governance (see [Line Endings](#line-endings)). `.editorconfig` sets `end_of_line` per file type and `.gitattributes` (`* -text`) stops git from normalizing; a repo missing either, or one that only sets `end_of_line` for `[*.md]` instead of carrying the full per-extension rules, drifts between LF and CRLF. The **defaults + per-extension EOL block is always-verbatim**; the `[*.cs]` + ReSharper style block at the end is **.NET-only** and may be dropped in a non-.NET repo (the file marks the boundary). A repo adopting `.gitattributes` for the **first time** must do a one-time explicit line-ending normalization: `* -text` tells git to stop normalizing, so pre-existing files keep whatever (possibly mixed) endings they have - convert each to its `.editorconfig` ending and commit that as a deliberate one-time pass, best isolated in its own commit. -- **[`CODESTYLE.md`](./CODESTYLE.md)** - the single code-style guide. Its **General** section is always carried; each **language section** (.NET, Python) is droppable, exactly like the `.editorconfig` `[*.cs]` boundary - keep the section(s) for the language(s) you ship and drop the rest. **Repo-root placement is load-bearing**: `AGENTS.md` links it as `./CODESTYLE.md` and `.github/copilot-instructions.md` as `../CODESTYLE.md`, so moving it breaks those links. Adapt the in-section repo-specific bits - the .NET project-folder list, the `InternalsVisibleTo` project names, and the VS Code task labels - to your repo. -- **[`.vscode/tasks.json`](./.vscode/tasks.json)** - carry your language's **named clean-compile definitions verbatim**: as VS Code tasks where the template ships them that way (the .NET group - `.NET Build` / `CSharpier Format` / `.NET Format`), or as the documented commands where it doesn't (Python's `ruff` / `pyright`, in `CODESTYLE.md`). Their names are owned by the matching `CODESTYLE.md` language section and their command sequence + arguments are the canonical clean-compile spec. Convenience tasks (`.NET Tool Update`, `.NET Outdated Upgrade`) and project-specific tasks (`.NET Benchmark`) are the adapt zone; a non-.NET repo drops the .NET task group and carries its own language's definitions. +- **[`.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. **Carry the whole file verbatim**, including the `[*.cs]` + ReSharper style block at the end - it is inert in a repo with no `.cs` files, and keeping it makes re-sync a clean overwrite (the block still marks the .NET boundary for readers). A repo adopting `.gitattributes` for the **first time** must do a one-time explicit line-ending normalization: `* -text` tells git to stop normalizing, so pre-existing files keep whatever (possibly mixed) endings they have - convert each to its `.editorconfig` ending and commit that as a deliberate one-time pass, best isolated in its own commit. +- **[`CODESTYLE.md`](./CODESTYLE.md)** - the single code-style guide. **Carry the whole file verbatim**, all language sections (.NET, Python) included - a section for a language the repo doesn't ship is inert and costs nothing, and keeping it makes re-sync a clean overwrite rather than a per-section merge. **Repo-root placement is load-bearing**: `AGENTS.md` links it as `./CODESTYLE.md` and `.github/copilot-instructions.md` as `../CODESTYLE.md`, so moving it breaks those links. The file ships generic, with neutral placeholders for the few repo-specific values it can't avoid (e.g. the `InternalsVisibleTo` project names); fill those placeholders in for your repo - that is filling a blank, not editing carried prose, so re-sync stays a full replacement. +- **[`.vscode/tasks.json`](./.vscode/tasks.json)** - carry your language's **named clean-compile definitions verbatim**: as VS Code tasks where the template ships them that way (the .NET group - `.NET Build` / `CSharpier Format` / `.NET Format`), or as the documented commands where it doesn't (Python's `ruff` / `pyright`, in `CODESTYLE.md`). The **clean-compile** task names are owned by the matching `CODESTYLE.md` language section and their command sequence + arguments are the canonical clean-compile spec. Convenience and project-specific tasks (e.g. tool updates, dependency upgrades, benchmarks) are the adapt zone the repo owns; a non-.NET repo drops the .NET task group and carries its own language's definitions. When the template changes one of these, re-sync the derived repo from the new version (see below). +The branch rulesets ([`.github/rulesets/{develop,main}.json`](./.github/rulesets/)) are deliberately **not** in this carry set: they are live GitHub config, not a file a derived repo consumes, so carrying and re-syncing them downstream only adds noise. They are maintained **in this template** as the source of truth and are reconciled against each repo's *live* config during porting/re-sync - see [Staying in Sync](#staying-in-sync-and-reporting-drift-upstream). + ### 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). For [`CODESTYLE.md`](./CODESTYLE.md), re-sync the whole file from the template and then drop the language section(s) you don't ship (always keeping the General section) - replacing the file wholesale and trimming whole sections is simpler to keep current than hand-editing per-language snippets. +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 by **full replacement** - replace the whole file or carried section, never reconcile a partial hand-merge - adapting only the noted placeholders. For [`CODESTYLE.md`](./CODESTYLE.md) and [`.editorconfig`](./.editorconfig), re-sync the **whole file** from the template - every section, including languages the repo doesn't ship (inert sections cost nothing) - so re-sync stays a clean overwrite, never a per-section merge. Re-syncing is **not** an occasion to add or grow comments: the carried text is authoritative as-is (see [Comments](#comments)). + +**Rulesets are reconciled live, not carried as files.** The branch rulesets are maintained **in this template** as [`.github/rulesets/{develop,main}.json`](./.github/rulesets/) - they are live GitHub config, not a file a derived repo consumes, so they are **not** carried and re-synced downstream as a per-repo copy. Instead they ride the re-sync loop from the hub: working from the template checkout, diff each derived repo's *live* rulesets against the template's committed JSON and correct any drift with a **full-payload PUT** (GET -> change -> PUT the whole object; partial PUTs `422`, per [README "Rules / Rulesets"](./README.md#rules--rulesets)). The diff catches drift either way - a corrected template ruleset a derived repo never picked up, or a live ruleset hand-edited away from the committed intent (`strict` re-enabled, a rule dropped, a merge method changed): + +```sh +# Sort the order-insensitive rules[] / bypass_actors[] before diffing - GitHub returns +# them unordered, so a reordered-but-equivalent ruleset must not read as drift. +norm='{name,target,enforcement,bypass_actors,conditions,rules} | .rules|=sort_by(.type) | .bypass_actors|=sort_by(.actor_id)' +for b in develop main; do + id=$(gh api "repos///rulesets" --jq ".[]|select(.name==\"$b\").id") + diff <(jq -S "$norm" ".github/rulesets/$b.json") \ + <(gh api "repos///rulesets/$id" --jq '{name,target,enforcement,bypass_actors,conditions,rules}' | jq -S "$norm") \ + && echo "$b: in sync" || echo "$b: DRIFT (see diff)" +done +``` **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. +#### Orchestrated Re-Sync: Hub and Downstream Personas + +When one operator re-syncs the whole fleet from the hub (every derived repo checked out on one machine), the work splits into two personas with separate duties. This playbook lives **here, committed**, because per-machine agent memory does not survive a machine switch. + +- **Hub / orchestrator** (acting in this template repo): owns the source of truth and *drives* consolidation. It directs each downstream sync, then **validates the result against the template** - confirming carried artifacts were **fully replaced, not partially hand-merged**, that **no comments were added or grown** (see [Comments](#comments)), and that line endings and lint match spec. It collects the template gaps the syncs surface, fixes them in the template through the normal review gate, has affected downstreams re-pull, and **never merges without the maintainer's OK**. +- **Downstream / derived** (acting in a derived repo): performs the local re-sync **under the orchestrator's direction** - **full-replace** each carried artifact, **honor the [Comments](#comments) rules** (no new prose, no growth), and **report any template gap upstream** rather than patching the template's intent locally. + +This guards the two recurring downstream regressions: *partial* updates where carry means full replacement, and comment accretion against the comment rules. The orchestrator catches both by diffing every result against the template. + #### Known Downstream Projects Sync is **bidirectional**. The flow above is the downstream-to-upstream direction (derived repos report drift up). The reverse direction is the maintainer's: **when changing a verbatim-carry artifact or another cross-cutting contract in this template, file a heads-up issue in each affected downstream repo below** so it can re-sync, rather than letting the change be discovered only on the next ad-hoc port. Keep this table current as projects are derived from or retired from the template. diff --git a/CODESTYLE.md b/CODESTYLE.md index ca9e890d..7dcad425 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -1,6 +1,6 @@ # Code Style and Formatting Rules -This is the single code-style guide for the repo. The **General** section applies to every language and is always carried. Each **language section** (.NET, Python) is self-contained and **droppable**: a repo with no .NET side drops the .NET section, a repo with no Python side drops the Python section - the same per-language model as [`.editorconfig`](./.editorconfig), whose `[*.cs]` block a non-.NET repo drops. +This is the single code-style guide for the repo. The **General** section applies to every language. Each **language section** (.NET, Python) is self-contained: a repo reads only the section(s) for the languages it ships and ignores the rest. The whole file is carried, not trimmed - an unused-language section costs nothing and keeps re-sync a clean overwrite, the same carry-whole model as [`.editorconfig`](./.editorconfig), whose inert `[*.cs]` block a non-.NET repo keeps. Cross-cutting *process* rules (PR titles, branching, US English, markdown style, comments philosophy, workflow YAML, PR review etiquette) live in [AGENTS.md](./AGENTS.md) and are not repeated here. @@ -18,7 +18,7 @@ Each language defines a **clean-compile** verification - the combination of buil - **Run it after every code change.** The relevant language's clean-compile must pass before you commit; CI runs the same checks as a backstop. - **The named task definition is the canonical spec** - its exact command sequence, arguments, and strictness. You may run it through the VS Code task **or** by invoking the equivalent native commands directly; either is fine **only if the sequence, arguments, and strictness match exactly**. No shortcuts and no more-lenient options (for example, never drop `--verify-no-changes` or loosen a `--severity`). -- **A local commit/pre-commit gate is the derived repo's choice - the template ships no hook runner only because no single runner fits every language it targets** (a `dotnet`-tool runner like Husky.Net suits .NET but not Python), **not** as a recommendation against commit gates. CI is the authoritative backstop regardless; a local gate is an additive convenience a repo may wire and keep - Husky.Net (and `dotnet husky run` as a style step) for .NET, `pre-commit` for Python. Keeping a working gate is not drift, and "no hooks ship by default" must not be read as "remove your gate to stay aligned". +- **A local commit/pre-commit gate is the repo's choice.** No single hook runner fits every language (a `dotnet`-tool runner like Husky.Net suits .NET but not Python), so none is mandated - but that is **not** a recommendation against commit gates. CI is the authoritative backstop regardless; a local gate is an additive convenience a repo may wire and keep - Husky.Net (and `dotnet husky run` as a style step) for .NET, `pre-commit` for Python. Keeping a working gate is not drift. ### Analyzer Diagnostics and Suppressions @@ -33,14 +33,14 @@ Each language defines a **clean-compile** verification - the combination of buil These apply repo-wide, in every directory: -1. **Markdown linting**: All `.md` files must be lint-clean (error and warning free) via the VS Code `markdownlint` extension. [`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc) at the repo root is the single source of truth - the davidanson `markdownlint` extension and a command-line `markdownlint-cli2` run both read it, so the IDE and CLI stay in lock-step. Rules it deliberately disables (e.g. `MD013` line-length, `MD033` inline HTML) are **intentional** - do not "fix" them. This file is carried verbatim by every derived repo (see the template's [Files and Sections Derived Repos Must Carry Verbatim](https://github.com/ptr727/ProjectTemplate/blob/main/AGENTS.md#files-and-sections-derived-repos-must-carry-verbatim) list). Fix violations at the source rather than disabling rules. +1. **Markdown linting**: All `.md` files must be lint-clean (error and warning free) via the VS Code `markdownlint` extension. [`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc) at the repo root is the single source of truth - the davidanson `markdownlint` extension and a command-line `markdownlint-cli2` run both read it, so the IDE and CLI stay in lock-step. Rules it deliberately disables (e.g. `MD013` line-length, `MD033` inline HTML) are **intentional** - do not "fix" them. Fix violations at the source rather than disabling rules. 2. **Spelling**: All spelling must be clean via the CSpell VS Code integration; words must be correctly spelled in **US English** (the repo-wide convention - see [AGENTS.md](./AGENTS.md)). Project-specific terms go in the workspace CSpell config. ## .NET -*This section applies only to the .NET side. A repo with no .NET projects drops the whole section - see [Adopting Without .NET](#adopting-without-net) at its end.* +*This section applies only to the .NET side. A repo with no .NET projects still carries it (the file is carried whole) and ignores it.* -This is the style guide for the **.NET projects** in this repo. **Adapt the project list to your repo**: this template ships [`NuGetLibrary/`](./NuGetLibrary/), [`Console/`](./Console/), [`Tests/`](./Tests/), [`Benchmarks/`](./Benchmarks/), and [`CodeGen/`](./CodeGen/); a derived repo names its own projects. +This is the style guide for any **.NET projects** in this repo. ### Build Requirements @@ -53,24 +53,20 @@ This is the style guide for the **.NET projects** in this repo. **Adapt the proj - After any code change it must pass before commit. Run the `.NET Format` task. To run it natively instead, reproduce that task chain from [`.vscode/tasks.json`](./.vscode/tasks.json) exactly - `CSharpier Format`, then `.NET Build`, then the `dotnet format style --verify-no-changes --severity=info ...` verify - without dropping or loosening any argument (tasks.json is the canonical command spec). Bare `dotnet format` alone, skipping CSharpier or the build, is not sufficient. 2. **Analyzer configuration** - - `latest-all` - - `true` - - Analyzer severity is `suggestion`, but all warnings must be addressed - see [Analyzer Diagnostics and Suppressions](#analyzer-diagnostics-and-suppressions); do not relax rules to dodge them. + - `true` with `latest-all` and `All` (full analyzer set enabled) + - `true` - any diagnostic surfaced as a warning fails the build, so it must be fixed or deliberately suppressed, not left to accumulate (see [Analyzer Diagnostics and Suppressions](#analyzer-diagnostics-and-suppressions)) 3. **CI lint backstop** - - `dotnet csharpier check` and `dotnet format style --verify-no-changes` run on every PR - - No git hooks ship by default - see README "Optional: enable git hooks locally" to opt in + - CI runs the clean-compile checks on every PR as the authoritative backstop + - Git hooks are optional; a repo may wire a local runner (Husky.Net) for pre-commit enforcement, but CI is the gate that matters #### Build Tasks -Available VS Code tasks (run them from VS Code's task runner - **Terminal -> Run Task** - or an agent's task-running tool). The first three are the clean-compile set, carried verbatim; the rest are convenience tasks a derived repo adapts or drops: +Available VS Code tasks (run them from VS Code's task runner - **Terminal -> Run Task** - or an agent's task-running tool). The three clean-compile tasks below are carried verbatim; a repo adds its own convenience tasks (tool updates, dependency upgrades, benchmarks) on top: - `.NET Build`: Build with diagnostic verbosity *(clean-compile)* - `CSharpier Format`: Auto-format code with CSharpier *(clean-compile)* - `.NET Format`: Run CSharpier and build, then verify formatting and style with `--verify-no-changes` *(clean-compile; the task to run after edits)* -- `.NET Tool Update`: Update dotnet tools *(convenience)* -- `.NET Outdated Upgrade`: Upgrade outdated NuGet dependencies, interactive prompt *(convenience)* -- `.NET Benchmark`: Run BenchmarkDotNet *(project-specific; present only if a Benchmarks project exists)* ### Tooling and Editor @@ -84,7 +80,7 @@ Available VS Code tasks (run them from VS Code's task runner - **Terminal -> Run - `dotnet-outdated-tool`: Dependency update checks - Nerdbank.GitVersioning: Version management -Pre-commit git hooks are not installed by default - CI is the lint backstop. See README "Optional: enable git hooks locally" if you want Husky.Net (or another runner) wired up locally. +CI is the authoritative lint backstop. Local pre-commit hooks are optional - wire Husky.Net (or another runner) if you want local enforcement. #### Editor Baseline @@ -112,7 +108,7 @@ Note: Code snippets are illustrative examples only. Replace namespaces/types to - Top-level statements for console apps - Pattern matching over traditional checks - Collection expressions when types loosely match - - Extension methods using `extension()` syntax + - Extension methods - the classic `this`-parameter form, or an `extension() { ... }` block on C# 14+ - Implicit object creation when type is apparent - Range and index operators @@ -204,7 +200,7 @@ Note: Code snippets are illustrative examples only. Replace namespaces/types to - `true` - Missing XML comments for public APIs are suppressed (`.editorconfig`) - Must document all public surfaces. - - Single-line summaries, additional details in remarks, document input parameters, returns values, exceptions, and add crefs + - Single-line summaries, additional details in remarks, document input parameters, return values, exceptions, and add crefs ```csharp /// @@ -336,8 +332,8 @@ Follow the scope hierarchy in [Analyzer Diagnostics and Suppressions](#analyzer- ```xml - - + + ``` @@ -345,15 +341,11 @@ Follow the scope hierarchy in [Analyzer Diagnostics and Suppressions](#analyzer- 1. **Code reviews**: All changes go through pull requests -### Adopting Without .NET - -If your derived project has no .NET side, drop this entire `.NET` section and delete the .NET projects and their build/release wiring: the NuGet build/publish jobs, the `[*.cs]` / ReSharper block in `.editorconfig`, the `.NET` task group in `.vscode/tasks.json`, and the `nuget` entries in `.github/dependabot.yml`. See [README.md](./README.md) Template Adoption for the full checklist. The Python side stands alone. - ## Python -*This section applies only to the Python side. A repo with no Python projects drops the whole section - see [Adopting Without Python](#adopting-without-python) at its end.* +*This section applies only to the Python side. A repo with no Python projects still carries it (the file is carried whole) and ignores it.* -This is the style guide for the **Python project** in this repo ([`PyPiLibrary/`](./PyPiLibrary/)). +This is the style guide for any **Python project(s)** in this repo. ### Toolchain @@ -369,7 +361,7 @@ This is the style guide for the **Python project** in this repo ([`PyPiLibrary/` ### Local Development Loop -From inside `PyPiLibrary/`: +From inside the Python project directory: ```sh uv sync # creates .venv, installs deps + dev group @@ -382,19 +374,19 @@ uv run pytest # run tests uv build # produce wheel + sdist in ./dist ``` -The Python clean-compile (see [Clean-Compile Verification](#clean-compile-verification)) is `uv run ruff format` + `uv run ruff check` + `uv run pyright`; run it (plus `uv run pytest`) before committing. The template ships these as documented commands, not VS Code tasks. CI runs the same commands via [`.github/workflows/build-pypilibrary-task.yml`](./.github/workflows/build-pypilibrary-task.yml). No git hooks ship by default - see the root README's "Optional: enable git hooks locally" section to wire up `pre-commit` for `ruff` and `pyright` if you want pre-commit checks locally. +The Python clean-compile (see [Clean-Compile Verification](#clean-compile-verification)) is `uv run ruff format` + `uv run ruff check` + `uv run pyright`; run it (plus `uv run pytest`) before committing. These are documented commands, not VS Code tasks. CI runs the same clean-compile commands as the authoritative backstop. Git hooks are opt-in; wire `pre-commit` for `ruff` and `pyright` yourself if you want local enforcement. ### Layout `src` layout - keeps the package out of the repo root and prevents accidental imports of unbuilt code: ```text -PyPiLibrary/ +/ pyproject.toml README.md uv.lock # committed for reproducible CI src/ - ptr727_projecttemplate_library/ + / __init__.py _version.py .py @@ -460,16 +452,12 @@ PyPiLibrary/ ### Versioning -`_version.py` ships with `__version__ = "0.0.0"` as a placeholder. The publish workflow uses `skip-existing: true` so the workflow won't fail, but no new PyPI versions will land until you wire `_version.py` to something that increments. See the **Template Adoption** section of [`README.md`](./PyPiLibrary/README.md) for the three usual options (`hatch-vcs`, version.json bridge, manual bumps). +`_version.py` ships with `__version__ = "0.0.0"` as a placeholder. Until you wire `_version.py` to something that increments (the usual options are `hatch-vcs`, a version.json bridge, or manual bumps), no new PyPI versions will land - publishing with `skip-existing: true` keeps a stuck placeholder version from failing the run. ### Linter Cleanliness Before pushing or opening a PR: - VS Code's **Problems** pane should be quiet for the files you touched. The relevant linters are ruff (via the `charliermarsh.ruff` extension) and pyright (via the `ms-python.python` extension's bundled Pylance). -- The CI gate is `uv run ruff check && uv run ruff format --check && uv run pyright && uv run pytest` - same as the local commands above, run from `PyPiLibrary/`. +- The CI gate is `uv run ruff check && uv run ruff format --check && uv run pyright && uv run pytest` - same as the local commands above, run from the Python project directory. - Markdown in this directory follows the repo-wide [Markdown and Spelling](#markdown-and-spelling) rules. - -### Adopting Without Python - -If your derived project does not need a Python side, delete the entire `PyPiLibrary/` folder, the `build-pypilibrary` job in `build-release-task.yml`, the `publish-pypi` job in `publish-release.yml`, the `build-pypilibrary-task.yml` workflow, the `uv` block in `.github/dependabot.yml`, the `Python.code-workspace` file, and the `.devcontainer/python/` directory. The .NET side stands alone. diff --git a/Docker/Dockerfile b/Docker/Dockerfile index 3709c11e..0b93c6b2 100644 --- a/Docker/Dockerfile +++ b/Docker/Dockerfile @@ -1,137 +1,137 @@ -# Description: Ubuntu latest release -# Based on: ubuntu:rolling -# .NET install: Ubuntu repository -# Platforms: linux/amd64, linux/arm64 -# Tag: ptr727/projecttemplate:latest - -# Docker build debugging: -# --progress=plain -# --no-cache - -# Test image in shell: -# docker run -it --rm --pull always --name Testing ubuntu:rolling /bin/bash -# docker run -it --rm --pull always --name Testing ptr727/projecttemplate:latest /bin/bash -# export DEBIAN_FRONTEND=noninteractive - -# Build Dockerfile -# docker buildx create --name "projecttemplate" --use -# docker buildx build --platform linux/amd64,linux/arm64 --file ./Docker/Dockerfile . - -# Build and log output -# docker buildx build --no-cache --progress=plain --platform linux/amd64 --file ./Docker/Dockerfile . 2>&1 | tee build.log - -# Test linux/amd64 target -# docker buildx build --load --platform linux/amd64 --tag projecttemplate:latest --file ./Docker/Dockerfile . -# docker run -it --rm --name ProjectTemplate-Test projecttemplate:latest /bin/bash - - -# Builder layer -FROM --platform=$BUILDPLATFORM ubuntu:rolling AS builder - -# Layer workdir -WORKDIR /Builder - -ARG \ - # Build platform args - TARGETPLATFORM \ - TARGETARCH \ - BUILDPLATFORM \ - # Build attributes - BUILD_CONFIGURATION="Debug" \ - BUILD_VERSION="1.0.0.0" \ - BUILD_FILE_VERSION="1.0.0.0" \ - BUILD_ASSEMBLY_VERSION="1.0.0.0" \ - BUILD_INFORMATION_VERSION="1.0.0.0" \ - BUILD_PACKAGE_VERSION="1.0.0.0" - -# Prevent EULA and confirmation prompts in installers -ENV DEBIAN_FRONTEND=noninteractive - -RUN \ - # Upgrade - apt update \ - && apt upgrade -y \ - # Install .NET SDK (for AOT add clang and zlib1g-dev) - # https://documentation.ubuntu.com/ubuntu-for-developers/howto/dotnet-setup - # https://learn.microsoft.com/en-us/dotnet/core/install/linux-ubuntu-install - # https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/ - && apt install -y \ - dotnet-sdk-10.0 \ - # Cleanup - && apt autoremove -y \ - && apt clean \ - && rm -rf /var/lib/apt/lists/* - -# Copy source -COPY . ./ProjectTemplate/. - -# Build project -COPY --chmod=ug=rwx,o=rx ./Docker/Build.sh ./ProjectTemplate -RUN ./ProjectTemplate/Build.sh - - -# Final layer -FROM ubuntu:rolling AS final - -ARG \ - # Build platform args - TARGETPLATFORM \ - TARGETARCH \ - BUILDPLATFORM \ - # Image label - LABEL_VERSION="1.0.0.0" - -# Label -LABEL name="ProjectTemplate" \ - version=${LABEL_VERSION} \ - description="C# .NET template project." \ - maintainer="Pieter Viljoen " - -# Prevent EULA and confirmation prompts in installers -ENV DEBIAN_FRONTEND=noninteractive - -RUN \ - # Upgrade - apt update \ - && apt upgrade -y \ - # Install dependencies - && apt install -y \ - ca-certificates \ - locales \ - locales-all \ - p7zip-full \ - tzdata \ - wget \ - && locale-gen --no-purge en_US en_US.UTF-8 \ - # Install .NET Runtime - && apt install -y \ - dotnet-runtime-10.0 \ - # Cleanup - && apt autoremove -y \ - && apt clean \ - && rm -rf /var/lib/apt/lists/* - -# Set locale to UTF-8 after running locale-gen -# https://github.com/dotnet/dotnet-docker/blob/main/samples/enable-globalization.md -ENV TZ=Etc/UTC \ - LANG=en_US.UTF-8 \ - LANGUAGE=en_US:en \ - LC_ALL=en_US.UTF-8 - -# Copy build output from builder layer -COPY --from=builder /Builder/Publish/ProjectTemplate/. /ProjectTemplate - -# Install debug tools -COPY --chmod=ug=rwx,o=rx ./Docker/InstallDebugTools.sh ./ProjectTemplate -RUN ./ProjectTemplate/InstallDebugTools.sh \ - && rm -rf ./ProjectTemplate/InstallDebugTools.sh - -# Print environment information -COPY --chmod=ug=rwx,o=rx ./Docker/Version.sh ./ProjectTemplate -RUN if [ "$BUILDPLATFORM" = "$TARGETPLATFORM" ]; then \ - /ProjectTemplate/Version.sh; \ - fi \ - && rm -rf ./ProjectTemplate/Version.sh - -# Set workdir -WORKDIR /ProjectTemplate +# Description: Ubuntu latest release +# Based on: ubuntu:rolling +# .NET install: Ubuntu repository +# Platforms: linux/amd64, linux/arm64 +# Tag: ptr727/projecttemplate:latest + +# Docker build debugging: +# --progress=plain +# --no-cache + +# Test image in shell: +# docker run -it --rm --pull always --name Testing ubuntu:rolling /bin/bash +# docker run -it --rm --pull always --name Testing ptr727/projecttemplate:latest /bin/bash +# export DEBIAN_FRONTEND=noninteractive + +# Build Dockerfile +# docker buildx create --name "projecttemplate" --use +# docker buildx build --platform linux/amd64,linux/arm64 --file ./Docker/Dockerfile . + +# Build and log output +# docker buildx build --no-cache --progress=plain --platform linux/amd64 --file ./Docker/Dockerfile . 2>&1 | tee build.log + +# Test linux/amd64 target +# docker buildx build --load --platform linux/amd64 --tag projecttemplate:latest --file ./Docker/Dockerfile . +# docker run -it --rm --name ProjectTemplate-Test projecttemplate:latest /bin/bash + + +# Builder layer +FROM --platform=$BUILDPLATFORM ubuntu:rolling AS builder + +# Layer workdir +WORKDIR /Builder + +ARG \ + # Build platform args + TARGETPLATFORM \ + TARGETARCH \ + BUILDPLATFORM \ + # Build attributes + BUILD_CONFIGURATION="Debug" \ + BUILD_VERSION="1.0.0.0" \ + BUILD_FILE_VERSION="1.0.0.0" \ + BUILD_ASSEMBLY_VERSION="1.0.0.0" \ + BUILD_INFORMATION_VERSION="1.0.0.0" \ + BUILD_PACKAGE_VERSION="1.0.0.0" + +# Prevent EULA and confirmation prompts in installers +ENV DEBIAN_FRONTEND=noninteractive + +RUN \ + # Upgrade + apt update \ + && apt upgrade -y \ + # Install .NET SDK (for AOT add clang and zlib1g-dev) + # https://documentation.ubuntu.com/ubuntu-for-developers/howto/dotnet-setup + # https://learn.microsoft.com/en-us/dotnet/core/install/linux-ubuntu-install + # https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/ + && apt install -y \ + dotnet-sdk-10.0 \ + # Cleanup + && apt autoremove -y \ + && apt clean \ + && rm -rf /var/lib/apt/lists/* + +# Copy source +COPY . ./ProjectTemplate/. + +# Build project +COPY --chmod=ug=rwx,o=rx ./Docker/Build.sh ./ProjectTemplate +RUN ./ProjectTemplate/Build.sh + + +# Final layer +FROM ubuntu:rolling AS final + +ARG \ + # Build platform args + TARGETPLATFORM \ + TARGETARCH \ + BUILDPLATFORM \ + # Image label + LABEL_VERSION="1.0.0.0" + +# Label +LABEL name="ProjectTemplate" \ + version=${LABEL_VERSION} \ + description="C# .NET template project." \ + maintainer="Pieter Viljoen " + +# Prevent EULA and confirmation prompts in installers +ENV DEBIAN_FRONTEND=noninteractive + +RUN \ + # Upgrade + apt update \ + && apt upgrade -y \ + # Install dependencies + && apt install -y \ + ca-certificates \ + locales \ + locales-all \ + p7zip-full \ + tzdata \ + wget \ + && locale-gen --no-purge en_US en_US.UTF-8 \ + # Install .NET Runtime + && apt install -y \ + dotnet-runtime-10.0 \ + # Cleanup + && apt autoremove -y \ + && apt clean \ + && rm -rf /var/lib/apt/lists/* + +# Set locale to UTF-8 after running locale-gen +# https://github.com/dotnet/dotnet-docker/blob/main/samples/enable-globalization.md +ENV TZ=Etc/UTC \ + LANG=en_US.UTF-8 \ + LANGUAGE=en_US:en \ + LC_ALL=en_US.UTF-8 + +# Copy build output from builder layer +COPY --from=builder /Builder/Publish/ProjectTemplate/. /ProjectTemplate + +# Install debug tools +COPY --chmod=ug=rwx,o=rx ./Docker/InstallDebugTools.sh ./ProjectTemplate +RUN ./ProjectTemplate/InstallDebugTools.sh \ + && rm -rf ./ProjectTemplate/InstallDebugTools.sh + +# Print environment information +COPY --chmod=ug=rwx,o=rx ./Docker/Version.sh ./ProjectTemplate +RUN if [ "$BUILDPLATFORM" = "$TARGETPLATFORM" ]; then \ + /ProjectTemplate/Version.sh; \ + fi \ + && rm -rf ./ProjectTemplate/Version.sh + +# Set workdir +WORKDIR /ProjectTemplate diff --git a/DotNet.code-workspace b/ProjectTemplate.code-workspace similarity index 78% rename from DotNet.code-workspace rename to ProjectTemplate.code-workspace index 74cd478e..70594fd0 100644 --- a/DotNet.code-workspace +++ b/ProjectTemplate.code-workspace @@ -15,6 +15,7 @@ "buildtransitive", "Buildx", "codegen", + "commitish", "contentfiles", "csdevkit", "datebadge", @@ -22,24 +23,32 @@ "debuglevel", "devcontainer", "distros", + "dockerbuild", + "Dockerfiles", "dockerhub", "dorny", "dotnettools", + "downstreams", "dryrun", "Emby", "envsubst", + "extensionless", "finalizers", + "Genericize", "gpgsign", "gruntfuggly", "HACS", "hatchling", + "heredocs", "homeassistant", "Jellyfin", "Keychain", + "kicad", "lastbuild", "libsecret", "LINQ", "logfile", + "mktemp", "nameof", "nbgv", "nektos", @@ -47,8 +56,10 @@ "noninteractive", "nugetlibrary", "onCreateCommand", + "Optix", "othercommand", "Pieter", + "pipefail", "postCreateCommand", "ProjectTemplate", "purpleair", @@ -58,15 +69,19 @@ "pyright", "quoteoftheday", "resharper", + "rhysd", "Rubba", "ruff", "Serilog", "settingsfile", + "shellcheck", "signingkey", "slnx", "snupkg", "softprops", "somecommand", + "subsetting", + "Triaging", "tzdata", "unvalidated", "venv", @@ -94,20 +109,32 @@ "editor.formatOnSave": true, "editor.defaultFormatter": "csharpier.csharpier-vscode" }, + "[python]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + } + }, + "python.terminal.activateEnvironment": false, "git.alwaysSignOff": true, "markdown.extension.toc.levels": "2..3" }, "extensions": { "recommendations": [ + "charliermarsh.ruff", "csharpier.csharpier-vscode", "davidanson.vscode-markdownlint", "editorconfig.editorconfig", + "fanaticpythoner.better-todo-tree", "github.vscode-github-actions", "ms-azuretools.vscode-docker", "ms-dotnettools.csdevkit", + "ms-python.python", "streetsidesoftware.code-spell-checker", "yzhang.markdown-all-in-one", - "fanaticpythoner.better-todo-tree" + "timonwong.shellcheck", + "arahata.linter-actionlint" ] } } diff --git a/Python.code-workspace b/Python.code-workspace deleted file mode 100644 index d554ecda..00000000 --- a/Python.code-workspace +++ /dev/null @@ -1,113 +0,0 @@ -{ - "folders": [ - { - "path": "." - } - ], - "settings": { - "cSpell.words": [ - "accessibilities", - "Allman", - "apikey", - "astral", - "autoremove", - "buildcache", - "buildtransitive", - "Buildx", - "codegen", - "contentfiles", - "csdevkit", - "datebadge", - "davidanson", - "debuglevel", - "devcontainer", - "dockerhub", - "dotnettools", - "dryrun", - "Emby", - "finalizers", - "gpgsign", - "gruntfuggly", - "hatchling", - "Jellyfin", - "Keychain", - "lastbuild", - "libsecret", - "LINQ", - "logfile", - "nameof", - "nbgv", - "nugetlibrary", - "nektos", - "Nerdbank", - "noninteractive", - "onCreateCommand", - "othercommand", - "Pieter", - "postCreateCommand", - "ProjectTemplate", - "pyproject", - "pypi", - "pypilibrary", - "pyright", - "quoteoftheday", - "resharper", - "Rubba", - "ruff", - "Serilog", - "settingsfile", - "signingkey", - "slnx", - "snupkg", - "softprops", - "somecommand", - "tzdata", - "Viljoen", - "winget", - "xunit", - "yzhang" - ], - "files.trimTrailingWhitespace": true, - "files.trimTrailingWhitespaceInRegexAndStrings": false, - "diffEditor.ignoreTrimWhitespace": false, - "editor.renderWhitespace": "boundary", - "files.encoding": "utf8", - "[markdown]": { - "files.trimTrailingWhitespace": false, - }, - "[plaintext]": { - "files.trimTrailingWhitespace": false, - }, - "[python]": { - "editor.formatOnSave": true, - "editor.defaultFormatter": "charliermarsh.ruff", - "editor.codeActionsOnSave": { - "source.organizeImports": "explicit" - } - }, - "python.terminal.activateEnvironment": false, - "git.alwaysSignOff": true, - "markdown.extension.toc.levels": "2..3" - }, - "extensions": { - "recommendations": [ - "charliermarsh.ruff", - "davidanson.vscode-markdownlint", - "editorconfig.editorconfig", - "github.vscode-github-actions", - "gruntfuggly.todo-tree", - "ms-azuretools.vscode-docker", - "ms-python.python", - "streetsidesoftware.code-spell-checker", - "yzhang.markdown-all-in-one" - ], - "unwantedRecommendations": [ - "ms-pyright.pyright", - "ms-python.mypy-type-checker", - "ms-python.pylint", - "ms-python.flake8", - "ms-python.isort", - "ms-python.black-formatter" - ] - } -} diff --git a/README.md b/README.md index 75208fd5..f29e275c 100644 --- a/README.md +++ b/README.md @@ -487,7 +487,7 @@ Licensed under the [MIT License][license-link]\ - `Always suggest updating pull request branches` - `Allow auto-merge` - Rules / Rulesets - **separate rulesets per branch**. Develop and main intentionally diverge on two rules - allowed merge methods and `Require linear history`. `Require branches to be up to date before merging` is **off on both** for related-but-distinct reasons (below); everything else is shared. - - **Configure these by exporting the template's rulesets and re-importing them - do not hand-build the rules.** The result must be **exactly two rulesets named `develop` and `main`** (the names are load-bearing: `AGENTS.md` and these docs reference them). Reconstructing each rule by hand is the step that has gone wrong on past ports. + - **Configure these by importing the committed ruleset JSON (`.github/rulesets/develop.json`, `.github/rulesets/main.json`) - do not hand-build the rules.** Those files are the versioned, PR-gated source of truth - they are maintained here in the template and imported into / diffed against a repo's live ruleset config during porting and re-sync (see [AGENTS.md "Staying in Sync"](./AGENTS.md#staying-in-sync-and-reporting-drift-upstream)), not carried and re-synced as a per-repo copy; the result must be **exactly two rulesets named `develop` and `main`** (the names are load-bearing: `AGENTS.md` and these docs reference them). Reconstructing each rule by hand is the step that has gone wrong on past ports. - **Step 0 - remove ALL legacy protection first.** Delete **every** classic branch-protection rule (Settings -> Branches) and **every** pre-existing or stray **ruleset** (Settings -> Rules -> Rulesets) - not just some - so enforcement isn't doubled or contradicted. This template uses rulesets *only*, configured exclusively by the JSON export/import in Steps 1-2 below; never hand-build the rules in the UI. Partial cleanup (leaving a stray ruleset or a classic rule behind) is what has gone wrong on past ports. Equivalent API: ```sh @@ -498,21 +498,22 @@ Licensed under the [MIT License][license-link]\ # gh api -X DELETE "repos///rulesets/" ``` - - **Step 1 - export the template's two rulesets**, keeping only the re-importable fields (the GET response also carries `id`, timestamps, `_links`, `source`, etc. that a create call rejects): + - **Step 1 - the canonical rulesets are the committed files** `.github/rulesets/{develop,main}.json`, each holding only the re-importable writable subset (`{name, target, enforcement, bypass_actors, conditions, rules}` - the live GET response also carries `id`, timestamps, `_links`, `source`, etc. that a create call rejects). They port verbatim with no placeholders (`conditions` key on `refs/heads/develop`|`refs/heads/main`, `bypass_actors` uses the global Admin role `actor_id: 5`, the required check binds by name), so import them as-is - no per-repo edits. **To change a ruleset, edit the live template rulesets, then regenerate the committed files from them** (this export is the source-of-truth refresh, run in the template repo and committed via PR - never the per-port export it replaces): ```sh for name in develop main; do id=$(gh api repos/ptr727/ProjectTemplate/rulesets --jq ".[] | select(.name==\"$name\") | .id") gh api "repos/ptr727/ProjectTemplate/rulesets/$id" \ - --jq '{name, target, enforcement, bypass_actors, conditions, rules}' > "$name-ruleset.json" + --jq '{name, target, enforcement, bypass_actors, conditions, rules}' \ + | jq -S '.' > ".github/rulesets/$name.json" done ``` - - **Step 2 - import into the new repo:** + - **Step 2 - import into the new repo from the committed files:** ```sh - for name in develop main; do - gh api -X POST "repos///rulesets" --input "$name-ruleset.json" + for b in develop main; do + gh api -X POST "repos///rulesets" --input ".github/rulesets/$b.json" done ``` @@ -632,6 +633,7 @@ Template improvements identified but deferred until a real project needs them, s - **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. +- **Resync the `publish-docker-readme-task.yml` "Validate inputs step" downstream** so derived repos pick up the input-contract guard (mutually-exclusive `repositories` vs `manifest`, paired `manifest` + `manifest-jq`). Trigger: the next orchestrated template re-sync, or a derived repo that hits a silent fall-through from a half-filled manifest pair. diff --git a/WORKFLOW.md b/WORKFLOW.md new file mode 100644 index 00000000..c985e47a --- /dev/null +++ b/WORKFLOW.md @@ -0,0 +1,227 @@ +# WORKFLOW.md + +The guide for CI/CD **workflows** (GitHub Actions): a deliberate mixture of code style, architecture, a **behavioral contract** (expected inputs and outputs), and a **test methodology**. Code style lives in [`CODESTYLE.md`](./CODESTYLE.md); this file is its sibling for everything under [`.github/workflows/`](./.github/workflows/). + +Its defining principle: **it describes required outcomes, not a required implementation.** Two repos may implement the same guarantee with different YAML. A workflow is correct when it **satisfies the contract** in section 4 and is **defect-free against the expected inputs and outputs** - not when it matches the template byte for byte. The conventions in section 2 keep workflows legible; the contract in section 4 is what they must *do*. + +Given this document, an agent must be able to do three things to any project: + +1. **Audit** - statically check the workflows against the conventions (section 2) and the structural facts each guarantee implies (section 5A). +2. **Test** - trace the expected inputs/outputs (section 5B) and, where warranted, drive a live probe (section 5C). +3. **Assess** - render a verdict: **operational** (every *applicable* guarantee holds and every *applicable* scenario's observed output equals the expected) or **not operational** (any mismatch - which is a *defect*, not a style nit). + +> **Canonical scope.** This document is authoritative for the workflow contract and test methodology (sections 3 to 6). The conventions in section 2 and the release policy also live in `AGENTS.md` ("Workflow YAML Conventions" and "Release Model"), which is authoritative where the two overlap; section 2 restates them so this file reads on its own. On any conflict in that overlap, `AGENTS.md` wins. + +The guarantees are distilled from failures observed in practice and stated as the **failure-mode each prevents**, so the document stays portable to any project. + +## 1. Purpose and How to Use This Document + +- **Contract, not implementation.** Conform to the *outcomes* in section 4. Shape, job names, and file layout may differ between repos; the input/output behavior may not. +- **Applicability.** A guarantee (or a 5A check, or a 5B scenario) is **applicable** only if the repo contains the construct it governs - a given target, a transfer artifact, a registry push, a wrapper-version source. An item that governs an absent construct is **N/A**: record it as N/A and **exclude it from the verdict**. N/A is never a defect. Section 6 names which items go N/A per project type; a near-empty pipeline (source-only) is mostly N/A and that is fine. +- **Operational is binary.** A workflow is operational only if every *applicable* guarantee holds. A single applicable input/output mismatch is a defect and makes the workflow non-operational, regardless of how clean the YAML looks. +- **Default branch.** Guarantees say "default branch" portably; the template implements it as the literal `main` in several places (the validate gate, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec`). These MUST all reference the repo's *actual* default branch; a divergence is a defect (section 5A). +- **Two layers when auditing.** The pipeline splits into an **orchestrator** layer (the PR entry workflow, the publisher, and the version/release/badge jobs) and a **build-leaf** layer (`build--task.yml`). Inputs like `github`/`nuget`/`dockerhub`/`expect_release_assets` live on the orchestrator; a leaf only ever receives `ref`/`branch`/`smoke` (and a derived `push`). When a check names an input, assert it in the layer that declares it. +- **The three verbs.** Audit (static), Test (trace + probe), Assess (verdict). Section 5 gives the exact procedure. + +## 2. Workflow Style Conventions + +Prescriptive style/legibility rules. Cheap to check, necessary but not sufficient (a perfectly styled workflow can still violate section 4). + +- **Action pinning.** Pin **every** action to a commit SHA with a trailing `# vX.Y.Z` comment. Use `# vX` only when the upstream floating major tag has no specific patch SHA. The single documented no-pin exception is a tool whose tag stream lags `master` such that tag-tracking would downgrade (here, `dotnet/nbgv@master`); invent no others. +- **Filename.** Reusable workflows (`on: workflow_call`) end in `-task.yml`; entry-point workflows do not (`-pull-request.yml`, `-release.yml`). Lowercase, hyphen-separated. +- **Workflow `name:`.** Reusable names end in **"task"**; entry-point names end in **"action"**. +- **Job and step `name:`.** Every job ends in **"job"**, every step in **"step"**. **Exception:** a job whose `name:` is a ruleset-bound required-check `context:` keeps that exact name. +- **Concurrency.** Top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }`. Document exceptions inline (D7). +- **Shells.** Every multi-line bash `run:` starts `set -euo pipefail`. +- **Conditionals.** Multi-line `if:` uses the folded scalar `if: >-`. +- **Boolean inputs.** A boolean used by both `workflow_call` and `workflow_dispatch` is declared in **both** trigger blocks; `workflow_dispatch` delivers the **string** `"true"`/`"false"`, so any `if:` compares both forms: `${{ inputs.foo == true || inputs.foo == 'true' }}`. +- **Reusable-workflow permissions.** Job-level `permissions:` are validated **before** `if:`, so even a skipped job needs valid permissions. Grant least privilege; a reusable callee's extra scope (e.g. `actions: write` for cleanup) is granted by the **caller**. +- **Allowlist `success` and `skipped` explicitly** across optional dependencies (`!= 'failure'` lets `cancelled` through). +- **Docker layer cache.** Cache to/from a registry tag (`type=registry`), never `type=gha`. +- **Line endings.** Workflow YAML follows `.editorconfig` (CRLF here); committed JSON state files follow the repo's JSON rule. Preserve endings on every edit. + +## 3. Architecture + +### Two Layers: Orchestration vs Build + +- **Orchestration** is generic and intended to be synced verbatim **at the job level**: the publish-plan + branch matrix in the publisher, the `get-version`, `validate-release`, and `github-release` jobs, the date-badge job, and the `changes -> smoke-build -> aggregator` shape of the PR workflow. These job *bodies* should not need per-repo edits. +- **Build** is repo-owned: the `build--task.yml` leaf tasks. +- **What the repo curates** (by design, not a leak): the *list* of targets. This is **not** a byte-for-byte file carry. Adding or dropping a target edits the orchestrator's surface - the `enable_` inputs and the `build-` job + its `github-release` `needs:` entry in the release task, **and** the `changes` paths-filter entry + output + the `smoke-build` enable-forward in the PR workflow. "Verbatim" applies to the `github-release` job and the version/publish-plan logic, not to the release task's job list or the paths-filter. Subsetting is symmetric: the same surface you trim to drop a target you extend to add a new one (e.g. a `release-asset--library` producer needs a new `enable_library` input, a `build-library` job, a `needs:` entry, and a `library` paths-filter). + +### The Seam Contract + +A target contributes a file to the GitHub release by uploading a workflow artifact named `release-asset--`. The release job collects **every** matching artifact by **pattern** (`pattern: release-asset--*` + `merge-multiple: true`), never an `artifact-ids:` naming one job's output. Canonical for **every** repo, single-target included; switching to an `artifact-id` handoff forks the release download and breaks the verbatim carry. + +### Reusable-Task Parameter Contract + +Every leaf and the release task take `ref`, `branch` (the **logical** branch that drives config/tags/prerelease), and where relevant `smoke`. Branch-derived config keys off `inputs.branch`, **never** `github.ref_name` (the publisher matrix builds the non-default branch from a run whose `github.ref_name` is the default branch). Artifact names are branch-suffixed so both legs coexist. + +### Versioning + +NBGV computes the version and MUST version from the **checked-out branch**, not the runner CI ref (set `IGNORE_GITHUB_REF=true`; `GITHUB_REF` is reserved and a step `env:` cannot override it). The default branch is the public-release ref, so it builds clean `X.Y.Z`; every other branch builds a prerelease `X.Y.Z-g`. `version.json`'s `version` is the major.minor floor; NBGV appends the git height as the patch. **NBGV and `version.json` are retained even by a repo with no compiled code** - they are the source of the release tag (`SemVer2`) and `target_commitish` (`GitCommitId`) and the prerelease classification; the .NET SDK is pulled in only as the versioning toolchain. A package build derives its registry version from the same NBGV outputs, but **not always from `SemVer2`**: the PyPI version is built from `AssemblyFileVersion` (four-part `M.N.P.B`) with a PEP 440 `.dev0` appended on the `develop` branch. A wrapper repo may drive its build/image version from an external committed `name -> version` state file while NBGV still tags the release. + +### Validate-at-Entry + +When a workflow's inputs carry a cross-input or input-versus-derived-state invariant, assert it **once** in a dedicated entry job/step the downstream jobs `needs:`, failing fast with `::error::` before any build or publish. + +### Resource Lifecycle + +Workflow artifacts are an **intra-run handoff** only; durable copies live on the release/registry. The rule: a transfer artifact handed **between jobs** is deleted by exact name/pattern **at its point of consumption**, the delete is **gated to the same condition as the consumer**, and it is **best-effort**. **Every** `upload-artifact` sets `retention-days: 1` as the universal failure-path backstop, so no terminal blanket-delete job is needed - and an intermediate consumed only within the same run (e.g. an executable's per-runtime outputs feeding an aggregation step) may rely on the retention backstop alone. The run is **never** blanket-deleted (`.artifacts[].id`). See D5. + +### Fast PR Feedback + +PRs validate fast and never publish: a paths-filter smoke-builds only changed targets; a validation job always runs; smoke builds compile/lint/test but upload nothing and push nothing; one required aggregator gates the merge. See D1. + +### Release Model + +Two-phase by default: PRs smoke-test, merges do not publish. The publisher (weekly schedule + manual dispatch) builds and publishes **both** branches via a matrix; its `push` trigger publishes only when an opt-in repository variable is set. Every release is a tag on the built commit plus a source zip, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design. + +### Output Seam by Destination + +Pick each output's path by **where the artifact goes**: + +- **File on the GitHub release** (zip, binary, packaged library): one leaf per output uploading `release-asset--`. The repo keeps `expect_release_assets: true` (its default). +- **Package-registry push** (NuGet, PyPI): the leaf builds and publishes to its registry. NuGet pushes from the leaf *and* uploads a `release-asset-*`; PyPI is **split** - the leaf only builds + uploads its build artifact, a separate publish job does the OIDC upload (so `id-token: write` is granted at one entry point, behind an environment gate) and contributes **no** `release-asset-*`. +- **Image-registry push** (Docker): the leaf pushes multi-arch tags, contributes no `release-asset-*`. +- **No file target** (Docker-only, PyPI-only, source-only): the release is tag + source zip + README + LICENSE. The repo's **caller MUST pass `expect_release_assets: false`** to the release task (the input is never set by the template's own publisher, which ships file targets and keeps the default `true`). This is the one case where the otherwise-verbatim publisher is edited; with the default `true` and no assets, the release-create step fails on `fail_on_unmatched_files`. + +## 4. Behavioral Contract - Expected Outcomes + +The required behaviors, organized by domain. Each is a **MUST**, stated as input -> output plus the failure-mode it prevents. A workflow that violates any *applicable* guarantee is **not operational**. + +### D1 - PR Fast-Feedback (Smoke) + +- **D1.1 Only changed targets build.** Input: a PR touching some targets. Output: the paths-filter marks exactly those targets and only their smoke builds run; unchanged targets skip. A repo's own targets MUST each have a filter entry (so a touched target is never silently skipped). *Prevents: rebuilding everything, and a changed target slipping through unbuilt.* +- **D1.2 A validation job always runs.** Input: any PR. Output: a type-appropriate validation job runs unconditionally and the aggregator `needs:` it. In a .NET repo this is the `unit-test` job (format/style/test); a non-.NET repo **replaces** it (not deletes) with its own validator (lint, schema-check) and re-points **every** `needs:` on it - both the aggregator and `smoke-build` (which `needs:` the validation job by name) - to the replacement. *Prevents: a PR merging with no validation, or a dangling `needs:` that fails the whole workflow to load.* +- **D1.3 Smoke never publishes and never uploads.** Input: `smoke: true`. Output: full compile/lint/test, but no registry/image push, no release, and **no** artifact uploads (every `upload-artifact`, including any aggregation job, is gated `!smoke`). *Prevents: a PR publishing; orphaned artifacts churning the storage quota.* +- **D1.4 Workflow-file changes are not smoke-built.** Input: a PR changing only `.github/workflows/**`. Output: the paths-filter excludes workflow files, so smoke-build skips. *Implication: there is no CI workflow-lint; lint workflow edits locally (actionlint).* +- **D1.5 One required aggregator gates merge.** Input: any PR. Output: a single aggregator job must **succeed**, `needs:` the changes job and the validation job, treat a **skipped** smoke build as pass, and **block** on `failure`/`cancelled`. Its name is ruleset-bound and MUST NOT be renamed. *Prevents: a paths-filter error letting a target-changing PR merge unbuilt.* + +### D2 - Input/State Validation at Entry + +- **D2.1 Validate before expensive work.** Output: a dedicated entry job/step asserts each cross-input/derived-state invariant and fails fast before builds; downstream jobs `needs:` it. +- **D2.2 Release branch matches version classification.** Input: a real (non-smoke) release build. Output: the gate fails loudly if the default branch carries a prerelease suffix **or** a non-default branch carries none; it strips `+buildmetadata` before testing for the prerelease `-` (only a core/prerelease `-` counts); and it is **skipped on smoke** (a detached PR head always versions as prerelease). *Prevents: a non-default leg published as stable; a build-metadata false-positive; the gate blocking every default-base promotion PR.* +- **D2.3 Publish only from the default branch.** Input: a dispatch/schedule publish. Output: a dispatch from a non-default ref fails fast. *Prevents: the matrix building the other leg from the wrong ref and shipping a malformed non-prerelease "Latest".* +- **D2.4 Mutually-exclusive / paired inputs are validated.** Input: a workflow with either/or or must-pair inputs (e.g. the docker-readme task's `repositories` XOR `manifest`+`manifest-jq`). Output: a half-filled or conflicting combination fails fast. *Prevents: a silent fall-through.* + +### D3 - Versioning and Classification + +- **D3.1 Version from the checked-out branch.** Input: a matrix publish dispatched from the default branch, each leg checking out its own branch. Output: each leg's version reflects **its** branch (`IGNORE_GITHUB_REF=true`). *Prevents: every leg classified as the public ref because the CI ref is the default branch.* +- **D3.2 Default = public, others = prerelease.** Output: default branch -> `X.Y.Z`; any other -> `X.Y.Z-g`. The default-branch literal in the gate, the `prerelease` expression, and `version.json` MUST all name the repo's real default branch. +- **D3.3 Version floor + git height.** Output: `version.json` sets the major.minor floor; NBGV appends the git height as the patch, bumped only for a functional change by the maintainer. NBGV and `version.json` are retained even by a no-compiler repo (they own the tag). +- **D3.4 Registry versions follow the classification, per registry.** Output: NuGet default = stable, others = prerelease (derived by NuGet.org from the SemVer2 `-g` suffix on `PackageVersion`, not a flag the workflow sets). PyPI builds from `AssemblyFileVersion` (`M.N.P.B`) and appends `.dev0` on the `develop` branch only (a two-branch literal, not a generic N-branch rule); the develop `.dev0` build must remain `pip install --pre`-selectable and sort above the default release (NBGV git height in the release segment keeps develop ahead). *Prevents: a non-default leg published as a release; a renamed/extra branch silently getting a plain version.* +- **D3.5 Wrapper repos may use an external version.** Output: a repo wrapping an upstream release drives its build/image version from a committed `name -> version` state file, while NBGV still tags the release. *Note: the template ships the tracker (the writer) but no consumer wiring - a wrapper must wire the leaf to read the state file (e.g. `jq` into the image tag) instead of `SemVer2`; if the leaf still tags off NBGV, the wrapper is not actually pinned to upstream.* + +### D4 - Release / Publish + +- **D4.1 Two-phase by default.** Output: PRs smoke-test; merges do **not** publish unless the opt-in variable is set; the publisher's schedule and dispatch always publish both branches. +- **D4.2 Tag the built commit.** Output: the release `target_commitish` is the built commit's SHA (NBGV's commit id), never `github.sha` or a moving branch ref. *Prevents: the tag landing on the default branch instead of the built tree.* +- **D4.3 Release contents.** Output: every release is a tag on the built commit plus the auto source zip, README, and LICENSE; file-producing targets attach `release-asset-*`; `prerelease` equals `branch != default`. A no-file-target repo reaches the tag-only shape **only** with `expect_release_assets: false` set by the caller (which relaxes `fail_on_unmatched_files` and skips the asset download); with the default `true` and no assets the release-create step fails. +- **D4.4 No-op republish.** Input: a re-run whose version is unchanged. Output: nothing is re-pushed - the release-create step is skipped when the tag exists (refreshed only on `workflow_dispatch`), and the paired asset-delete is skipped with it; registry pushes are no-ops. The NuGet/PyPI publish steps are **not** statically gated on existence - they run and the **server** dedupes (`dotnet nuget push --skip-duplicate` turns a 409 into success; PyPI `skip-existing: true`). **Docker always re-pushes** the image (base-image refresh), independently of the release-create skip, within the same run. *Prevents: duplicate releases and wasted pushes.* + +### D5 - Resource Cleanup + +- **D5.1 Delete at the point of consumption.** Output: the job that downloads a **cross-job** transfer artifact deletes it (by exact name/pattern) right after consuming it. An intermediate consumed only within the same run (e.g. an executable's per-runtime outputs feeding an in-run aggregation) MAY instead rely on the `retention-days: 1` backstop. *Prevents: transfer artifacts accumulating against the storage quota.* +- **D5.2 Gate the delete to the consumer's condition.** Output: the delete runs under the **same** condition as its consuming step. Where the consumer is conditional (the GitHub release create), the delete is conditional too; where the consumer always runs when its job runs (the PyPI publish step), the delete always runs - so on a no-op re-run the `release-asset-*` delete is **skipped** while the PyPI build-artifact delete still **runs** (its publish ran). *Prevents: deleting freshly built assets on a no-op re-run.* +- **D5.3 Best-effort.** Output: cleanup is `continue-on-error`, tolerates a failed listing, and deletes **all** matching ids. *Prevents: a cleanup hiccup reddening a job whose publish succeeded.* +- **D5.4 Retention backstop.** Output: **every** `upload-artifact` sets `retention-days: 1`. +- **D5.5 Never blanket-delete.** Output: cleanup MUST NOT enumerate and delete the run's whole artifact set. *Prevents: destroying diagnostic/log artifacts and auto-emitted build-records.* + +### D6 - Seam / Architecture Conformance + +- **D6.1 Pattern handoff.** Output: the release job downloads by `pattern:`/`merge-multiple:`, not `artifact-ids:`; targets upload `release-asset--`. Canonical for single-target. +- **D6.2 Branch drives config.** Output: branch-derived config reads `inputs.branch`, never `github.ref_name`. +- **D6.3 Branch-suffixed artifacts.** Output: artifact names are branch-suffixed so both legs coexist. +- **D6.4 Target add/drop is consistent.** Output: adding or dropping a target updates **all** of: the `enable_` input, the `build-` job and its `github-release` `needs:` entry, the `changes` paths-filter entry + output, and the `smoke-build` enable-forward (and, for PyPI, the separate `publish-pypi` job). The `github-release` job body stays verbatim. *Prevents: a partial subset that startup-fails on a missing leaf or never smoke-builds a target.* + +### D7 - Concurrency, Permissions, Safety + +- **D7.1 Publisher serializes.** Output: the publisher uses a **global, ref-independent** concurrency group with `cancel-in-progress: false`. *Prevents: a schedule and a dispatch double-pushing, or a cancelled publish leaving a partial release.* +- **D7.2 Skipped jobs still need valid permissions.** Output: every reusable job declares valid `permissions:`; a callee's extra scope (e.g. `actions: write` for cleanup, or `id-token: write` for OIDC) is granted by the caller and appears at exactly the one entry point that needs it. +- **D7.3 Boolean inputs both forms.** Output: declared in both trigger blocks, compared against `true` and `'true'`. +- **D7.4 Optional-dependency chaining.** Output: cross-job conditions allowlist `success`/`skipped` explicitly. + +### D8 - Bots / Automation + +- **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened`; dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.* +- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source; Dependabot targets both branches, security PRs to default. +- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it, and the bump ships on the **next** publish. The tracker's `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `-` head/base pairs, or auto-merge silently never fires. + +### D9 - Style / Static (See Section 2) + +- **D9.1** Every action SHA-pinned with a version comment (sole exception: the documented lagging-tag tool). +- **D9.2** File/workflow/job/step names follow the suffix rules; ruleset-bound names verbatim. +- **D9.3** Bash `run:` blocks start `set -euo pipefail`; multi-line `if:` uses `>-`. +- **D9.4** Docker layer cache targets a registry tag, not `type=gha`; `cache-to` writes only the built branch's `buildcache-` and only on push, while `cache-from` reads both branches; multi-image repos use a per-image cache tag. +- **D9.5** Line endings follow `.editorconfig`. + +## 5. Test Methodology + +An agent verifies a project in three escalating modes, then renders a verdict. **Skip N/A items** (section 1): a check or scenario for an absent construct is recorded N/A, not failed. + +### 5A. Static Audit (No Execution) + +Read the workflow files plus `version.json` and assert the structural fact behind each *applicable* D-guarantee, each pass/fail/N-A with a `file:line` citation. Remember the two layers: assert each input in the file that declares it. + +**Core (every repo):** + +- **D1:** a `changes` paths-filter job exists, covers each of the repo's targets, and **excludes** `.github/workflows/**`; the PR entry workflow's smoke call sets `github/nuget/dockerhub: false` on the release task; the leaf receives `smoke: true` and a derived `push` (false on smoke); every build-task `upload-artifact` (and any aggregation job) is gated `!smoke`; the aggregator `needs:` the `changes` and validation jobs, blocks on `failure`/`cancelled`, passes on `skipped`; a validation job runs unconditionally. +- **D2:** an entry validation job/step exists per complex-input workflow; the release gate checks both directions, strips `+buildmetadata`, and skips on smoke; the publisher rejects a non-default-ref dispatch. +- **D3:** the version step sets `IGNORE_GITHUB_REF=true`; the default-branch literal in the gate (`== 'main'`), the `prerelease` expression (`!= 'main'`), and `version.json`'s `publicReleaseRefSpec` all name the repo's actual default branch. +- **D4:** `target_commitish` is the NBGV commit id; `prerelease` equals `branch != default`; the release-create step is gated `exists == false || workflow_dispatch`; the asset-delete step is gated identically. +- **D5:** each cross-job transfer artifact has a delete step at its consumer, gated to the consumer's condition, `continue-on-error: true`, looping all ids; **every** upload sets `retention-days: 1`; **no** `.artifacts[].id` blanket delete exists anywhere. +- **D6:** the release download uses `pattern:`/`merge-multiple:` (no `artifact-ids:`); branch-derived config reads `inputs.branch` (a `github.ref_name` in such config is a finding); artifact names are branch-suffixed; the target set is consistent across the release task and the paths-filter. +- **D7:** the publisher concurrency group is ref-independent with `cancel-in-progress: false`; reusable jobs declare permissions; boolean `if:` uses both forms. +- **D8/D9:** merge-bot concurrency keys on PR number; the upstream tracker's branch prefix matches the merge-bot's head-ref pairs (wrapper repos); actions are SHA-pinned; names/shells/conditionals follow section 2. + +**Per-type addenda (apply only the ones present):** + +- **Console/executable:** the smoke runtime matrix is a strict non-empty subset of the full matrix; the per-runtime outputs (`publish--`) are aggregated by `pattern:` + `merge-multiple:` into one `release-asset--` and the aggregation job is gated `!smoke`; the per-runtime intermediates rely on the retention backstop (no explicit delete is required for an in-run intermediate). +- **NuGet:** the publish step is gated `if: inputs.push` only (not on an existence check) and uses `--skip-duplicate`; `*.nupkg` push also carries the paired `.snupkg` to the symbol server where symbols are enabled; the `release-asset` zip carries the package(s). +- **PyPI:** `publish-pypi` declares `environment: { name: pypi }`; `id-token: write` appears only on that job (absent from the build/PR path); `skip-existing: true` is set on the publish action; the build artifact is deleted after publish; the `pypi` environment has a deployment-branch rule. +- **Docker:** a Docker-only repo's caller passes `expect_release_assets: false`; the leaf reads the external state file for the tag instead of `SemVer2` (wrapper repos only - a plain Docker repo correctly tags off `SemVer2` and records this N/A); the readme/date-badge jobs are gated main-only; the docker-readme task validates `repositories` XOR `manifest`+`manifest-jq`; the buildcache follows D9.4. + +### 5B. End-to-End Trace Scenarios (No Execution, Deterministic from the YAML) + +For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the inputs and emit the predicted **run/skip + version + release + artifact-end-state** table, then compare to the expected. Scenarios that exercise an absent target are N/A. Minimum set: + +| # | Input | Expected output | Exercises | +| --- | --- | --- | --- | +| S1 | PR touching a build target | `changes` flags it; validation runs; that target's smoke build runs; no push, **no uploads**; validate-release **skipped (smoke), succeeds**; release **skipped**; aggregator **success**; version = prerelease; no release; no dangling artifacts | D1, D2.2, D3 | +| S2 | PR changing only docs | smoke-build **skipped**; validation runs; aggregator **success** | D1.1, D1.5 | +| S3 | PR changing only `.github/workflows/**` | filter excludes -> smoke-build **skipped**; aggregator **success** | D1.4 | +| S4 | PR base = default branch, carrying a build target | smoke versions as prerelease; validate-release **skipped (smoke)** so the default-branch arm does **not** fire; aggregator **success**; promotion not blocked | D1.3, D2.2 | +| S5 | push to non-default branch, opt-in unset | `setup` -> publish=false; nothing publishes | D4.1 | +| S6 | push to non-default branch, opt-in set | publish=true; that branch publishes a **prerelease** | D3, D4 | +| S7 | scheduled/dispatched publish from default branch | both legs: non-default -> `X.Y.Z-g`, `prerelease=true`, registry prerelease, `release-asset-*` consumed-then-deleted; default -> `X.Y.Z`, `prerelease=false`, registry stable, badge/readme run; PyPI build-artifact deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 | +| S8 | dispatch from a non-default ref | `setup` **fails fast** | D2.3 | +| S9 | re-run publish, version unchanged | release-create **skipped**, `release-asset-*` delete **skipped**; NuGet/PyPI pushes no-op (server dedupe); **PyPI build-artifact still deleted** (its publish ran); **Docker still re-pushes** the image; no duplicate release | D4.4, D5.2 | +| S10 | branch/version classification disagree | validate-release **fails loud**; build/publish skip | D2.2 | +| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `-` PR -> merge-bot auto-merges -> the new version ships on the **next** publish | D8.3, D3.5 | + +### 5C. Live Probe (Where Warranted) + +- Open a trivial-change PR touching one target and confirm S1. +- Drive a `smoke: true` push-probe of the build task for **both** the default and a non-default branch and assert the version classification (clean vs prerelease) and that the gate passes - **without publishing**. *Caveat: the Docker leg logs in to the registry even on smoke and reads the buildcache, so it needs `DOCKER_HUB_*` secrets and cannot run on a fork PR (same-repo only).* +- Per registry: after a real publish, query NuGet.org for the expected version + prerelease classification (and the `.snupkg` on the symbol server), and confirm a re-run added no duplicate; for PyPI inspect the `Compute PyPI version step` log and the built `dist/*` filenames for `.dev0` off `develop` vs a plain version on the default branch. +- Inspect the latest real publish's logs for `PublicRelease`/`SemVer2` per leg and confirm the artifact lifecycle (uploaded, consumed, deleted; none left behind). + +### Assessment + +The workflow is **operational** iff every *applicable* 5A item passes and every *applicable* 5B scenario's observed output equals the expected (confirmed by 5C where a live signal exists). N/A items are excluded, never counted as failures. Any *applicable* mismatch is a **defect** -> **not operational**. Procedure: + +1. **Audit** with 5A; record pass/fail/N-A with `file:line`. +2. **Trace** the applicable S-scenarios with 5B; diff predicted vs expected. +3. **Probe** with 5C only for guarantees a static trace cannot settle (live version classification, registry state, artifact lifecycle). +4. **Verdict:** operational / not operational, with the failing guarantee(s) and the triggering input for each, and the list of items recorded N/A. + +## 6. Per-Project-Type Test Walkthroughs + +Each type maps the *applicable* S-scenarios onto its targets; the differences are which leaf tasks exist and what each produces, which 5A addenda apply, and which scenarios are N/A. Walking these is the self-check that the contract holds for each shape. + +- **Console / executable application.** Target produces `release-asset--executable` (a 7z archive, `Console.7z`) by building a per-runtime `dotnet publish` matrix, then an aggregation job downloads the per-runtime `publish--` intermediates (`pattern:` + `merge-multiple:`), zips them, and uploads the single asset. Smoke builds a strict subset of runtimes; the per-runtime upload **and** the aggregation job are both gated `!smoke`, so smoke uploads nothing. The per-runtime intermediates rely on `retention-days: 1` (no explicit delete). Test: S1 with a console change smoke-builds the subset and uploads nothing; S7 attaches the 7z, `prerelease=true` on the non-default leg and `prerelease=false` on the default leg (GitHub auto-marks the stable default release "Latest"; the workflow does not set it). +- **NuGet library.** The leaf both pushes (`dotnet nuget push *.nupkg --skip-duplicate`, gated `if: push` only) and uploads `release-asset--nugetlibrary`; configuration is Release on the default branch, Debug otherwise. Where symbols are enabled (`snupkg`), the push auto-carries the paired `.snupkg` to NuGet.org's symbol server and the asset zip also contains it - a triple surface. NuGet.org derives `isPrerelease` from the SemVer2 `-g` suffix (the workflow sets no such flag). Test: S7 non-default leg publishes a prerelease package + asset, default a stable; S9 re-run is a server-side `--skip-duplicate` no-op. 5C: query NuGet.org for both versions and the symbol package. +- **PyPI library.** The leaf builds + uploads `pypilibrary-build-`; a **separate** `publish-pypi` job (with `environment: pypi`, `id-token: write`, `actions: write`) does the OIDC Trusted-Publishing upload with `skip-existing: true`, then **consume-then-deletes** the build artifact - **unconditionally on consume**, so on S9 it is deleted even though the `release-asset-*` delete is skipped. The version is `AssemblyFileVersion` with `.dev0` appended on `develop` only, and must stay `--pre`-selectable and sorted above the default release. PyPI contributes no `release-asset-*`; a PyPI-only repo sets `expect_release_assets: false` at the caller. Test: S7 default leg publishes a release, non-default a `.dev0`; S9 is a `skip-existing` no-op; 5C inspects the `dist/*` filenames and the compute-version log. +- **Docker image.** The leaf pushes multi-arch tags with a per-branch registry buildcache (`buildcache-`; a multi-image repo adds a per-image tag) (`cache-to` only the built branch and only on push, `cache-from` both branches); no `release-asset-*`, so a Docker-only repo's caller passes `expect_release_assets: false`; the readme (`peter-evans/dockerhub-description`, `DOCKER_HUB_ACCESS_TOKEN`) and date-badge jobs run **only** when the default branch publishes; the docker-readme task validates `repositories` XOR `manifest`+`manifest-jq` and a multi-image repo derives its publish matrix from the manifest. Docker **always re-pushes** the image, independently of a skipped release-create (S9). A **wrapper** repo tracks an upstream release: the upstream tracker writes a `name -> version` state file and the merge-bot auto-merges the bump PR (S11), and the leaf MUST read that file for the immutable tag instead of `SemVer2` (the template ships the tracker but not this consumer wiring). Test: S7 default leg pushes `latest` + the version tag and updates readme/badge; non-default pushes the develop tag; S9 still re-pushes; S11 ships the bumped upstream version next publish. 5C Docker probe needs `DOCKER_HUB_*` secrets and same-repo (not fork) runs. +- **Data / asset library.** A single new leaf: validate -> zip -> upload `release-asset--library` (`retention-days: 1`, upload gated `!smoke` - mirror the nugetlibrary leaf's shape). Because the template has no such leaf, you **add a target** (D6.4): a new `enable_library` input + `build-library` job + `github-release` `needs:` entry in the release task, and a `library` paths-filter entry + `changes` output + `smoke-build` enable-forward in the PR workflow (without it, D1.1 never smoke-builds the library). Keep `expect_release_assets: true` (it has a file target, unlike Docker). The .NET `unit-test` job is replaced by a type-appropriate validator with the aggregator **and** `smoke-build` both re-pointed to it (D1.2/D1.5); `version.json` + the NBGV `get-version` step are retained (they own the tag). Test: S1 smoke runs validate+zip and uploads nothing; S7 attaches the zip, prerelease on the non-default leg; S9 on a *scheduled* re-run release-create + asset-delete skip (the existing zip is untouched, no registry push), while a `workflow_dispatch` re-run **refreshes** the release and re-runs the asset-delete (the asset is re-uploaded then re-deleted). N/A: the nuget/pypi/docker/executable 5A addenda and their scenario clauses. +- **Source-only / no build.** No package/image leaf: remove all four `build-*` jobs and their `github-release` `needs:` entries (leaving `get-version -> validate-release -> github-release`, which fires on `github && !smoke`), and the caller passes `expect_release_assets: false` so the release is tag + source zip + README + LICENSE with no asset download. With no target the paths-filter matches nothing, so `smoke-build` is **structurally always skipped** - validation is carried solely by the (replaced, non-.NET) validation job that the aggregator and `smoke-build`'s own `needs:` must both point at (D1.2; or drop the never-running `smoke-build` job). NBGV and `version.json` are still retained (they own the tag). Applicable scenarios: S1 (validation only), S5/S6 (publish gating), S7 (tag-only release), S8 (dispatch guard), S9 (no-op republish), S10 (classification gate). N/A: S2-S4 (assume a smoke-built target), the artifact-lifecycle and registry clauses of S7/S9, the D5/D6 artifact items, and all per-type 5A addenda - recorded N/A, not failed.