Summary
Harden NetPace's dependency-update pipeline against supply-chain risk without removing the automated Dependabot flow. The human merge gate stays; this issue adds assurance around it so that a reviewed-and-merged bump provably ships exactly what was reviewed, a same-day compromised publish is never ingested, and a known-vulnerable dependency fails the build.
The reasoning, alternatives, and trade-offs are recorded in a Change Intent Record: docs/change-intent-records/2026-07-13-dependency-supply-chain-hardening.md (already drafted on branch feature/dependency-supply-chain-hardening). Base your implementation on that branch so the CIR and the implementation ship together.
Background
Current state (verified July 2026):
- Dependabot:
.github/dependabot.yml — monthly NuGet PRs, assigned to the maintainer, no auto-merge. This human gate is kept.
- Versions pinned exactly in each
.csproj, except Roslynator, which floats [4.15.0, ) in src/Directory.Build.props.
- No
packages.lock.json anywhere — transitive dependencies float and nothing records content hashes.
- No
nuget.config — single implicit nuget.org feed.
- CodeQL (
.github/workflows/codeql.yml) is SAST over our own code; it does not scan dependencies for known advisories.
Scope
In scope (three changes):
- Lock files + locked-mode restore on CI/release only.
- 14-day Dependabot cooldown.
- Hard-failing vulnerable-dependency (SCA) check in CI.
Out of scope (deliberately deferred — do not implement):
- Package source-mapping /
nuget.config — no second feed exists yet, so there is no dependency-confusion vector to close.
- Explicitly pinning Roslynator — locking (change 1) freezes the floating range as a side effect, so this is no longer a security gap. Leaving it as a follow-up.
- Any SLSA / build-provenance attestation.
Implementation tasks
1. Lock files + locked-mode restore
Outcome: committed packages.lock.json files, and CI/release restores fail if the resolved dependency graph differs from what is locked. Developer machines restore normally (unlocked), so routine local work isn't blocked.
Recommended mechanism:
- In
src/Directory.Build.props, enable lock-file generation for all projects:
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
- Gate locked-mode to CI so local restores stay unlocked, e.g.
<RestoreLockedMode Condition="'$(CI)' == 'true'">true</RestoreLockedMode> (GitHub Actions sets CI=true; this also covers the implicit restores inside dotnet publish/dotnet pack, which a per-command --locked-mode flag would miss).
- Run
dotnet restore src locally once to generate a packages.lock.json per project, and commit them.
- Confirm the four explicit restore steps still succeed under lock — no need to add
--locked-mode to each if the MSBuild property approach above is used, but if you instead go the per-command route, cover all of these plus the implicit restores in publish/pack:
.github/workflows/dotnet.yml:21
.github/workflows/codeql.yml:41
.github/workflows/publish-nuget.yml:56
.github/workflows/release-binaries.yml:78 (plus the dotnet publish/dotnet pack steps that restore on the fly)
Note for the maintainer: after accepting any future dependency bump, packages.lock.json must be regenerated. Dependabot does this within its own PRs; a hand-edited version bump that skips regeneration will (by design) turn CI red.
2. Dependabot cooldown (14 days)
Outcome: Dependabot does not open a version-bump PR until the candidate release is at least 14 days old.
- Add a
cooldown: block to .github/dependabot.yml for the existing nuget update entry, with a 14-day window applied to all dependency updates.
3. Hard-failing SCA check
Outcome: CI fails the build when any direct or transitive dependency has a known advisory.
- Add a step to the PR build pipeline (
.github/workflows/dotnet.yml) that runs dotnet list package --vulnerable --include-transitive and fails the job if any vulnerability is reported. Note: this command exits 0 even when vulnerabilities are found, so the step must inspect the output (e.g. grep for advisory markers) and exit non-zero itself.
- Place it after restore/build so transitive resolution is available.
Acceptance criteria
- AC1 — A CI or release-pipeline restore fails when the resolved dependency graph (including transitive packages) differs from the committed lock files; a developer's local
dotnet restore on an unchanged graph still succeeds without locked-mode friction.
- AC2 —
packages.lock.json files are committed for every restorable project under src/.
- AC3 — Dependabot does not raise a version-bump PR for a release younger than 14 days.
- AC4 — When a dependency (direct or transitive) has a known advisory, the PR build fails; when none do, it passes.
- AC5 — The three changes leave the existing human review gate intact: Dependabot still opens maintainer-assigned PRs and nothing auto-merges.
- AC6 — The CIR (
docs/change-intent-records/2026-07-13-dependency-supply-chain-hardening.md) ships in the same PR.
Verification
dotnet build src and dotnet test src pass locally with zero warnings (warnings are errors in this repo).
- Locally simulate CI locked-mode:
CI=true dotnet restore src succeeds on the committed lock files; then bump a version in a .csproj without regenerating the lock file and confirm CI=true dotnet restore src fails (then revert).
- Confirm the SCA step fails the job when pointed at a known-vulnerable package (can be demonstrated in a scratch commit, then reverted) and passes on the clean tree.
- Open the PR and confirm all workflow jobs run to completion under the new lock/SCA constraints.
Maintainer manual step (cannot be automated in-repo)
Enable Dependabot security alerts and security updates in the repository Settings → Code security. This is the SCA layer that runs on GitHub's side, independent of the version-update PRs, and complements the in-CI check from task 3. Note this in the PR description as a required post-merge action.
Constraints / notes for the implementing agent
- Follow
CLAUDE.md and the constitution. These changes are build/CI configuration, not production C#, so the RED-GREEN TDD cycle does not cleanly apply; verification is via the CI runs and the local simulation above.
- Per
CLAUDE.md: touching release-binaries.yml requires checking whether docs/RELEASING.md needs a corresponding update (the restore/lock contract).
- Do not hard-wrap markdown prose; one line per paragraph/bullet.
- Raise a PR that requests an
@claude review when complete.
Summary
Harden NetPace's dependency-update pipeline against supply-chain risk without removing the automated Dependabot flow. The human merge gate stays; this issue adds assurance around it so that a reviewed-and-merged bump provably ships exactly what was reviewed, a same-day compromised publish is never ingested, and a known-vulnerable dependency fails the build.
The reasoning, alternatives, and trade-offs are recorded in a Change Intent Record:
docs/change-intent-records/2026-07-13-dependency-supply-chain-hardening.md(already drafted on branchfeature/dependency-supply-chain-hardening). Base your implementation on that branch so the CIR and the implementation ship together.Background
Current state (verified July 2026):
.github/dependabot.yml— monthly NuGet PRs, assigned to the maintainer, no auto-merge. This human gate is kept..csproj, except Roslynator, which floats[4.15.0, )insrc/Directory.Build.props.packages.lock.jsonanywhere — transitive dependencies float and nothing records content hashes.nuget.config— single implicit nuget.org feed..github/workflows/codeql.yml) is SAST over our own code; it does not scan dependencies for known advisories.Scope
In scope (three changes):
Out of scope (deliberately deferred — do not implement):
nuget.config— no second feed exists yet, so there is no dependency-confusion vector to close.Implementation tasks
1. Lock files + locked-mode restore
Outcome: committed
packages.lock.jsonfiles, and CI/release restores fail if the resolved dependency graph differs from what is locked. Developer machines restore normally (unlocked), so routine local work isn't blocked.Recommended mechanism:
src/Directory.Build.props, enable lock-file generation for all projects:<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile><RestoreLockedMode Condition="'$(CI)' == 'true'">true</RestoreLockedMode>(GitHub Actions setsCI=true; this also covers the implicit restores insidedotnet publish/dotnet pack, which a per-command--locked-modeflag would miss).dotnet restore srclocally once to generate apackages.lock.jsonper project, and commit them.--locked-modeto each if the MSBuild property approach above is used, but if you instead go the per-command route, cover all of these plus the implicit restores in publish/pack:.github/workflows/dotnet.yml:21.github/workflows/codeql.yml:41.github/workflows/publish-nuget.yml:56.github/workflows/release-binaries.yml:78(plus thedotnet publish/dotnet packsteps that restore on the fly)Note for the maintainer: after accepting any future dependency bump,
packages.lock.jsonmust be regenerated. Dependabot does this within its own PRs; a hand-edited version bump that skips regeneration will (by design) turn CI red.2. Dependabot cooldown (14 days)
Outcome: Dependabot does not open a version-bump PR until the candidate release is at least 14 days old.
cooldown:block to.github/dependabot.ymlfor the existingnugetupdate entry, with a 14-day window applied to all dependency updates.3. Hard-failing SCA check
Outcome: CI fails the build when any direct or transitive dependency has a known advisory.
.github/workflows/dotnet.yml) that runsdotnet list package --vulnerable --include-transitiveand fails the job if any vulnerability is reported. Note: this command exits0even when vulnerabilities are found, so the step must inspect the output (e.g. grep for advisory markers) and exit non-zero itself.Acceptance criteria
dotnet restoreon an unchanged graph still succeeds without locked-mode friction.packages.lock.jsonfiles are committed for every restorable project undersrc/.docs/change-intent-records/2026-07-13-dependency-supply-chain-hardening.md) ships in the same PR.Verification
dotnet build srcanddotnet test srcpass locally with zero warnings (warnings are errors in this repo).CI=true dotnet restore srcsucceeds on the committed lock files; then bump a version in a.csprojwithout regenerating the lock file and confirmCI=true dotnet restore srcfails (then revert).Maintainer manual step (cannot be automated in-repo)
Enable Dependabot security alerts and security updates in the repository Settings → Code security. This is the SCA layer that runs on GitHub's side, independent of the version-update PRs, and complements the in-CI check from task 3. Note this in the PR description as a required post-merge action.
Constraints / notes for the implementing agent
CLAUDE.mdand the constitution. These changes are build/CI configuration, not production C#, so the RED-GREEN TDD cycle does not cleanly apply; verification is via the CI runs and the local simulation above.CLAUDE.md: touchingrelease-binaries.ymlrequires checking whetherdocs/RELEASING.mdneeds a corresponding update (the restore/lock contract).@claudereview when complete.