NuGet 包制作与发布实战:从 dotnet pack 到私有 NuGet 服务器

写在前面

上一篇讲的是 NuGet 的消费侧:包怎么装、怎么还原、packages.config 到 Central Package Management 演进了什么。这一篇换个方向,讲生产侧——你写了一个类库,怎么把它做成一个规范的 .nupkg,怎么定版本号,包里那些配置属性都是干什么的,最后怎么把它发布到 nuget.org,或者搭一个自己的私有 NuGet 服务器。

打包从来不只是"压缩一个文件夹"。 一个 NuGet 包同时是程序集的载体、依赖声明的清单、MSBuild 行为的载体和身份与许可的凭证。把这些面都配对了,使用者 dotnet add package 一行命令就能正确地用上你的库;配错了,下游就要用 issue 和反射来还债。

本文所有命令与包结构均在 .NET SDK 10.0.403 下实际运行验证,工具生态与政策信息核对自 2026-11 的官方文档(nuget.org 的 API Key 政策在 2026 年下半年有过重要变更,网上不少教程已经过时,文中会专门标注)。

一、从类库到第一个 .nupkg

1.1 最短路径

打包的起点是一个普通的类库项目。除 5.1 节明确使用 PowerShell 外,多行命令采用 Bash 的 \ 续行语法;PowerShell 用户请合并成一行执行。<...> 是需要替换的占位符,不要连尖括号一起输入:

1
2
dotnet new classlib -n Jiwei.DemoLib
cd Jiwei.DemoLib

.NET 10 SDK 生成的 Jiwei.DemoLib.csproj 只有三行有效配置:

1
2
3
4
5
6
7
8
9
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

现在直接打包:

1
dotnet pack -c Release

命令成功,bin/Release/ 下出现 Jiwei.DemoLib.1.0.0.nupkg。注意两点:

  • 什么元数据都没配,打包照样成功。包 ID 默认取程序集名(PackageId 默认值为 $(AssemblyName)),版本默认 1.0.0。NuGet 在本地打包时不强制 Description、Authors 之类的元数据——但它们会以占位值进入包里,发布到 nuget.org 之前应当补齐(见第二节)。
  • .NET 8 SDK 起,TargetFramework 为 net8.0 及以上的项目 dotnet pack 默认就走 Release 配置(PackRelease 属性默认 true),本文仍显式传 -c Release,让意图一目了然。

1.2 包里面到底装了什么

.nupkg 本质是一个改了扩展名的 zip 文件。把刚才的包解开看:

1
2
3
4
5
6
7
8
Jiwei.DemoLib.1.0.0.nupkg
├── Jiwei.DemoLib.nuspec                  ← 包清单(由 csproj 属性生成)
├── [Content_Types].xml                   ← OPC 打包部件清单
├── _rels/.rels                           ← OPC 关系定义
├── package/services/metadata/core-properties/nuget.psmdcp
└── lib/
    └── net10.0/
        └── Jiwei.DemoLib.dll             ← 编译产物按 TFM 归位

[Content_Types].xml、_rels/、package/ 这三样是 OPC(Open Packaging Conventions)打包格式的固定部件——与 .docx 同源——平时不需要关心,官方文档也不逐项解释它们,知道"包 = zip + 约定目录 + 清单"即可。真正要理解的是 lib/ 和 .nuspec:

  • lib/{TFM}/ 是程序集的家。每个目标框架一个子目录,还原时 NuGet 按"与消费项目 TFM 最匹配"的原则挑一个。官方明确建议:永远不要把程序集直接放在 lib/ 根目录。
  • .nuspec 是包的清单:ID、版本、作者、描述、依赖组都记在这里。上面这个包的清单长这样:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2012/06/nuspec.xsd">
  <metadata>
    <id>Jiwei.DemoLib</id>
    <version>1.0.0</version>
    <authors>Ji Wei</authors>
    <license type="expression">MIT</license>
    <licenseUrl>https://licenses.nuget.org/MIT</licenseUrl>
    <description>演示用类库:打包、发布与私有源验证</description>
    <tags>demo pack publish</tags>
    <repository type="git" url="https://github.com/jiwei/demo-lib" />
    <dependencies>
      <group targetFramework="net10.0" />
    </dependencies>
  </metadata>
</package>

这是补齐元数据后重新打包的结果(见 2.1),可以看到 <dependencies> 里按 TFM 分好了组——这个演示库没有任何包依赖,所以组是空的;一旦引用了别的包,每个 TFM 组里会自动出现对应的 <dependency> 条目。

1.3 csproj 属性与 .nuspec:写哪个

.nuspec 有两种生产方式:

  • SDK-style 项目(现在的默认):不写 .nuspec,把一切写在 csproj 属性里。dotnet pack 会在打包时自动生成 .nuspec 并塞进包内,官方文档原话是"If you are creating a package using dotnet.exe pack or msbuild pack target, we recommend that you include all the properties that are usually in the .nuspec file in the project file instead"。
  • 手写 .nuspec 仍有两个场合:一是维护 packages.config 时代的非 SDK-style 老项目,用 nuget.exe pack 打包时以 .nuspec 为输入;二是 .NET 10 新增的能力——dotnet pack foo.nuspec 可以直接吃一个 .nuspec 文件、不需要工程文件、不跑 MSBuild(还配了 --version 参数覆盖清单里的版本)。对全新项目,优先 csproj 属性。

二、包的元数据:csproj 里的配置属性

2.1 常用属性一览

下面这张表覆盖日常包元数据的九成场景(权威出处是 MSBuild pack targets 文档):

属性作用未设置时的默认
PackageId包 ID,全局唯一身份$(AssemblyName)
Version包版本(SemVer 2.0.0)见第四节
Authors作者,分号分隔占位值(文档两处描述不一致,建议显式设置)
Title展示名(nuget.org 标题)$(PackageId)
Description描述,搜索与列表页的主要文字占位文本
PackageTags标签,空格分隔,参与搜索空
PackageProjectUrl项目主页空
RepositoryUrl / RepositoryType源码仓库地址 / 类型(git)空
PackageReleaseNotes版本变更说明空
Copyright版权声明空
PackageRequireLicenseAcceptance安装前是否要求接受许可false
DevelopmentDependency标记为开发期依赖,不流入下游false

两个容易混淆的点:

  • Company 不进包。nuspec 元数据里没有 company 元素,Company 只影响程序集的 AssemblyCompany 特性(AssemblyInfo 一侧),别指望它出现在 nuget.org 页面上。
  • Owners 和 Summary 在 MSBuild 打包路线里不可用,它们只存在于手写 .nuspec 的世界。

2.2 图标、readme、许可证三件套

nuget.org 的包页面主要由这三样撑起门面。三个文件的共同套路:属性声明文件名 + None Include 把文件打进包根目录:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<PropertyGroup>
  <PackageIcon>icon.png</PackageIcon>
  <PackageReadmeFile>readme.md</PackageReadmeFile>
  <PackageLicenseExpression>MIT</PackageLicenseExpression>
</PropertyGroup>

<ItemGroup>
  <None Include="images\icon.png" Pack="true" PackagePath="\" />
  <None Include="docs\readme.md" Pack="true" PackagePath="\" />
</ItemGroup>

各自的规则:

项要求与版本门槛弃用提示
图标 PackageIconJPEG/PNG,≤ 1 MB,推荐 128×128 分辨率(推荐值,非硬性)旧的 PackageIconUrl 已弃用;NuGet 5.3+ 只配 PackageIconUrl 时 pack 会报 NU5048 警告
自述 PackageReadmeFile仅 Markdown(.md);NuGet 5.10.0 preview 2 / .NET SDK 5.0.300 起支持缺失时 NuGet 会输出"缺少自述文件"的建议提示(本文 1.1 节实测见到),nuget.org 页面没有 readme 区域
许可 PackageLicenseExpressionSPDX 表达式(如 MIT、BSD-2-Clause OR MIT),nuget.org 只接受 OSI/FSF 批准的许可证;非标准许可用 PackageLicenseFile 打包一个 .txt/.md 文件旧的 PackageLicenseUrl 已弃用,会产生 NU5125 警告

许可证配置的三者(表达式 / 许可文件 / URL)同时只能设一个。license expression 打出来的包里,nuspec 会自动补一个指向 https://licenses.nuget.org/{表达式} 的 licenseUrl——1.2 节的实测清单里可以看到——这让旧客户端也能跳到许可证页面。

RepositoryUrl/RepositoryType 之外,.NET 8 起 Source Link 发生了质的变化,值得单独说:

.NET 8 起,Source Link 内置于 .NET SDK 且默认启用(覆盖 GitHub、Azure Repos、GitLab、Bitbucket)。.NET 8+ 的项目不需要再引用 Microsoft.SourceLink.GitHub 包、也不需要设置 SourceLink 相关属性;反而显式引用包会覆盖 SDK 内置版本。

Source Link 的产出是:PDB 里嵌入"源码 → 仓库 URL + commit"的映射,调试你的库时 VS 能自动按 commit 拉到对应源码。配套的两个属性:

  • PublishRepositoryUrl:Source Link 默认不把仓库 URL 写进包清单,设为 true 时随包发布(分支信息同理受其控制);
  • ContinuousIntegrationBuild:在 CI 上设为 true 时启用官方构建专属行为(典型的是源码路径规范化为相对路径),与 Deterministic 配合产出确定性构建——官方库建议清单(library guidance)对此的建议级别是 CONSIDER。

顺手补一个与"包质量"直接相关的属性:

1
<GenerateDocumentationFile>true</GenerateDocumentationFile>

它把 XML 文档注释生成为 .xml 并随 DLL 一起放进 lib/,使用者在 IDE 里能看到你的 IntelliSense 提示。代价是所有 public 成员缺注释都会冒 CS1591 警告,通常配 <NoWarn>$(NoWarn);CS1591</NoWarn> 或老老实实补注释。

三、依赖、多目标与包内高级布局

3.1 多目标:TargetFrameworks 与包内布局

把演示库改成双目标:

1
<TargetFrameworks>netstandard2.0;net10.0</TargetFrameworks>

重新打包,一个包里出现两套产物,nuspec 也按 TFM 分成两个依赖组(均为实测结果):

1
2
3
lib/
├── net10.0/Jiwei.DemoLib.dll
└── netstandard2.0/Jiwei.DemoLib.dll
1
2
3
4
<dependencies>
  <group targetFramework="net10.0" />
  <group targetFramework=".NETStandard2.0" />
</dependencies>

SDK-style 项目的好处就在这:pack target 知道布局约定,自动把程序集放进正确的 lib/ 子目录、按 TFM 生成依赖组。多目标实跑时踩到两个真实坑,都值得记下:

  • netstandard2.0 目标默认用 C# 7.3,<Nullable>enable</Nullable> 直接报错"无效的 nullable 值"——要么 <LangVersion>latest</LangVersion>,要么接受老语言版本;
  • 消费侧的坑:如果 nuget.config 用 <clear /> 只留私有源,构建 netstandard2.0 目标会因还原不到 NETStandard.Library 而失败(NU1101)——它本身不进包(SDK 隐式引用按开发期资产处理),但构建机需要能从某个源还原它。

另外两条布局纪律:声明在 dependencies group 里的 TFM 与 lib/、ref/ 目录里的 TFM 不完全匹配时,pack 报 NU5128 警告;TargetFramework(单数)存在时 TargetFrameworks(复数)被忽略——两者只能活一个。

3.2 依赖的资产控制:谁该进包,进包后给谁用

包的依赖声明不只是"要哪些包",还有"拿来干什么"。PackageReference 的三个资产属性在打包语境下最常见的用法:

1
2
3
4
5
6
<ItemGroup>
  <!-- 旧 SDK 项目的示例;.NET 8+ SDK 的受支持宿主无需显式引用此包 -->
  <!-- 打包时忽略它:不进入 nuspec 依赖(纯开发期工具) -->
  <PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0"
                    PrivateAssets="all" />
</ItemGroup>
  • PrivateAssets="all":这个依赖不流入包——分析器、构建工具类引用的标准配置;
  • ExcludeAssets="runtime":编译要用、运行时不要(少见,如仅编译期常量包);
  • IncludeAssets:反向收窄,如 IncludeAssets="analyzers" 只要分析器资产。

与它配合的是包自己一侧的 DevelopmentDependency=true(见 2.1 表):它将包标记为开发期依赖,避免被列入其他包的依赖清单;在 PackageReference(NuGet 4.8+)下,还会排除该包的编译资产,不能把它用于需要向消费者暴露 lib/ API 的普通类库。分析器、源生成器、内部构建工具可按用途采用这个标记。

3.3 build/ 目录:把 MSBuild 行为装进包

lib/ 装程序集,build/ 装 MSBuild 文件。约定是三个目录:

目录注入时机适用
build/NuGet 2.5+,直接消费者常规 props/targets
buildMultiTargeting/外层构建(多目标一次编译多个 TFM)需要感知"外层"的脚本
buildTransitive/NuGet 5.0+,沿依赖图传给所有下游想让行为穿透传递依赖

目录里放 <包ID>.props 和 <包ID>.targets,也可以按 build/{TFM}/ 分框架。消费端 restore 后这些文件会被自动 import——packages.config 时代 .props 加在项目文件顶部、.targets 加在底部;PackageReference 项目则由 restore 生成的 *.nuget.g.props/.targets 引入,效果等价。官方文档同时给了两条红线:props/targets 里不要改 TargetFramework、PackageReference 这类会影响 restore 的属性/项;只有 build 文件而没有 lib//ref/ 产物的包,新工具链会提示 NU5127。

想看现实例子:Microsoft.NET.Test.Sdk 怎么把 VSTest 目标注入你的测试项目、coverlet.msbuild 怎么在测试运行后挂接覆盖率统计——把这两个包解开看它们的 build/ 目录便知。这也是"包不只是 DLL"最直观的证据。

3.4 analyzers/ 与 contentFiles/:两个约定目录

  • analyzers/dotnet/cs/:Roslyn 分析器与源生成器的家。路径约定是 analyzers/{framework}/{language}/,dotnet 是目前唯一有效的 framework 值,语言段为 cs/vb/fs。放进去的 DLL 在安装时自动挂为分析器,Roslyn 官方 cookbook 明确说源生成器"用与分析器相同的方式打包"。分析器/生成器包的完整姿势:产物进 analyzers/、DevelopmentDependency=true、不往 lib/ 放东西。
  • contentFiles/:想往消费项目里放源码/配置文件时用。ContentTargetFolders 默认 content;contentFiles,实际落位 contentFiles/{language}/{TFM}/,条目上可配 buildAction(默认 Compile)与 copyToOutput(默认 false)。这是 packages.config 时代"content 文件 + 变换"的现代替代品,日常类库很少用到,知道入口即可。

原生库怎么打包(runtimes/{RID}/native/ 目录、DllImportResolver 按平台解析)本文不展开,见《C# 原生互操作与设备 SDK(八):C++ ABI 与跨平台封装》与官方 Native files in .NET packages 指南。

四、版本号规范:SemVer 2.0.0

4.1 三段语义

NuGet 采用 Semantic Versioning 2.0.0(NuGet 4.3.0+ / VS 2017 15.3+ 全面支持,更早客户端对 SemVer 2.0.0 特有版本不可见)。三段各自管什么,官方文档的建议直白到不用翻译:

段何时递增
MAJOR破坏性变更
MINOR新增功能,向后兼容
PATCH向后兼容的缺陷修复
-Suffix(可选)先行版标记

对库作者真正有分量的是第一行的连带责任:公共 API 一旦破坏,就必须升 MAJOR——哪怕你觉得"没人会用那个重载"。

4.2 先行版:构成规则与优先级

先行版跟在补丁号后面,- 加点分隔的标识符,如 1.0.0-beta.2。规范约束:标识符只能用 ASCII 字母数字和连字符、不得为空、纯数字标识符不得有前导零(beta.01 非法,beta.1 合法)。

比较先行版优先级的规则(SemVer 规范 §11,NuGet 遵循同一套):

  • 纯数字标识符按数值比较,带字母的按 ASCII 字典序;
  • 数字标识符优先级低于字母数字标识符;
  • 前缀全部相同时,标识符段数多的优先级高;
  • 任何先行版都低于对应的正式版。

规范的示例链一步到位:

1
2
1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta
< 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0

注意 beta.2 < beta.11 是数值比较(不是字典序的 “11” < “2”),这也是"beta.1 之后应该发 beta.2 还是 beta.10"这类争论的标准答案。还原侧的对应行为:默认不解析先行版——依赖图里没有任何包显式要求先行版时,NuGet 只考虑稳定版;dotnet add package --prerelease 或版本写 1.0.0-alpha* 才会把先行版拉进来。

4.3 构建元数据:可以带,但会被剥掉

SemVer 允许 1.0.0+git.abc123 这样的构建元数据,NuGet 的处理规则值得记两条:

  • 比较与匹配时剥离:1.0.7+r3456 在 NuGet 眼里就是 1.0.7。同版本推两次即使后缀不同,对源来说也是同一个版本(不可覆盖,见 5.5)。
  • 文件名与 URL 使用规范化版本(剥掉 + 后缀):打包产物是 Jiwei.DemoLib.1.0.0.nupkg 而不是 1.0.0+meta.nupkg——这是 NuGet 的版本规范化规则,并非 URL 禁止使用 +。

带点分隔先行版(1.0.0-alpha.1)或构建元数据的版本被官方称为"SemVer 2.0.0 特有版本":nuget.org 上的这类包对旧客户端不可见(需要 NuGet 4.3.0+ / .NET SDK 2.0.0+);自建 V2(OData)源默认会把 SemVer 2.0.0 特有版本整体过滤掉——生产环境遇到"包明明推上去了却搜不到",先查版本形态和源协议。

4.4 csproj 里的版本属性:Version / VersionPrefix / VersionSuffix

三个属性与 dotnet pack --version-suffix 的组合行为,官方给过一张明确的表:

csproj 里设置了打出的包版本
都不设1.0.0
Version$(Version)
仅 VersionPrefix$(VersionPrefix)
仅 VersionSuffix1.0.0-$(VersionSuffix)
VersionPrefix + VersionSuffix$(VersionPrefix)-$(VersionSuffix)

另有 PackageVersion 作为最终出口(默认取 Version,显式设置会盖掉 Prefix/Suffix 的拼装结果)。两个实践要点(均经 .NET 10 SDK 实测):

  • 日常开发写 VersionPrefix,不要写死 Version。官方文档原话:“If you want to use --version-suffix, specify VersionPrefix and not Version in the project file”——csproj 里已有 Version 时,--version-suffix 会被忽略。
  • dotnet pack --version-suffix rc.1 会覆盖 csproj 里的 VersionSuffix:实测 csproj 配 beta.1、命令行传 rc.1,产物是 1.0.0-rc.1.nupkg。命令行传入的是 MSBuild 全局属性,优先级高于项目文件内赋值——CI 里按构建号注入先行版后缀正是靠这一机制。

4.5 让版本跟着 Git 走:MinVer 与 Nerdbank.GitVersioning

手工维护三段版本容易忘,主流做法是让版本从 Git 信息推导。两个活跃的现成方案(均为 2026-11 核实的最新稳定版):

工具最新稳定版机制与定位
MinVer8.0.0极简主义:版本完全由 Git tag 决定,无配置文件;tag 之间的提交自动推导出先行版
Nerdbank.GitVersioning3.10.94一个 version.json 声明主版本,公共版本从 Git 高度(提交高度)生成;仓库在 dotnet 组织下、有 .NET Foundation 支持

两者的共同卖点:本地构建与 CI 构建产出一致的版本号,不用在 csproj 里来回改数字。选型上一句话:tag 驱动选 MinVer,版本文件驱动选 NB.GV。

4.6 先行版命名习惯

-alpha(功能未完成,实验性)、-beta(功能集完整、已知缺陷尚存)、-rc(候选发布,没有重大问题就转正)是被广泛认可的命名习惯——官方文档明确说明:技术上任何字符串都合法,NuGet 只按"有没有后缀"判定先行版、不做更多解释,alpha/beta/rc 只是"package developers generally follow recognized naming conventions"。放心用社区看得懂的名字,但别指望后缀本身携带机器语义。

五、发布到 NuGet 服务器

5.1 发布前:先在本地源把闭环走通

推到公网之前,把"打包 → 发布 → 消费"闭环在本地文件夹源上走一遍,是成本最低的验证。完整流程(Windows PowerShell + .NET 10 SDK;在新建的临时工作目录执行,独立于前面各节):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# 1. 准备本地源、配置和类库
New-Item -ItemType Directory -Force local-feed | Out-Null
$feedPath = (Resolve-Path local-feed).Path
'<configuration><packageSources><clear /></packageSources></configuration>' | Set-Content nuget.config
dotnet nuget add source "$feedPath" --name LocalFeed --configfile nuget.config
dotnet new classlib -n Jiwei.DemoLib -o src/Jiwei.DemoLib
'namespace Jiwei.DemoLib; public static class Greeter { public static string Hello(string name) => $"Hello, {name}!"; }' | Set-Content src/Jiwei.DemoLib/Greeter.cs
dotnet pack src/Jiwei.DemoLib -c Release

# 2. 推送(本地文件夹源是文件复制语义,没有认证环节)
dotnet nuget push src/Jiwei.DemoLib/bin/Release/Jiwei.DemoLib.1.0.0.nupkg --source "$feedPath"

# 3. 隔离缓存,确保消费端从本地源取包;在临时会话中执行
$env:NUGET_PACKAGES = Join-Path (Get-Location) '.packages'
dotnet new console -n Consumer
cd Consumer
dotnet add package Jiwei.DemoLib --version 1.0.0
'Console.WriteLine(Jiwei.DemoLib.Greeter.Hello("NuGet"));' | Set-Content Program.cs
dotnet run
# 完成后关闭临时 PowerShell 会话,或恢复此前的 NUGET_PACKAGES 设置

三个实测细节:--source 直接给相对路径(./local-feed)时被拒(“指定的源无效”),换成绝对路径或配置好的源名即可;push 到空目录产生的是扁平布局(nupkg 直接落在根上,见 6.2 的布局讨论);闭环终点是 dotnet run 输出 Hello, NuGet!——验证"包能用"永远以消费端跑通为准,不以 push 成功为准。

再配两个体检命令:dotnet package search <包ID> --exact-match --source <源>(.NET 8.0.2xx SDK 起)列出包在某个源上的稳定版本(先行版另加 --prerelease;--exact-match 会忽略分页参数),验证"推上去没有";dotnet nuget why <项目或解决方案> <包ID>(.NET 8.0.4xx 起)解释依赖图里谁带进了这个包。

5.2 nuget.org:API Key 新政与 push

发布到 nuget.org 需要账号和 API Key。这里有时效性很强的新政:2026-08-17 起,新建 API Key 最长有效期从可选的一年收紧为 30 天;此前创建的旧 Key 已于 2026-11-01 全部过期。官方给的理由是供应链安全(缩短凭据泄露的窗口期),官方推荐迁移到 5.3 节的 Trusted Publishing。

Key 的创建路径:nuget.org 右上角用户名 → API Keys → Create。作用域用 glob 模式圈定包 ID(如 Jiwei.*),Key 只在创建时显示一次,过期前 10 天会收到提醒邮件。

推送命令的完整形态:

1
2
3
dotnet nuget push Jiwei.DemoLib.1.0.0.nupkg \
  --api-key <你的APIKey> \
  --source https://api.nuget.org/v3/index.json

常用参数:--skip-duplicate 批量推送时把 409(同版本已存在)降级为警告继续推后面的包;-t/--timeout 指定超时秒数(默认 300 秒);--no-symbols 不推符号包。CI 里从 NuGet 7.6(.NET SDK 10.0.300 起)可以不写 --api-key,改设 NUGET_API_KEY 环境变量。另有一个 .NET 10 新参数 --allow-insecure-connections:允许推到纯 HTTP 源——它存在的意义恰恰是提醒你默认不允许这么干(自建源请上 HTTPS,或至少在私有网络内)。

5.3 CI 发布:Trusted Publishing,而不是存一个长期 Key

GitHub Actions 场景下,当前官方推荐 Trusted Publishing(2025-09 上线):工作流用 OIDC 向 nuget.org 换一个有效期 1 小时的临时 API Key,仓库里不落任何长期凭据。要点:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read          # checkout 读取仓库
      id-token: write          # OIDC 换 Key 的前提
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 10.0.x
      - run: dotnet pack -c Release -o artifacts
      - uses: NuGet/login@v1   # 官方登录 action,输出临时 NUGET_API_KEY
        id: login
        with:
          user: <你的nuget.org用户名>
      - run: dotnet nuget push "artifacts/*.nupkg"
             --api-key ${{ steps.login.outputs.NUGET_API_KEY }}
             --source https://api.nuget.org/v3/index.json

nuget.org 侧要在账户的 Trusted Publishing 页登记仓库与 workflow 名。API Key 路线(5.2 节)在 CI 上依然可用——配上 NUGET_API_KEY 环境变量 + 30 天轮换——但官方态度明确:新项目优先 Trusted Publishing。

5.4 符号包:snupkg 与嵌入式 PDB

让使用者能调试进你的库,有两条路:

路线一:符号包(snupkg)。 打包时加两个属性:

1
dotnet pack -c Release -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg

产物多出一个 Jiwei.DemoLib.1.0.0-beta.1.snupkg——解包实测里面只有 .pdb(加 nuspec 和 OPC 部件)。注意 SymbolPackageFormat 不设时默认是遗留格式 symbols.nupkg(整包带符号),现在应当显式指定 snupkg;dotnet pack --include-symbols 只是 IncludeSymbols 的命令行形式,同样需要配格式属性。推送时:nuget.exe 的语义是同目录下主包与 snupkg 一起推(先主包后符号包);dotnet CLI 的 -n/--no-symbols 定义反向印证了默认行为——符号包在场就随主包推送,-ss/--symbol-source、-sk/--symbol-api-key 可指定独立符号服务器。nuget.org 的符号服务器端点是 https://symbols.nuget.org/download/symbols,消费者把它加进 VS 的符号源即可;只接受 portable PDB,Windows PDB 与原生项目符号不收。

路线二:PDB 嵌入程序集。 <DebugType>embedded</DebugType> 把 PDB 直接塞进 DLL,使用者无需配置符号源。代价是二进制变大——不管使用者是否需要调试你的源码。Source Link 官方对发布到 nuget.org 的库的建议是路线一:同时发布 snupkg。

5.5 发布之后:校验、签名与不可变性

push 成功不是终点。nuget.org 的处理流水线:校验 + 索引通常 15 分钟内完成(超 1 小时未上架再联系支持),期间包出现在账户的 Unlisted Packages 里,完成后收到邮件。所有上传到 nuget.org 的包会被自动仓库签名(repository signature)——这也是不可变性的来源:

  • 同 ID + 同版本永不可覆盖:重复 push 得到 409;
  • 包内容(含 nuspec)一旦签名即不可变,改一个字都不行;
  • 不支持永久删除:现行政策下作者只能 unlist(从搜索与列表消失,精确版本仍可还原,保住下游构建)或 deprecate(标记弃用并推荐替代)。“发布后 72 小时内可删"是早已过时的说法,别信旧教程。

所以每次 push 前值得自问:这个包推上去就是 forever 的。顺带的硬性上限:单包 ≤ 250 MB。

六、搭建私有 NuGet 服务器

6.1 为什么需要私有源

四类典型诉求:内部分发(公司公共库不外发)、离线/受控环境(产线、内网无法直连 nuget.org)、缓存加速(一次下载全员复用)、供应链治理(只允许经过审查的包版本进入构建)。2026 年的治理趋势也体现在工具上:dotnet nuget add source 文档开篇就提醒"Use package sources that you trust”,背后就是依赖混淆(dependency confusion——公开源上有人注册了与你私有包同名的 ID,配合 <clear /> 用错源配置就会被钓鱼)这类风险;NuGet 的 Package Source Mapping(按包 ID 前缀固定源)和 .NET SDK 11(11.0.100)起 dotnet nuget update source 新增的 --min-publish-age-hours(自动选版时排除尚未"满龄"的版本,防"刚发布的投毒版本")都是为此服务的。本文主线是"搭一个能用的私有源",治理深潜留作以后。

6.2 第一档:本地文件夹源与网络共享

零依赖方案:一个目录就是源。push 侧是文件复制(NuGet 识别出本地/UNC 路径就直接复制,不走 HTTP);消费侧把目录配成源即可。它有两种布局:

布局形态怎么产生
扁平所有 nupkg 堆在根目录dotnet nuget push 推空目录(本文实测)、直接拷贝
分层<包ID>/<版本>/*.nupkgnuget add / nuget init(nuget.exe 专属命令)

官方对规模的提示很直接:旧版 NuGet 只支持单文件夹布局,其性能"远低于分层结构"。当前语义下 dotnet nuget push 会尊重源里已有的布局——源里已经有分层结构就沿用分层;空源第一次 push 落成扁平。想要分层起步,用 nuget.exe 的 nuget init 初始化,或从全局包缓存(%userprofile%\.nuget\packages,天然分层)拷一个包目录进去。目录可以放在网络共享(UNC 路径直接当源用),这就是最简"团队私有源"。

6.3 第二档:BaGetter——一个容器跑起来的 V3 服务器

要 HTTP 服务、搜索、网页界面、符号与镜像,最轻的活跃方案是 BaGetter。先交代沿革:曾经最流行的轻量服务器 BaGet(loic-sharma/BaGet)自 2024 年中起实质停滞(仓库未归档但无维护动向);社区续作 BaGetter(bagetter/BaGetter)从其 fork 而来持续演进,微软官方文档的托管清单里也已单列 BaGetter。另一些老教程的主角 NuGet.Server 见 6.4 末尾。

Docker 一行起步(官方快速开始命令):

1
docker run -p 5000:8080 -v ./bagetter-data:/data bagetter/bagetter:latest

镜像 bagetter/bagetter,容器内端口 8080(示例映射宿主 5000;快速起步用 latest,生产部署建议固定到具体版本号)。常用配置走环境变量(__ 是层级分隔符):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
services:
  bagetter:
    image: bagetter/bagetter:latest
    ports:
      - "5000:8080"
    environment:
      ApiKey: "NUGET-SERVER-API-KEY"          # 推送凭据;也支持多 Key(Authentication:ApiKeys)
      Database__Type: Sqlite                   # MySql/SqlServer/PostgreSql/AzureTable 可选
      Database__ConnectionString: "Data Source=/data/bagetter.db"
      Storage__Type: FileSystem                # 亦支持 S3/Azure Blob/GCS
      Storage__Path: /data/packages
      Mirror__Enabled: true                    # 镜像 nuget.org,未命中时回源拉取并缓存
      Mirror__PackageSource: https://api.nuget.org/v3/index.json
    volumes:
      - ./bagetter-data:/data

上述 HTTP 示例仅用于本机验证,生产部署应配置 HTTPS;Compose 的卷同时保存数据库与包文件,避免重建容器后丢失。V3 端点是标准的 http://localhost:5000/v3/index.json,所以 dotnet CLI、Visual Studio、MSBuild 全部即插即用。推送与消费:

1
2
3
4
5
6
7
8
# 推送
dotnet nuget push Jiwei.DemoLib.1.0.0.nupkg \
  --source http://localhost:5000/v3/index.json \
  --api-key NUGET-SERVER-API-KEY --allow-insecure-connections

# 消费:nuget.config 里加源
dotnet nuget add source http://localhost:5000/v3/index.json --name CompanyFeed --allow-insecure-connections
dotnet add package Jiwei.DemoLib

几个值得知道的能力边界(官方文档核实):支持 snupkg 符号服务;Mirror 开启后兼任 nuget.org 缓存代理(构建加速 + 离线场景);默认包大小上限 8 GiB;删除行为默认 unlist,可配 PackageDeletionBehavior: HardDelete 真删——私有源自建源没有 nuget.org 的不可变性教条,这是它"可控"的一面。

6.4 第三档:重型自托管与托管服务

BaGetter 之上还有一整个光谱。2026-11 时点的关键事实(选型时最容易踩的过时认知已加粗):

方案NuGet 能力2026-11 现状要点
ProGet(Inedo)V3、符号、连接器代理上游Free 版可用且允许商用:不限用户不限 feed;但无 feed 级安全、无 AD/LDAP、无个人 API Key;付费版 $2,395/年起
Nexus Repository(Sonatype)V2 + V3,proxy/hosted/group免费版(2025-02 起从 OSS 更名 Community Edition)即含 NuGet v2/v3;符号服务器是 3.95.0(2026 年)新增
Artifactory(JFrog)V3、符号(7.36+)免费线(JCR/OSS/CE)都不含 NuGet——OSS 只管 Maven/Gradle 系,NuGet 属商业版能力
Azure ArtifactsV3、上游源计费锚点是存储:每组织 2 GiB 免费,超出约 $2/GiB(“前 5 用户免费"是 DevOps Basic 用户许可的话术,不是 Artifacts 本体)
GitHub PackagesNuGet registry公开包免费;GitHub Free 私有包 500 MB 存储 / 1 GB 月流量;个人访问令牌认证只支持 classic PAT(发布用 write:packages;Actions 另可用 GITHUB_TOKEN)
GitLab / Gitea 内置 registryV2+V3GitLab 12.8 引入(13.3 进 Free,14.1 支持符号);Gitea 1.17 起支持(V2+V3+符号)
Sleet(emgarten)静态 V3 feed无服务器进程:sleet push 写包 + 对象存储(S3/Azure Blob/MinIO)直接提供索引;活跃维护(当前 7.2.0)

NuGet.Server 的谢幕值得单独一句:这个 .NET Foundation 时代的 ASP.NET/IIS 方案只支持 V2 协议,最终版本停在 3.4.2(2022);其 GitHub 仓库已于 2026-05-29 归档为只读——虽然微软文档页尚未加注弃用标记,但新项目没有任何理由再选它。

6.5 私有源的认证与配置管理

私有源有了,团队怎么用得整齐、用得安全:

配置的层级与归属。 nuget.config 分三级:机器级、用户级、解决方案/项目级(从盘根逐级下探),离当前目录越近优先级越高;<clear /> 掐断继承。官方最佳实践是把 nuget.config 放解决方案仓库根目录、随代码提交——源的清单、Package Source Mapping 规则、 <clear /> 一起进版本控制,每人每台 CI 拿到的都是同一套源配置。

凭据的存放。 dotnet nuget add source --username --password 存凭据时,加密只在 Windows 上可用、且只能在加密时的同一机器同一用户下解密——Linux/macOS 与 CI 上没有加密选项,要么 --store-password-in-clear-text,要么用环境变量宏,要么上凭据提供程序(authenticated feeds 文档给的安全性推荐顺序:credential provider > 加密 > 环境变量 > 明文)。GitHub Packages 是典型例子,官方命令直接含明文开关:

1
2
3
dotnet nuget add source --username <GitHub用户名> --password <你的PAT> \
  --store-password-in-clear-text --name github \
  "https://nuget.pkg.github.com/<OWNER>/index.json"

在 GitHub Actions 里则优先用自带的 GITHUB_TOKEN(配 write:packages 权限),连 PAT 都不用发。源配置的日常维护用 dotnet nuget list source / update source / remove source(三者都支持 --configfile 指定目标文件)。

七、避坑与决策清单

全文踩过、见过的高频坑,收拢成一张表:

坑症状解法
--version-suffix 不生效包版本没有后缀csproj 里改用 VersionPrefix,删掉 Version(有 Version 时后缀被忽略)
netstandard2.0 目标构建失败“无效的 nullable 值:C# 7.3”该 TFM 默认语言版本老,显式 <LangVersion>latest</LangVersion>
私有源 <clear /> 后还原失败NU1101 找不到 NETStandard.Library构建期依赖也需要源:保留 nuget.org 或自建镜像/上游
dotnet nuget push 本地源被拒“指定的源无效”相对路径在部分 shell 下不识别,用绝对路径或配置源名
只配了 PackageIconUrl/PackageLicenseUrlNU5048 / NU5125 警告换 PackageIcon + 文件打包 / license expression 或 file
--include-symbols 打出老式符号包产出 .symbols.nupkg 而非 .snupkg再加 -p:SymbolPackageFormat=snupkg
SemVer 2.0.0 特有版本"消失”点分隔先行版/带 + 的包在源上搜不到旧客户端或 V2 源默认过滤;客户端升 4.3+/SDK 2.0+,源用 V3
同版本想覆盖重发409 冲突nuget.org 不可覆盖不可删,只能升版本;自建源可配 HardDelete

发布路径的决策树:

  • 个人开源库 → nuget.org + Trusted Publishing(CI 零长期凭据)+ snupkg;
  • 公司内部分发,起步阶段 → BaGetter 单容器(半天上线),镜像 nuget.org 顺手解决缓存;
  • 公司规模化(权限、审计、多制品类型) → ProGet 或 Nexus(预算有限选前者 Free 版起步)、Azure Artifacts(已在 Azure 生态);别指望 Artifactory 免费版;
  • 离线/强管控环境 → 文件夹源或 Sleet 静态 feed + 镜像同步,配合 Package Source Mapping 与最小发布年龄策略。

八、结语

从 dotnet new classlib 到一个别人 dotnet add package 就能用的包,中间的每一步——元数据三件套、SemVer 版本纪律、依赖与资产控制、发布闭环验证——都是在替下游的使用者省时间。而"发布"这个动作本身在 2026 年的两极化也很有意思:nuget.org 一侧在收紧(30 天 API Key、不可删除、签名不可变),私有源一侧在 democratize(一个容器甚至一个目录就是一个源)。工具一直在变,这篇文章里的大部分命令细节都会有保质期,但那两条主线不会变:对下游负责的包才值得发布,验证过的发布才算发布。

参考资料

打包与元数据

  1. MSBuild pack targets 参考(包元数据属性权威表)
  2. .nuspec 参考(nuspec 元素与依赖组约定)
  3. dotnet pack 命令参考(VersionPrefix/Suffix 组合表、.NET 10 直接打包 nuspec)
  4. 支持多目标框架打包
  5. 包内 MSBuild props 与 targets(build/ 目录约定)
  6. 分析器打包约定(analyzers/ 目录)
  7. Roslyn 源生成器 cookbook(源生成器打包)

版本规范

  1. Semantic Versioning 2.0.0 规范
  2. NuGet 包版本概念(SemVer 2.0.0 支持范围与规范化)
  3. 先行版包(命名习惯与还原行为)
  4. MinVer / Nerdbank.GitVersioning

符号与 SourceLink

  1. 符号包 snupkg 指南
  2. SourceLink 仓库文档(.NET 8 内置、snupkg vs 嵌入 PDB 对比)
  3. 库作者指南:SourceLink 与确定性构建建议

发布与 nuget.org

  1. dotnet nuget push 命令参考
  2. 发布到 nuget.org(API Key、校验时长、上传要求)
  3. Scoped API Keys(作用域与过期策略)
  4. Trusted Publishing(OIDC 临时凭据工作流)
  5. nuget.org 删除政策(unlist 与不可变性)
  6. NuGet 供应链安全与 API Key 时长收紧公告

私有源

  1. 自托管 NuGet 源总览
  2. 本地文件夹源(布局与性能)
  3. BaGetter 文档(Docker、配置、镜像)
  4. ProGet 版本与许可功能对比 / Inedo 定价
  5. Nexus Repository NuGet 文档(V2/V3 与符号服务器)
  6. Artifactory 自托管版本功能矩阵(免费线不含 NuGet)
  7. Azure Artifacts 入门(2 GiB/组织免费额度) / Azure DevOps 定价
  8. GitHub Packages 的 NuGet registry / Packages 计费
  9. Sleet:静态 NuGet feed 生成器
  10. NuGet 配置文件行为(层级、凭据加密边界) / 带认证源的凭据最佳实践
Licensed under CC BY-NC-SA 4.0