Skip to content

Commit 229389b

Browse files
MalcolmnixonMalcolm NixonCopilot
authored
Bump DemaConsulting.NuGet.Caching to 1.4.0 and enhance config handling (#38)
* Bump DemaConsulting.NuGet.Caching to 1.4.0 and pass packages.config directory as config root DemaConsulting.NuGet.Caching 1.4.0 fixes GitHub issue #37: EnsureCachedAsync previously called Settings.LoadDefaultSettings(null), which never scanned the working directory (or any ancestor) for a project/repo-local nuget.config - only machine/user-wide settings were loaded, so repo-scoped package sources were silently invisible. Bump the package reference to 1.4.0 and thread the directory containing packages.config through PackageInstaller.InstallAsync/InstallPackageAsync as a new optional configRoot parameter, forwarded to EnsureCachedAsync's root argument. This ensures nuget-installer discovers a repo-local nuget.config the same way dotnet restore does, regardless of the current working directory the tool is invoked from. Verified end-to-end against a real repo with a repo-local nuget.config and JFrog Artifactory sources: cleared the local NuGet cache, removed the output packages folder, and confirmed nuget-installer downloads and installs the package correctly using the released 1.4.0 package (not a project reference). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add regression test proving configRoot enables repo-local nuget.config discovery Addresses PR #38 reviewer feedback requesting test coverage for the configRoot derivation added to Program.RunToolLogic. Adds an end-to-end test that builds an isolated temp repo layout (nuget.config at the root defining a local-folder package source, packages.config nested in a subdirectory) and verifies Program.Run resolves and installs the package using only the repo-local config, plus a negative test confirming resolution fails without that config present. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Malcolm Nixon <Malcolm.Nixon@hiarc.inc> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 2b94e5c commit 229389b

4 files changed

Lines changed: 263 additions & 7 deletions

File tree

‎src/DemaConsulting.NuGetInstaller/DemaConsulting.NuGetInstaller.csproj‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848

4949
<!-- Runtime Dependencies -->
5050
<ItemGroup>
51-
<PackageReference Include="DemaConsulting.NuGet.Caching" Version="1.3.0" />
51+
<PackageReference Include="DemaConsulting.NuGet.Caching" Version="1.4.0" />
5252
<PackageReference Include="DemaConsulting.TestResults" Version="1.10.0" />
5353
</ItemGroup>
5454

‎src/DemaConsulting.NuGetInstaller/NuGet/PackageInstaller.cs‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,21 @@ internal static class PackageInstaller
3636
/// <param name="packages">The list of packages to install.</param>
3737
/// <param name="outputDirectory">The output directory for package extraction.</param>
3838
/// <param name="excludeVersion">When <see langword="true"/>, use {Id}/ folder naming instead of {Id}.{Version}/.</param>
39+
/// <param name="configRoot">
40+
/// The directory from which to discover the applicable <c>nuget.config</c> (typically the
41+
/// directory containing the <c>packages.config</c> file). When <see langword="null"/>,
42+
/// <see cref="NuGetCache.EnsureCachedAsync(string, string, string?, System.Threading.CancellationToken)"/>
43+
/// falls back to the current working directory.
44+
/// </param>
3945
/// <returns>A task representing the asynchronous operation.</returns>
4046
/// <exception cref="ArgumentNullException">Thrown when <paramref name="outputDirectory"/> is <see langword="null"/>.</exception>
4147
/// <exception cref="ArgumentException">Thrown when <paramref name="outputDirectory"/> is empty.</exception>
4248
public static async Task InstallAsync(
4349
Context context,
4450
IReadOnlyList<PackageEntry> packages,
4551
string outputDirectory,
46-
bool excludeVersion)
52+
bool excludeVersion,
53+
string? configRoot = null)
4754
{
4855
ArgumentNullException.ThrowIfNull(outputDirectory);
4956
ArgumentException.ThrowIfNullOrEmpty(outputDirectory);
@@ -53,7 +60,7 @@ public static async Task InstallAsync(
5360

5461
// Install all packages in parallel
5562
await Task.WhenAll(packages.Select(entry =>
56-
InstallPackageAsync(context, entry, outputDirectory, excludeVersion))).ConfigureAwait(false);
63+
InstallPackageAsync(context, entry, outputDirectory, excludeVersion, configRoot))).ConfigureAwait(false);
5764
}
5865

