diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index 12bea3d..971aa7e 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -19,7 +19,7 @@ jobs: merge-bot: name: Merge bot pull request job - uses: ptr727/ProjectTemplate/.github/workflows/merge-bot-task.yml@b823bc493fcdc47e9d9d9d2fc183e3ca10114146 # 2.0.512 + uses: ptr727/ProjectTemplate/.github/workflows/merge-bot-task.yml@f3b4cc98654878e63ebfe21b68b8516b7bb46469 # 2.0.526 secrets: CODEGEN_APP_CLIENT_ID: ${{ secrets.CODEGEN_APP_CLIENT_ID }} CODEGEN_APP_PRIVATE_KEY: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 5cc4c59..178f655 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -32,7 +32,7 @@ jobs: # The schedule always publishes, matching this workflow's own trigger. plan: name: Plan release job - uses: ptr727/ProjectTemplate/.github/workflows/publish-plan-task.yml@b823bc493fcdc47e9d9d9d2fc183e3ca10114146 # main promotion + uses: ptr727/ProjectTemplate/.github/workflows/publish-plan-task.yml@f3b4cc98654878e63ebfe21b68b8516b7bb46469 # main promotion with: event_name: ${{ github.event_name }} actor: ${{ github.actor }} @@ -44,7 +44,7 @@ jobs: name: Validate job needs: [plan] if: ${{ needs.plan.outputs.publish == 'true' }} - uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@b823bc493fcdc47e9d9d9d2fc183e3ca10114146 # main promotion + uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@f3b4cc98654878e63ebfe21b68b8516b7bb46469 # main promotion secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} @@ -57,7 +57,7 @@ jobs: needs: [plan, validate] if: >- ${{ needs.plan.outputs.publish == 'true' && needs.validate.result == 'success' }} - uses: ptr727/ProjectTemplate/.github/workflows/build-release-task.yml@b823bc493fcdc47e9d9d9d2fc183e3ca10114146 # main promotion + uses: ptr727/ProjectTemplate/.github/workflows/build-release-task.yml@f3b4cc98654878e63ebfe21b68b8516b7bb46469 # main promotion permissions: contents: write # actions: write lets the reusable github-release job delete its release-asset-* transfer artifacts (D5.1). diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 5fa51b2..f65f72f 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -43,7 +43,7 @@ jobs: validate: name: Validate sources job - uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@b823bc493fcdc47e9d9d9d2fc183e3ca10114146 # main promotion + uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@f3b4cc98654878e63ebfe21b68b8516b7bb46469 # main promotion permissions: contents: read secrets: @@ -56,7 +56,7 @@ jobs: name: Smoke build job needs: [changes] if: needs.changes.outputs.release == 'true' - uses: ptr727/ProjectTemplate/.github/workflows/build-release-task.yml@b823bc493fcdc47e9d9d9d2fc183e3ca10114146 # main promotion + uses: ptr727/ProjectTemplate/.github/workflows/build-release-task.yml@f3b4cc98654878e63ebfe21b68b8516b7bb46469 # main promotion permissions: contents: read secrets: diff --git a/.gitignore b/.gitignore index a75e90b..a5e5d96 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,11 @@ .claude BenchmarkDotNet.Artifacts/ + +# Coverage output (Microsoft.Testing.Extensions.CodeCoverage) coverage/ +[Tt]est[Rr]esults/ +*.cobertura.xml +*.coverage secrets/ diff --git a/Directory.Packages.props b/Directory.Packages.props index 710857c..6a9230b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,10 +6,10 @@ - + @@ -19,7 +19,6 @@ - diff --git a/OPERATIONS.md b/OPERATIONS.md index be12039..45e30a7 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -17,7 +17,7 @@ dotnet test dotnet husky run ``` -CI differs in two places. It substitutes `dotnet csharpier check .` for the format step, and it runs the suite as `dotnet test --collect:"XPlat Code Coverage" --results-directory ./coverage` so coverlet emits the report the Codecov upload consumes. The style verify is identical. So a local run that formats a file leaves CI clean, while an unformatted commit fails there rather than being fixed. +CI differs in two places. It substitutes `dotnet csharpier check .` for the format step, and it runs the suite as `dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`. That form has `Microsoft.Testing.Extensions.CodeCoverage` emit the report the Codecov upload consumes. CI then renames each `.cobertura.xml` to `coverage-.cobertura.xml`, because `codecov-cli`'s file finder matches only `*coverage*.*` and an exact `cobertura.xml`. The style verify is identical. So a local run that formats a file leaves CI clean, while an unformatted commit fails there rather than being fixed. The lint set runs in containers, matching the `Lint:` tasks in [`.vscode/tasks.json`](./.vscode/tasks.json): @@ -44,7 +44,7 @@ Publishing never happens as a side effect of a merge. A release is a `workflow_d ### Fix a red Dependabot PR -A grouped update (`nuget-deps`, `actions-deps`) can go red for a reason the bump itself cannot fix, because Dependabot only edits version numbers, never source or project files. Two known failure classes: a formatter tool bump (`csharpier` in [`.config/dotnet-tools.json`](./.config/dotnet-tools.json)) changes a formatting rule and flags an untouched file elsewhere in the tree, or a test SDK bump (`Microsoft.NET.Test.Sdk`, `xunit.v3`) turns on the test project's `IsTestingPlatformApplication` flag, which the .NET 10 SDK now refuses to run through the classic VSTest-based `dotnet test` command it uses. Set `false` on the test project to keep it on the `xunit.runner.visualstudio` adapter path, since [Microsoft's own opt-in](https://aka.ms/dotnet-test-mtp-error) to the new Microsoft.Testing.Platform runner needs a `global.json` change that breaks the `--collect:"XPlat Code Coverage"` argument this repo's CI relies on. +A grouped update (`nuget-deps`, `actions-deps`) can go red for a reason the bump itself cannot fix, because Dependabot only edits version numbers, never source or project files. There are two known failure classes. A formatter tool bump (`csharpier` in [`.config/dotnet-tools.json`](./.config/dotnet-tools.json)) changes a formatting rule and flags an untouched file elsewhere in the tree. Or a test-stack bump moves `Microsoft.Testing.Extensions.CodeCoverage` or `xunit.v3` onto a Microsoft.Testing.Platform major the other does not declare. Those two are the only packages that bring the platform in. The coverage extension declares it directly, and `xunit.v3` reaches it through `xunit.v3.mtp-v2`. `Microsoft.NET.Test.Sdk` carries the VSTest stack instead (`Microsoft.TestPlatform.TestHost` and `Microsoft.CodeCoverage`) and declares no MTP dependency at all. The `nuget-deps` group still bumps all three together, so a grouped bump touching any of them is worth reading. A skew between the two that do declare it throws `TypeLoadException` and runs zero tests. It still writes a well-formed Cobertura file reporting full coverage, so only the non-zero exit says the run reported nothing. Read [`WORKFLOW.md`](./WORKFLOW.md) D1.6, then check what each package declares with `dotnet nuget why PhotoCleanerTests/PhotoCleanerTests.csproj Microsoft.Testing.Platform`. Do not reach for `dotnet list package --include-transitive` here. NuGet unifies the platform to one version per target framework. That command therefore prints a single healthy row whichever major each package was actually built against. Diagnose from the failing job's own log rather than the checks summary, since a check only names the job that failed: @@ -98,3 +98,4 @@ The Immich API key can be given inline with `--apikey` or read from a file with - [Docker/](./Docker/) holds the multi-architecture `Dockerfile` and the Docker Hub README. - [.github/workflows/](./.github/workflows/) holds the CI and release pipelines. The pull request check reaches the hub-hosted `validate-task.yml` and `build-release-task.yml` in ptr727/ProjectTemplate, and the release chain reaches those two plus `publish-plan-task.yml`, rather than carrying their own copies. - Analyzer and package configuration is central: `Directory.Build.props` carries the analyzer set, and `Directory.Packages.props` pins every package version. +- [global.json](./global.json) selects Microsoft.Testing.Platform as the test runner for the whole repo. The `test.runner` key is what needs the .NET 10 SDK or later, since that SDK is what reads it. MTP itself predates the key, and an MTP test project is a self-hosting executable. xUnit v3 ships separate VSTest and MTP integrations. Four things put this repo on the MTP one: this key, no `xunit.runner.visualstudio` adapter, no `IsTestingPlatformApplication` opt-out, and `Microsoft.Testing.Extensions.CodeCoverage` rather than a VSTest collector. A move back is not this repo's alone to make, since the coverage invocation lives in the hub-hosted validator. `global.json` declares no `sdk` key, so it pins no SDK version and affects nothing but test execution. diff --git a/PhotoCleanerTests/PhotoCleanerTests.csproj b/PhotoCleanerTests/PhotoCleanerTests.csproj index e7be5c2..4e13171 100644 --- a/PhotoCleanerTests/PhotoCleanerTests.csproj +++ b/PhotoCleanerTests/PhotoCleanerTests.csproj @@ -2,8 +2,6 @@ true PhotoCleanerTests - - false @@ -13,16 +11,14 @@ - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/WORKFLOW.md b/WORKFLOW.md index e7a75c7..de7ddfc 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -149,7 +149,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input - **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, and 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: a workflow-only change is not smoke-built, but actionlint still validates it in CI.* This repo's `changes` job filter is exactly this exclusion (`'!.github/workflows/**'`), so a PR touching only workflow files skips `smoke-build`. Any other change, including a docs-only one, matches the filter and runs it. - **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: the job `name:` and the ruleset `context:` are the same string and MUST be renamed together, never independently. *Prevents: a paths-filter error letting a target-changing PR merge unbuilt.* Here the aggregator is `check-workflow-status`, named `Check pull request workflow status job`, and it `needs: [changes, validate, smoke-build]`, treating each `success` or `skipped` result as passing and failing on anything else. -- **D1.6 Coverage is reported to Codecov (C# and Python).** Input: a C# or Python repo's validation/test job. Output: tests run with coverage collection (`dotnet test --collect:"XPlat Code Coverage"` or `pytest --cov-report=xml`) and a `codecov/codecov-action` step uploads it, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). `CODECOV_TOKEN` lives in the repo's **actions** secret store and reaches the reusable validator via `secrets: inherit`. Required for **every** C# and Python repo that has tests. Where this guarantee does not apply (a `lint-only` profile for that type, per `registry/repos.json`), the hub's `spec/secrets.json` `typeMechanisms` mapping is not claimed for that repo, and the absence is not drift. The repo also ships a **`codecov.yml`** that sets the project and patch statuses to **`informational: true`** so a coverage delta never gates a PR (a distinct knob from `fail_ci_if_error`, which only guards the upload step), and excludes intentionally-untested, non-shipped code (an example/demo or benchmark project) from the coverage denominator via `ignore`. A repo may override this to enforce a coverage threshold where its quality bar requires it. Coverage output is a build artifact, so `.gitignore` excludes it (e.g. `coverage/` and `*.cobertura.xml`, with `.gitignore` the full source of truth) so a blanket `git add -A` won't stage the untracked output. *Prevents: coverage silently going unreported; a stale, unused token; a coverage regression blocking an unrelated PR; a coverage artifact committed by a blanket add.* +- **D1.6 Coverage is reported to Codecov (C# and Python).** Input: a C# or Python repo's validation/test job. Output: tests run with coverage collection (`dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage` or `pytest --cov-report=xml`) and a `codecov/codecov-action` step uploads it, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). `CODECOV_TOKEN` lives in the repo's **actions** secret store and reaches the reusable validator via `secrets: inherit`. Required for **every** C# and Python repo that has tests. That C# invocation runs under **Microsoft.Testing.Platform**, which an MTP-based test project on the .NET 10 SDK and later requires, since running one through the VSTest target fails outright. A repo whose test project is MTP-based, in practice any repo on xunit.v3 4.0.0 or later, therefore also ships a root **`global.json`** declaring `{"test": {"runner": "Microsoft.Testing.Platform"}}`, references **`Microsoft.Testing.Extensions.CodeCoverage`** at **18.9.0 or later** in place of `coverlet.collector`, whose VSTest data collector MTP ignores without failing, and drops `xunit.runner.visualstudio`, the VSTest adapter MTP replaces. A repo whose test project is not yet MTP-based keeps the VSTest collector and its existing pin on the reusable validator, and that lagging state is a migration still owed rather than drift, until its own bump makes the project MTP-based and forces the move. The version floor is load-bearing rather than cautionary. Below 18.1.0 the extension is built against Microsoft.Testing.Platform 1.x, and an 18.0.x resolution, which is what a `>= 18.0.0` range picks, throws a `TypeLoadException` against the 2.x platform xunit.v3 4.0.0 carries, runs zero tests, and still writes a well-formed Cobertura file reporting full coverage, so only the non-zero exit says the run reported nothing. 18.9.0 is the first release on Microsoft.Testing.Platform 2.3.x, where every test project writes into the one shared `--results-directory` the invocation names rather than resolving that relative path per project. Two details of the invocation are equally load-bearing, and neither failure reds the job on its own. `--coverage-output` stays unset, because pinning one filename gives every test project in the solution the same path and a repo with more than one then keeps only whichever ran last. The default name that produces, `.cobertura.xml`, is in turn one `codecov-cli`'s own file finder does not match, its patterns being `*coverage*.*` and an exact `cobertura.xml`, so the validator prefixes each report to `coverage-.cobertura.xml` before the upload step reads the directory. Where this guarantee does not apply (a `lint-only` profile for that type, per `registry/repos.json`), the hub's `spec/secrets.json` `typeMechanisms` mapping is not claimed for that repo, and the absence is not drift. The repo also ships a **`codecov.yml`** that sets the project and patch statuses to **`informational: true`** so a coverage delta never gates a PR (a distinct knob from `fail_ci_if_error`, which only guards the upload step), and excludes intentionally-untested, non-shipped code (an example/demo or benchmark project) from the coverage denominator via `ignore`. A repo may override this to enforce a coverage threshold where its quality bar requires it. Coverage output is a build artifact, so `.gitignore` excludes it (e.g. `coverage/` and `*.cobertura.xml`, with `.gitignore` the full source of truth) so a blanket `git add -A` won't stage the untracked output. *Prevents: coverage silently going unreported, a test project stranded on a runner the current SDK refuses, a stale and unused token, a coverage regression blocking an unrelated PR, and a coverage artifact committed by a blanket add.* ### D2 - Input/State Validation at Entry diff --git a/codecov.yml b/codecov.yml index d6c803e..63f98e3 100644 --- a/codecov.yml +++ b/codecov.yml @@ -12,5 +12,8 @@ coverage: informational: true # The benchmarks project is a performance harness rather than shipped app code, and is intentionally not unit-tested. +# Microsoft.Testing.Extensions.CodeCoverage also instruments the source generators' output, which lands under obj. +# That output is build product rather than reviewed source, and exists in no checkout Codecov can map a path to. ignore: - "PhotoCleanerBenchmarks/**" + - "**/obj/**" diff --git a/global.json b/global.json new file mode 100644 index 0000000..9009caf --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +}