Skip to content

🔧 Run GitHub Actions CI on Linux with CLR4 coverage#1686

Closed
angularsen wants to merge 8 commits into
masterfrom
agl-codex/migrate-ci-pipelines-to-github
Closed

🔧 Run GitHub Actions CI on Linux with CLR4 coverage#1686
angularsen wants to merge 8 commits into
masterfrom
agl-codex/migrate-ci-pipelines-to-github

Conversation

@angularsen

@angularsen angularsen commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Summary

  • Move the primary v6 GitHub Actions build and pull-request workflows from Windows to Linux.
  • Build every shipped library target (netstandard2.0, net8.0, net9.0, and net10.0) on Linux, while running the full modern test suite and coverage on net10.0.
  • Add explicit Windows CLR4 compatibility testing for pull requests, pushes to master, and manual runs. The complete suite executes on net48 against the shipped netstandard2.0 assets and contributes separately flagged Codecov coverage.
  • Publish detailed CLR4 test results for ordinary, fork, and Dependabot pull requests through a separate artifact-only workflow_run workflow with narrowly scoped write permission.
  • Make GitHub Actions authoritative for v6 validation and NuGet.org publishing, disabling duplicate Azure Pipeline triggers on master while preserving the branch-specific Azure/nanoFramework pipeline on maintenance/v5.
  • Update README badges, CI documentation, and companion package README metadata.

Why CLR4 testing matters

The library ships netstandard2.0 assets, but the modern Linux suite executes the net10.0 assets. Running the test projects on .NET Framework 4.8 exercises those netstandard2.0 binaries on CLR4, including different conditional compilation paths and runtime behavior. Introducing this explicit coverage has already surfaced compatibility defects that the modern suite did not detect.

Compatibility fixes surfaced by CLR4 testing

  • Use EnumHelper.GetValues<T>() in tests that must compile on .NET Framework 4.8.
  • Fix the non-NET RootN fallback so odd roots of negative values return a real negative result instead of NaN.
  • Correct net48-only test interface and static-abstract compilation paths.
  • Assert ArgumentNullException.ParamName instead of runtime-specific exception messages.

Validation

  • ./Build/build.ps1 passed locally: all shipped targets compiled, 46,257 tests passed on net10.0, 20 skipped, and four coverage reports were generated.
  • The complete CLR4 suite passed locally:
    • UnitsNet: 42,691 passed, 20 skipped
    • NumberExtensions: 1,720 passed
    • NumberExtensions CS14: 1,720 passed
    • Json.NET: 115 passed
  • A CLR4 dotCover probe produced a detailed report containing the instrumented netstandard2.0 UnitsNet assembly.
  • Changed workflows pass actionlint; git diff --check passes.

Deployment notes

  • After merge, remove Azure DevOps UI trigger overrides, schedules, branch policies, and status checks targeting master.
  • Keep the Azure configuration required by maintenance/v5.
  • The automatic CLR4 result-publishing workflow_run can first execute after its workflow exists on the default branch.

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review: CI/build modernization + netstandard2.0/net48 test coverage

This PR is primarily CI/build infrastructure (no new quantities/units, no changes to generated quantity code). Summary below.

Breaking changes

None for library consumers. All changes are internal test/CI/build tooling. The IQuantity.QuantityInfo / IQuantityOfType<T> shapes are untouched — only test scaffolding (HowMuch.cs) was updated to match the pre-existing conditional interface members under !NET.

CI/workflow changes

  • Good cleanup: adding permissions: contents: read and concurrency groups to ci.yml/pr.yml/net48-compatibility.yml are solid security/hygiene wins (least-privilege GITHUB_TOKEN, cancel stale runs).
  • Switching pr.yml/ci.yml build-and-test to ubuntu-latest while keeping a separate scheduled net48-compatibility.yml on windows-latest for the CLR4 matrix is a sensible split.
  • net48-compatibility.yml runs on a schedule + workflow_dispatch only — worth confirming that's intentional (regressions on net48 won't block PRs/CI, only surface weekly or on manual trigger). Given net48's slow pace of change, that tradeoff seems reasonable, but flagging it explicitly.
  • publish-nuget job: the new Azure Artifacts steps are gated with if: env.AZURE_DEVOPS_PAT != '', but the Configure Azure Artifacts step sets source-url and expects NUGET_AUTH_TOKEN env — worth double-checking that actions/setup-dotnet@v6's source-url input actually authenticates NuGet.config with NUGET_AUTH_TOKEN as expected (standard pattern, likely fine, just unexercised until a PAT secret exists).
  • Nice fix: replacing the hand-rolled GPG-verified Codecov download/upload script with codecov/codecov-action@v5 removes a lot of fragile, Windows-specific shell logic.

Code quality / generated code

The only generator change is in CodeGen/Generators/UnitsNetGen/UnitTestBaseClassGenerator.cs:

- var units = Enum.GetValues<{_unitEnumName}>();
+ var units = EnumHelper.GetValues<{_unitEnumName}>();

This is needed because Enum.GetValues<T>() (the generic overload) doesn't exist before .NET 5/net48, and this test now needs to compile under the new net48/netstandard2.0 test targets. The change is applied consistently and mechanically across all ~130 generated *TestsBase.g.cs files — confirmed for a representative sample across quantity kinds: LengthTestsBase.g.cs (ILinearQuantity), TemperatureTestsBase.g.cs (IAffineQuantity), and LevelTestsBase.g.cs (ILogarithmicQuantity) all get the identical one-line substitution. EnumHelper (UnitsNet/InternalHelpers/EnumHelper.cs) is a small internal wrapper that dispatches to Enum.GetValues<TEnum>() on NET7_0_OR_GREATER and falls back to Enum.GetValues(typeof(TEnum)).Cast<TEnum>().ToArray() otherwise — straightforward and correct, and UnitsNet.Tests already has InternalsVisibleTo access.

The same substitution is applied by hand in UnitsNet.Tests/CustomCode/PressureTests.cs (Enum.GetValues<PressureReference>()EnumHelper.GetValues<PressureReference>()).

Potential bug / correctness (worth calling out explicitly)

UnitsNet/Extensions/LogarithmicQuantityExtensions.cs: the !NET fallback for RootN used to be Math.Pow(number, 1.0 / n), which silently returns NaN for negative number since 1.0/n is essentially never an integer exponent. The new fallback special-cases the sign (via BitConverter.DoubleToInt64Bits(number) < 0) and mirrors double.RootN's semantics (real negative root for odd n, NaN for even n). This is a genuine behavior fix, not just a refactor — worth calling out explicitly in the PR description since it changes runtime results on netstandard2.0/net48 for negative inputs to GeometricMean and similar logarithmic-quantity helpers.

Test coverage

  • Good additions: NetStandard20AssetTests.cs (added to UnitsNet.Tests, UnitsNet.NumberExtensions.Tests, UnitsNet.NumberExtensions.CS14.Tests, UnitsNet.Serialization.JsonNet.Tests) assert the referenced assembly's TargetFrameworkAttribute is actually .NETStandard,Version=v2.0 when TestNetStandard20=true — a nice guard against silently testing against the wrong build output.
  • Minor nit: UnitsNet.Tests/LogarithmicQuantityExtensionsTest.cs now has its own private RootN helper (identical to the new prod fallback) instead of calling double.RootN directly, because the test project can now also target net48 where double.RootN doesn't exist. Understandable given the net48 target, but it means the test's expected values are computed with the same formula as production rather than an independent (BCL) reference — if there were ever a subtle bug in the sign-handling, both would agree and the test wouldn't catch it. Not blocking, just worth knowing.
  • The UnitsNetBaseJsonConverterTest/UnitsNetIComparableJsonConverterTest/UnitsNetIQuantityJsonConverterTest changes from asserting the full localized ArgumentNullException.Message string to asserting .ParamName instead are a solid robustness improvement — ArgumentNullException message text has changed across .NET versions/runtimes, so this was likely already a source of cross-TFM flakiness.

Other

  • HowMuch.cs (custom test quantity): the #if !NET guard became #if !NET || TEST_NETSTANDARD20, and the explicit interface member switched from QuantityInfo IQuantity.QuantityInfo => Info; to IQuantityInstanceInfo<HowMuch> IQuantityOfType<HowMuch>.QuantityInfo => Info;. Traced through IQuantity.cs: under !NET, IQuantityOfType<TQuantity> redeclares QuantityInfo as IQuantityInstanceInfo<TQuantity> (not the base QuantityInfo type), so this now matches the actual interface shape produced when UnitsNet.csproj is built for netstandard2.0/net48. Since the test project forces NET to stay defined for its own conditional code while adding TEST_NETSTANDARD20 to select this branch, this correctly mirrors what a real netstandard2.0/net48 consumer would need to implement. Subtle — a short comment explaining why TEST_NETSTANDARD20 needs to be OR'd in here (despite NET being simultaneously defined in the test project) would help future readers.
  • README.md/GITHUB_ACTIONS_MIGRATION_README.md updates clearly describe the maintenance/v5 (Azure DevOps, nanoFramework) vs master (GitHub Actions v6) split.

Overall: a large but low-risk, well-scoped infra PR. The one substantive behavior change (RootN negative-number fallback) should be called out explicitly in the PR description/changelog since it's a real bugfix bundled into what's otherwise framed as a CI migration.


Reviewed by Claude

@angularsen
angularsen force-pushed the agl-codex/migrate-ci-pipelines-to-github branch from bfdb66b to cb9689b Compare July 20, 2026 18:42
@angularsen angularsen changed the title Modernize GitHub Actions CI for cross-platform builds and publishing 🔧ci: Cross-platform GitHub Actions CI Jul 20, 2026
@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review: #1686 — Cross-platform GitHub Actions CI

This PR migrates CI from Azure Pipelines to GitHub Actions (Linux runners), adds a scheduled .NET Framework 4.8 compatibility workflow, and adds netstandard2.0 asset test coverage. No unit/quantity definitions are touched. Overall solid and well-scoped; a few items below.

Breaking changes

None for library consumers — this is CI/build/test infrastructure only. The public API surface is unchanged. The HowMuch.cs conditional-compilation tweak (UnitsNet.Tests/CustomQuantities/HowMuch.cs) only adjusts which explicit interface members a test-only custom quantity implements under !NET || TEST_NETSTANDARD20, and both IQuantityOfType<T>/IQuantityInstanceInfo<T> already exist in UnitsNet/IQuantity.cs / IQuantityInfo.cs, so it's just adapting to an existing interface, not introducing one.

Generated code changes

CodeGen/Generators/UnitsNetGen/UnitTestBaseClassGenerator.cs swaps Enum.GetValues<TUnit>() for EnumHelper.GetValues<TUnit>() in the HasAtLeastOneAbbreviationSpecified test. This is a correct, necessary fix: Enum.GetValues<T>() requires .NET 7+, and this PR is what first exercises the generated tests against netstandard2.0 and net48 targets. The change is applied identically and mechanically across all ~130 regenerated *TestsBase.g.cs files regardless of quantity kind — confirmed the same one-line diff in LengthTestsBase.g.cs (linear), TemperatureTestsBase.g.cs (affine), and LevelTestsBase.g.cs (logarithmic). Since EnumHelper already existed in UnitsNet/InternalHelpers/EnumHelper.cs, this is just closing a gap where the generator hadn't been updated to use it. Good, low-risk change.

Potential bug: Azure Artifacts push likely fails silently on missing auth

In .github/workflows/ci.yml, the publish-nuget job:

- name: Configure Azure Artifacts
  if: env.AZURE_DEVOPS_PAT != ''
  uses: actions/setup-dotnet@v6
  with:
    source-url: https://pkgs.dev.azure.com/...
  env:
    NUGET_AUTH_TOKEN: ${{ secrets.AZURE_DEVOPS_PAT }}

- name: Push to Azure Artifacts
  if: env.AZURE_DEVOPS_PAT != ''
  run: dotnet nuget push "**/*.nupkg" --skip-duplicate --api-key AzureArtifacts
  working-directory: nugets

actions/setup-dotnet's source-url writes a NuGet.Config entry that references %NUGET_AUTH_TOKEN% at usage time, not embedding the secret value at config-generation time — per the action's docs, NUGET_AUTH_TOKEN needs to be set in every subsequent step that touches that source. The "Push to Azure Artifacts" step has no env: block, so NUGET_AUTH_TOKEN is unset there and the push will likely fail with a 401. Since this only gates the optional Azure Artifacts mirror (guarded by AZURE_DEVOPS_PAT being present) and runs after the nuget.org push, it won't block the primary release, but it should be fixed by adding the same env: NUGET_AUTH_TOKEN: ${{ secrets.AZURE_DEVOPS_PAT }} to the push step.

Style/conventions

  • Good practice: Push to nuget.org now passes the secret via env: NUGET_ORG_APIKEY and references it as "$NUGET_ORG_APIKEY" in the run: block instead of interpolating ${{ secrets.* }} directly into the shell command — avoids the secret ending up literally in the process/command line and is the recommended GitHub Actions pattern.
  • permissions: contents: read (plus checks: write where needed) and concurrency groups with cancel-in-progress: true are sensible additions that weren't there before.
  • The pr.yml "Publish test results" step now gates on github.event.pull_request.head.repo.full_name == github.repository, correctly skipping check-run publishing for fork PRs where the default GITHUB_TOKEN lacks write permission — good fix, avoids spurious CI failures for external contributors.
  • Build/build-functions.psm1 and Build/init.ps1 switching from "$root\path" string concatenation to Join-Path is a nice cross-platform correctness improvement (needed now that the build also runs on Linux runners), and the OS-conditional reportgenerator/reportgenerator.exe binary name is handled correctly.

Bug fix bundled into this PR

UnitsNet/Extensions/LogarithmicQuantityExtensions.cs's netstandard2.0/net48 fallback for RootN previously did Math.Pow(number, 1.0 / n), which returns NaN for a negative base with fractional exponent (e.g. cube root of -8). The new implementation special-cases negatives correctly (odd root of negative → negate the root of the absolute value; even root of negative → NaN), matching double.RootN's actual .NET semantics. This is a legitimate, welcome bug fix, though it's somewhat orthogonal to a "CI migration" PR — worth calling out in the PR description since it changes runtime behavior for consumers still on net48/netstandard2.0, not just test/CI infra. The test file duplicates the same logic as a private helper rather than reusing the (rightly) private implementation — acceptable given visibility constraints, but worth a comment noting the duplication is intentional so it doesn't drift silently if the source implementation changes again.

Test coverage

The new NetStandard20AssetTests.cs (added identically to 4 test projects) is a nice sanity check that netstandard2.0 project references actually resolve to the netstandard2.0 asset rather than silently falling back to another TFM. The exception-assertion changes in the JsonNet serializer tests (Assert.Equal(paramName, exception.ParamName) instead of matching the full exception message) are a good robustness improvement — ArgumentNullException.Message text is runtime/culture-dependent and this decouples the tests from that.

Minor

  • Build/build-functions.psm1's new netstandard2.0 test loop re-runs the full test suite for all 4 test projects a second time (--framework net8.0 -p:TestNetStandard20=true), roughly doubling local/CI build script test time. Intentional given the goal, but worth confirming CI duration is acceptable.
  • Several third-party action versions are bumped to majors newer than what's publicly available as of my knowledge cutoff (actions/checkout@v7, actions/setup-dotnet@v6, actions/upload-artifact@v7, actions/download-artifact@v8). I can't verify these exist — worth double-checking they resolve, since a bad tag would fail the workflow outright.

No security concerns at medium+ severity, and no new quantities/units in this PR so .agents/criteria-for-adding-quantities-and-units.md doesn't apply.

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review

Reviewed the CI/build migration to GitHub Actions plus the incidental code changes it carries along. Overall this is a well-scoped, carefully executed infra PR — no new quantities/units, and the generated-code diff is mechanical and consistent everywhere.

Breaking changes

None for library consumers. All changes are to CI, build scripts, test infrastructure, and test-only code. The one production source change (UnitsNet/Extensions/LogarithmicQuantityExtensions.cs) only affects the #if !NET fallback path (netstandard2.0/net48 builds), and is a behavior fix, not a break (see below).

Possible doc/workflow mismatch

The PR description says the net48 workflow will "run the complete suite weekly (and on demand) on Windows/CLR4", but .github/workflows/net48-compatibility.yml only defines:

on:
  push:
    branches:
      - master
  workflow_dispatch:

There's no schedule:/cron trigger. The updated README.md text ("...runs the complete test suite on CLR4 after every push to master...") matches the actual workflow, but not the PR description. Worth confirming whether a weekly cron was intended and simply dropped, or whether the PR description is just stale.

Changes to generated code

CodeGen/Generators/UnitsNetGen/UnitTestBaseClassGenerator.cs swaps one remaining Enum.GetValues<{_unitEnumName}>() call for the existing EnumHelper.GetValues<{_unitEnumName}>() helper (already used elsewhere in the same generator, e.g. line 327/355) — this is what fans out into the ~130 one-line diffs across UnitsNet.Tests/GeneratedCode/TestsBase/*.g.cs (verified in LengthTestsBase.g.cs, TemperatureTestsBase.g.cs, LevelTestsBase.g.cs — all identical mechanical change). Good, low-risk consistency cleanup; presumably needed since Enum.GetValues<T>() isn't available when the test host runs against a netstandard2.0-targeted UnitsNet.dll reference (the new TestNetStandard20 scenario), whereas EnumHelper.GetValues<T>() already handles that.

Incidental bug fix bundled in a CI PR

LogarithmicQuantityExtensions.RootN's non-NET fallback changed from:

return Math.Pow(number, 1.0 / n);

to a version that special-cases negative number with odd n (previously Math.Pow(-8, 1.0/3) returns NaN instead of -2). This is a legitimate bug fix, but it's scope creep for a PR framed as CI migration — worth calling out explicitly in the PR description/changelog since it changes runtime behavior on net48/netstandard2.0 targets. Also note UnitsNet.Tests/LogarithmicQuantityExtensionsTest.cs now duplicates this exact algorithm as a private RootN test helper (replacing calls to double.RootN) so expected values match the library's manual-fallback math bit-for-bit rather than the BCL's implementation. That's a reasonable reason to duplicate it, but it does mean the two copies must be kept in sync by hand if the algorithm changes again — maybe worth a comment linking them, or extracting to a shared internal helper both prod and test code call into.

Good, non-obvious fixes worth highlighting

  • UnitsNet.Serialization.JsonNet.Tests/*.cs: replacing Assert.Equal("Value cannot be null. (Parameter 'x')", ex.Message) with Assert.Equal("x", ex.ParamName) is the right call — ArgumentNullException.Message text differs across runtimes/frameworks (and locales), so asserting on Message was already fragile and would likely have broken under the new net48/netstandard2.0 CI legs.
  • pr.yml's "Publish test results" step now gates on github.event.pull_request.head.repo.full_name == github.repository before invoking EnricoMi/publish-unit-test-result-action. Good — avoids failing/needing elevated permissions for forked-repo PRs.
  • ci.yml/pr.yml now set permissions: contents: read (plus checks: write on pr.yml) explicitly rather than relying on default token permissions — good least-privilege practice.

Style/consistency nit

UnitsNet.Tests/CustomQuantities/HowMuch.cs's explicit interface implementation was updated from #if !NET to #if !NET || TEST_NETSTANDARD20, changing the member from QuantityInfo IQuantity.QuantityInfo to IQuantityInstanceInfo<HowMuch> IQuantityOfType<HowMuch>.QuantityInfo => Info;. This looks correct (matches the interface actually needed on non-static-abstract-member targets) but it's a fairly quiet fix bundled with the CI plumbing — a one-line comment on why this shape changed would help future readers who aren't tracing through the IQuantityOfType<T> interface hierarchy.

Test coverage

Nice addition: NetStandard20AssetTests.cs (added to UnitsNet.Tests, UnitsNet.NumberExtensions(.CS14).Tests, UnitsNet.Serialization.JsonNet.Tests) asserts via TargetFrameworkAttribute that the referenced assembly really is the netstandard2.0 build when TEST_NETSTANDARD20 is set — a good guard against the SetTargetFramework project-reference trick silently no-op'ing and giving false confidence that the netstandard2.0 asset was exercised.

Action version pins

actions/checkout@v7, actions/setup-dotnet@v6, actions/upload-artifact@v7, actions/download-artifact@v8, codecov/codecov-action@v5 — please double check these major versions are actually published/stable at merge time; if any of these are wrong/nonexistent the workflow will fail outright on the next run.

Nothing here blocks merging — mostly a couple of things to double-check (the missing cron trigger, action version pins) and two spots where non-CI behavior changes ride along and would benefit from a one-line callout in the PR description or a code comment.

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review: #1686 — Cross-platform GitHub Actions CI

This is a CI/infra migration PR (158 files, mostly workflows + mechanical generated-test changes). Here's my review against the usual checklist.

Breaking changes

None to the public API. One behavior-relevant fix worth calling out: UnitsNet/Extensions/LogarithmicQuantityExtensions.cs — the netstandard2.0/net48 fallback for RootN previously did Math.Pow(number, 1.0/n), which returns NaN for negative number even with an odd n (e.g. cube root of -8). The new fallback special-cases sign/even-n to match double.RootN semantics used on net. This is a genuine bug fix that changes output for netstandard2.0/net48 consumers going from wrong (NaN) to correct — good catch, but worth a mention in the changelog since it's a behavior change, not pure CI plumbing.

New quantities/units

None added — not applicable here.

Generated code

Only one generator line changed, in CodeGen/Generators/UnitsNetGen/UnitTestBaseClassGenerator.cs:
```csharp

  • var units = Enum.GetValues<{_unitEnumName}>();
  • var units = EnumHelper.GetValues<{_unitEnumName}>();
    ```
    This ripples mechanically through ~130 *TestsBase.g.cs files (e.g. LengthTestsBase.g.cs, TemperatureTestsBase.g.cs, LevelTestsBase.g.cs all get the identical one-line change). Makes sense: the generic Enum.GetValues<T>() overload is only available under NET7_0_OR_GREATER, and EnumHelper.GetValues<T>() already has the non-generic fallback needed to compile the test suite for net48. Clean, consistent, no logic change for the runtimes that already supported the generic overload.

Code quality / style

  • Build/build-functions.psm1, Build/init.ps1, Build/build.ps1: good cross-plat cleanup — Join-Path instead of hardcoded \ separators, and an OS-aware reportgenerator/reportgenerator.exe resolution. Idiomatic PowerShell.
  • .csproj conditionals consistently gate net48 behind '$(IncludeNet48)' == 'true' and $([MSBuild]::IsOSPlatform('Windows')), so a plain dotnet build on Linux (or without the flag) doesn't break — good guard.
  • New NetStandard20AssetTests.cs in each test project (UnitsNet.Tests, NumberExtensions(.CS14), JsonNet) asserts the referenced assembly's TargetFrameworkAttribute is exactly .NETStandard,Version=v2.0 — a nice guard rail to make sure the "test the shipped netstandard2.0 asset" mechanism (SetTargetFramework=...netstandard2.0 + TestNetStandard20/TEST_NETSTANDARD20) is actually exercising what it claims to.

Potential bugs / things to double-check

  • Several action versions jump noticeably (actions/checkout@v7, actions/upload-artifact@v7, actions/download-artifact@v8, actions/setup-dotnet@v6, codecov/codecov-action@v5). Worth a final sanity check that these tags actually exist/are stable at merge time — a typo'd/nonexistent tag would hard-fail every run.
  • ci.yml/pr.yml add concurrency: { cancel-in-progress: true }. For pr.yml that's clearly fine; for ci.yml (push-triggered, includes the publish-nuget job) a rapid second push to master could in theory cancel an in-flight dotnet nuget push. Low risk in practice given push cadence to master, but worth knowing it's possible.
  • Build/build-functions.psm1 now runs the full test matrix twice (once normal, once with -p:TestNetStandard20=true against net8.0 host) and merges both sets of *.coverage.xml into one HtmlSummary — coverage numbers will reflect two runs of the same tests against different assemblies. Probably intentional/harmless, but flagging in case a specific per-target coverage report was expected instead.

Security

Net positive:

  • New permissions: contents: read (+ checks: write where needed) on all three workflows — good move to least-privilege, workflows previously ran with default (often broader) token permissions.
  • NuGet publish step now passes the API key via env: NUGET_ORG_APIKEY and "$NUGET_ORG_APIKEY" in the script rather than interpolating ${{ secrets.NUGET_ORG_APIKEY }} directly into the run body — reduces risk of the secret leaking into logs or being affected by shell-injection via the interpolation.
  • pr.yml's "Publish test results" step is now gated on github.event.pull_request.head.repo.full_name == github.repository, correctly skipping the step (and its checks: write usage) for PRs from forks.
  • The old hand-rolled codecov download + GPG-verify shell script is replaced with the official codecov/codecov-action@v5 — removes a chunk of custom trust logic in favor of a maintained action, generally a good security simplification.

Test coverage

Good — the NetStandard20AssetTests additions are a solid, minimal-footprint way to assert the CI is actually exercising the shipped netstandard2.0 binaries rather than accidentally testing the higher-TFM build. The RootN fallback fix would benefit from an explicit negative-odd-root test case in LogarithmicQuantityExtensionsTest.cs (currently the test-local RootN helper mirrors the fix but I didn't see a case asserting the negative-input behavior itself).

Overall this looks like a well-executed, low-risk infra migration with one nice incidental bug fix. Nothing blocking.

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review: 🔧ci: Cross-platform GitHub Actions CI

This is a pure CI/build-infra PR — no new quantities/units, no public API surface changes. Overall solid work; the netstandard2.0-asset testing is a genuine improvement that already found (and fixed) a real bug. A few things worth a look before merging.

Highlights

  • UnitsNet/Extensions/LogarithmicQuantityExtensions.cs: the non-NET RootN fallback previously did Math.Pow(number, 1.0/n), which returns NaN for negative number with odd n (e.g. cube root of −8). The new fallback special-cases the sign and is correct. This bug was latent because CI never actually executed the shipped netstandard2.0/net48 assemblies before — nice catch, and a good justification for the new TestNetStandard20 test mode.
  • Same root cause fixed in UnitsNet.Tests/CustomQuantities/HowMuch.cs — its #if !NET explicit interface implementation (QuantityInfo IQuantity.QuantityInfo) no longer matched the current IQuantityOfType<TQuantity> interface shape and would have failed to compile for netstandard2.0/net48. Also effectively "dead" until this PR's coverage made it live.
  • CodeGen/Generators/UnitsNetGen/UnitTestBaseClassGenerator.cs: Enum.GetValues<T>()EnumHelper.GetValues<T>() in the generated HasAtLeastOneAbbreviationSpecified() test. Enum.GetValues<T>() is netstandard2.1+/.NET 5+ only, so this was blocking net48/netstandard2.0 compilation. Verified the regenerated .g.cs diffs are uniform across Length, Temperature, and Level test bases (and consistent with the two other pre-existing EnumHelper.GetValues<T>() call sites in the generator) — this is the single source-of-truth change producing all ~130 mechanical .g.cs file diffs.
  • Switching Assert.Equal("Value cannot be null. (Parameter 'x')", ex.Message) to Assert.Equal("x", ex.ParamName) in the JsonNet tests is a good, low-risk fix — ArgumentNullException.Message wording differs across runtimes/frameworks and shouldn't be pinned in tests.
  • Build/build-functions.psm1 / init.ps1: Join-Path instead of hardcoded \-based paths, and a conditional reportgenerator/reportgenerator.exe tool name — correct fix for cross-platform (Linux) execution.
  • Azure Pipelines is cleanly neutered for master (trigger: none, pr: none, no-op job) while explicitly preserving maintenance/v5's pipeline — matches the stated migration intent.

Questions / possible issues

  1. Pinned Action versionsactions/checkout@v7, actions/upload-artifact@v7, actions/download-artifact@v8, actions/setup-dotnet@v6 are newer major versions than I could verify from this environment (no outbound web access). Worth a final sanity check that these tags actually exist before merge, since a bad tag fails the whole workflow immediately.
  2. UnitsNet.Tests/LogarithmicQuantityExtensionsTest.cs — the test now hand-duplicates the exact fallback algorithm from LogarithmicQuantityExtensions.RootN (sign-handling included) into a private RootN in the test file, instead of using double.RootN (available on all target frameworks here, since the test project itself always targets net8.0+ even when exercising the netstandard2.0 asset). This makes the assertion closer to "the same formula equals itself" than an independent correctness check. Not a blocker, but consider keeping double.RootN as the reference value, or at least a comment explaining why it was inlined.
  3. net48-compatibility.yml publishes results via EnricoMi/publish-unit-test-result-action/windows@v2, a third-party action — same version-pin caveat as Added short and long tons. #1, and it's a supply-chain dependency worth being deliberate about (seems pre-existing though, not new to this PR for the Windows variant).
  4. Minor: codecov/codecov-action@v5 in pr.yml has fail_ci_if_error: false while ci.yml has fail_ci_if_error: true — intentional (don't want flaky Codecov uploads to block/red PR checks, but do want it strict on master)? Worth confirming that's deliberate rather than a copy-paste inconsistency.

Test coverage

The new NetStandard20AssetTests.cs files (asserting TargetFrameworkAttribute.FrameworkName == ".NETStandard,Version=v2.0") are a nice guardrail to ensure the "exact shipped asset" test mode is actually wired correctly and not silently falling back to the net8.0 build.

Style/conventions

No violations of AGENTS.md conventions spotted — no unit/quantity JSON changes, generated code changes trace back to a single generator source change as expected, file headers preserved on new files.

No security concerns beyond the general secrets-handling note above (all secret usage looks conventional — env-var indirection into dotnet nuget push, no pull_request_target, no secrets exposed to fork PR runs).

🤖 Generated with Claude Code

@angularsen
angularsen force-pushed the agl-codex/migrate-ci-pipelines-to-github branch from 551886b to 5f0521f Compare July 21, 2026 09:41
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review: #1686 — Cross-platform GitHub Actions CI

This is a CI/build-infrastructure PR with no changes to unit definitions or public API surface.

Breaking changes

None. No Common/UnitDefinitions/*.json changes, no public API changes. All source edits are to workflows, PowerShell build scripts, and test code (mostly test-only conditional compilation plus one internal-helper swap).

New quantities or units

None added.

Generated code changes

Only one generator template line changed, in CodeGen/Generators/UnitsNetGen/UnitTestBaseClassGenerator.cs:

-            var units = Enum.GetValues<{_unitEnumName}>();
+            var units = EnumHelper.GetValues<{_unitEnumName}>();

This is mechanically applied to every *TestsBase.g.cs file's HasAtLeastOneAbbreviationSpecified() test — confirmed consistent across LengthTestsBase, TemperatureTestsBase, LevelTestsBase, and the rest of the ~130 generated test files, so codegen was clearly re-run correctly and matches the generator template.

EnumHelper.GetValues<T>() (UnitsNet/InternalHelpers/EnumHelper.cs) wraps Enum.GetValues<T>() on NET7_0_OR_GREATER and falls back to Enum.GetValues(typeof(T)).Cast<T>().ToArray() otherwise. Since the test host itself always runs on net8.0+ regardless of the TEST_NETSTANDARD20 flag, this swap doesn't change the test's actual behavior — it's purely for consistency with the internal helper used elsewhere in the library. Harmless, but worth a one-line mention in the PR description for readers wondering why this line churns across 130 files in an otherwise CI-only PR.

Code quality / best practices

  • The netstandard2.0 asset-testing approach (loading the shipped netstandard2.0 binaries into a net8.0 test host via SetTargetFramework + TestNetStandard20 MSBuild property + DisableImplicitFrameworkDefines) is a clever way to get real coverage of the netstandard2.0 fallback code paths without needing a runtime that can host netstandard2.0 directly. The new NetStandard20AssetTests.TestsReferenceNetStandard20Asset tests, which assert on TargetFrameworkAttribute, are a good safety net against the project-reference override silently failing to apply.
  • Build/build-functions.psm1 / init.ps1 cross-platform fixes (Join-Path instead of hardcoded \, OS-conditional reportgenerator executable name) look correct and necessary for Linux runners.
  • Good secret-handling improvement in ci.yml: the NuGet push step now passes the API key via env: + shell variable interpolation ("$NUGET_ORG_APIKEY") instead of embedding ${{ secrets.NUGET_ORG_APIKEY }} directly in the run: script, reducing the risk of the secret leaking into logs.
  • Added permissions: blocks (contents: read, checks: write where needed) and concurrency groups to all three workflows — solid hardening and avoids redundant concurrent runs.
  • pr.yml's test-result publishing step now gates on github.event.pull_request.head.repo.full_name == github.repository, avoiding failures on forked PRs where the action lacks write access. Good defensive addition.
  • Worth a second look: ci.yml's concurrency: { group: ci-..., cancel-in-progress: true } applies to the whole workflow, including the publish-nuget job. If two pushes land on master in quick succession, an in-flight NuGet publish could in theory be cancelled mid-push by the newer run. Likely an acceptable edge case, but worth confirming it's a conscious choice.

Test coverage

  • Good addition: NetStandard20AssetTests across UnitsNet.NumberExtensions.Tests, UnitsNet.NumberExtensions.CS14.Tests, and UnitsNet.Serialization.JsonNet.Tests (mirroring the pre-existing one in UnitsNet.Tests), verifying the loaded assembly really is the netstandard2.0 build so a broken SetTargetFramework override fails loudly instead of silently testing the wrong assembly.
  • The ArgumentNullException assertions were changed from matching the full CLR-generated message ("Value cannot be null. (Parameter 'x')") to just asserting ParamName. Good robustness fix — that message text differs between .NET Framework and modern .NET/netstandard2.0 runtimes, which would otherwise make these tests brittle across the new multi-TFM matrix.
  • IQuantityTests.StaticAbstract_Info_ReturnsSameAsTypedInfo is correctly excluded (#if NET && !TEST_NETSTANDARD20) when running against the netstandard2.0 asset, since static abstract interface members aren't available there.

Potential issues / nits

  • GITHUB_ACTIONS_MIGRATION_README.md reads as ongoing CI documentation now rather than a one-time migration note, but its own "Migration notes" section implies it (or parts of it) should be cleaned up/removed post-merge. Consider moving durable parts into Docs/ so this doesn't go stale as a forgotten root-level file.

Overall this is a well-scoped, carefully validated infra migration — the author reports both Linux netstandard2.0 and Windows net48 suites passing, plus actionlint and git diff --check. No blocking concerns from this review.

@angularsen
angularsen force-pushed the agl-codex/migrate-ci-pipelines-to-github branch from 5f0521f to 42fc5c3 Compare July 21, 2026 09:49
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review: #1686 — Cross-platform GitHub Actions CI

This is an infra-only PR (no new quantities/units, no changes to the shipped public API), so I focused on the CI/build changes, the generated-code template change, and a couple of incidental fixes bundled in.

Breaking changes

None for consumers of the NuGet packages. All production changes are either CI/build tooling or #if !NET polyfill internals (LogarithmicQuantityExtensions.RootN) whose observable behavior only improves (see below).

Changes to generated code

CodeGen/Generators/UnitsNetGen/UnitTestBaseClassGenerator.cs swaps the HasAtLeastOneAbbreviationSpecified test template from Enum.GetValues<T>() to EnumHelper.GetValues<T>(), which cascades into all ~130 *TestsBase.g.cs files (e.g. LengthTestsBase.g.cs, TemperatureTestsBase.g.cs, LevelTestsBase.g.cs all get the identical one-line swap). This is necessary because Enum.GetValues<TEnum>() is only available under NET7_0_OR_GREATER, and this PR makes net48 a real (opt-in) test target again — EnumHelper.GetValues<T>() (UnitsNet/InternalHelpers/EnumHelper.cs) already had a net48-safe fallback and is InternalsVisibleTo-exposed to the test assembly, so this is the correct, minimal fix rather than a hand-rolled duplicate. Good use of existing infra.

Code quality / good practices

  • concurrency: groups added to both ci.yml and pr.yml to cancel superseded runs, and explicit permissions: blocks scoped to least privilege (contents: read, plus id-token: write/checks: write only where needed) — nice hardening.
  • Codecov upload now goes through codecov/codecov-action@v6 with use_oidc: true instead of the old hand-rolled script that downloaded the codecov binary, imported a GPG key, and verified a SHA256SUM by string comparison. This removes a chunk of security-sensitive shell logic and a long-lived CODECOV_TOKEN dependency for the public-repo path — good simplification and reduces attack surface.
  • if-no-files-found: error added to the nuget-packages artifact upload in ci.yml — catches a silently-empty pack step.
  • The publish-unit-test-result-action step is now gated on github.event.pull_request.head.repo.full_name == github.repository, which avoids the well-known failure mode of that action lacking permissions on fork PRs. Good defensive addition.
  • Build/build-functions.psm1 / init.ps1 / build.ps1 moving from "$root\..." string concatenation to Join-Path is the right way to make the PowerShell scripts cross-platform.

Minor suggestions (non-blocking)

  • Third-party actions (EnricoMi/publish-unit-test-result-action@v2, codecov/codecov-action@v6) are pinned by floating major-version tag rather than commit SHA. Since these run with checks: write / id-token: write, consider pinning by SHA for supply-chain hardening — optional given this is a well-known, actively maintained action, but worth considering given the CI now also has id-token: write for OIDC.
  • UnitsNet.Tests/LogarithmicQuantityExtensionsTest.cs adds a private RootN helper that's a verbatim copy of the !NET fallback in UnitsNet/Extensions/LogarithmicQuantityExtensions.cs (needed because double.RootN doesn't exist on net48, and the production method is private). Understandable given InternalsVisibleTo doesn't help with private members, but flagging the duplication in case a future change to the polyfill's edge-case handling needs to stay in sync in two places.
  • UnitsNet.Tests/CustomQuantities/HowMuch.cs changes the explicit interface implementation from IQuantity.QuantityInfo to IQuantityInstanceInfo<HowMuch> IQuantityOfType<HowMuch>.QuantityInfo — this matches the shape already used by all generated quantities (e.g. Density.g.cs), so it looks like a necessary straggler fix to keep the hand-written custom-quantity test fixture compiling, rather than something CI-related. Worth a one-line mention in the PR description since it's a substantive (if small) code change riding along in an otherwise infra-only PR.

Bug fix bundled in (worth calling out)

LogarithmicQuantityExtensions.cs's !NET RootN fallback previously did Math.Pow(number, 1.0 / n), which returns NaN for a negative number with odd n (e.g. cube root of -8). The new implementation handles the sign explicitly and matches double.RootN's behavior on modern .NET. This is a genuine correctness fix for the netstandard2.0/net48 code path, not just a CI change — good catch, and nice that the new net48-compatibility.yml workflow will now actually exercise this path again.

Test coverage

Not reduced. Moving the full net48 suite to run post-merge (net48-compatibility.yml, triggered on push to master, not on PRs) trades slightly slower feedback on net48 regressions for much faster PR CI — a reasonable and clearly-documented trade-off (comment in the workflow file explains it and offers the escape hatch of adding a pull_request trigger if regressions start slipping through).

Security

No concerns at or above medium severity. The removal of the manual GPG/SHA256 verification script in favor of the official Codecov action + OIDC is a net improvement, not a regression. permissions: blocks look correctly minimized per job.

Overall: a well-structured, security-conscious CI migration. Nothing blocking; the suggestions above are all optional polish.

@codecov

codecov Bot commented Jul 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96%. Comparing base (64b4564) to head (b430a7f).
⚠️ Report is 8 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master   #1686    +/-   ##
=======================================
- Coverage      96%     96%    -1%     
=======================================
  Files         454     456     +2     
  Lines       29497   30107   +610     
=======================================
+ Hits        28459   29043   +584     
- Misses       1038    1064    +26     
Flag Coverage Δ
net48 96% <ø> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review: CI migration to GitHub Actions (#1686)

This is a CI/build infrastructure PR — no new quantities/units, and only one line of generated-code-generator change, so I'll focus feedback there.

Breaking changes

None for library consumers. This only touches build/CI infra, test projects, and one internal test-generator template. No changes to shipped UnitsNet/GeneratedCode or public APIs.

Generated code / code generator changes

CodeGen/Generators/UnitsNetGen/UnitTestBaseClassGenerator.cs swaps Enum.GetValues<{_unitEnumName}>()EnumHelper.GetValues<{_unitEnumName}>() in the generated HasAtLeastOneAbbreviationSpecified test, propagating to all ~130 *TestsBase.g.cs files plus the hand-written PressureTests.cs. This is correct: Enum.GetValues<TEnum>() isn't available pre-.NET 5, and UnitsNet/InternalHelpers/EnumHelper.cs already has the right shim (Enum.GetValues(typeof(TEnum)).Cast<TEnum>() for older TFMs). I checked for other Enum.GetValues< call sites and this is the only one — no missed spots. Good, mechanical, low-risk change tied directly to the goal of actually running the net48/netstandard2.0 test suite in CI.

Other correctness fixes bundled in

A couple of real fixes travel with the CI migration commit:

  • UnitsNet/Extensions/LogarithmicQuantityExtensions.cs: the #else (non-NET) fallback for RootN previously did Math.Pow(number, 1.0/n), which returns NaN for negative bases with odd n (e.g. cube root of −8) instead of matching double.RootN's real-valued result. Now handles sign explicitly. Good catch — but note UnitsNet.Tests/LogarithmicQuantityExtensionsTest.cs duplicates this exact fixed logic as a private RootN test helper (since it can't call the library's private method and double.RootN isn't available pre-.NET 7 either). Minor duplication; not worth blocking on, but a comment noting it mirrors the extension's fallback would help future readers avoid divergence.
  • UnitsNet.Tests/CustomQuantities/HowMuch.cs: the explicit !NET interface implementation was fixed from QuantityInfo IQuantity.QuantityInfo to IQuantityInstanceInfo<HowMuch> IQuantityOfType<HowMuch>.QuantityInfo, matching the actual IQuantityOfType<TQuantity> interface shape in IQuantity.cs. Looks like this was silently wrong before and only surfaces once net48 compiles this file for real.
  • Two ArgumentNullException message assertions changed from matching the full localized .Message to just .ParamName — more robust across runtimes/cultures, good change.

CI/build workflow changes

  • Moving ci.yml/pr.yml build+test to ubuntu-latest, keeping only net48 on a separate windows-latest post-merge workflow (net48-compatibility.yml, push-to-master only, not on PRs) is a sensible split — it keeps PR feedback fast while still validating the netstandard2.0 assets on real CLR4 after merge.
  • Replacing the hand-rolled Codecov binary download/GPG-verify script with codecov/codecov-action@v6 + OIDC (use_oidc: true) is a nice security/maintenance win (removes a chunk of ad-hoc supply-chain-sensitive script, no more static CODECOV_TOKEN needed for the public repo case).
  • Added concurrency groups (cancel-in-progress) and least-privilege permissions: blocks to both workflows — good practice.
  • pr.yml's "Publish test results" step is correctly gated on github.event.pull_request.head.repo.full_name == github.repository, avoiding failures for forked-repo PRs that can't get a checks: write token.
  • Azure Pipelines files are reduced to inert trigger: none / pr: none stubs with a notice — reasonable interim approach; the PR description already calls out the manual follow-up needed (removing Azure DevOps UI-level triggers/branch policies) since those can't be expressed in YAML alone.
  • Build/build-functions.psm1 / init.ps1 switch to Join-Path and resolve reportgenerator vs reportgenerator.exe by platform — correctly makes the build scripts run on Linux pwsh, matching the new ubuntu-latest runners.
  • csproj TargetFrameworks conditionally add net48 only when IncludeNet48=true and on Windows — keeps Linux builds from trying (and failing) to target net48, while the Windows workflow explicitly opts in via -p:IncludeNet48=true. UnitsNet.Benchmark.csproj gets the same Windows-only treatureatment for its net48 target.

Test coverage

UnitsNet.Tests/CustomCode/IQuantityTests.cs wraps StaticAbstract_Info_ReturnsSameAsTypedInfo in #if NET, since it exercises static-abstract-interface-member access that isn't available under net48 — correctly scoped rather than deleted.

Nits

  • UnitsNet.Benchmark.csproj/test csproj comment: "treatureatment" typo above is mine, ignore — no actual typos spotted in the diff itself.
  • Consider whether the duplicated RootN test helper (mentioned above) should instead expose the library's RootN via [InternalsVisibleTo] to avoid drift between the real implementation and the test's copy — optional, low priority.

Overall this is a well-scoped, well-tested infra migration with good validation notes in the PR description (test counts on both Linux and Windows net48). No concerns blocking merge from my side.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Correction to my review above: the "Nits" section had a stray typo ("treatureatment") — meant to say the Benchmark csproj's Windows-only net48 conditional is consistent with the other test csproj changes. No other typos found in the diff; review substance stands.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review: 🔧ci: Cross-platform GitHub Actions CI

This is primarily a CI/build infrastructure PR (153 files, but ~128 are a single mechanical line change repeated across generated test files). Overall it's a well-scoped, well-documented migration with good validation evidence in the PR description.

Breaking changes

None to the shipped library's public API. UnitsNet/UnitsNet.csproj and other library projects are untouched; the only production code change is in UnitsNet/Extensions/LogarithmicQuantityExtensions.cs (see below), which is a private helper, not public surface.

Not a new quantity/unit PR

No changes under Common/UnitDefinitions/, so the criteria in .agents/criteria-for-adding-quantities-and-units.md don't apply here.

Changes to generated code

  • CodeGen/Generators/UnitsNetGen/UnitTestBaseClassGenerator.cs changes one line: Enum.GetValues<{_unitEnumName}>()EnumHelper.GetValues<{_unitEnumName}>() in the HasAtLeastOneAbbreviationSpecified test. This propagates correctly and consistently to all 128 generated *TestsBase.g.cs files (verified — no stragglers left on the old pattern). EnumHelper.GetValues<T> (pre-existing in UnitsNet/InternalHelpers/EnumHelper.cs) falls back to Enum.GetValues(typeof(T)).Cast<T>() on pre-.NET7 targets, since the generic Enum.GetValues<T>() overload isn't available on netstandard2.0/net48. This is a necessary and correctly mechanical fix to make the test suite compile against the net48 compatibility target. Spot-checked LengthTestsBase.g.cs (ILinearQuantity) and confirmed the using UnitsNet.InternalHelpers; is already present, so it compiles cleanly. No other generator logic changed — Temperature/IAffineQuantity and Level/ILogarithmicQuantity test bases get the same single-line change, nothing quantity-type-specific.

Bug fixes surfaced by the net48 target

  • UnitsNet/Extensions/LogarithmicQuantityExtensions.cs: the !NET fallback for RootN previously did Math.Pow(number, 1.0 / n), which returns NaN for negative number with odd n (e.g. cube root of -8), diverging from double.RootN on modern .NET. The new implementation correctly handles the negative/odd-root case by operating on Math.Abs(number) and re-applying the sign. Good catch — this was presumably never exercised because net48 wasn't part of the automated test matrix before. Nice byproduct of doing the net48 migration properly rather than papering over it.
    • Minor coverage note: UnitsNet.Tests/LogarithmicQuantityExtensionsTest.cs duplicates this same RootN algorithm as a private test helper (since production RootN is private) to compute expected values, but no test exercises negative-input geometric means, so this exact fix isn't independently verified by a test — it just avoids a latent runtime bug. Consider a direct [Theory] test for RootN/GeometricMean with a negative value and odd root count to lock in the fix.
  • UnitsNet.Tests/CustomQuantities/HowMuch.cs: the !NET explicit interface implementation was fixed from QuantityInfo IQuantity.QuantityInfo to IQuantityInstanceInfo<HowMuch> IQuantityOfType<HowMuch>.QuantityInfo => Info;. This looks like a real fixture bug — it didn't match the actual IQuantityOfType<TQuantity> interface member (IQuantityInstanceInfo<TQuantity> QuantityInfo) already on master, so it presumably never compiled under a real !NET (net48) build until now. Good that enabling net48 testing caught this.

CI/workflow review

  • Sensible move: Linux runners for the main build/test matrix (net8/9/10), Windows kept only for the after-merge net48 compatibility check — this should meaningfully cut PR CI latency.
  • permissions: contents: read at workflow level with narrow per-job elevation (id-token: write only on the job that needs Codecov OIDC) is good least-privilege practice, and dropping the stored CODECOV_TOKEN in favor of OIDC removes a persistent secret.
  • net48-compatibility.yml triggers on push: branches: [master] only (not PRs), with a documented comment as to why (no net48 library target, only exercises netstandard2.0 assets). Reasonable trade-off, though it does mean net48 regressions land on master before being caught — as already called out in the PR description as a known limitation.
  • Good fork-safety fix in pr.yml: the "Publish test results" step now gates on github.event.pull_request.head.repo.full_name == github.repository, avoiding failures on forked PRs where the default GITHUB_TOKEN can't write check runs. Also switched from the /windows variant of EnricoMi/publish-unit-test-result-action to the standard one, correctly following the runner move to ubuntu-latest.
  • Minor inconsistency: actions/upload-artifact@v7 is used to publish nuget-packages in ci.yml, but publish-nuget downloads via actions/download-artifact@v8. These major versions are cross-compatible (both v4+ storage backend), but pinning matching majors would be cleaner and less confusing to future maintainers.
  • Several third-party/official action versions bumped fairly aggressively (checkout@v7, setup-dotnet@v6, upload-artifact@v7/download-artifact@v8, codecov/codecov-action@v6). Worth double-checking these tags actually exist and are the intended ones before merge — a typo'd or non-existent version pin fails loudly, but it's cheap to verify given how many were bumped at once.
  • Build/build-functions.psm1 and init.ps1: switching from "$root\..." string concatenation to Join-Path is the right way to make the PowerShell build script OS-path-agnostic for Linux runners, and the reportgenerator/reportgenerator.exe OS-conditional binary name is a needed companion fix. Straightforward and correct.

Test coverage

  • UnitsNet.Serialization.JsonNet.Tests: switching assertions from matching the full exception Message string to just ParamName for ArgumentNullException cases is a good robustness improvement — full BCL exception message text is version/runtime-dependent (e.g., wording differs across .NET 8/9/10), so asserting on ParamName avoids spurious failures purely from expanding the test matrix, while still verifying the meaningful part of the exception.
  • UnitsNet.Tests/CustomCode/IQuantityTests.cs: wrapping StaticAbstract_Info_ReturnsSameAsTypedInfo in #if NET is correct, since static abstract interface members aren't available pre-.NET7/net48.

Nits

  • GITHUB_ACTIONS_MIGRATION_README.md rewrite reads well and is much more useful than the original "files to move" placeholder — consider whether this file should eventually live under Docs/ per the repo's existing documentation convention, or be folded into Docs/README.md's CI section, rather than staying at repo root long-term (not a blocker for this PR).

Overall: solid, low-risk migration with good self-review evidence in the PR description (actual CI run links, local validation, actionlint/git diff --check passing). The two incidental bug fixes (RootN sign handling, HowMuch fixture) are a good sign that the net48 target is doing its job of catching real gaps rather than just being a checkbox.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review: Cross-platform GitHub Actions CI

This is a CI/tooling migration PR — no new quantities/units, and no changes to the production quantity/unit generators. 156 files changed, but ~130 of those are a single mechanical line in generated test files, so the actual reviewable surface is small. Nice work overall; a few notes below.

Breaking changes

None to the public API. One behavior-relevant fix: LogarithmicQuantityExtensions.RootN's non-NET fallback (UnitsNet/Extensions/LogarithmicQuantityExtensions.cs) previously did Math.Pow(number, 1.0/n), which returns NaN for a negative base with odd n (e.g. RootN(-8, 3) should be -2 but returned NaN on netstandard2.0/net48). The new sign-extraction logic matches double.RootN's semantics on NET. This is a genuine bug fix and worth a line in the changelog since it changes output for negative inputs on non-NET targets, even though it's not something this PR set out to do.

Generated code changes

The only generator touched is CodeGen/Generators/UnitsNetGen/UnitTestBaseClassGenerator.cs, which swaps Enum.GetValues<{_unitEnumName}>() for EnumHelper.GetValues<{_unitEnumName}>() in the HasAtLeastOneAbbreviationSpecified test. That's because the generic Enum.GetValues<T>() overload only exists on .NET 7+, and this PR reinstates net48/netstandard2.0 test coverage (via the new net48-compatibility.yml workflow), so the generated tests need the polyfill in UnitsNet/InternalHelpers/EnumHelper.cs. This propagates identically to every *TestsBase.g.cs file (confirmed on LengthTestsBase.g.cs, LevelTestsBase.g.cs, and others) — no quantity-specific generator logic (linear/affine/logarithmic) was touched, so Length, Temperature, and Level code generation is otherwise unaffected.

Code quality

  • Good: UnitsNet.Serialization.JsonNet.Tests assertions changed from Assert.Equal("Value cannot be null. (Parameter 'x')", ex.Message) to Assert.Equal("x", ex.ParamName). Exact ArgumentNullException.Message wording is runtime/culture-dependent, so this is a more robust and more portable assertion — especially relevant now that tests run against net8/9/10 and net48 in the same suite.
  • UnitsNet.Tests/LogarithmicQuantityExtensionsTest.cs reimplements the same RootN fallback logic as a private test helper (duplicating LogarithmicQuantityExtensions.RootN's non-NET branch) instead of reusing double.RootN conditionally like the production code does. Minor DRY concern — if the production fallback's math ever regresses, the test's copy won't catch it since it embeds the same logic. Not blocking, just worth a #if NET ... double.RootN ... #else mirror instead of a full reimplementation if convenient.
  • UnitsNet.Benchmark.csproj's net48 target is gated only by IsOSPlatform('Windows'), whereas all the test .csproj changes gate net48 behind both IncludeNet48 == 'true' and IsOSPlatform('Windows'). That means a plain local Windows build (dotnet build) will still try to build/restore the net48 benchmark target even though net48 is otherwise opt-in everywhere else now. Probably fine since Benchmark isn't part of build.ps1's test loop, but the asymmetry looks unintentional — worth double-checking it doesn't surprise Windows contributors doing a full solution build.

CI/workflow changes

  • Replacing the bespoke codecov.exe download + GPG signature verification script with codecov/codecov-action@v6 + use_oidc: true is a nice simplification and removes the stored CODECOV_TOKEN secret entirely.
  • Security nit (fixed by this PR, worth calling out): the old ci.yml nuget-push step interpolated the secret directly into the shell command (--api-key ${{ secrets.NUGET_ORG_APIKEY }}), which is a known GitHub Actions injection footgun (expression expansion happens before the shell sees the string). The new version passes it via env: NUGET_ORG_APIKEY and references "$NUGET_ORG_APIKEY" inside the script — correct fix, good catch.
  • Top-level permissions: contents: read with job-scoped id-token: write only where OIDC is needed is good least-privilege practice.
  • pr.yml's new guard github.event.pull_request.head.repo.full_name == github.repository before publishing test results avoids a predictable failure on fork PRs (forked PR runs get a read-only GITHUB_TOKEN, which publish-unit-test-result-action needs write access for). Good defensive addition.
  • net48-compatibility.yml declares permissions: contents: read / checks: write at the workflow level rather than per-job; harmless here since there's only one job, but inconsistent with ci.yml/pr.yml's per-job scoping — minor style nit only.
  • Concurrency groups added to ci.yml/pr.yml (cancel superseded runs) are a good addition given the switch to a wider SDK matrix (8/9/10) per run.

Performance

  • Moving build-and-test from windows-latest to ubuntu-latest for the main CI/PR workflows should meaningfully cut wall-clock time and Actions minutes cost, while net48-compatibility.yml preserves net48/CLR4 coverage on Windows without blocking PRs (push-to-master + manual dispatch only) — reasonable trade-off between coverage and PR feedback latency.
  • -p:TestTfmsInParallel=false in Build/build-functions.psm1 serializes TFMs within a single dotCover session per the comment explaining .NET 9+ parallel test hosts hang under one profiler session. This trades some parallelism for reliability; given it's scoped only to the dotCover coverage run (not dotnet test in general), the cost should be limited to CI, not local dev iteration.

Test coverage

No new functional tests added (expected for a CI-focused PR), but the IQuantityTests.cs static-abstract-interface test is correctly wrapped in #if NET since static abstract interface members aren't available pre-.NET 7, and HowMuch.cs's non-NET custom IQuantityOfType<HowMuch>.QuantityInfo implementation was fixed to match the current interface shape — both necessary and correctly scoped to unblock reinstating net48 as a test target.

Summary

Solid, well-scoped infra PR. The two things I'd actually want addressed before merge are minor: the RootN behavior change deserves a changelog mention, and the UnitsNet.Benchmark.csproj net48 gating inconsistency is worth a quick check to confirm it's intentional. Everything else is either a clear improvement (Codecov OIDC, nuget secret handling, ParamName assertions) or a necessary mechanical consequence of reinstating net48 coverage.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review: 🔧ci: Cross-platform GitHub Actions CI

This is a CI/build-infrastructure PR (no Common/UnitDefinitions/ changes, no new quantities/units), so I focused on the workflow changes, the one generator change, and the incidental fixes it surfaced.

Breaking changes

None to the public API. There is one behavioral fix in UnitsNet/Extensions/LogarithmicQuantityExtensions.cs: the non-.NET fallback for RootN used Math.Pow(number, 1.0 / n), which returns NaN for a negative number with odd n (e.g. cube root of a negative dB sum in GeometricMean). The new code handles the sign explicitly. This only affects netstandard2.0/net48 consumers and is a correctness fix, not a regression — but worth calling out explicitly in the PR body/changelog since it changes runtime output for those targets.

Generated code

Only one generator template changed: CodeGen/Generators/UnitsNetGen/UnitTestBaseClassGenerator.cs swaps Enum.GetValues<{_unitEnumName}>() for EnumHelper.GetValues<{_unitEnumName}>() in the HasAtLeastOneAbbreviationSpecified test. That's because the generic Enum.GetValues<TEnum>() isn't available on netstandard2.0/net48, which this PR newly exercises via the net48-compatibility.yml workflow. The change cascades mechanically into ~130 *TestsBase.g.cs files (e.g. LengthTestsBase.g.cs, TemperatureTestsBase.g.cs, LevelTestsBase.g.cs) with an identical one-line diff each — exactly the right way to do it (edit the generator, regenerate, don't hand-edit .g.cs). Good adherence to the repo convention.

Bugs surfaced by this change (good catches)

Enabling real test execution on net48 (previously untested) surfaced two latent bugs, both fixed correctly:

  • UnitsNet.Tests/CustomQuantities/HowMuch.cs: the #if !NET branch implemented the old QuantityInfo IQuantity.QuantityInfo shape instead of the current IQuantityInstanceInfo<HowMuch> IQuantityOfType<HowMuch>.QuantityInfo, which would have failed to compile/behave correctly on net48.
  • The RootN fallback bug mentioned above.

This is exactly the value of actually running the net48 suite rather than just compiling for it — nice validation that the new workflow works.

Minor nits

  • UnitsNet.Tests/LogarithmicQuantityExtensionsTest.cs now has a private RootN that duplicates the production fallback algorithm verbatim (needed since double.RootN isn't available pre-.NET 7). Since the test literally re-implements the same logic being tested, it can't catch a mistake made identically in both places. Not blocking, just noting the test is closer to a golden-value/regression check than an independent verification.
  • GITHUB_ACTIONS_MIGRATION_README.md at repo root reads like a temporary migration note — consider folding it into Docs/ or removing after merge per its own "Migration notes" section, rather than leaving a permanent root-level file.

Security

Good improvements here, nothing new to flag:

  • Codecov auth moved from a stored CODECOV_TOKEN secret to short-lived OIDC (use_oidc: true + id-token: write scoped only to the build-and-test job).
  • NUGET_ORG_APIKEY is now passed via env: + "$NUGET_ORG_APIKEY" in the run script instead of interpolated directly into the run: block — reduces script-injection risk if the secret value ever contained shell metacharacters.
  • Explicit top-level permissions: contents: read with per-job escalation only where needed (id-token: write, checks: write).
  • publish-unit-test-result-action step is now gated on github.event.pull_request.head.repo.full_name == github.repository, avoiding failures (and the GITHUB_TOKEN permission issues) on fork PRs.

Other observations

  • Consistent net48 opt-in pattern across all four test .csproj files (TargetFrameworks Condition="'$(IncludeNet48)' == 'true' and $([MSBuild]::IsOSPlatform('Windows'))") — clean and matches the new net48-compatibility.yml workflow's -p:IncludeNet48=true.
  • Sensible scope reduction: full multi-target build validates all shipped TFMs, but only net10.0 runs with coverage in the main pipeline, while net48-compatibility.yml covers the behaviorally-distinct netstandard2.0 assets on CLR4 post-merge without blocking PRs. Matches the PR description and avoids redundant test runs across .NET 8/9/10 which share the same code paths.
  • Pinned action versions (checkout@v7, setup-dotnet@v6, upload-artifact@v7, download-artifact@v8, codecov-action@v6) are newer majors than commonly seen — worth double-checking these tags exist/are stable at merge time if CI hasn't actually run green on them yet (the PR description says it has, so likely fine).

Overall: well-scoped, mechanical, and the incidental bug fixes are a nice bonus from actually exercising the net48 path. No blocking issues found.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review

This is a CI/build infrastructure migration (Azure Pipelines → GitHub Actions) with no new quantities or units. Overall it's a well-executed, carefully-scoped migration — nice attention to security and cross-platform detail. A few notes:

Breaking changes

None for library consumers. UnitsNet.Serialization.JsonNet, UnitsNet.NumberExtensions, and UnitsNet.NumberExtensions.CS14 now embed README.md (PackageReadmeFile), matching what UnitsNet.csproj already does — a nice-to-have, not a breaking change.

Changes to generated code

CodeGen/Generators/UnitsNetGen/UnitTestBaseClassGenerator.cs makes one mechanical substitution: Enum.GetValues<T>()EnumHelper.GetValues<T>() in the HasAtLeastOneAbbreviationSpecified test. I spot-checked the regenerated output across LengthTestsBase.g.cs (ILinearQuantity), TemperatureTestsBase.g.cs (IAffineQuantity), and LevelTestsBase.g.cs (ILogarithmicQuantity) — the diff is identical and consistent in all three (and by extension the other ~125 regenerated files). EnumHelper.GetValues<T> (UnitsNet/InternalHelpers/EnumHelper.cs) is pre-existing and correctly falls back to Enum.GetValues(typeof(T)).Cast<T>() before NET7_0_OR_GREATER, since the generic Enum.GetValues<T>() overload isn't available on netstandard2.0/net48. This is exactly the right fix to unblock the new CLR4/net48 compatibility suite.

Code quality / correctness fixes bundled in

A few real bug fixes ride along with the CI migration, since they were required to get the net48 suite green:

  • UnitsNet/Extensions/LogarithmicQuantityExtensions.cs: the netstandard2.0 RootN fallback previously did Math.Pow(number, 1.0/n), which returns NaN for a negative base with an odd root (e.g. cube root of -8). The fix special-cases negative numbers correctly. Good catch. Minor nit: the test file (LogarithmicQuantityExtensionsTest.cs) duplicates this exact algorithm in a local RootN helper instead of asserting against an independently-computed expected value — it verifies the test compiles/matches the impl rather than validating correctness (pre-existing pattern via double.RootN on NET, so not a regression, just worth knowing it's not an independent check).
  • UnitsNet.Tests/CustomQuantities/HowMuch.cs: updates the #if !NET explicit interface implementation from IQuantity.QuantityInfo to IQuantityOfType<HowMuch>.QuantityInfo, matching the interface shape used elsewhere (e.g. generated quantities). This was presumably a latent compile error on netstandard2.0/net48 that just wasn't being built/tested before.
  • Null-argument assertions in the JSON.NET serialization tests now check exception.ParamName instead of exception.Message — correct, since ArgumentNullException.Message text differs between .NET Framework and modern .NET, and ParamName is the stable contract.

These are reasonable to bundle since they're purely enablers for the new CI target, not unrelated scope creep.

CI/workflow specifics

  • Good security hygiene: Codecov now authenticates via short-lived OIDC (use_oidc: true) instead of a stored CODECOV_TOKEN; NUGET_ORG_APIKEY is passed via env: and referenced as $NUGET_ORG_APIKEY rather than interpolated directly into the run: string, avoiding secret leakage into shell history/process args; explicit permissions: blocks scope tokens per job.
  • pr.yml correctly swaps EnricoMi/publish-unit-test-result-action/windows@v2 → the non-Windows variant now that the runner moved from windows-latest to ubuntu-latest — easy to miss, glad it was caught.
  • One thing worth double-checking: ci.yml's concurrency: { group: ci-..., cancel-in-progress: true } applies to the whole workflow, which includes publish-nuget. If two pushes to master land close together, an in-flight NuGet publish could theoretically be cancelled mid-push by a newer run. --skip-duplicate mitigates a lot of the risk, but you may want cancel-in-progress: false (or a separate concurrency group) for the publish job specifically, since builds are cheap to cancel but publishes aren't.
  • net48-compatibility.yml only triggers on push to master (pull_request is commented out) and can't be dispatched pre-merge on a net-new workflow file, so the first real signal comes after merging to master. That's clearly documented in the PR description and GITHUB_ACTIONS_MIGRATION_README.md, and the author validated locally, so it's a reasonable, acknowledged tradeoff rather than an oversight — just flagging it as the one place a regression could reach master before being caught.
  • Action versions bumped fairly far ahead (actions/checkout@v7, actions/setup-dotnet@v6, actions/upload-artifact@v7, actions/download-artifact@v8, codecov/codecov-action@v6) — assuming these are verified to exist and were pinned intentionally (the PR's linked CI runs suggest so), just worth a sanity check that they're pinned to a real released tag and not a typo'd future version.

Build scripts

Build/build-functions.psm1 / build.ps1 / init.ps1 correctly switch from hardcoded \-separated Windows paths to Join-Path, and pick reportgenerator vs reportgenerator.exe based on OSVersion.Platform — necessary and correctly done for the new Linux runners.

Test coverage

Reasonable trade-off: full suite + coverage only runs on net10.0 in the main pipeline (net8/9 covered by build-only), with the CLR4/netstandard2.0 path fully tested separately post-merge. Documented rationale (net8.0, net9.0, net10.0 exercise the same code paths) is sound.

No security concerns beyond the OIDC/secret-handling improvements noted above (which are net positives).

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review: CI/PR — Cross-platform GitHub Actions CI (#1686)

This is a CI/infra migration (no changes under Common/UnitDefinitions/, so no new quantities/units to evaluate against the addition criteria). Overall the direction is solid: moving to Linux runners, testing multiple TFMs, and OIDC-based Codecov auth are good improvements. A few notes below.

Breaking changes

None to the public API surface. The one behavior change is a bug fix in UnitsNet/Extensions/LogarithmicQuantityExtensions.cs's non-NET RootN fallback (used on netstandard2.0/.NET Framework 4.8):

-        return Math.Pow(number, 1.0 / n);
+        bool isNegative = BitConverter.DoubleToInt64Bits(number) < 0;
+        if (isNegative && n % 2 == 0) return double.NaN;
+        double root = Math.Pow(Math.Abs(number), 1.0 / n);
+        return isNegative ? -root : root;

Previously Math.Pow(negative, 1.0/n) returned NaN for a negative base even when n is odd (e.g. a geometric mean where the dB sum is negative), unlike double.RootN used on NET. This now matches the modern-runtime behavior. It's corrective, but worth calling out in release notes since consumers building for netstandard2.0/net48 previously got NaN and will now get a real number for these inputs.

New quantities/units

None — no JSON unit definitions changed, so nothing to evaluate against .agents/criteria-for-adding-quantities-and-units.md.

Generated code

Only one generator template changed, CodeGen/Generators/UnitsNetGen/UnitTestBaseClassGenerator.cs:

-            var units = Enum.GetValues<{_unitEnumName}>();
+            var units = EnumHelper.GetValues<{_unitEnumName}>();

This accounts for the bulk of the diff (~130 *TestsBase.g.cs files, one line each — e.g. LengthTestsBase.g.cs, TemperatureTestsBase.g.cs, LevelTestsBase.g.cs all get the identical change in HasAtLeastOneAbbreviationSpecified()). Makes sense: the generic Enum.GetValues<TEnum>() overload requires .NET 5+, so it can't compile under the new net48 target. Mechanical, low-risk change, uniform across quantity kinds (linear/affine/logarithmic all get the same fix).

Relatedly, UnitsNet.Tests/CustomCode/PressureTests.cs swaps Enum.GetValues<PressureReference>() for EnumHelper.GetValues<...>() for the same reason, and UnitsNet.Tests/CustomQuantities/HowMuch.cs's #if !NET branch now implements IQuantityOfType<HowMuch>.QuantityInfo returning IQuantityInstanceInfo<HowMuch> instead of the old IQuantity.QuantityInfo — this looks like a genuine fix for what would otherwise be a broken/uncompilable netstandard2.0 build path that simply wasn't exercised until this PR added net48 CI.

Potential issue: doc/workflow mismatch

GITHUB_ACTIONS_MIGRATION_README.md states the net48-compatibility workflow "runs the complete test suite ... after every push to master and on demand. It does not run for pull requests." But .github/workflows/net48-compatibility.yml actually declares:

on:
  push:
    branches: [master]
  pull_request:
    branches: [master]
  workflow_dispatch:

It does trigger on PRs (and the PR description itself says it runs "for pull requests, every push to master, and manual runs"). Worth fixing the doc, or dropping pull_request from the trigger if non-PR-blocking was actually intended — right now the two disagree.

Code quality / best practices

  • Good: concurrency groups + cancel-in-progress added to ci.yml/pr.yml to avoid piling up redundant runs.
  • Good: explicit timeout-minutes on all new/changed jobs.
  • Good: minimal permissions: contents: read at workflow level, with id-token: write scoped only to jobs that need OIDC.
  • Good: NUGET_ORG_APIKEY is now passed via env: and referenced as an env var in the run script instead of interpolated directly into the YAML run: block, reducing (already low) risk from template-expansion in Actions run: steps.
  • The Assert.Equal("...", exception.Message) -> Assert.Equal(paramName, exception.ParamName) changes in the JsonNet converter tests are a nice robustness improvement, since ArgumentNullException.Message text differs between .NET Framework and .NET, which is exactly the kind of runtime-specific brittleness the net48 lane would otherwise hit.
  • Build/build-functions.psm1/init.ps1/build.ps1 switching from hardcoded backslash path joins and .exe extensions to Join-Path + a platform-conditional reportgenerator binary name is the right way to make the same script work on both Windows and Linux runners.

Test coverage

The RootN fallback fix is exercised indirectly by existing GeometricMean tests that already include inputs producing a negative summed value (e.g. InlineData(1, DecibelMilliwatt, -1, DecibelWatt)), so it's covered without needing new tests. Worth double-checking these particular cases actually run against the #else fallback path (net48/netstandard2.0) now that net48-compatibility.yml exists, rather than only against the NET path on the Linux CI.

Security

No medium+ severity concerns. The move to Codecov OIDC (use_oidc: true) instead of a stored CODECOV_TOKEN secret is a solid reduction in blast radius versus a leaked/misused long-lived token.

Nice cleanup overall: the Azure Pipelines files become inert stubs (trigger: none / pr: none) rather than being deleted outright, which keeps maintenance/v5 unaffected while retiring v6/master's use of them.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review

This is a substantial but low-risk PR: it's almost entirely CI/build infrastructure, plus a handful of test/library fixes that were needed to make the netstandard2.0/net48 path actually testable. No new quantities or units, and I don't see any breaking changes to the public API.

Breaking changes

None to the public UnitsNet API surface. The one behavior change is in UnitsNet/Extensions/LogarithmicQuantityExtensions.cs's private RootN fallback (the #else/non-NET branch used on netstandard2.0/net48): it now correctly handles negative inputs (Math.Pow with a negative base and fractional exponent returns NaN, so the old fallback silently produced NaN for e.g. odd roots of negative numbers). This is a genuine bug fix, worth calling out explicitly in the PR description since it's a real behavior change riding along with the CI migration, not pure infra.

Generated code / code generator

CodeGen/Generators/UnitsNetGen/UnitTestBaseClassGenerator.cs swaps the last remaining Enum.GetValues<T>() call (in HasAtLeastOneAbbreviationSpecified) for the existing EnumHelper.GetValues<T>() polyfill — the other two template call sites (unitsOrderedByName, Assert.All) already used EnumHelper.GetValues, so this just closes the gap. Verified this is consistently regenerated across all ~130 *TestsBase.g.cs files (Length, Temperature, Level all match), and EnumHelper is internal but UnitsNet.Tests has InternalsVisibleTo, so this compiles fine. This only matters because net48/netstandard2.0 lack the generic Enum.GetValues<T>() overload — makes sense given the new CLR4 compatibility workflow now actually exercises this code path.

Other non-CI fixes bundled in

A few small fixes surfaced by actually running the full suite on net48/netstandard2.0 (previously untested or under-tested on that TFM):

  • UnitsNet.Tests/CustomQuantities/HowMuch.cs: fixes the #if !NET explicit interface implementation to match the current IQuantityOfType<T>/IQuantityInstanceInfo<T> interfaces (pre-existing, not introduced by this PR).
  • UnitsNet.Tests/CustomCode/IQuantityTests.cs: wraps the static-abstract-interface test in #if NET (not supported pre-.NET 5).
  • UnitsNet.Tests/CustomCode/PressureTests.cs: same Enum.GetValuesEnumHelper.GetValues swap as above.

These all look correct and are small enough to be fine bundled with the CI work, but a one-line callout in the PR description ("also fixes a few net48-only compile issues surfaced by the new compatibility workflow") would help future readers understand why a "CI" PR touches production/test logic.

Minor style/maintainability note

UnitsNet.Tests/LogarithmicQuantityExtensionsTest.cs now has its own private RootN helper that duplicates the exact fallback logic in LogarithmicQuantityExtensions.cs, used unconditionally (not gated by #if NET) — so on net8.0/net9.0/net10.0 test runs, the test's expected value is now computed via the Math.Pow-based approximation instead of the more precise double.RootN. Functionally fine given the existing tolerance-based assertions, but it's duplicated logic that will drift if the fallback algorithm changes. Consider #if NET/#else in the test to match production, or extract the fallback into a shared internal helper both can call.

CI/workflow design

  • The fork-PR-safe pattern for publishing CLR4 test results (net48-compatibility.yml uploads artifacts with a read-only token; a separate publish-net48-test-results.yml triggered by workflow_run downloads them and publishes checks with checks: write) is the right way to avoid the classic "pwn request" vulnerability, and the code comments explaining why the split exists are a nice touch.
  • pr.yml's "Publish test results" step is gated on github.event.pull_request.head.repo.full_name == github.repository, correctly avoiding a permission failure for fork PRs.
  • Nice cleanup: Build/build-functions.psm1 and Build/init.ps1 switch from Windows-style \-joined paths to Join-Path, and pick reportgenerator vs reportgenerator.exe based on OS — needed for the Linux runner move and looks correct.
  • Codecov auth via short-lived OIDC (use_oidc: true) instead of a stored CODECOV_TOKEN is a solid security improvement.

One inconsistency worth a look: publish-net48-test-results.yml pins third-party actions by commit SHA (actions/download-artifact@3e5f45b2... / EnricoMi/publish-unit-test-result-action@d0a4676d...), which is the right call for a workflow with checks: write. The other three workflows (ci.yml, pr.yml, net48-compatibility.yml) instead use floating major-version tags (actions/checkout@v7, codecov/codecov-action@v6, etc.). Given the SHA-pinning discipline already demonstrated in this PR, it'd be more consistent (and reduces supply-chain risk from a compromised/re-tagged action) to pin those too, or at least document why they're treated differently (e.g. lower privilege / first-party actions).

Test coverage

Main ci.yml/pr.yml pipelines now build all shipped TFMs (netstandard2.0, net8.0, net9.0, net10.0) but only execute tests on net10.0 (Build/build-functions.psm1's Start-Tests hardcodes --framework net10.0). This is a deliberate, documented tradeoff ("behaviorally distinct runtime paths") and is reasonable, but it does mean a regression that only manifests on net8.0/net9.0 (and not net10.0 or netstandard2.0/net48) would no longer be caught by any workflow. Given the multi-target support is a selling point of the library, worth considering an occasional (e.g. weekly scheduled) full-matrix test run, though I wouldn't block on this.

Overall this looks well thought through — the validation section in the PR description is thorough, the security-sensitive parts (OIDC, workflow_run split) are handled correctly, and the incidental fixes are small and traceable. Nice work.

@angularsen
angularsen marked this pull request as ready for review July 21, 2026 18:43
@angularsen angularsen changed the title 🔧ci: Cross-platform GitHub Actions CI 🔧 Migrate v6 CI to GitHub Actions with CLR4 coverage Jul 21, 2026
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review: #1686 – Cross-platform GitHub Actions CI

Solid infra migration overall — Linux-based CI, OIDC-based Codecov auth instead of a stored token, SHA-pinned third-party actions in the write-privileged workflow_run publisher, and a sensible split of the CLR4/net48 suite into its own workflow. A few things worth a look before merging.

Breaking changes

None for consumers. This is CI/build-only, plus one behavioral fix in product code (see below). No JSON unit definitions were touched, so no new/changed quantities or units to review against .agents/criteria-for-adding-quantities-and-units.md.

Possible doc/config mismatch

.github/workflows/net48-compatibility.yml triggers on both push (to master) and pull_request (to master):

on:
  push:
    branches: [master]
  pull_request:
    branches: [master]

But GITHUB_ACTIONS_MIGRATION_README.md states the workflow "does not run for pull requests," while README.md says it runs "without blocking pull requests" — two different claims, and neither matches a workflow that actually triggers on: pull_request. The in-file comment ("Remove pull_request for post-merge validation only, or remove push for PR validation only") suggests this was meant to be a template choice, but as shipped both triggers are active. Worth confirming the intended behavior and aligning both docs with the actual trigger config.

UnitsNet/Extensions/LogarithmicQuantityExtensions.cs — real behavior change, not just CI

The netstandard2.0/net48 fallback for RootN changed from:

return Math.Pow(number, 1.0 / n);

to properly handling negative bases (odd n → negative root, even nNaN), matching double.RootN semantics used on NET. This is a genuine bug fix (previously any negative base returned NaN for both odd and even n, since Math.Pow on a negative base with a fractional exponent is NaN in .NET regardless of parity) — good catch, and it makes sense this surfaced once net48 is actually exercised in CI again. Two notes:

  • This changes observable output of GeometricMean() for netstandard2.0/net48 consumers with negative values (e.g. 3+ negative dB quantities) — worth a changelog line since it's a behavior change bundled into an otherwise CI-only PR.
  • Test coverage: LogarithmicQuantityExtensionsTest duplicates the same fixed logic as a private RootN helper for its expected-value calculations, but all GeometricMean test cases use only 1 or 2 quantities, so n is never odd in the test data — the negative-odd-root branch (isNegative ? -root : root) is never actually exercised. Consider adding a 3-quantity case with a negative sum to cover that path.

Generated code changes

CodeGen/Generators/UnitsNetGen/UnitTestBaseClassGenerator.cs swaps Enum.GetValues<T>() for EnumHelper.GetValues<T>() in the HasAtLeastOneAbbreviationSpecified test template (Enum.GetValues<T>() is a generic overload only available NET7_0_OR_GREATER, so it broke net48/netstandard2.0). This is a single mechanical line change in the generator, uniformly propagated to all ~130 *TestsBase.g.cs files regardless of quantity kind — verified identical diffs for e.g. LengthTestsBase.g.cs (ILinearQuantity) and LevelTestsBase.g.cs (ILogarithmicQuantity). EnumHelper is a pre-existing internal helper (UnitsNet/InternalHelpers/EnumHelper.cs), not new, and the generated files already using UnitsNet.InternalHelpers;, so no new dependency wiring needed. Same fix applied to PressureTests.cs (custom, hand-written) for the same reason.

Other product-code compatibility fixes bundled in

Two more non-CI fixes needed to make the net48/netstandard2.0 build/test succeed, both look correct and scoped to #if !NET:

  • UnitsNet.Tests/CustomQuantities/HowMuch.cs: updates the manual IQuantity explicit interface implementation to match the current IQuantityOfType<T>/IQuantityInstanceInfo<T> shape.
  • UnitsNet.Tests/CustomCode/IQuantityTests.cs: guards the static-abstract-interface test with #if NET since static abstract members aren't available pre-.NET 7.

These look reasonable, but it's worth flagging that a "CI migration" PR now also touches runtime behavior and test-fixture interface implementations — might be worth a one-line callout in the PR description so reviewers don't miss it while skimming past the CI files.

Test assertion quality improvement

UnitsNet.Serialization.JsonNet.Tests/*.cs changed Assert.Equal("Value cannot be null. (Parameter 'x')", ex.Message) to Assert.Equal("x", ex.ParamName). Good change — ArgumentNullException.Message text is runtime/culture-dependent (and apparently differs across .NET 8/9/10, which is presumably why this needed fixing to unblock the new multi-TFM matrix); asserting on ParamName is the correct, stable thing to check.

Security

  • The net48-compatibility.yml test job inherits workflow-level permissions: id-token: write and also runs on pull_request (i.e., executes potentially fork-submitted code via dotnet run --project CodeGen and dotnet test). This mirrors the existing pattern already present in pr.yml, and Codecov's OIDC tokens are narrowly audience-scoped, so this isn't a new class of risk — but given the PR explicitly reasoned about not granting checks: write to this same job to protect against untrusted fork code (nice comment explaining that), it's a little inconsistent not to apply the same reasoning to id-token: write. Not blocking, just worth a conscious call — either scope permissions per-job or add a comment explaining why id-token: write is considered safe here too.
  • Third-party actions in the elevated publish-net48-test-results.yml workflow are SHA-pinned (actions/download-artifact@3e5f45b2..., EnricoMi/publish-unit-test-result-action@d0a4676...) — good practice for the write-privileged workflow. The lower-privilege workflows use floating major-version tags (@v6/@v7) for both official and third-party actions, which is a reasonable place to relax pinning, but worth being intentional/consistent about the policy (e.g. always SHA-pin third-party actions, tags OK for actions/*).

Nit

Build/build-functions.psm1 / init.ps1 cross-platform path fixes (Join-Path instead of raw \ string concatenation, reportgenerator vs reportgenerator.exe by OS) look correct and necessary for the Linux runner switch.

Nice work overall — the workflow restructuring, timeout bounds, concurrency groups, and OIDC auth are all good improvements over the previous Windows/Azure setup.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review: PR #1686 — Cross-platform GitHub Actions CI

This is a CI/build infrastructure migration with no changes to the public quantity/unit API surface. No new quantities or units are introduced.

Breaking changes
None for library consumers. One notable behavior fix bundled in: UnitsNet/Extensions/LogarithmicQuantityExtensions.cs's netstandard2.0/net48 fallback for RootN previously did Math.Pow(number, 1.0/n), which returns NaN for a negative base with odd n (e.g. cube root of -8). The new implementation handles the sign explicitly and matches double.RootN semantics used on NET. This is a correctness fix, not a regression, but worth calling out explicitly in the PR description since it changes output for negative inputs on non-NET targets (net48/netstandard2.0 consumers).

Code generation changes
Only one generator touched: CodeGen/Generators/UnitsNetGen/UnitTestBaseClassGenerator.cs swaps Enum.GetValues<TEnum>() for EnumHelper.GetValues<TEnum>() in the generated HasAtLeastOneAbbreviationSpecified test (e.g. in LengthTestsBase.g.cs, TemperatureTestsBase.g.cs, LevelTestsBase.g.cs, all ~130 *TestsBase.g.cs files). This is because the generic Enum.GetValues<T>() overload isn't available on netstandard2.0/net48, and EnumHelper is the existing internal shim for that. Applied consistently everywhere — no stray diffs.

Other bundled non-CI fixes
A few hand-written files needed updates to compile again under the reinstated net48 target:

  • UnitsNet.Tests/CustomQuantities/HowMuch.cs: updates the explicit interface implementation to IQuantityInstanceInfo<HowMuch> IQuantityOfType<HowMuch>.QuantityInfo to match the already-existing IQuantity.cs interface shape.
  • UnitsNet.Tests/CustomCode/PressureTests.cs: Enum.GetValues<PressureReference>()EnumHelper.GetValues<...>(), same net48 reason.
  • UnitsNet.Tests/CustomCode/IQuantityTests.cs: wraps a static-abstract-interface-member test in #if NET since that C# feature isn't available for netstandard2.0/net48.

These are legitimate and necessary for the stated goal, just worth flagging since they're functional (not CI-config) changes inside a PR titled as CI work.

CI/workflow design

  • Good separation of concerns: net48-compatibility.yml runs with minimal permissions (contents: read, id-token: write) so it's safe on fork/Dependabot PR heads, while publish-net48-test-results.yml is the only workflow with checks: write and never checks out or executes contributor code — it only downloads immutable artifacts by run-id and publishes results. Correct mitigation for the artifact/workflow_run 'pwn request' pattern.
  • pr.yml gates the EnricoMi/publish-unit-test-result-action step on github.event.pull_request.head.repo.full_name == github.repository, since checks: write wouldn't be honored for forked PRs anyway.
  • Codecov now authenticates via OIDC (use_oidc: true) instead of a stored CODECOV_TOKEN — good reduction in stored-secret surface.
  • concurrency groups + timeout-minutes added to every job — good hygiene.
  • Minor nit: publish-net48-test-results.yml pins actions to full commit SHAs, but ci.yml/pr.yml/net48-compatibility.yml use floating major-version tags (@v7, @v6, @v2) for similar actions. Consider applying SHA-pinning consistently for supply-chain hardening — optional, not blocking.
  • UnitsNet.Benchmark.csproj's net48 inclusion is gated only on IsOSPlatform('Windows'), while the test .csproj files gate on IncludeNet48 == true AND IsOSPlatform('Windows'). Likely intentional, but the asymmetry might confuse a future contributor.

Test coverage
No functional test coverage lost — the CLR4/net48 suite is preserved (moved to its own scheduled/opt-in workflow rather than dropped), and the 'only collect coverage on net10.0' scoping is reasonable and explained inline.

Security
No medium+ severity issues found. Permission scoping is tight and follows current GitHub Actions best practice (least-privilege per-job permissions:, split-workflow pattern for fork-safe check publishing, OIDC over static tokens, secret referenced via env: rather than inlined into the run: script for the NuGet push step).

Overall: solid, well-reasoned CI migration. The main thing I'd want called out more prominently is the LogarithmicQuantityExtensions RootN correctness fix, since it changes observable numeric output for negative-base/odd-root inputs on non-NET targets.

@angularsen angularsen changed the title 🔧 Migrate v6 CI to GitHub Actions with CLR4 coverage 🔧 Run GitHub Actions CI on Linux with CLR4 coverage Jul 21, 2026
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review: Run GitHub Actions CI on Linux with CLR4 coverage

This is primarily a CI/build infrastructure migration, with a few small but real bug fixes surfaced by the new .NET Framework 4.8 (CLR4) coverage. No new quantities/units are added, so that part of the review checklist doesn't apply.

Breaking changes

None for library consumers. One thing worth flagging for anyone extending IQuantity manually: UnitsNet.Tests/CustomQuantities/HowMuch.cs had to change its #if !NET explicit interface implementation from

QuantityInfo IQuantity.QuantityInfo { get { return Info; } }

to

IQuantityInstanceInfo<HowMuch> IQuantityOfType<HowMuch>.QuantityInfo => Info;

to compile under net48. This is just test/sample code, but it suggests any third-party custom IQuantity implementation targeting net48/netstandard2.0 would hit the same compile break. Might be worth a callout in the custom-quantity docs if not already covered.

CI/CD changes (the bulk of the diff)

  • Good separation of concerns for the fork-PR problem: net48-compatibility.yml runs on pull_request (not pull_request_target) with no elevated permissions and just uploads artifacts; a separate publish-net48-test-results.yml (triggered via workflow_run) does the actual checks: write publishing from a read-only context that never checks out contributor code. That's the right mitigation for the classic "pwn request" pattern, and it's called out clearly in comments.
  • codecov/codecov-action now uses OIDC (use_oidc: true) instead of a stored token — good reduction in secret surface.
  • NUGET_ORG_APIKEY moved from string interpolation in run: ... --api-key ${{ secrets.NUGET_ORG_APIKEY }} to an env: var referenced via shell variable in ci.yml — good hygiene, keeps the secret out of the literal script text.
  • Minor inconsistency: publish-net48-test-results.yml pins third-party actions to a commit SHA (actions/download-artifact@3e5f45b...# v8), but ci.yml/pr.yml/net48-compatibility.yml use floating major-version tags (actions/checkout@v7, codecov/codecov-action@v6, EnricoMi/publish-unit-test-result-action@v2). If SHA-pinning is the intended bar here, consider applying it consistently — a re-tagged/compromised action still runs with contents: read in those jobs.
  • Build/build-functions.psm1 / init.ps1: switching hardcoded \-paths to Join-Path and picking reportgenerator vs reportgenerator.exe by OS platform is the right way to make the build script work on both Windows and the new Linux runners.

Bug fixes surfaced by CLR4 testing

  • UnitsNet/Extensions/LogarithmicQuantityExtensions.cs — the non-NET fallback for RootN previously did Math.Pow(number, 1.0 / n), which returns NaN for any negative base regardless of whether n is odd or even (Math.Pow doesn't special-case fractional exponents with negative bases). This is a real bug: GeometricMean over 3+ PowerRatio/Level values whose log-space sum is negative would have silently returned NaN on net48/netstandard2.0. The fix (split off the sign via Math.Abs, negate the result for odd n) is correct.
    • Nit: bool isNegative = BitConverter.DoubleToInt64Bits(number) < 0; is a non-obvious way to read the sign bit — it also treats -0.0 as negative, unlike number < 0. If that's intentional (preserving sign through -0.0), a short comment would help; otherwise number < 0 reads the same for every non-zero case and is more obvious.
    • Test-coverage gap: LogarithmicQuantityExtensionsTest.cs only exercises GeometricMean with 1 or 2 quantities (n = 1 or 2) — never the odd-n (3+) path this fix actually targets. Worth adding a 3-quantity case whose log-space sum is negative, since that's exactly the scenario that returned NaN before.
    • The RootN helper is now duplicated (production code + a copy added to the test file, since double.RootN isn't available pre-.NET 7). Minor risk of the two drifting apart later; a comment linking them, or extracting a shared internal helper, would help if it doesn't complicate multi-targeting.
  • UnitsNet.Serialization.JsonNet.Tests/* — assertions switched from matching the full exception message ("Value cannot be null. (Parameter 'x')") to exception.ParamName. Correct fix, since ArgumentNullException.Message text differs between .NET Framework and modern .NET; the old assertions were runtime-coupled without needing to be.
  • CodeGen/Generators/UnitsNetGen/UnitTestBaseClassGenerator.csEnum.GetValues<T>()EnumHelper.GetValues<T>() in the generated HasAtLeastOneAbbreviationSpecified test (the generic Enum.GetValues<T>() overload isn't available on netstandard2.0/net48). I spot-checked the regenerated output across quantity types with different quantity kinds — LengthTestsBase.g.cs (linear), TemperatureTestsBase.g.cs (affine), LevelTestsBase.g.cs (logarithmic), plus PressureTestsBase.g.cs — and confirmed the change is purely in the shared generator template, producing an identical one-line diff in every generated file. Consistent with EnumHelper already existing in UnitsNet/InternalHelpers/EnumHelper.cs and being adopted the same way in hand-written test code (e.g. PressureTests.cs).

Test coverage

  • The CLR4 suite is a genuinely new addition — UnitsNet.Tests.csproj previously didn't target net48 at all (only net8.0;net9.0;net10.0), so this closes a real gap where the shipped netstandard2.0 binaries were never actually executed on .NET Framework.
  • As noted above, the one bug fix that most needed a new regression test (RootN with odd n and a negative input) doesn't have one yet.

Other

  • azure-pipelines*.yml are reduced to no-op stubs with a notice rather than deleted — reasonable if you want to keep them discoverable in history, though if there's no plan to ever revive them for v6, deleting outright would reduce clutter (they're trivially recoverable from git history). Minor/optional.
  • Overall this is well-scoped and the security-sensitive parts (fork PR handling, secrets, permissions) look like deliberate design rather than incidental copy-paste.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review

This is primarily a CI/build-infrastructure migration (Windows Azure Pipelines/Windows GH Actions → Linux GH Actions + a dedicated Windows CLR4/net48 compatibility workflow), plus a handful of compatibility fixes it surfaced. No new quantities or units, and no public API changes — reviewed against .agents/criteria-for-adding-quantities-and-units.md is N/A here.

Breaking changes

None to the public library API. The one behavior change is in LogarithmicQuantityExtensions.RootN (non-NET fallback) — this is a bug fix (see below), and since it only affects the pre-.NET 7 fallback path (netstandard2.0 consumers on old runtimes), it shouldn't be considered breaking, just a correctness improvement worth a release note callout.

Bug fix: RootN fallback (UnitsNet/Extensions/LogarithmicQuantityExtensions.cs:26-35)

Good catch. The old fallback Math.Pow(number, 1.0/n) returned NaN for any negative input, even for odd n where a real negative root exists (e.g. RootN(-8, 3) should be -2). The new code correctly special-cases even/odd n using the sign bit (handles -0.0 correctly too via BitConverter.DoubleToInt64Bits).

This is reachable from real usage: GeometricMean sums raw values in log space (sumInLogSpace, e.g. dB), which is commonly negative, and takes RootN(sumInLogSpace, nbQuantities) where nbQuantities is the sequence length — so any 3-, 5-, 7-item (odd count) GeometricMean call with a negative dB sum hits exactly the code path that was broken.

Test coverage gap: all GeometricMean tests in LogarithmicQuantityExtensionsTest.cs use exactly 2 quantities (even n), so the odd-n/negative-sum path this fix addresses isn't exercised by any test. Consider adding a case with 3+ quantities summing to a negative log-space value to lock in the regression.

Generated code changes

CodeGen/Generators/UnitsNetGen/UnitTestBaseClassGenerator.cs:1134: single-line template change, Enum.GetValues<T>()EnumHelper.GetValues<T>(), mechanically propagated to all ~131 *TestsBase.g.cs files (verified consistent across e.g. LengthTestsBase.g.cs, the affine TemperatureTestsBase.g.cs, and logarithmic-adjacent quantities like ThermalConductivityTestsBase.g.cs). This is correct: Enum.GetValues<T>() requires .NET 5+ and isn't available under net48, and EnumHelper.GetValues<T>() (UnitsNet/InternalHelpers/EnumHelper.cs) already provides the right runtime-conditional polyfill used elsewhere in the codebase. No behavior change on NET runtimes.

Code quality

  • HowMuch.cs (test custom quantity): switching the net48-only explicit interface implementation from the removed non-generic IQuantity.QuantityInfo getter to IQuantityOfType<HowMuch>.QuantityInfo correctly matches the actual interface member declared in IQuantity.cs for non-NET builds.
  • Exception assertions changed from matching .Message strings to .ParamName (UnitsNetBaseJsonConverterTest.cs etc.) — good, avoids brittleness from .NET Framework vs modern runtime exception message formatting differences.
  • Build/*.ps1 path handling switched to Join-Path throughout for cross-platform (Linux/Windows) compatibility — consistent and correct.

CI/workflow design

  • Good fork-safety pattern: net48-compatibility.yml runs contributor code with permissions: {}/read-only and uploads artifacts + the raw event JSON; a separate publish-net48-test-results.yml (triggered via workflow_run, never checks out contributor code) does the privileged checks: write publishing keyed to the specific run-id. This avoids the classic "pwn request" pitfall. That workflow also pins the third-party action to a full commit SHA rather than a tag, appropriately given its elevated permissions.
  • Secrets are now passed via env: instead of being interpolated directly into run: scripts (e.g. NUGET_ORG_APIKEY in ci.yml) — better shell-injection hygiene than the previous inline ${{ secrets.* }} interpolation.
  • Minor inconsistency worth a sanity check: pr.yml's build-and-test job declares checks: write directly on the job that also builds/executes PR branch code (unlike the more conservative net48 split). In practice GitHub forces GITHUB_TOKEN to read-only for pull_request events from forks regardless of the declared permissions: block, so this isn't currently exploitable — but it's a different pattern from the one just introduced for net48, and would become risky if the repo's fork-PR token settings ever changed. Not a blocker, just flagging the inconsistency.
  • azure-pipelines.yml/azure-pipelines-pr.yml are cleanly neutered (trigger: none / pr: none + a no-op informational stub) rather than deleted, which matches the PR description's intent to keep maintenance/v5's separate Azure config untouched.

Test coverage

Aside from the GeometricMean odd-n gap noted above, the CLR4 fixes are well covered by mirrored local test helpers (e.g. LogarithmicQuantityExtensionsTest.RootN) and the #if NET/#endif gating in IQuantityTests.cs correctly excludes the static-abstract-only test on net48.

Performance

Restricting full test execution + coverage collection to net10.0 only (while still compiling all shipped TFMs) is a reasonable trade-off for CI cost, as called out in the PR description — net8.0/net9.0 only get compiled, not executed, on the fast path. Worth confirming that's an acceptable risk (the CLR4 workflow covers the netstandard2.0 asset on a genuinely different runtime, but nothing directly executes on net8.0/net9.0 binaries specifically).

Overall this is a solid, well-scoped migration with real compatibility bugs fixed along the way and thoughtful attention to CI security (secret handling, fork-safe check publishing, OIDC for Codecov). Nice work.

@angularsen
angularsen force-pushed the agl-codex/migrate-ci-pipelines-to-github branch from be20de5 to 4f5bcfe Compare July 21, 2026 19:09
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review

This is a CI/infrastructure migration PR (Azure Pipelines → GitHub Actions on Linux, plus a new Windows-based CLR4/.NET Framework 4.8 compatibility workflow). No unit definitions (Common/UnitDefinitions/*.json) changed and no new quantities/units are introduced, so the "new quantities/units" review criteria don't apply here.

Breaking changes

None for library consumers. netstandard2.0, net8.0, net9.0, and net10.0 are still all built and shipped; the change is only to which frameworks CI tests and where. Two library behavior changes are worth calling out as intentional bug fixes surfaced by the new CLR4 coverage:

  • LogarithmicQuantityExtensions.RootN (UnitsNet/Extensions/LogarithmicQuantityExtensions.cs): the non-NET fallback previously did Math.Pow(number, 1.0 / n), which returns NaN for a negative base with a fractional exponent (e.g. odd roots of negative numbers). The fix special-cases the sign using BitConverter.DoubleToInt64Bits(number) < 0 (correctly handles -0.0, unlike number < 0), returns NaN for even roots of negatives, and negates the result for odd roots — this now matches double.RootN semantics on NET. Good catch and correct fix, and it's covered by GeometricMean tests via a local RootN test helper.

Changes to generated code

The only generated-code change is mechanical: CodeGen/Generators/UnitsNetGen/UnitTestBaseClassGenerator.cs swaps Enum.GetValues<{_unitEnumName}>() for the pre-existing EnumHelper.GetValues<{_unitEnumName}>() in the HasAtLeastOneAbbreviationSpecified test (Enum.GetValues<T>() is a NET7+-only generic overload, unavailable on net48/older netstandard2.0 consumers of the test suite). This regenerates identically across all ~130 *TestsBase.g.cs files regardless of quantity kind — verified the same one-line diff appears for LengthTestsBase.g.cs (ILinearQuantity), TemperatureTestsBase.g.cs (IAffineQuantity), and LevelTestsBase.g.cs (ILogarithmicQuantity). EnumHelper was already referenced elsewhere in these generated files (using UnitsNet.InternalHelpers; was already present), so this is a pure consistency fix, not a new dependency.

Other compatibility fixes (non-generated)

  • UnitsNet.Tests/CustomQuantities/HowMuch.cs: the #if !NET explicit interface implementation was updated from QuantityInfo IQuantity.QuantityInfo to IQuantityInstanceInfo<HowMuch> IQuantityOfType<HowMuch>.QuantityInfo. Worth double-checking this matches the actual IQuantityOfType<T> contract used on non-static-abstract-member runtimes — since this compiled fine before under NET presumably via default interface members, but apparently never compiled under !NET until now. Might be worth a comment noting why the signature differs from the NET path, since it's non-obvious from the diff alone.
  • Assertions on ArgumentNullException were changed from checking .Message ("Value cannot be null. (Parameter 'x')") to .ParamName — correct, since the full exception message text is runtime/localization-dependent and shouldn't be asserted on anyway.

CI/workflow design

  • The new net48-compatibility.yml (Windows runner, executes contributor code) is intentionally scoped with only contents: read + id-token: write — no checks: write. The companion publish-net48-test-results.yml uses workflow_run (runs in the base repo's context, never checks out PR code) to consume artifacts and publish check results with checks: write. This is the correct, secure pattern for handling fork/Dependabot PRs without exposing a write-scoped token to untrusted code — nice attention to detail, and the comments in the workflow explain the reasoning.
  • Minor inconsistency: in publish-net48-test-results.yml, third-party actions are pinned to commit SHA (actions/download-artifact@3e5f45b2... and EnricoMi/publish-unit-test-result-action@d0a4676d...), which is best practice for a workflow with checks: write. But pr.yml's build-and-test job also has checks: write (for the same EnricoMi/publish-unit-test-result-action@v2) and references it by floating tag rather than SHA. Since that job also runs contributor code and holds a write permission, consider pinning it too for consistency (low severity — pull_request on a fork gets a read-only token regardless, and the publish step is additionally gated to same-repo PRs only).
  • codecov/codecov-action@v6 with use_oidc: true plus id-token: write is a reasonable move away from the old hand-rolled binary download/GPG-verify script in the previous Windows workflow — simpler and removes a chunk of custom supply-chain-sensitive shell code.
  • ci.yml/pr.yml bumped several actions to versions that don't appear to exist yet as of today (actions/checkout@v7, actions/setup-dotnet@v6, actions/upload-artifact@v7, actions/download-artifact@v8, codecov/codecov-action@v6) — worth confirming these are actually published before merge, since a typo'd/nonexistent version would break CI immediately.

Test coverage

Coverage collection was intentionally narrowed to net10.0 only in the main Linux workflow (build still validates all target frameworks; net8.0/net9.0/net10.0 compile the same code paths, per the comment added in Build/build-functions.psm1), with netstandard2.0 coverage now coming from the new CLR4 workflow instead. Reasonable tradeoff, clearly documented.

Nit

UnitsNet.NumberExtensions{,.CS14}/*.csproj and UnitsNet.Serialization.JsonNet/*.csproj now pack README.md via PackageReadmeFile/<None Include="../README.md"> — unrelated to the CI migration but a reasonable drive-by improvement; might be worth splitting into its own PR next time for easier review/bisection, though it's small enough here not to matter much.

Overall this is a well-scoped, carefully-reasoned CI migration with good security hygiene around the fork-PR/workflow_run split, and the compatibility bugs it surfaced (especially the RootN fix) are genuine, correctly-fixed issues.

@angularsen
angularsen force-pushed the agl-codex/migrate-ci-pipelines-to-github branch from 4f5bcfe to 6caa1d5 Compare July 21, 2026 19:13
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review

This is a CI/build-infrastructure PR (GitHub Actions migration + CLR4/.NET 4.8 compatibility testing) with no new quantities/units and no functional generator changes beyond a one-line template fix. Overall solid work — the fork-PR security pattern in particular is done correctly.

Breaking changes

None to the public API. Internally, the test projects (UnitsNet.Tests, UnitsNet.NumberExtensions*.Tests, UnitsNet.Serialization.JsonNet.Tests) now default to TargetFrameworks=net10.0 only, with net48 added conditionally (IncludeNet48=true + Windows). Previously they multi-targeted net8.0;net9.0;net10.0. The main library still builds/ships netstandard2.0/net8.0/net9.0/net10.0 — only the test execution matrix shrank. That's a reasonable, explicitly-justified tradeoff (identical code paths across net8/9/10), but worth flagging as a deliberate reduction in test-execution coverage rather than a pure no-op.

New quantities/units

None introduced.

Generated code changes

Only one generator template line changed, in CodeGen/Generators/UnitsNetGen/UnitTestBaseClassGenerator.cs:

- var units = Enum.GetValues<{_unitEnumName}>();
+ var units = EnumHelper.GetValues<{_unitEnumName}>();

This propagates identically to every generated *TestsBase.g.cs file's HasAtLeastOneAbbreviationSpecified test — verified the same one-line diff in Length (ILinearQuantity), Level (ILogarithmicQuantity), and Temperature (IAffineQuantity). Good fix: Enum.GetValues<TEnum>() requires .NET 7+, which breaks the net48 CLR4 suite. EnumHelper.GetValues<TEnum>() (UnitsNet/InternalHelpers/EnumHelper.cs, pre-existing) falls back to Enum.GetValues(typeof(T)).Cast<T>().ToArray() on older TFMs. Minimal, mechanical, correct.

Code quality / bugs

  • Real bug fix: LogarithmicQuantityExtensions.RootN's non-NET fallback used to do Math.Pow(number, 1.0/n), which returns NaN for a negative base with any fractional exponent — even for odd n, where a real negative root exists (e.g. cube root of -8 should be -2). The fix detects sign via BitConverter.DoubleToInt64Bits(number) < 0 (correctly handles -0.0 too), computes Math.Pow(Abs(number), 1/n), and reapplies the sign, only returning NaN for even n on a negative input — matching double.RootN's behavior on modern NET. This affects GeometricMean for logarithmic quantities (Level, PowerRatio, AmplitudeRatio) built against netstandard2.0/net48. Good catch, and the test helper (LogarithmicQuantityExtensionsTest.RootN) mirrors the same logic so previously-passing modern-.NET tests now also validate the netstandard2.0 code path's correctness by proxy.
  • HowMuch.cs/IQuantityTests.cs: narrow, appropriately-scoped #if NET / #if !NET gating for static-abstract-interface-member test code that can't compile pre-net7 — consistent with existing patterns elsewhere.
  • Build scripts (Build/build-functions.psm1, init.ps1, build.ps1) switched from "$root\path" string concatenation to Join-Path, plus an OS check for reportgenerator[.exe] — correct, minimal changes to make the scripts Linux-runnable.
  • JSON.NET test assertions changed from matching full ArgumentNullException.Message strings to asserting ParamName — correct, since the .NET Framework CLR4 exception message text differs from modern .NET's. Arguably a better assertion regardless of platform (message text is not a stable contract).

CI/workflow design

  • The fork-PR-safety pattern is the standout piece: net48-compatibility.yml runs on pull_request with only contents: read + id-token: write (safe to execute untrusted fork code), uploads test results and the raw event JSON as artifacts, and a separate publish-net48-test-results.yml triggered via workflow_run (permissions: {} at top level, checks: write only in the job) downloads those artifacts by exact run-id and never checks out code. This is the textbook-correct way to publish check results for fork PRs without exposing a privileged token to untrusted code, and it's self-documented with an inline comment explaining why.
  • Minor inconsistency: actions in publish-net48-test-results.yml are pinned to full commit SHAs (actions/download-artifact@3e5f45b2..., EnricoMi/publish-unit-test-result-action@d0a4676d...), but ci.yml, pr.yml, and net48-compatibility.yml reference the same/similar actions by mutable tag (@v6, @v7, @v8). Since SHA-pinning discipline was already applied to the one workflow that matters most for supply-chain risk (the privileged one), consider extending it to the others too for consistency, or a comment noting it's intentionally scoped to the privileged workflow only.
  • ci.yml's concurrency.cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} correctly avoids cancelling an in-flight master run that may be mid-publish to NuGet.
  • NuGet push step now reads the API key from an env var ($NUGET_ORG_APIKEY) rather than interpolating ${{ secrets.NUGET_ORG_APIKEY }} directly into the run script — good hardening against accidental script-injection/log-leakage patterns.
  • Azure Pipelines are cleanly disabled for v6 (trigger: none / pr: none with a no-op explanatory step) while maintenance/v5's pipeline is left untouched, per the PR description.

Test coverage

No scope creep — new/changed tests are exactly what's needed to validate the CLR4 fixes (ParamName assertions, the RootN fallback helper, EnumHelper swaps).

Security

No medium+ severity issues. The one workflow with elevated (checks: write) permissions is written defensively as described above.

@angularsen
angularsen force-pushed the agl-codex/migrate-ci-pipelines-to-github branch from 6caa1d5 to 08facd0 Compare July 21, 2026 19:28
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review: #1686 — Run GitHub Actions CI on Linux with CLR4 coverage

This is primarily a CI/infra migration (158 files, but ~150 are one mechanical line each in generated test files). Overall this is a well-scoped, carefully documented change. Notes below.

Breaking changes

  • No public API breaks. One behavior change worth flagging explicitly in the PR/release notes: UnitsNet/Extensions/LogarithmicQuantityExtensions.cs's non-NET RootN fallback (used by netstandard2.0/.NET Framework consumers, e.g. GeometricMean) previously did Math.Pow(number, 1.0/n), which returns NaN for negative numbers with odd n (e.g. cube root of -8). The new MathHelper.RootN (UnitsNet/InternalHelpers/MathHelper.cs) correctly returns -2. This is a genuine bug fix, but any consumer on those TFMs that was (knowingly or not) relying on the old NaN result will see a different, correct value now — worth a callout in the changelog since it's a silent behavior change rather than a compile-time break.

Generated code changes

The only generator change is in CodeGen/Generators/UnitsNetGen/UnitTestBaseClassGenerator.cs: Enum.GetValues<T>()EnumHelper.GetValues<T>() in the HasAtLeastOneAbbreviationSpecified test. Enum.GetValues<T>() is a generic method only available on modern TFMs, so it wasn't compiling under the new net48 CLR4 suite; EnumHelper.GetValues<T>() (pre-existing internal helper) shims it via Enum.GetValues(typeof(T)).Cast<T>() on older runtimes.

Verified this is applied identically and mechanically across quantity kinds with no incidental changes:

  • LengthTestsBase.g.cs (ILinearQuantity)
  • TemperatureTestsBase.g.cs (IAffineQuantity)
  • LevelTestsBase.g.cs (ILogarithmicQuantity)

All three (and the ~150 other regenerated files) contain the exact same single-line diff, so this is low-risk.

Code quality / correctness

  • MathHelper.RootN: uses BitConverter.DoubleToInt64Bits(number) < 0 to detect the sign (including for -0.0), returns NaN for even roots of negatives, otherwise negates the root of the absolute value for odd roots. This matches double.RootN semantics for the cases exercised by tests (8/3→2, -8/3→-2, -16/2→NaN). Small, correct, well-scoped internal helper.
  • UnitsNet.Tests/CustomQuantities/HowMuch.cs: the #if !NET explicit interface implementation was updated from QuantityInfo IQuantity.QuantityInfo to IQuantityInstanceInfo<HowMuch> IQuantityOfType<HowMuch>.QuantityInfo => Info;. Checked against generated code (e.g. Length.g.cs) — this now matches the real generated pattern, so this custom test quantity was simply out of sync with the current interface shape and is now fixed, not a new pattern being introduced.
  • Nice attention to detail in the workflow comments explaining why (e.g. concurrency cancel-in-progress disabled on master because a run may be publishing an immutable package set; publish-net48-test-results.yml explicitly explains why it's safe to grant checks: write there — it never checks out or executes contributor code, only downloads artifacts from the specific triggering run).

Security

  • The workflow_run-based test-result publishing (publish-net48-test-results.yml) follows the standard secure pattern for fork PRs: elevated (checks: write) permissions live in a workflow that never executes PR code, only consumes artifacts (uploaded by the read-only pull_request workflow) scoped to the specific triggering run ID. Good.
  • net48-compatibility.yml and pr.yml's build-and-test jobs run on pull_request (not pull_request_target) with id-token: write for OIDC-based Codecov uploads, and execute contributor code. This is a normal/recommended pattern (avoids exposing CODECOV_TOKEN to fork PRs), but since id-token: write + untrusted code execution is a sensitive combination, worth a quick sanity check that Codecov's OIDC trust policy is scoped tightly enough that a minted token can't be leveraged beyond coverage upload for this repo.
  • pr.yml's "Publish test results" step is correctly gated on github.event.pull_request.head.repo.full_name == github.repository, so fork PRs won't attempt to write checks with a token that wouldn't have permission to do so anyway.

Minor / non-blocking

  • Several action versions jump noticeably (actions/checkout@v4→v7, setup-dotnet@v4→v6, upload-artifact@v4→v7, download-artifact@v4→v8, codecov-action@v6). Worth double-checking these tags exist and resolve as expected once this actually runs in Actions, since local build.ps1 validation (per the PR description) doesn't exercise the workflow YAML itself.
  • Action pinning is inconsistent: publish-net48-test-results.yml pins download-artifact/publish-unit-test-result-action by commit SHA (good hardening, since it holds checks: write), while other workflows use floating major-version tags. Not required to fix, but if this repo wants consistent SHA-pinning for third-party actions, this would be the moment to apply it uniformly.
  • The ArgumentNullException message-string assertions replaced with ParamName checks (UnitsNetBaseJsonConverterTest.cs, UnitsNetIComparableJsonConverterTest.cs, UnitsNetIQuantityJsonConverterTest.cs) are a solid improvement — decouples tests from runtime/culture-specific exception message text, which is exactly the kind of thing that was silently failing only on net48/CLR4.

Test coverage

Good — the MathHelper.RootN fix ships with a small parametrized test (8,3,2; -8,3,-2; -16,2,NaN) directly in LogarithmicQuantityExtensionsTest.cs, and the PR description documents concrete local validation numbers for both the modern and CLR4 suites.

Nothing else stood out. Nice, thorough writeup in the PR description.

@angularsen
angularsen force-pushed the agl-codex/migrate-ci-pipelines-to-github branch 2 times, most recently from 27c2e78 to f011d80 Compare July 21, 2026 19:41
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review: 🔧 Run GitHub Actions CI on Linux with CLR4 coverage

Reviewed the diff against 0432ae2 (base) via gh pr diff 1686. This is a CI/build infrastructure PR — no new quantities/units, and generated quantity code is untouched.

🐛 Likely bug: Linux build will fail due to a hardcoded Windows path separator

Build/build-functions.psm1:30 (unchanged by this PR):

function Start-Build {
  write-host -foreground blue "Start-Build (dotnet CLI)...`n---"

  # Use dotnet CLI for all main projects - cross-platform compatible
  dotnet build --configuration Release /p:ContinuousIntegrationBuild=true "$root\UnitsNet.slnx"
  ...

Every other path in this same file was migrated to Join-Path in this PR for cross-platform support (e.g. Start-Tests, Start-PackNugets, Compress-ArtifactsAsZip, init.ps1), but this line still concatenates with a literal \. In PowerShell, \ inside a double-quoted string is not an escape character, so on Linux this evaluates to something like /home/runner/work/UnitsNet/UnitsNet\UnitsNet.slnx — a single path segment containing a literal backslash, not .../UnitsNet\UnitsNet.slnx as two segments. dotnet build will not find this file on Linux.

Since ci.yml and pr.yml now run ./Build/build.ps1 on ubuntu-latest, and build.ps1 calls Start-Build, this looks like it will break the very Linux workflows this PR introduces. The PR description says validation ran "./Build/build.ps1 passed locally," which was presumably on Windows, so this wouldn't have been caught locally. Worth fixing to (Join-Path $root "UnitsNet.slnx") for consistency with the rest of the file, and worth double-checking against an actual ubuntu-latest run rather than just actionlint.

Positive notes on the workflow design

  • The publish-net48-test-results.yml / net48-compatibility.yml split is a nice security-conscious pattern for fork PRs: the workflow that checks out and runs contributor code (net48-compatibility.yml) only gets a read-only token, while the privileged checks: write workflow (publish-net48-test-results.yml) never checks out or executes contributor code — it only downloads immutable artifacts by run-id and pins the third-party actions it uses (actions/download-artifact and EnricoMi/publish-unit-test-result-action) to full commit SHAs. That's the right way to publish check results for fork PRs without exposing a privileged token to PR-controlled code.
  • Switching Codecov upload from a hand-rolled CODECOV_TOKEN + manual GPG-verified binary download to codecov/codecov-action@v6 with use_oidc: true removes a secret and a chunk of bespoke verification script — good simplification and reduces the attack surface.
  • NUGET_ORG_APIKEY is now passed via env: and referenced as $NUGET_ORG_APIKEY in the run: step instead of interpolated directly into the command line — avoids the secret showing up in process listings/argv.

Minor / non-blocking

  • Action pinning is inconsistent: publish-net48-test-results.yml pins to full SHAs (good, since it holds checks: write), but ci.yml/pr.yml/net48-compatibility.yml use mutable version tags (@v7, @v6, @v2, etc.) for the same or similar actions. Not a blocker given those jobs have narrower/read-leaning permissions, but SHA-pinning everywhere (or documenting why it's only done for the privileged workflow) would be more consistent.
  • MathHelper.RootN (new file) detects sign via BitConverter.DoubleToInt64Bits(number) < 0 rather than number < 0. This is presumably intentional to preserve the sign of -0.0, but as written it's non-obvious — a one-line comment would help future readers avoid "simplifying" it back to number < 0 and silently changing -0.0 handling.

Generated code

The only generated-code changes are in the test generator (CodeGen/Generators/UnitsNetGen/UnitTestBaseClassGenerator.cs): Enum.GetValues<T>()EnumHelper.GetValues<T>() in the HasAtLeastOneAbbreviationSpecified test, mechanically propagated to all ~150 *TestsBase.g.cs files (e.g. AbsorbedDoseOfIonizingRadiationTestsBase.g.cs, AccelerationTestsBase.g.cs). EnumHelper.GetValues<T>() already existed and falls back to Enum.GetValues(typeof(T)).Cast<T>() pre-.NET 7, so this is a correct, well-scoped fix to make the test project compile under net48 — no quantity/unit codegen logic changed, no public API impact.

Test coverage

Good: the new MathHelper.RootN fallback (fixing the net48-only negative-odd-root-returns-NaN bug called out in the description) has direct unit tests covering positive, negative-odd, and negative-even cases (LogarithmicQuantityExtensionsTest.cs). No breaking changes to the public API — all changes are internal helpers, test code, or CI configuration.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review: GitHub Actions CI migration

Solid infra PR — no changes to the public API, quantity/unit definitions, or code generators (the .agents/criteria-for-adding-quantities-and-units.md checklist doesn't apply here). Went through the workflow files, Build/build-functions.psm1, the csproj retargeting, and the two test fixes.

Breaking changes

None for consumers. The only functional code change is a hand-written test-only quantity (HowMuch.cs), not shipped code.

Security — nice pattern, one suggestion

  • The split between net48-compatibility.yml (runs contributor PR code, read-only permissions: {}-adjacent) and publish-net48-test-results.yml (triggered via workflow_run, only downloads immutable artifacts, holds checks: write) is exactly the right mitigation for the classic "pwn request" risk of giving write-permissioned workflows access to untrusted fork code. The inline comments explaining why the permission is split are a nice touch.
  • Also good: nuget push no longer interpolates ${{ secrets.NUGET_ORG_APIKEY }} directly into the run: script block (old ci.yml) — it's now passed via env: and referenced as "$NUGET_ORG_APIKEY", which is the recommended way to keep secrets out of the literal command line.
  • Minor inconsistency: publish-net48-test-results.yml pins actions to full commit SHAs (@3e5f45b2... # v8), while every other workflow uses floating major-version tags (@v7, @v6). Since that's precisely the workflow with elevated (checks: write) permissions, the same SHA-pinning would be worth applying consistently — either everywhere, or at minimum on pr.yml and net48-compatibility.yml, which also run before/around contributor code.

CI/test design

  • Consolidating test execution to net10.0 only (with net48 re-added conditionally via IncludeNet48 for the Windows CLR4 job) is a reasonable simplification — net8.0/net9.0 exercise the same code paths as net10.0, so running the full suite three times added CI time without real coverage benefit. The comment in build-functions.psm1 explaining this rationale is exactly the kind of "why" comment this repo's conventions ask for.
  • Worth double-checking the actions version bumps actually resolve: actions/checkout@v7, actions/setup-dotnet@v6, actions/upload-artifact@v7, actions/download-artifact@v8. These are significant major-version jumps from what shipped in the prior workflow (v4) — if any tag doesn't exist, CI will fail fast on the first run, so low risk, but flagging in case it wasn't verified against the marketplace.
  • concurrency groups and timeout-minutes added across all jobs are a good addition that was missing before (avoids wasted runners on stale runs, e.g. the master cancel-in-progress: false correctly avoids interrupting an in-progress publish).

Test-only code changes

  • HowMuch.cs (custom quantity example in tests) previously implemented IQuantity.QuantityInfo directly, which no longer matches the current interface shape. The fix (IQuantityInstanceInfo<HowMuch> IQuantityOfType<HowMuch>.QuantityInfo => Info;) is consistent with the pattern already generated for every real quantity (e.g. Temperature.g.cs, AmplitudeRatio.g.cs), so this was a genuine bug in the test fixture surfaced by adding net48 back into CI, not a new pattern being introduced.
  • Wrapping the static-abstract-member tests in IQuantityTests.cs with #if NET is consistent with how the library itself conditionally compiles static abstract interface members (IQuantity.cs), since those aren't available pre-.NET 5/netstandard2.0.

Test coverage

The net48/CLR4 workflow is a valuable addition — it already surfaced a real RootN bug (odd roots of negative numbers returning NaN instead of a negative result) and the HowMuch.cs compile issue above, which the modern-only suite couldn't catch. Nice justification of the extra CI cost in the PR description.

Overall: clean, well-reasoned CI migration with good security hygiene. The action-pinning consistency point is the only thing I'd nit on before merging.

@angularsen
angularsen force-pushed the agl-codex/migrate-ci-pipelines-to-github branch from f011d80 to d1b95cd Compare July 21, 2026 19:47
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review: CI migration to GitHub Actions with CLR4 coverage

This PR is CI/build infrastructure only — no JSON unit definitions, generators, or generated code (*/GeneratedCode/) are touched, and no public API changes. Reviewed against AGENTS.md/CLAUDE.md conventions.