5966
/// <summary>
@@ -63,15 +70,22 @@ await Task.WhenAll(packages.Select(entry =>
6370
/// <param name="entry">The package entry to install.</param>
6471
/// <param name="outputDirectory">The output directory for package extraction.</param>
6572
/// <param name="excludeVersion">When <see langword="true"/>, use {Id}/ folder naming instead of {Id}.{Version}/.</param>
73+
/// <param name="configRoot">
74+
/// The directory from which to discover the applicable <c>nuget.config</c>, forwarded to
75+
/// <see cref="NuGetCache.EnsureCachedAsync(string, string, string?, System.Threading.CancellationToken)"/>.
76+
/// </param>
6677
/// <returns>A task representing the asynchronous operation.</returns>
6778
private static async Task InstallPackageAsync(
6879
Context context,
6980
PackageEntry entry,
7081
string outputDirectory,
71-
bool excludeVersion)
82+
bool excludeVersion,
83+
string? configRoot)
7284
{
73-
// Ensure the package is in the local NuGet cache
74-
var cacheFolder = await NuGetCache.EnsureCachedAsync(entry.Id, entry.Version).ConfigureAwait(false);
85+
// Ensure the package is in the local NuGet cache. Passing configRoot (typically the
86+
// directory containing packages.config) allows a project/repo-local nuget.config to be
87+
// discovered the same way `dotnet restore` discovers it.
88+
var cacheFolder = await NuGetCache.EnsureCachedAsync(entry.Id, entry.Version, configRoot).ConfigureAwait(false);
7589

7690
// Build paths - NuGet global package cache uses lowercase for filenames
7791
var nupkgPath = Path.Combine(cacheFolder, $"{entry.Id}.{entry.Version}.nupkg".ToLowerInvariant());

‎src/DemaConsulting.NuGetInstaller/Program.cs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,8 +241,12 @@ private static void RunToolLogic(Context context)
241241
// Resolve output directory
242242
var outputDirectory = context.OutputDirectory ?? Directory.GetCurrentDirectory();
243243

244+
// Resolve the directory containing packages.config so a project/repo-local nuget.config
245+
// is discovered the same way `dotnet restore` discovers it
246+
var configRoot = Path.GetDirectoryName(Path.GetFullPath(context.PackagesConfigFile));
247+
244248
// Install
245-
PackageInstaller.InstallAsync(context, packages, outputDirectory, context.ExcludeVersion)
249+
PackageInstaller.InstallAsync(context, packages, outputDirectory, context.ExcludeVersion, configRoot)
246250
.GetAwaiter().GetResult();
247251
}
248252
}
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
// Copyright (c) DEMA Consulting
2+
//
3+
// Permission is hereby granted, free of charge, to any person obtaining a copy
4+
// of this software and associated documentation files (the "Software"), to deal
5+
// in the Software without restriction, including without limitation the rights
6+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
// copies of the Software, and to permit persons to whom the Software is
8+
// furnished to do so, subject to the following conditions:
9+
//
10+
// The above copyright notice and this permission notice shall be included in all
11+
// copies or substantial portions of the Software.
12+
//
13+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19+
// SOFTWARE.
20+
21+
using System.IO.Compression;
22+
using DemaConsulting.NuGetInstaller.Cli;
23+
24+
namespace DemaConsulting.NuGetInstaller.Tests;
25+
26+
/// <summary>
27+
/// End-to-end tests proving that <see cref="Program.Run"/> derives a <c>configRoot</c> from
28+
/// the <c>packages.config</c> location and forwards it through to NuGet settings discovery,
29+
/// so a project/repo-local <c>nuget.config</c> is found the same way <c>dotnet restore</c>
30+
/// finds it - regardless of the process's actual current working directory.
31+
/// </summary>
32+
/// <remarks>
33+
/// This is a regression test for GitHub issue #37: <c>NuGetCache.EnsureCachedAsync</c>
34+
/// previously ignored any repo-local <c>nuget.config</c> because no root directory was
35+
/// forwarded to <c>Settings.LoadDefaultSettings</c>. Each test here builds an isolated
36+
/// temporary "repository" containing a <c>nuget.config</c> at its root (defining a local
37+
/// folder package source and a private global packages folder, so no real machine state
38+
/// is touched) and a nested <c>packages.config</c> in a subdirectory - mirroring the
39+
/// <c>HiArc.LaunchCode.HardwareSim</c> layout where <c>nuget.config</c> lives several
40+
/// directories above the project's <c>packages.config</c>.
41+
/// </remarks>
42+
public class ProgramConfigRootTests
43+
{
44+
/// <summary>
45+
/// Test that <c>Program.Run</c> installs a package from a source defined only in a
46+
/// repo-local <c>nuget.config</c> located in an ancestor of the <c>packages.config</c>
47+
/// directory, proving the derived <c>configRoot</c> is honored end-to-end.
48+
/// </summary>
49+
[Fact]
50+
public void Program_Run_WithRepoLocalNugetConfigAboveNestedPackagesConfig_InstallsFromLocalSource()
51+
{
52+
// Arrange - build an isolated "repository" layout:
53+
// repoRoot/
54+
// nuget.config <- defines the only source that can resolve the package
55+
// feed/{id}.{version}.nupkg <- the package content
56+
// project/packages.config <- references the package; several levels of walk-up
57+
// from here reach nuget.config, matching the real
58+
// HardwareSim repo layout
59+
// packages/ <- installer output directory
60+
// global-packages/ <- private global packages folder (keeps this test
61+
// from touching the real machine-wide NuGet cache)
62+
var repoRoot = Path.Combine(Path.GetTempPath(), $"nuget_installer_config_root_test_{Guid.NewGuid():N}");
63+
var feedDir = Path.Combine(repoRoot, "feed");
64+
var projectDir = Path.Combine(repoRoot, "project");
65+
var outputDir = Path.Combine(repoRoot, "packages");
66+
var globalPackagesDir = Path.Combine(repoRoot, "global-packages");
67+
var packageId = $"Test.ConfigRootDiscovery.{Guid.NewGuid():N}";
68+
const string version = "1.0.0";
69+
70+
try
71+
{
72+
Directory.CreateDirectory(feedDir);
73+
Directory.CreateDirectory(projectDir);
74+
Directory.CreateDirectory(globalPackagesDir);
75+
76+
// Write the package into the local folder feed
77+
File.WriteAllBytes(
78+
Path.Combine(feedDir, $"{packageId}.{version}.nupkg"),
79+
CreateMinimalNupkgBytes(packageId, version));
80+
81+
// Write a repo-local nuget.config at the repo root defining the local feed as the
82+
// only source and a private global packages folder
83+
File.WriteAllText(Path.Combine(repoRoot, "nuget.config"), $"""
84+
<?xml version="1.0" encoding="utf-8"?>
85+
<configuration>
86+
<config>
87+
<add key="globalPackagesFolder" value="{globalPackagesDir}" />
88+
</config>
89+
<packageSources>
90+
<clear />
91+
<add key="repo-local-feed" value="{feedDir}" />
92+
</packageSources>
93+
</configuration>
94+
""");
95+
96+
// Write packages.config nested below the repo root, matching how HardwareSim's
97+
// packages.config sits several directories below its repo-root nuget.config
98+
var configPath = Path.Combine(projectDir, "packages.config");
99+
File.WriteAllText(configPath, $"""
100+
<?xml version="1.0" encoding="utf-8"?>
101+
<packages>
102+
<package id="{packageId}" version="{version}" />
103+
</packages>
104+
""");
105+
106+
var originalOut = Console.Out;
107+
try
108+
{
109+
using var outWriter = new StringWriter();
110+
Console.SetOut(outWriter);
111+
using var context = Context.Create([configPath, "-o", outputDir]);
112+
113+
// Act - run the program; the process's actual working directory is unrelated to
114+
// repoRoot, so success can only be explained by Program deriving configRoot from
115+
// the packages.config location and NuGetCache honoring it
116+
Program.Run(context);
117+
118+
// Assert - installation succeeded and the package was extracted
119+
Assert.Equal(0, context.ExitCode);
120+
var expectedFolder = Path.Combine(outputDir, $"{packageId}.{version}");
121+
Assert.True(
122+
Directory.Exists(expectedFolder),
123+
$"Expected package folder to exist at: {expectedFolder}");
124+
}
125+
finally
126+
{
127+
Console.SetOut(originalOut);
128+
}
129+
}
130+
finally
131+
{
132+
if (Directory.Exists(repoRoot))
133+
{
134+
Directory.Delete(repoRoot, recursive: true);
135+
}
136+
}
137+
}
138+
139+
/// <summary>
140+
/// Test that <c>Program.Run</c> fails to resolve a package whose only source is defined
141+
/// in a <c>nuget.config</c> that does not exist anywhere above the <c>packages.config</c>
142+
/// directory, proving the previous test's success is due to genuine discovery of the
143+
/// repo-local config rather than an unrelated fallback.
144+
/// </summary>
145+
[Fact]
146+
public void Program_Run_WithoutRepoLocalNugetConfig_PackageNotFound_ThrowsInvalidOperationException()
147+
{
148+
// Arrange - same nested packages.config as above, but with no nuget.config anywhere in
149+
// the temp tree, so the package (which does not exist on any real source) cannot resolve
150+
var repoRoot = Path.Combine(Path.GetTempPath(), $"nuget_installer_config_root_test_{Guid.NewGuid():N}");
151+
var projectDir = Path.Combine(repoRoot, "project");
152+
var outputDir = Path.Combine(repoRoot, "packages");
153+
var packageId = $"Test.ConfigRootDiscovery.{Guid.NewGuid():N}";
154+
const string version = "1.0.0";
155+
156+
try
157+
{
158+
Directory.CreateDirectory(projectDir);
159+
160+
var configPath = Path.Combine(projectDir, "packages.config");
161+
File.WriteAllText(configPath, $"""
162+
<?xml version="1.0" encoding="utf-8"?>
163+
<packages>
164+
<package id="{packageId}" version="{version}" />
165+
</packages>
166+
""");
167+
168+
var originalOut = Console.Out;
169+
try
170+
{
171+
using var outWriter = new StringWriter();
172+
Console.SetOut(outWriter);
173+
using var context = Context.Create([configPath, "-o", outputDir]);
174+
175+
// Act & Assert - with no repo-local source defining this package, resolution must
176+
// fail; NuGetCache.EnsureCachedAsync surfaces this as InvalidOperationException,
177+
// which Program.Run (called directly, bypassing Main's try/catch) propagates
178+
Assert.Throws<InvalidOperationException>(() => Program.Run(context));
179+
}
180+
finally
181+
{
182+
Console.SetOut(originalOut);
183+
}
184+
}
185+
finally
186+
{
187+
if (Directory.Exists(repoRoot))
188+
{
189+
Directory.Delete(repoRoot, recursive: true);
190+
}
191+
}
192+
}
193+
194+
/// <summary>
195+
/// Builds a minimal valid .nupkg byte array for the given package identity using only
196+
/// <see cref="ZipArchive"/> (no NuGet SDK package-building types are referenced by this
197+
/// test project). NuGet's local-folder feed resolution only requires a top-level
198+
/// <c>*.nuspec</c> entry containing valid package metadata; full OPC parts
199+
/// (<c>[Content_Types].xml</c>, relationship parts) are not required for reading.
200+
/// </summary>
201+
/// <param name="packageId">The NuGet package identifier.</param>
202+
/// <param name="version">The package version string.</param>
203+
/// <returns>A byte array containing a minimal .nupkg archive.</returns>
204+
private static byte[] CreateMinimalNupkgBytes(string packageId, string version)
205+
{
206+
using var stream = new MemoryStream();
207+
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
208+
{
209+
// Top-level nuspec entry - required by NuGet's package reader to identify the package
210+
var nuspecEntry = archive.CreateEntry($"{packageId}.nuspec");
211+
using (var entryStream = nuspecEntry.Open())
212+
using (var writer = new StreamWriter(entryStream))
213+
{
214+
writer.Write($"""
215+
<?xml version="1.0" encoding="utf-8"?>
216+
<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
217+
<metadata>
218+
<id>{packageId}</id>
219+
<version>{version}</version>
220+
<authors>Test</authors>
221+
<description>Minimal test package for configRoot discovery tests.</description>
222+
</metadata>
223+
</package>
224+
""");
225+
}
226+
227+
// A placeholder content file so the extracted package folder is non-empty
228+
var contentEntry = archive.CreateEntry("lib/net8.0/_placeholder.txt");
229+
using (var entryStream = contentEntry.Open())
230+
using (var writer = new StreamWriter(entryStream))
231+
{
232+
writer.Write("placeholder");
233+
}
234+
}
235+
236+
return stream.ToArray();
237+
}
238+
}

0 commit comments

Comments
 (0)