From 5e0646b832ba30b836bde72c01eb32c930032325 Mon Sep 17 00:00:00 2001 From: Brandon Date: Tue, 15 Sep 2026 16:20:03 -0400 Subject: [PATCH] Stop Dependabot's restore rewriting multi-targeted lock files Dependabot's NuGet discovery invokes Restore through ProjectReferenceBuildTargets, so each referenced multi-targeted project was restored with one TargetFramework and its packages.lock.json rewritten with net10.0 only. That broke both Dependabot NuGet PRs (#42, #45). Reproduced locally with the same msbuild invocation (from dependabot-core's SdkProjectDiscovery.cs): three lock files lost net8.0. Directory.Build.targets sends the lock file to obj/ when a multi-targeted project is restored under a single TargetFramework, which ordinary restore never does. With it, the reproduction changes nothing; a fresh locked restore still fails on a lock file missing a framework. tools/lockfile-probe/check.sh runs the reproduction in validate.sh full and fails without the fix. Closes #63 Co-Authored-By: Claude Opus 5 --- Directory.Build.targets | 21 ++++++++++++++ scripts/validate.sh | 9 ++++++ tools/lockfile-probe/check.sh | 54 +++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 Directory.Build.targets create mode 100755 tools/lockfile-probe/check.sh diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..c71302a --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,21 @@ + + + + + $(MSBuildProjectExtensionsPath)single-framework.packages.lock.json + + + diff --git a/scripts/validate.sh b/scripts/validate.sh index 6850f46..b43e40b 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -199,6 +199,15 @@ fi # This packs it and builds a real net8.0 consumer against the .nupkg, then builds this # repository's own packaged projects, from a copy, with that analyzer attached. `full` only: # it packs and restores, which the inner loop should not pay for on every run. +# Dependabot's NuGet discovery restores referenced projects one framework at a time, which used +# to rewrite the multi-targeted lock files without net8.0 (#63). Directory.Build.targets stops +# that; this reproduces the exact invocation in a copy of the tree and fails if any lock file +# changes. `full` only: it restores every test project's graph. +if [[ "$MODE" == "full" ]]; then + step "A single-framework restore leaves the lock files alone" + run "lockfile-probe" tools/lockfile-probe/check.sh || true +fi + if [[ "$MODE" == "full" ]]; then step "Analyzer reaches a consumer, and the kernel passes it" run "analyzer-probe" tools/analyzer-probe/check.sh || true diff --git a/tools/lockfile-probe/check.sh b/tools/lockfile-probe/check.sh new file mode 100755 index 0000000..276493f --- /dev/null +++ b/tools/lockfile-probe/check.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# check.sh -- prove a single-framework restore cannot rewrite a multi-targeted lock file. +# +# Dependabot's NuGet discovery runs, in each project directory: +# dotnet msbuild /t:Restore,ResolveProjectReferences,GenerateBuildDependencyFile +# /p:ProjectReferenceBuildTargets="Restore;ResolveProjectReferences;GenerateBuildDependencyFile" +# (dependabot-core, nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Discover/SdkProjectDiscovery.cs). +# Through the project references, that restored RulesKernel, RulesKernel.Randomness and +# RulesKernel.Testing with one TargetFramework each and wrote net10.0-only lock files: issue #63, +# reproduced exactly this way. Directory.Build.targets redirects the lock file in that context. +# +# This runs the same invocation from every test project in a clean copy of the tree and fails +# if any tracked packages.lock.json changed. It reads the copy's own `git status`, so a lock file +# that changed is named, not inferred. +# +# tools/lockfile-probe/check.sh +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# A copy of the working tree -- tracked files plus untracked ones that are not ignored, the +# inventory tools/repo-checks.py examines -- committed into a scratch repository so its lock +# files have a baseline to diff against. This checkout's obj/ and lock files are never touched. +mkdir -p "$WORK/tree" +(cd "$REPO_ROOT" && git ls-files -z --cached --others --exclude-standard | xargs -0 cp --parents -t "$WORK/tree") +(cd "$WORK/tree" && git init -q && git add -A \ + && git -c user.name=probe -c user.email=probe@example.invalid commit -qm baseline) + +mapfile -t projects < <(cd "$WORK/tree" && grep -rl --include=*.csproj 'true' tests probes | sort) +if [[ "${#projects[@]}" -eq 0 ]]; then + echo "FAIL: no test projects found to restore from -- this check proved nothing" + exit 1 +fi + +for project in "${projects[@]}"; do + if ! (cd "$WORK/tree/$(dirname "$project")" && dotnet msbuild "$(basename "$project")" \ + "/t:Restore,ResolveProjectReferences,GenerateBuildDependencyFile" \ + '/p:ProjectReferenceBuildTargets="Restore;ResolveProjectReferences;GenerateBuildDependencyFile"' \ + /p:TreatWarningsAsErrors=false -nologo -v:q) >"$WORK/restore.log" 2>&1; then + echo "FAIL: the Dependabot-style restore of $project did not run" + tail -10 "$WORK/restore.log" + exit 1 + fi +done + +changed="$(cd "$WORK/tree" && git status --porcelain -- '*packages.lock.json')" +if [[ -n "$changed" ]]; then + echo "FAIL: a Dependabot-style restore rewrote lock files (issue #63):" + printf '%s\n' "$changed" | sed 's/^/ /' + exit 1 +fi +echo "ok ${#projects[@]} Dependabot-style restore(s) left every packages.lock.json unchanged"