Skip to content

Instantly share code, notes, and snippets.

@xavierpena
Last active October 5, 2020 08:56
Show Gist options
  • Save xavierpena/6d6999923072e8d6b4ce0d604d88c8cd to your computer and use it in GitHub Desktop.
Save xavierpena/6d6999923072e8d6b4ce0d604d88c8cd to your computer and use it in GitHub Desktop.
Dotnet packer standalone
using System;
using System.IO;
using System.Xml.Linq;
using System.Xml.XPath;
using System.Linq;
using System.Collections.Generic;
using System.Diagnostics;
/// <summary>
/// Without this code, package references from the other project are not applied to the package (they are simply ignored).
///
/// Many thanks to @WallaceKelly for comming up with a bundling solution (modify the .csproj file with the following parameters):
///
/// <PropertyGroup>
/// <TargetsForTfmSpecificBuildOutput>$(TargetsForTfmSpecificBuildOutput);CopyProjectReferencesToPackage</TargetsForTfmSpecificBuildOutput>
/// <!-- include PDBs in the NuGet package -->
/// <AllowedOutputExtensionsInPackageBuildOutputFolder>$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb</AllowedOutputExtensionsInPackageBuildOutputFolder>
/// </PropertyGroup>
///
/// <Target Name="CopyProjectReferencesToPackage" DependsOnTargets="ResolveReferences">
/// <ItemGroup>
/// <BuildOutputInPackage Include="@(ReferenceCopyLocalPaths->WithMetadataValue('ReferenceSourceTarget', 'ProjectReference'))" />
/// </ItemGroup>
/// </Target>
///
/// <!-- + add PrivateAssets="all" to each of your <ProjectReference ...> -->
///
/// Source: https://github.com/nuget/home/issues/3891#issuecomment-459848847
///
/// </summary>
public static class DotnetPackerStandalone
{
public static void Pack(PackageConfig conf)
{
var projectFileFullPath = Path.Combine(conf.ProjDirectoryPath, conf.ProjectFileName);
var originalXmlStr = File.ReadAllText(projectFileFullPath);
if (!originalXmlStr.Contains("TargetsForTfmSpecificBuildOutput"))
throw new Exception("Be sure to use @WallaceKelly's workaround: https://github.com/nuget/home/issues/3891#issuecomment-459848847");
var newXmlStr = BuildMergedProjFile(projectFileFullPath);
// Write "enriched" csproj text:
File.WriteAllText(projectFileFullPath, newXmlStr);
try
{
var arguments = $"pack {conf.ProjDirectoryPath} --include-symbols --force --output {conf.NugetOutputDirectory} --runtime {conf.Runtime} -p:PackageVersion={conf.PackageVersionNumberStr}";
var process = new Process();
process.StartInfo.FileName = "dotnet";
process.StartInfo.Arguments = arguments;
process.StartInfo.WindowStyle = ProcessWindowStyle.Maximized;
process.Start();
process.WaitForExit();
if (process.ExitCode != 0)
throw new Exception("Packaging process exited with exit code " + process.ExitCode);
}
finally
{
// Restore default csproj text:
File.WriteAllText(projectFileFullPath, originalXmlStr);
}
}
/// <summary>
/// Creates a csproj file string that includes all the sub-project nuget packages
/// </summary>
private static string BuildMergedProjFile(string mainProjectFilePath)
{
var packageReferences = new Dictionary<string, List<PackageReference>>();
var mainProjXmlStr = File.ReadAllText(mainProjectFilePath);
var mainProjXmlDoc = XDocument.Parse(mainProjXmlStr);
var projectReferences = GetAllProjectReferences(mainProjXmlDoc, privateAssetsOnly: true);
if (!projectReferences.Any())
throw new Exception($"Project file '{mainProjectFilePath}' has no project references with the attribute 'PrivateAssets=\"all\"'.");
var basePath = Path.GetDirectoryName(mainProjectFilePath);
AppendSubProjectReferences(basePath, projectReferences, ref packageReferences);
ValidateNugetPackageVersions(packageReferences);
var newXmlStr = BuildMergedXmlStr(packageReferences, mainProjXmlStr);
return newXmlStr;
}
/// <summary>
/// Formats the the new package references and adds them to the previous csproj file text
/// </summary>
private static string BuildMergedXmlStr(Dictionary<string, List<PackageReference>> packageReferences, string mainProjXmlStr)
{
var uniquePackages = packageReferences
.SelectMany(pair => pair.Value)
.GroupBy(pr => pr.PackageName)
.Select(group => group.First())
.ToList();
var uniquePackagesAsStrList = uniquePackages
.Select(item => $@"<PackageReference Include=""{item.PackageName}"" Version=""{item.Version}"" />")
.ToList();
var newLine = "\r\n";
var uniquePackagesMergedStr =
"\t<!-- Automatically generated (references from bundled sub-projects) -->"
+ $"{newLine}\t<ItemGroup>"
+ $"{newLine}\t\t" + string.Join("\r\n\t\t", uniquePackagesAsStrList)
+ $"{newLine}\t</ItemGroup>"
+ $"{newLine}";
var newXmlStr = mainProjXmlStr.Replace(
"</Project>",
uniquePackagesMergedStr + $"{newLine}</Project>");
return newXmlStr;
}
/// <summary>
/// All nuget packages accross sub-projects must share a single version.
/// If this is not the case, this function will throw an exception and will
/// point to the project/package/version that is resonsible for this error.
/// </summary>
private static void ValidateNugetPackageVersions(Dictionary<string, List<PackageReference>> packageReferences)
{
var packagesAsList = packageReferences
.SelectMany(pair => pair.Value)
.GroupBy(pr => pr.PackageName)
.ToDictionary(
pair => pair.Key,
pair => pair.ToList()
);
var errors = new List<string>();
foreach (var packageGroup in packagesAsList)
{
var packageName = packageGroup.Key;
var packageVersions = packageGroup.Value
.Select(pr => pr.Version)
.Distinct()
.ToList();
if (packageVersions.Count > 1)
{
var versionsPerProject = packageReferences
.Where(pair => pair.Value.Any(pr => pr.PackageName == packageName))
.ToDictionary(
pair => pair.Key,
pair => pair.Value.Where(pr => pr.PackageName == packageName).Single().Version
);
var error = $"Package '{packageName}' has {packageVersions.Count} different versions ({string.Join(", ", packageVersions)}): {string.Join(", ", versionsPerProject.Select(pair => $"'{pair.Key}':{pair.Value}"))}";
errors.Add(error);
}
}
if (errors.Any())
throw new Exception($"Nuget package errors:\r\n{string.Join("\r\n", errors)}");
}
/// <summary>
/// Recursive function that loops through all sub-projects and collects all package references
/// </summary>
private static void AppendSubProjectReferences(string basePath, List<string> projectReferences, ref Dictionary<string, List<PackageReference>> packageReferences)
{
foreach (var projectReference in projectReferences)
{
var fileName = Path.GetFileName(projectReference);
if (packageReferences.ContainsKey(fileName))
continue;
var subProjFilePath = Path.Combine(basePath, projectReference);
var subProjXmlStr = File.ReadAllText(subProjFilePath);
var subProjXmlDoc = XDocument.Parse(subProjXmlStr);
var partialPackageReferences = GetAllPackageReferences(subProjXmlDoc);
packageReferences.Add(fileName, partialPackageReferences);
var subBasePath = Path.GetDirectoryName(subProjFilePath);
var subProjectReferences = GetAllProjectReferences(subProjXmlDoc, privateAssetsOnly: false);
foreach (var subProjectReference in subProjectReferences)
AppendSubProjectReferences(subBasePath, subProjectReferences, ref packageReferences);
}
}
/// <summary>
/// Parses and collects all package project references from the csproj document
/// </summary>
private static List<string> GetAllProjectReferences(XDocument doc, bool privateAssetsOnly)
=> doc.XPathSelectElements("//ProjectReference")
.Where(pr => privateAssetsOnly ? IsPrivateAsset(pr) : true)
.Select(pr => pr.Attribute("Include").Value)
.ToList();
/// <summary>
/// Tells if a project reference has PrivateAssets="all".
/// Those are the only top-level project references that will be bundled.
/// On the other hand, all sub-project references (that are not referenced at the top level) will be bundled.
/// </summary>
private static bool IsPrivateAsset(XElement pr)
=> pr.Attribute("PrivateAssets") != null && pr.Attribute("PrivateAssets").Value.ToLower() == "all";
/// <summary>
/// Parses and collects all package package references from the csproj document
/// </summary>
private static List<PackageReference> GetAllPackageReferences(XDocument doc)
=> doc.XPathSelectElements("//PackageReference")
.Select(pr => new PackageReference
{
PackageName = pr.Attribute("Include").Value,
Version = new Version(pr.Attribute("Version").Value)
}).ToList();
}
#region "Helper classes"
public class PackageConfig
{
/// <summary>
/// "path/to/exported/nugets/directory"
/// </summary>
public string NugetOutputDirectory { get; set; } // ;
/// <summary>
/// "path/to/project/directory"
/// </summary>
public string ProjDirectoryPath { get; set; } // ;
/// <summary>
/// "ProjectName.csproj"
/// </summary>
public string ProjectFileName { get; set; }
/// <summary>
/// "net461"
/// </summary>
public string Runtime { get; set; }
/// <summary>
/// "1.0.0"
/// </summary>
public string PackageVersionNumberStr { get; set; }
}
public class PackageReference
{
public string PackageName { get; set; }
public Version Version { get; set; }
}
#endregion
@HeadOnBenni
Copy link

@xavierpena thanks for the awesome work. Unfortunately I'm not quite sure how to call your code or how to integrate it into the dotnet pack command. Could you briefly explain this?
I already saw your comment (NuGet/Home#3891 (comment)) but I´m not quite sure where you inserted this code.
Thank you very much!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment