Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/merge-bot-pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/publish-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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 }}

Expand All @@ -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).
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/test-pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
.claude

BenchmarkDotNet.Artifacts/

# Coverage output (Microsoft.Testing.Extensions.CodeCoverage)
coverage/
[Tt]est[Rr]esults/
*.cobertura.xml
*.coverage

secrets/
3 changes: 1 addition & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
<PackageVersion Include="AwesomeAssertions" Version="9.6.0" />
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="CliWrap" Version="3.10.5" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.10.0" />
<PackageVersion Include="ptr727.Utilities" Version="4.0.28" />
<PackageVersion Include="Serilog" Version="4.4.0" />
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
Expand All @@ -19,7 +19,6 @@
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
<PackageVersion Include="System.CommandLine" Version="2.0.11" />
<PackageVersion Include="xunit.analyzers" Version="2.0.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="4.0.0" />
<PackageVersion Include="xunit.v3" Version="4.0.0" />
</ItemGroup>
</Project>
5 changes: 3 additions & 2 deletions OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<guid>.cobertura.xml` to `coverage-<guid>.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):

Expand All @@ -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 `<IsTestingPlatformApplication>false</IsTestingPlatformApplication>` 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:

Expand Down Expand Up @@ -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.
10 changes: 3 additions & 7 deletions PhotoCleanerTests/PhotoCleanerTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
<PropertyGroup>
<IsTestProject>true</IsTestProject>
<RootNamespace>PhotoCleanerTests</RootNamespace>
<!-- Keeps `dotnet test` on the VSTest adapter (xunit.runner.visualstudio) instead of MTP's own entry point, which .NET 10 SDK now hard-blocks under classic `dotnet test`; see https://aka.ms/dotnet-test-mtp-error -->
<IsTestingPlatformApplication>false</IsTestingPlatformApplication>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AwesomeAssertions" />
Expand All @@ -13,16 +11,14 @@
<PackageReference Include="CliWrap" />
<PackageReference Include="Serilog" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="coverlet.collector">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<!-- MTP's own coverage extension, driving the coverage collection the hub validator's `dotnet test` step requests; coverlet.collector is a VSTest data collector MTP ignores without failing -->
<!-- PrivateAssets alone, deliberately without the IncludeAssets list xunit.analyzers carries below, since that list omits the compile assets and MTP's generated SelfRegisteredExtensions.cs then fails to build with CS0234 -->
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" PrivateAssets="all" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.analyzers">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../PhotoCleaner/PhotoCleaner.csproj" />
Expand Down
2 changes: 1 addition & 1 deletion WORKFLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<guid>.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-<guid>.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.*
Comment thread
ptr727 marked this conversation as resolved.

### D2 - Input/State Validation at Entry

Expand Down
3 changes: 3 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**"
5 changes: 5 additions & 0 deletions global.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}