之前给 .NET Core 项目做 CI/CD 的时候,我最早用的是 Jenkins,单独找了一台服务器,装环境、配插件、写脚本,折腾了好几天才把最基本的构建和测试跑通。后来换了 GitHub Actions,整个流程一下子轻了很多——不用自己养构建服务器,流水线配置直接跟着仓库走,push 代码就自动触发持续集成和持续交付。这篇文章就围绕“用 GitHub Actions 给 .NET Core 项目做 CI/CD”这件事,把我实际落地过程中的思路、配置细节和踩过的坑一次说清楚。内容适合两类人:一类是正在用 GitHub 托管 .NET Core 代码、想给项目加上自动化流水线但还没动手的开发者;另一类是已经写过一个简单的 workflow,但想做得更完整、少踩坑的团队。
1. 为什么我最终选了 GitHub Actions 做 CI/CD
1.1 先想明白持续集成和持续交付到底解决什么问题
聊工具之前,先把概念对齐一下。持续集成(Continuous Integration,CI)的核心动作是“频繁地把代码合并到主干,并自动执行构建和测试”,目的是尽早发现集成问题。持续交付(Continuous Delivery,CD)则是在 CI 的基础上,把通过验证的产物自动发布到目标环境,比如 NuGet 仓库、测试服务器或者生产服务器。
我见过不少团队,嘴上说在做 CI/CD,实际流程是:开发本地跑一下 build,然后手动上传到服务器,再手动敲一串命令启动。这种方式有两个要命的地方。第一,集成冲突和被忽略的测试失败会攒到最后一起爆发,定位问题的时间成本极高。第二,发布过程依赖某个人的记忆和操作习惯,今天能成功,明天换个环境可能就失败。
把这两个动作自动化之后,效果非常直接。每次提交代码,机器自动拉代码、还原依赖、编译、跑测试、收集覆盖率,只有全部通过才会继续往下走。这套流程跑起来之后,整个项目的“安全感”完全不一样,代码回归的时候敢改东西,发布的时候不用再盯着屏幕祈祷。
1.2 方案选型对比:Jenkins、GitLab CI、Azure DevOps,为什么是 Actions
现在市面上主流的 CI/CD 工具不少,每个我都实际用过或者认真评估过。简单做一个横向对比,这样大家能理解为什么最后落到 GitHub Actions 上。
| 对比项 | Jenkins | GitLab CI | Azure DevOps | GitHub Actions |
|---|---|---|---|---|
| 部署方式 | 自建服务器,需维护 | 自建或在 SaaS 上使用 | SaaS 或自架代理 | 纯 SaaS,零部署 |
| 配置文件 | 通常是 Jenkinsfile 或界面配置 | .gitlab-ci.yml | 多种语言可选 | .github/workflows/*.yml |
| 与代码仓库集成 | 靠 Webhook 和插件 | 同平台集成度高 | 需要连接 GitHub | 原生集成,无需额外配置 |
| 免费额度 | 无,成本在服务器上 | 有一定免费额度 | 有一定免费额度 | 公共仓库免费,私有仓库有月度免费额度 |
| 生态 | 插件极多,但质量参差不齐 | 内置功能丰富 | 功能全面,绑定微软生态 | Marketplace 生态增长快 |
这个对比里最关键的差异是“维护成本”。Jenkins 的插件体系确实强大,但版本升级、插件兼容、主节点安全这些问题,都需要有人持续花时间去照料。而 GitHub Actions 的 runner 由平台托管,我们只需要维护 workflow 文件本身。对于一个中小型的 .NET Core 项目,我不需要为了跑流水线单独准备一台服务器,也不需要担心构建环境被污染.
另外一个很实际的优势是“免跳转”。代码在 GitHub 上,PR 审查、Issue 讨论、Actions 运行结果都在同一个页面里,开发同学不需要切到另一个系统看构建状态。尤其对个人项目和小团队来说,少一个系统就是少一份负担。
1.3 适合的使用场景和需要留意的边界
GitHub Actions 不是什么场景都适合,我个人的判断是这样的:如果你的代码托管在 GitHub,并且你的流水线在平台托管的 runner 上就能跑完,那它基本是最优解。个人开源项目、内部业务系统、中小团队的产品项目都适用。
但如果你的构建需要特殊网络环境,比如内网才能访问的私有依赖源、需要连到公司内部测试设备的场景,托管 runner 就力不从心了。这时候可以选择自托管 runner,把 Agent 部署到自己的机器上,workflow 配置基本不用改。自托管 runner 能解决网络和硬件定制的问题,同时也把一部分维护责任接回来了,内存、磁盘、Agent 版本升级都要自己管。
另外还要留意免费额度的限制。私有仓库的免费执行分钟数有限,如果团队的活跃度很高,或者使用了很大的 Windows/macOS runner,额度可能不够用。账单和配额政策会调整,动手之前建议以官方文档当前版本为准。公共仓库的免费额度相对宽松,这也是大量开源项目选择它的原因。
2. 动手之前先把 GitHub Actions 的几个核心概念搞懂
2.1 Workflow、Job、Step 的关系
GitHub Actions 的配置写在.github/workflows目录下,每个 YAML 文件就是一个 Workflow(工作流)。Workflow 由事件(Event)触发,比如 push 代码、创建 PR、打 tag,或者手动触发。
一个 Workflow 里面可以定义多个 Job(任务),不同 Job 之间默认是并行执行的。每个 Job 跑在一个独立的 runner 上,也就是一个虚拟环境。Job 里面又包含若干 Step(步骤),Step 是这个模型里最小的执行单元,可以是一条命令,也可以是复用别人写好的 Action。
用流水线做类比:Workflow 是整条生产线,Job 是几个不同的工段,Step 是工段里的具体动作。比如“构建工段”里有“拉取代码”和“执行 dotnet build”两个动作。
这里有一个新手容易忽略的点:Job 之间如果需要有先后顺序,必须在 YAML 里显式声明needs依赖,否则它们是并行跑的。比如“部署发布”这个 Job 必须在“构建测试”跑完之后才执行,就要写成:
jobs: build: runs-on: ubuntu-latest steps: - run: echo "build" deploy: needs: build runs-on: ubuntu-latest steps: - run: echo "deploy"2.2 Runner、Action 和 Marketplace
Runner 是实际执行任务的机器。托管 runner 由 GitHub 提供,Ubuntu、Windows、macOS 都有,已经预装了常见的开发工具链和 SDK,直接runs-on: ubuntu-latest就能用。.NET Core 的构建跑在 Ubuntu runner 上是完全没问题的,不需要额外装什么。
Action 是封装好的可复用单元,类似“函数”。最常用的几个官方 Action,任何一个做 .NET CI/CD 的项目几乎都会用到:
actions/checkout:把仓库代码拉取到 runner 上actions/setup-dotnet:安装指定版本的 .NET SDKactions/cache:缓存依赖项,比如 NuGet 包,显著加速流水线
在 workflow 里用uses关键字引用 Action,同时指定版本:
- uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: dotnet-version: '8.0.x'这里的版本 tag 值得留意。官方 Action 一般会用 v1、v2 这样的主版本标签,根据项目依赖情况,锁定到一个大版本是比较稳的做法。实际使用中,我遇到过别人写死了某次 commit 的哈希,之后想升级得手动改,非常麻烦。
2.3 Secrets 和环境变量的设计
CI/CD 流水线里免不了要持有一些敏感信息,比如 NuGet API Key、服务器的 SSH 私钥、数据库连接串。这些内容绝对不能直接写在 YAML 文件里,因为仓库里的文件会随着 git 历史永久留存。
正确的做法是使用 GitHub 仓库的 Secrets 功能。在仓库的 Settings -> Secrets and variables -> Actions 里添加键值对,然后在 workflow 中用${{ secrets.XXX }}引用。
用 Secrets 时有几个细节必须清楚。第一,Secrets 不会出现在日志里,Actions 平台会自动对输出的 secret 值打码。第二,如果 workflow 的触发事件是来自 fork 仓库的 pull_request,外部贡献者提交的代码不会拿到 Secrets,这是 GitHub 有意为之的安全机制。个人项目如果也要接外部 PR,需要考虑这个限制。第三,一个 repository、environment 和 org 级别都有 secrets,作用范围不同,按需配置。
环境变量则用env关键字定义,可以放在 Workflow、Job 或者 Step 三个级别。放的位置不同,作用范围也不同。Job 级变量在 Job 内所有 Step 都可用,Step 级变量只对当前 Step 生效。我个人的习惯是:不会变的配置放环境变量,敏感的放 Secrets,这样 YAML 看起来干净,也不容易泄密。
3. 手把手搭建 .NET Core 项目的完整流水线
3.1 准备一个结构规范的项目仓库
在写 workflow 之前,先把仓库结构整利落。CI/CD 的很多麻烦其实源于项目结构混乱。一个推荐的 .NET Core 仓库结构长这样:
SimulationProject/ ├── .github/ │ └── workflows/ │ ├── ci.yml │ └── cd.yml ├── src/ │ └── SimulationProject.Api/ │ ├── SimulationProject.Api.csproj │ └── Program.cs ├── tests/ │ └── SimulationProject.Api.Tests/ │ ├── SimulationProject.Api.Tests.csproj │ └── UnitTest1.cs ├── SimulationProject.sln └── README.md让 workflow 简单的最有效方式,是保证一条命令能把整个解决方案构建出来:
dotnet restore SimulationProject.sln dotnet build SimulationProject.sln --configuration Release --no-restore dotnet test SimulationProject.sln --configuration Release --no-build这三条命令的顺序就是 CI 的核心逻辑:还原依赖 -> 编译 -> 测试。后面写的所有 YAML 基本都是围绕这三步来的。
3.2 持续集成部分:从零写好一个 CI workflow
下面是一个完整的 CI workflow,我直接用在实际项目中验证过,没有任何多余的装饰:
name: ci-build-test on: push: branches: [main, develop] pull_request: branches: [main] jobs: build-test: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup .NET SDK uses: actions/setup-dotnet@v4 with: dotnet-version: '8.0.x' - name: Restore dependencies run: dotnet restore SimulationProject.sln - name: Build solution run: dotnet build SimulationProject.sln --configuration Release --no-restore - name: Run tests run: dotnet test SimulationProject.sln --configuration Release --no-build --logger "trx;LogFileName=test-results.trx" - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: name: test-results path: '**/TestResults/*.trx'逐个说一下关键点。
on字段指定触发条件,上面配置的是 push 到 main 或 develop 分支时、以及向 main 发起 PR 时触发。这样每次代码提交都会跑一遍构建和测试。
actions/setup-dotnet的作用是安装指定版本 SDK。dotnet-version: '8.0.x'表示安装最新的 8.0 补丁版本,这样既能锁定大版本,又能自动获得修复补丁。务必在 runner 的 SDK 列表之外显式安装,因为 ubuntu-latest 镜像预装的 SDK 版本和你本地的可能有差异,本地能编和 CI 能编是两回事。
dotnet build里我加了--no-restore。为什么?因为上一步已经显式 restore 过了,用--no-restore可以避免 build 过程中再次检查依赖。这个参数的作用是让每个 step 的职责更单一,排查问题时一眼就能看出是哪一步出了问题。
测试步骤有几个细节。加了--no-build,表示沿用上一步编译出来的程序集,避免重复编译。--logger "trx;LogFileName=test-results.trx"会把测试结果输出为标准 TRX 格式,这个格式可以被后续的上传步骤保存为 Artifact。
Upload test results这一步用了if: always()。默认情况下,如果某个 step 失败,后面的 step 不会执行。但测试一旦失败,其实是最需要测试结果文件的时候,所以用if: always()确保即使测试挂了,测试结果也能被上传。这个经验是我在一次排查测试失败时总结出来的,没有这一步,CI 挂了之后你只能在日志里看输出,非常痛苦。
3.3 不止能编译:加入代码格式校验与覆盖率统计
如果 CI 只做“能编译 + 测试通过”这件事,那离生产级质量还差点距离。我建议至少再加两样东西:代码格式化校验和测试覆盖率。
.NET SDK 自带了dotnet format,可以做格式化校验。它的--verify-no-changes模式非常适合放在 CI 里,如果代码没有按照.editorconfig的约定格式化,这个命令会返回非零退出码,流水线随之失败:
- name: Verify formatting run: dotnet format SimulationProject.sln --verify-no-changes --no-restore这一条防线能挡住不少团队里“各写各的风格”的问题。注意第一次跑的时候可能全仓库一路飘红,建议先在本地跑一次dotnet format自动修复,把基线拉齐再进 CI。
测试覆盖率我用的是 .NET 自带的--collect:"XPlat Code Coverage"参数,它能生成 Cobertura 格式的覆盖率报告。覆盖率不只是追求高数字,核心作用是帮助发现“哪些核心路径完全没有测试”。我一般会给项目定一个最低覆盖率门槛,比如 80%,用专门的阈值检查工具来实现:
- name: Collect coverage run: dotnet test SimulationProject.sln --configuration Release --no-build --collect:"XPlat Code Coverage" --settings coverlet.runsettings运行完之后需要把覆盖率报告上传到某个平台或 Artifact 里才能在界面上直观查看。没有专门平台的团队,最简单的方法是先上传为 Artifact,开发者下载后用工具打开。如果想在 PR 里直接看到覆盖率变化,就要考虑接入 Codecov、SonarCloud 这类服务,它们的 GitHub App 会自动在 PR 上留评论。
3.4 持续交付部分:发布 NuGet 包和部署到服务器
CI 跑通之后,CD 就是水到渠成的事。我实际做过两条最典型的 CD 分支:一条是发布 NuGet 包给其他项目引用,另一条是把 API 部署到一台 Linux 服务器上并重启服务。
先看发布 NuGet 包的 workflow。这个场景用 tag 作为触发信号是最自然的。代码推送到v1.2.3这样的 tag 时,触发打包和发布:
name: cd-publish-nuget on: push: tags: - 'v*' jobs: publish: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup .NET SDK uses: actions/setup-dotnet@v4 with: dotnet-version: '8.0.x' - name: Pack run: dotnet pack src/SimulationProject.Api/SimulationProject.Api.csproj --configuration Release --output ./artifacts - name: Push to NuGet run: dotnet nuget push ./artifacts/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicatedotnet pack负责把项目打成 .nupkg 包,--output指定输出目录。dotnet nuget push负责推到 NuGet 仓库。这里的关键动作是把 API Key 放到 Secrets 中,YAML 里只留引用。
实际项目中 NuGet 发布可能不止一个包,或者需要先构建解决方案再打包多个项目,这时最好在步骤里先dotnet build --configuration Release --no-restore,然后再逐个 pack,保证打的包是刚构建出来的最新产物。
再来看部署到 Linux 服务器的 workflow。这个场景通常包含三步:生成发布文件 -> 上传文件到服务器 -> 在服务器上执行重启命令。生成发布文件用:
- name: Publish API run: dotnet publish src/SimulationProject.Api/SimulationProject.Api.csproj --configuration Release --no-restore --output ./publish上传和远程执行命令的方式有很多种。我用过一套组合:appleboy/scp-action上传目录,再用appleboy/ssh-action执行远端命令。也可以直接用rsync加 SSH,但 rsync 在 Windows runner 上不如 Linux 方便。上传之前要把主机地址、SSH 用户名、私钥配到 Secrets 里:
- name: Upload to server uses: appleboy/scp-action@v0.1.7 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} source: "publish/*" target: "/var/www/simulationproject" strip_components: 1 - name: Restart service on server uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd /var/www/simulationproject systemctl restart simulationproject-api这段配置里有个细节容易被忽略:source: "publish/*"表示上传的是 publish 目录下的所有内容,strip_components: 1表示去掉路径里的第一层目录,也就是把publish/本身剥掉,这样文件会直接落到服务器的目标目录里。如果不加这个参数,服务器上会多出一层名为publish的目录。
4. 流水线运行中的常见问题与排查技巧
4.1 我真实踩过坑的地方
先说最扎心的一个:fork 仓库的 PR 不会执行 secrets。一个外部贡献者往我的开源模拟项目提交了 PR,CI 流程会正常跑,但带 Secrets 的步骤会直接被跳过,或者以无法访问 secrets 的身份失败。这是 GitHub 的安全设计,不能修改,但可以通过两个方案减弱影响:一是把带敏感信息的 CD 步骤和普通 CI 步骤拆到不同 workflow,二是对 fork PR 单独使用pull_request_target事件并非常小心地处理。对于一般团队,直接采用“fork PR 只跑 CI,维护者合并后再手动触发 CD”的策略是最稳的。
接下来是 workflow 一直排队的问题。免费额度耗尽或者仓库配置了并发限制都会导致排队。遇到长时间的 pending,先去检查当前月份的 Actions 使用分钟数,如果归零,要么等配额刷新,要么换成自托管 runner。
Runner 上安装 SDK 失败也是常事。actions/setup-dotnet有时会因为网络问题下载失败,或者在指定版本不可用时直接报错。处理办法是先在本地确认 SDK 版本确实公开发布了,然后把dotnet-version改成你本地实际验证过的版本。另外,runner 里可能同时存在多个 SDK,如果 global.json 文件缺失,dotnet 命令可能自动选一个高版本 SDK,导致行为不可预期,建议在仓库根目录放好 global.json 锁定最后验证过的 SDK 版本。
还有一个经常碰到的问题:dotnet test时,测试项目编译依赖的某些配置只在本地才存在,CI 上没有。最典型的是开发者把连接字符串、第三方接口地址直接写进了 appsettings.json,然后在 CI 里跑集成测试时连不上外部服务。这种问题要尽早规划,在项目里设计依赖注入和配置抽象,把外部服务用接口隔离,CI 环境下注入内存实现。
4.2 快速排查办法和调试技巧
调试 Actions 时效率最高的三个手段,我按使用频率排一下。
第一是进入仓库的 Actions 标签页,点击具体的 workflow run,展开每个 step 看实时日志。日志里有每个命令的完整输出,大部分问题在这一步就能找到原因。
第二是在本地用act工具模拟运行 Actions。这个工具能读取 workflow 文件,在本地容器里跑,非常适合快速验证 YAML 语法和命令是否可用。不过有些 GitHub 特有的功能在act中支持不完整,比如某些内置 Action 或 secrets 的注入,所以act通过之后还要在 GitHub 上再确认一次。
第三是使用一行能看懂状态的小技巧:在 workflow 里加timeout-minutes,默认 6 小时的托管 runner 超时对大部分项目来说太久了,配置 10 到 20 分钟的超时能让异常情况尽早显形,不必干等:
jobs: build-test: runs-on: ubuntu-latest timeout-minutes: 15日志里如果遇到错误码,比如 exit code 137,这类是内存不够,通常是被 dotnet build 的大量并行编译进程吃掉了 runner 内存。可以限制 MSBuild 的并行度:
dotnet build SimulationProject.sln --configuration Release --no-restore -m:1-m:1强制单进程构建,慢但稳。这个参数非常有用,我在大型解决方案上用过很多次。
5. 让流水线跑得更快、更稳的进阶配置
5.1 用缓存把还原时间降下来
.NET 项目最耗时的环节通常是依赖还原。每次 CI 都是从零下载 NuGet 包,一个稍微大一点的项目就能耗掉好几分钟。actions/cache可以显著改善这个问题。NuGet 包的缓存路径在 Ubuntu runner 上是~/.nuget/packages:
- name: Cache NuGet packages uses: actions/cache@v4 with: path: ~/.nuget/packages key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }} restore-keys: | nuget-${{ runner.os }}-这里hashFiles('**/*.csproj')会根据所有 .csproj 文件的内容生成一个哈希值。依赖声明没变化时,缓存可以直接命中,跳过重新下载;依赖一旦有变化,哈希值改变,会生成新的缓存。这是最稳妥的缓存策略。
实际对比下来,加了这层缓存之后,还原时间大约能从 3 分钟降到 30 秒以内。对于一天要跑几十次流水线的团队,这个优化性价比极高。
5.2 矩阵构建:一次跑多个系统环境和 SDK 版本
如果你的 .NET Core 项目需要保证在多个操作系统上兼容,可以使用矩阵(Matrix)策略。GitHub Actions 允许一个 Job 定义多个参数组合,比如系统、SDK 版本,然后并行为每一组参数执行整个 Job:
jobs: build-test: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] dotnet-version: ['8.0.x'] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ matrix.dotnet-version }} - run: dotnet restore SimulationProject.sln - run: dotnet build SimulationProject.sln --configuration Release --no-restore - run: dotnet test SimulationProject.sln --configuration Release --no-build这套配置特别适合做类库或者跨平台工具项目。在 Windows 上运行的 runner 是付费环境,会把额度消耗得更快,所以矩阵策略最好用在有价值的场景,不要每个小项目都全平台跑一遍。
5.3 并发控制和分支环境策略
当团队多个成员同时向 main 分支推送代码时,多个 workflow 可能会同时跑,很浪费资源。GitHub Actions 提供了并发控制能力,同组的 workflow 一旦有新的运行,之前的运行会被取消或排队:
concurrency: group: ci-${{ github.ref }} cancel-in-progress: truegithub.ref表示当前分支的引用,这样每个分支的流水线是独立的,同一个分支上的旧运行会被新运行替代。
对于 CD 部署,我习惯按环境拆 workflow 或者使用 environment。比如 main 分支对应生产,develop 分支对应测试,可以定义两个 environment,并为生产环境配置“需要人工审核才能继续”的机制。这样生产发布多了一道保护,误发也能拦住。environment 的设置在仓库 Settings -> Environments 里可以配置保护规则,评审、分支限制都可以加上。
6. 从 CI 到 CD,这个方案还能怎么扩展
6.1 把 Docker 镜像构建也放进流水线
如果 .NET Core 服务打算以容器方式部署,在 workflow 里构建和推送镜像可以往下这样加:先dotnet publish出发布文件,再用 Dockerfile 把它打进镜像,最后推到镜像仓库。用docker/build-push-action可以避免手动拼 docker login 和 build 命令的麻烦。镜像仓库的账号密码一样放在 Secrets 里,不要在 workflow 里写明文。
在上面的 CI 步骤之后再加一个 Job: - needs: build-test - 构建 Docker 镜像 - 推送镜像到容器镜像仓库 - 可选:触发服务器拉取新镜像并重新部署这样 CD 就从“上传文件 + 重启服务”演进成了“推送镜像 + 远端拉取”,服务器上要做的事更少,回滚也更简单——把镜像 tag 指回上一个版本即可。
6.2 用环境维度管理 .NET 应用的配置
部署到不同环境的时候,会面临“同一个程序包,不同环境要用不同配置”的问题。GitHub Actions 的 Environment 机制在这里能派上大用场。可以为test、staging、production各建一个 environment,每个 environment 有独立的 secrets 和变量。
比如生产环境的数据库连接串存在生产 environment 的 secrets 里,staging 环境的使用 staging 的 secrets。Deploy 时引用方式:
jobs: deploy: environment: production runs-on: ubuntu-latest steps: - name: Deploy run: echo "Deploying to production with ${{ secrets.PROD_CONNECTION }}"这样既不会把敏感配置写进仓库,也让 CD 流程在不同环境中复用同一套 workflow 配置。我在多个项目里都采用了这种方式,收益相当明显。
7. 我实际用下来的一些体会
把 .NET Core 项目的 CI/CD 切到 GitHub Actions 上,前后已经过了大半年,跑了上百次流水线。最大的感受是这个方案足够“轻”——不用维护服务器,配置跟着仓库走,新人接手项目时看一下 workflow 文件就能理解整个构建发布流程。
如果只从一个地方开始,我建议先把 CI 做扎实:push 和 PR 触发构建、测试、格式化检查。这个阶段做好了,代码质量会肉眼可见地提升。CD 等 CI 稳定了再往上加,不要一开始就把所有 Action 都堆上去,不然出了问题都不知道是哪个环节先挂的。
最后分享一个我踩过最深的坑:一次升级 NuGet 包的版本,本地构建测试全通过,但 CI 一直失败,最后发现是我在 global.json 里指定的 SDK 版本在 CI 环境下解析到了不同的补丁版,行为不一致。从那以后我养成了一个习惯——本地验证通过后,再刻意让 CI 用全新的还原目录跑一遍,确保没有依赖本地缓存才能通过。流水线这种东西,稳定可靠比花哨重要得多。