Breaking changes
None. This only touches workflows, build scripts, and test-project configuration (target frameworks, #if guards). No changes to UnitsNet/GeneratedCode or public types.

New quantities or units
Not applicable — no Common/UnitDefinitions/*.json changes.

Changes to generated code
None — CodeGen/ and */GeneratedCode/*.g.cs are untouched.

Code quality / correctness

  • UnitsNet.Tests/CustomQuantities/HowMuch.cs: the #if !NET branch now implements IQuantityInstanceInfo<HowMuch> IQuantityOfType<HowMuch>.QuantityInfo instead of the old QuantityInfo IQuantity.QuantityInfo. This matches the actual interface contract in IQuantity.cs (IQuantityOfType<TQuantity>.QuantityInfo is IQuantityInstanceInfo<TQuantity> on non-NET targets), so this looks like a real bug fix that was only caught because this PR adds net48 (CLR4) test execution — a good example of the new coverage paying off immediately.
  • Build/build-functions.psm1: restricting dotnet dotcover test to --framework net10.0 is reasonable given net8/9/10 share the same compiled code paths, and the comment explains the "why" well. One caveat: net8.0/net9.0 test execution (not just compilation) no longer happens anywhere in CI — only net10.0 and (via the new workflow) net48/netstandard2.0 are actually run. A runtime-only regression specific to net8 or net9 alone would go undetected. Worth a sentence in the PR description acknowledging this tradeoff.

Security

  • Good pattern in publish-net48-test-results.yml: triggered by workflow_run, starts from permissions: {}, only escalates to checks: write in the job, and never checks out contributor code — only downloads immutable artifacts from the completed run. This avoids the classic "pwn request" pitfall where a fork PRs code executes with write-level tokens.
  • Third-party actions here are pinned to full commit SHA (actions/download-artifact@3e5f45b..., EnricoMi/publish-unit-test-result-action@d0a4676d...) since this workflow has write permissions, while other new/modified workflows (ci.yml, pr.yml, net48-compatibility.yml) pin to tags only (@v7, @v6, etc.). Defensible split, but worth confirming its intentional policy rather than an oversight.
  • Switching Codecov upload to OIDC (use_oidc: true) instead of the CODECOV_TOKEN secret is a good change — removes a secret from the PR-triggered workflow surface.

Performance / CI cost

  • Adding concurrency groups (with cancel-in-progress disabled specifically on master to avoid cancelling a publish-in-progress run) is a solid improvement over the old Azure Pipelines setup.
  • Restricting dotcover/test execution to a single TFM in the main build (net10.0) meaningfully cuts CI time; the CLR4 suite compensates for the netstandard2.0-specific paths.

Test coverage

  • Coverage collection is now split: net10.0 coverage from the main Linux workflow, netstandard2.0 (via net48) coverage from the new Windows workflow, both uploaded to Codecov with the net48 flag distinguishing the latter. Double-check that any existing Codecov flag configuration accounts for the new flag so reporting does not silently regress.

Style / conventions

  • Workflow YAML is consistent and readable; step names use sentence case consistently.
  • azure-pipelines.yml / azure-pipelines-pr.yml are left as inert placeholders (checkout: none + echo notice) rather than deleted outright — reasonable during transition, but consider deleting in a follow-up once the Azure DevOps triggers/schedules/branch policies mentioned in the deployment notes are actually removed.

Overall this is a well-structured, security-conscious CI migration with good documentation in the PR description and inline comments. The one thing I would want addressed or explicitly acknowledged before merge is the loss of net8.0/net9.0 test execution in CI (build-only now for those TFMs).

@angularsen
angularsen force-pushed the agl-codex/migrate-ci-pipelines-to-github branch from d1b95cd to db3ae18 Compare July 21, 2026 19:53
Motivation:
Modern test TFMs exercise the same code paths, while CLR4 provides distinct compatibility coverage.

Changes:
Target net10 on every platform and automatically add net48 on Windows.
Motivation:
Collecting coverage for equivalent modern TFMs duplicates work.

Changes:
Run modern tests and coverage on net10; leave netstandard coverage to the CLR4 workflow.
Motivation:
Companion NuGet packages should display the project documentation.

Changes:
Pack the repository README with the NumberExtensions and JsonNet packages.
Motivation:
The v6 build has no Windows-only dependency and can use Linux as its primary runner.

Changes:
Move CI and PR builds to Linux, modernize actions, use Codecov OIDC, and protect master publishing.
Motivation:
Linux net10 tests do not execute the shipped netstandard2.0 assets on CLR4.

Changes:
Run the full net48 suite with coverage on Windows for PRs, master pushes, and manual runs.
Motivation:
Fork PRs cannot safely grant checks:write to workflows executing contributor code.

Changes:
Publish uploaded CLR4 artifacts through a restricted workflow_run workflow with pinned actions.
Motivation:
Azure and GitHub duplicated v6 validation and publishing, while maintenance/v5 still needs Azure.

Changes:
Disable Azure triggers on master and retain branch-specific v5 pipeline support.
Motivation:
The README still referenced Azure CI and a temporary migration guide was obsolete.

Changes:
Document Linux and CLR4 GitHub Actions, update badges, and remove the migration guide.
@angularsen
angularsen force-pushed the agl-codex/migrate-ci-pipelines-to-github branch from db3ae18 to b430a7f Compare July 21, 2026 19:55
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review: CI migration to GitHub Actions + CLR4 coverage

This PR is infrastructure-only (17 files, all workflows/build scripts/csproj/README) — no changes under Common/UnitDefinitions/, GeneratedCode/, or CustomCode/, so the usual "new quantity/unit" and "generated code" review criteria don't apply here. No breaking changes for library consumers.

PR description vs. diff mismatch

The description lists source-level "Compatibility fixes surfaced by CLR4 testing" (EnumHelper.GetValues<T>() in tests, the non-NET RootN fallback, ArgumentNullException.ParamName assertions). None of those files are in this diff — MathHelper.RootN already exists on master independent of this PR. Worth confirming those fixes already landed in a separate PR/commit so reviewers aren't hunting for them here; if so, trimming that section from the description (or noting "already merged in #xxxx") would avoid confusion.

Nice security pattern

publish-net48-test-results.yml correctly splits the elevated checks: write permission into a separate workflow_run-triggered job that never checks out or executes contributor code — only downloads immutable artifacts by run-id. It also pins actions/download-artifact and EnricoMi/publish-unit-test-result-action to full commit SHAs, unlike the other workflows which use major-version tags. That's the right tradeoff: SHA-pin only the workflow with write permissions that can be triggered by fork PRs. Good mitigation for the "pwn request" pattern.

Minor: net48 testing duplicated/removed from local dev loop

Build/build-functions.psm1's Start-Tests now hardcodes --framework net10.0 in the shared dotcover test call, so build.ps1 (used both by CI and local Windows dev) no longer exercises net48 at all. The net48-compatibility.yml workflow independently re-implements the test loop with its own hardcoded $testProjects array. Two consequences worth considering:

  • A contributor on Windows running build.ps1 locally no longer gets net48 test feedback — they'd need to know to run the CLR4 workflow-style commands manually.
  • The test project list now lives in two places (implicitly via the solution/build script, and explicitly in the workflow's $testProjects array) — adding a new test project requires remembering to update the workflow file too.

Not blocking, but a comment in build-functions.psm1 pointing to net48-compatibility.yml (or vice versa) would help future contributors avoid drift.

Workflow logic

  • ci.yml/pr.yml: sensible permissions: scoping (top-level contents: read, job-level narrow grants), concurrency groups added, and the switch from the Windows-specific publish-unit-test-result-action/windows@v2 to the plain @v2 action correctly follows the runner OS move from windows-latestubuntu-latest.
  • The Publish test results step in pr.yml guards on github.event.pull_request.head.repo.full_name == github.repository, correctly skipping fork PRs where the token would lack checks: write anyway — good defensive check even though GitHub would also enforce this.
  • azure-pipelines.yml/azure-pipelines-pr.yml are reduced to trigger: none / pr: none stubs with a checkout: none notice step rather than being deleted — reasonable if there's a desire to keep the pipeline definitions discoverable/re-enablable, but worth confirming that's intentional vs. just deleting them outright now that GitHub Actions is authoritative for master.

csproj changes

Conditionally including net48 only when $([MSBuild]::IsOSPlatform('Windows')) in the four test projects is a clean way to keep dotnet build/dotnet test working on Linux CI without needing a Windows-only TFM. PackageReadmeFile/README inclusion additions for the NumberExtensions/JsonNet packages look like a reasonable, unrelated-but-small packaging fix bundled in.

Overall: solid, well-documented migration. The main thing worth resolving before merge is the stale-looking "compatibility fixes" section in the description, and optionally a note tying together the two net48 test-project lists.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review: CI/build infrastructure migration (Linux + CLR4 coverage)

This PR is exclusively CI/build tooling — no Common/UnitDefinitions/*.json, GeneratedCode/, or CustomCode/ changes, so the "new quantities/units" and "generated code" review criteria don't apply here.

Breaking changes

None to the public API. The only behavior change for contributors: local Start-Tests in Build/build-functions.psm1 now hardcodes --framework net10.0, so a local test.bat/build.bat run on Linux/macOS will no longer attempt (and fail) the net48 target — that's a net improvement, not a regression, since net48 is now conditionally only added to TargetFrameworks on Windows anyway.

PR description vs. diff

The description's "Compatibility fixes surfaced by CLR4 testing" section (⁠EnumHelper.GetValues<T>(), the non-NET RootN fallback fix, ArgumentNullException.ParamName assertions) doesn't appear in this diff — I confirmed all of those already exist on master (UnitsNet/InternalHelpers/MathHelper.cs:15-25, EnumHelper.GetValues<T>() usages in UnitsNet.Tests/GeneratedCode/TestsBase/*.g.cs, ParamName asserts in UnitAbbreviationsCacheTests.cs). Looks like those landed in an earlier, already-merged PR and the description here is stale/carried over — worth trimming before merge so the PR record matches its actual diff.

Code quality / best practices

  • Good separation of concerns: net48-compatibility.yml runs the CLR4 suite on windows-latest, while ci.yml/pr.yml build all shipped targets (netstandard2.0, net8.0/9.0/10.0) on Linux but only run tests+coverage on net10.0 (Build/build-functions.psm1:57-67) — since net8/9/10 compile identical code paths, this avoids 3x redundant test runs and moves the bulk of CI to cheaper/faster Linux runners.
  • The publish-net48-test-results.yml / workflow_run split is the right pattern for granting checks: write without letting fork/Dependabot PR code run with elevated permissions — it only downloads immutable artifacts from the specific triggering run and never checks out contributor code. Nice touch documenting why in the comment (net48-compatibility.yml:240-242).
  • Minor inconsistency: only publish-net48-test-results.yml pins third-party actions by commit SHA (actions/download-artifact@3e5f45b2c... # v8, EnricoMi/publish-unit-test-result-action@d0a4676d... # v2). Other workflows reference the same/similar actions by mutable tag (@v7, @v6, @v8). Since that's the one workflow with checks: write, pinning there is the highest-value spot, but consider extending SHA-pinning repo-wide for defense in depth, especially for the publish-nuget job that pushes to NuGet.org.
  • --framework net10.0 is now duplicated in Build/build-functions.psm1 and independently in 4 .csproj files' TargetFrameworks. Not urgent, but if net11.0 becomes the modern target in the future, this is 5 places to update in sync — a single MSBuild property or psm1 variable could reduce that to one.

Test coverage

Net new: net48-compatibility.yml adds a real CLR4 test tier (all 4 test projects) with its own Codecov flag (flags: net48), which is a genuine coverage improvement over the prior all-Windows setup that never distinguished netstandard2.0/CLR4 behavior from net8+.

Security

Nothing at medium+ severity. The id-token: write permission added for OIDC-based Codecov upload (use_oidc: true) is appropriately scoped (no secrets exposed to fork PRs), and the elevated-permission publish-net48-test-results.yml workflow correctly isolates checks: write behind workflow_run + artifact-only access as noted above.

Overall this is a well-thought-out, well-documented CI migration — the reasoning comments in the workflow files (concurrency, permission splits, --framework choice) make the non-obvious decisions easy to follow for future maintainers.

@angularsen angularsen closed this Jul 21, 2026
@angularsen

Copy link
Copy Markdown
Owner Author

Split into multiple commits and pushed directly to master instead.

@angularsen
angularsen deleted the agl-codex/migrate-ci-pipelines-to-github branch July 21, 2026 20:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant