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"
+ }
+}