为什么.NET MAUI项目需要CI/CD流水线
如果你手动构建和发布过.NET MAUI应用,你大概率知道那种痛苦——每次发版要在不同平台上分别编译、签名、打包,然后手动上传到Google Play Console和App Store Connect。说实话,干过一两次之后就会想:能不能让机器自动帮我搞定这些?
答案当然是可以的。CI/CD(持续集成/持续部署)流水线就是干这个的。
自动化这些重复步骤能带来几个实实在在的好处:
- 一致性:每次构建和部署走完全相同的流程,不会因为"忘了勾某个选项"翻车
- 速度:Push代码后自动触发构建,你可以去喝杯咖啡等结果
- 可追溯性:每个版本都关联到具体的Git提交,出了问题能快速定位
- 团队协作:开发人员不用在本地折腾签名证书和密钥库(这个真的太香了)
GitHub Actions是GitHub内置的CI/CD平台,跟.NET MAUI项目配合得非常好。接下来我会从零开始,带你搭建一套完整的Android和iOS自动构建与发布流水线,基于.NET 10和最新的MAUI工作负载。
前置准备与项目结构
开发环境要求
- .NET 10 SDK
- GitHub仓库(用于托管代码和跑Actions)
- Android签名密钥库(Keystore)
- Apple开发者账号(用于iOS证书和描述文件)
- Google Play Console开发者账号(用于Android发布)
- App Store Connect API密钥(用于TestFlight上传)
推荐的项目目录结构
MyMauiApp/
├── .github/
│ └── workflows/
│ ├── ci.yml # 持续集成工作流
│ ├── cd-android.yml # Android CD工作流
│ └── cd-ios.yml # iOS CD工作流
├── src/
│ └── MyMauiApp/
│ ├── MyMauiApp.csproj
│ └── ...
├── tests/
│ └── MyMauiApp.Tests/
└── global.json
建议把工作流文件按平台分开管理,这样可以独立触发和调试,排查问题时会轻松不少。另外,强烈推荐用global.json锁定SDK版本——我踩过本地和CI环境SDK版本不一致的坑,排查起来真的很费时间。
创建基础CI工作流
先来搞定持续集成部分。我们需要一个工作流,在每次Push和Pull Request时自动编译项目并运行测试。
name: CI Build
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
build-android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Install MAUI Workload
run: dotnet workload install maui-android --ignore-failed-sources
- name: Restore Dependencies
run: dotnet restore src/MyMauiApp/MyMauiApp.csproj
- name: Build Android
run: dotnet build src/MyMauiApp/MyMauiApp.csproj -c Release -f net10.0-android --no-restore
build-ios:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Install MAUI Workload
run: dotnet workload install maui-ios --ignore-failed-sources
- name: Restore Dependencies
run: dotnet restore src/MyMauiApp/MyMauiApp.csproj
- name: Build iOS
run: dotnet build src/MyMauiApp/MyMauiApp.csproj -c Release -f net10.0-ios --no-restore
run-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Run Unit Tests
run: dotnet test tests/MyMauiApp.Tests/MyMauiApp.Tests.csproj -c Release --logger trx
几个关键点要注意
- Android构建可以跑在
ubuntu-latest上,成本最低 - iOS构建必须用
macos-15运行器——没办法,Xcode工具链只有macOS上有 - 加上
--ignore-failed-sources可以避免NuGet源临时抽风导致工作负载装不上 - 始终对项目文件(
.csproj)而不是解决方案文件(.sln)执行dotnet build或dotnet publish,不然可能会把不该编译的项目也带上
Android自动构建与签名
第一步:生成签名密钥库
还没有密钥库?没关系,用下面的命令生成一个:
keytool -genkey -v \
-keystore myapp-release.keystore \
-alias myapp \
-keyalg RSA \
-keysize 2048 \
-validity 10000
划重点:一定要备份好密钥库文件和密码。丢了密钥库就意味着没法用同一签名去更新你的应用了,这可不是闹着玩的。
第二步:把密钥库存到GitHub Secret里
密钥库是二进制文件,不能直接当Secret用。得先转成Base64字符串:
# macOS / Linux
base64 -i myapp-release.keystore -o keystore-base64.txt
# Windows PowerShell
[Convert]::ToBase64String([IO.File]::ReadAllBytes("myapp-release.keystore")) | Set-Content keystore-base64.txt
然后到GitHub仓库的 Settings → Secrets and variables → Actions,创建这几个Secrets:
ANDROID_KEYSTORE_BASE64:密钥库的Base64编码内容ANDROID_KEYSTORE_PASSWORD:密钥库密码ANDROID_KEY_ALIAS:密钥别名ANDROID_KEY_PASSWORD:密钥密码PLAYSTORE_SERVICE_ACCOUNT_JSON:Google Play服务账号JSON内容
第三步:Android CD工作流
这才是核心部分——当你Push一个版本标签时,自动完成构建、签名和上传到Google Play。
name: Android CD
on:
push:
tags:
- 'v*'
env:
PROJECT_PATH: src/MyMauiApp/MyMauiApp.csproj
DOTNET_VERSION: '10.0.x'
jobs:
deploy-android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'microsoft'
java-version: '17'
- name: Install MAUI Workload
run: dotnet workload install maui-android --ignore-failed-sources
- name: Decode Keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > myapp-release.keystore
- name: Publish Android App
run: |
dotnet publish ${{ env.PROJECT_PATH }} \
-f net10.0-android \
-c Release \
-p:AndroidKeyStore=true \
-p:AndroidSigningKeyStore=${{ github.workspace }}/myapp-release.keystore \
-p:AndroidSigningKeyAlias=${{ secrets.ANDROID_KEY_ALIAS }} \
-p:AndroidSigningKeyPass=${{ secrets.ANDROID_KEY_PASSWORD }} \
-p:AndroidSigningStorePass=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
- name: Upload to Google Play
uses: r0adkll/[email protected]
with:
serviceAccountJsonPlainText: ${{ secrets.PLAYSTORE_SERVICE_ACCOUNT_JSON }}
packageName: com.yourcompany.myapp
releaseFiles: src/MyMauiApp/bin/Release/net10.0-android/publish/*-Signed.aab
track: internal
status: draft
Android签名参数一览
| 参数 | 说明 |
|---|---|
AndroidKeyStore=true | 启用密钥库签名 |
AndroidSigningKeyStore | 密钥库文件的绝对路径 |
AndroidSigningKeyAlias | 密钥库中的密钥别名 |
AndroidSigningKeyPass | 密钥密码,支持env:和file:前缀 |
AndroidSigningStorePass | 密钥库密码,支持env:和file:前缀 |
顺便说一下,在.NET 10中dotnet publish默认就是Release配置了,所以-c Release其实可以省略。不过我习惯写上,看着明确一些。
iOS自动构建与TestFlight发布
iOS这边会稍微麻烦一点(苹果的生态嘛,你懂的),但跟着步骤走其实也不复杂。
第一步:配置App Store Connect API密钥
去 App Store Connect → 用户和访问 → 集成 → App Store Connect API,创建一个新的API密钥。创建完你会拿到三样东西:
- Issuer ID:发行者ID
- Key ID:密钥ID
- Private Key (.p8):私钥文件——注意,这个只能下载一次,千万别弄丢了
把这三个值存为GitHub Secrets:
APPSTORE_ISSUER_IDAPPSTORE_KEY_IDAPPSTORE_PRIVATE_KEY:.p8文件的完整内容
第二步:准备签名证书和描述文件
iOS应用签名需要两个东西:
- Distribution Certificate (.p12):Apple Distribution签名证书及其私钥
- Provisioning Profile (.mobileprovision):把应用ID、证书和设备绑在一起的描述文件
把证书导出为.p12之后,用Base64编码存到GitHub Secret里:
IOS_CERTIFICATE_BASE64:证书的Base64编码IOS_CERTIFICATE_PASSWORD:证书导出时设的密码
第三步:iOS CD工作流
name: iOS CD
on:
push:
tags:
- 'v*'
env:
PROJECT_PATH: src/MyMauiApp/MyMauiApp.csproj
DOTNET_VERSION: '10.0.x'
jobs:
deploy-ios:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- name: Install MAUI Workload
run: dotnet workload install maui-ios --ignore-failed-sources
- name: Import Code Signing Certificate
uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.IOS_CERTIFICATE_BASE64 }}
p12-password: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
- name: Download Provisioning Profile
uses: maui-actions/apple-provisioning@v1
with:
bundle-identifiers: com.yourcompany.myapp
profile-types: IOS_APP_STORE
issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}
- name: Publish iOS App
run: |
dotnet publish ${{ env.PROJECT_PATH }} \
-f net10.0-ios \
-c Release \
-p:ArchiveOnBuild=true \
-p:RuntimeIdentifier=ios-arm64 \
-p:CodesignKey="Apple Distribution" \
-p:CodesignProvision="MyMauiApp"
- name: Upload to TestFlight
uses: apple-actions/upload-testflight-build@v3
with:
app-path: src/MyMauiApp/bin/Release/net10.0-ios/ios-arm64/publish/MyMauiApp.ipa
issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}
iOS构建踩坑备忘
ArchiveOnBuild=true是生成.ipa文件的关键,漏掉这个参数就不会产出可安装包CodesignKey的值要跟密钥链中安装的证书名称对上CodesignProvision的值要和下载的描述文件名称一致- .NET 10中
RuntimeIdentifier默认已经是ios-arm64了,但我建议还是写上,一目了然 maui-actions/apple-provisioning这个Action会自动创建临时密钥链,不会跟运行器的默认密钥链打架
版本号自动管理
每次上传到商店的版本号必须递增,这是硬性要求。手动改版本号?算了吧,早晚会忘的。下面是个靠谱的自动化方案。
在.csproj中配置版本属性
<PropertyGroup>
<ApplicationDisplayVersion>1.0.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
</PropertyGroup>
ApplicationDisplayVersion:用户看到的版本号(比如1.2.3)ApplicationVersion:内部版本号(整数,必须逐次递增)
在工作流中自动提取和设置版本号
- name: Extract Version from Tag
id: version
run: |
TAG=${GITHUB_REF#refs/tags/v}
echo "display_version=$TAG" >> $GITHUB_OUTPUT
echo "build_number=${{ github.run_number }}" >> $GITHUB_OUTPUT
- name: Publish with Version
run: |
dotnet publish ${{ env.PROJECT_PATH }} \
-f net10.0-android \
-c Release \
-p:ApplicationDisplayVersion=${{ steps.version.outputs.display_version }} \
-p:ApplicationVersion=${{ steps.version.outputs.build_number }} \
-p:AndroidKeyStore=true \
...
这样一来,推送v1.2.0标签时,用户可见版本号自动变成1.2.0,内部构建号用GitHub Actions的运行编号,自动递增不重复。简单又可靠。
GitHub Secrets安全最佳实践
CI/CD流水线里塞了一堆敏感信息,安全这块不能马虎。
用Environment保护关键环境
jobs:
deploy-android:
runs-on: ubuntu-latest
environment: production
steps:
...
GitHub Environments可以让你给不同环境(staging、production等)配不同的Secrets。最重要的是,你可以设置"人工审批"——部署到生产环境前必须有人点个确认,这在团队协作中非常重要。
密钥管理清单
下面这张表列出了所有需要管理的Secrets,建议存好备用:
| Secret名称 | 用途 | 获取方式 |
|---|---|---|
ANDROID_KEYSTORE_BASE64 | Android签名密钥库 | keytool生成后Base64编码 |
ANDROID_KEYSTORE_PASSWORD | 密钥库密码 | 生成密钥库时设置 |
ANDROID_KEY_ALIAS | 密钥别名 | 生成密钥库时设置 |
ANDROID_KEY_PASSWORD | 密钥密码 | 生成密钥库时设置 |
PLAYSTORE_SERVICE_ACCOUNT_JSON | Google Play发布权限 | Google Cloud Console创建 |
APPSTORE_ISSUER_ID | App Store Connect API发行者ID | App Store Connect创建 |
APPSTORE_KEY_ID | App Store Connect API密钥ID | App Store Connect创建 |
APPSTORE_PRIVATE_KEY | App Store Connect API私钥 | App Store Connect下载.p8文件 |
IOS_CERTIFICATE_BASE64 | iOS签名证书 | Keychain Access导出后Base64编码 |
IOS_CERTIFICATE_PASSWORD | 证书导出密码 | 导出.p12时设置 |
优化构建性能与控制成本
GitHub Actions按分钟收费,而macOS运行器的价格是Linux的10倍。不做点优化的话,账单看着会有点肉疼。以下几个策略亲测有效。
缓存NuGet包
- name: Cache NuGet Packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
用条件触发避免浪费
on:
push:
branches: [ main ]
paths-ignore:
- '**/*.md'
- 'docs/**'
- '.gitignore'
改了个README就跑一遍完整构建?大可不必。paths-ignore帮你过滤掉文档类变更。
用workload restore替代workload install
- name: Restore Workloads
run: dotnet workload restore src/MyMauiApp/MyMauiApp.csproj
dotnet workload restore只装项目真正需要的工作负载,比dotnet workload install maui快得多——后者会把所有平台的工作负载全装上,很多你根本用不到。
成本对比
| 运行器类型 | 每分钟费率 | 适用场景 |
|---|---|---|
ubuntu-latest | $0.008 | Android、测试 |
windows-latest | $0.016 | Windows |
macos-15 | $0.08 | iOS、macOS |
所以Android构建一定要用Linux运行器,成本差距是10倍。实际体验下来,典型的Android构建大概4到7分钟,iOS构建5到10分钟。
常见问题排查
搭流水线的过程中,你大概率会遇到下面这些问题。别问我怎么知道的。
工作负载安装失败
dotnet workload install步骤报错?十有八九是NuGet源临时不可用。加个--ignore-failed-sources就行:
dotnet workload install maui-android --ignore-failed-sources
Java SDK版本不对
Android构建如果报"Java SDK 11.0 or above is required"之类的错,那就是运行器自带的Java版本太老了。在工作流里显式指定:
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'microsoft'
java-version: '17'
iOS描述文件扩展名的坑
有些GitHub Actions下载描述文件时,不管目标平台是啥,统一存成.mobileprovision。但如果是Mac Catalyst项目,正确的扩展名应该是.provisionprofile。遇到这个问题需要加一步重命名操作。这个坑比较隐蔽,构建到签名阶段才会报错。
dotnet publish对解决方案文件报错
记住:dotnet publish永远指向.csproj文件,别用.sln。对解决方案执行publish会尝试逐个发布每个项目,各种莫名其妙的错误保管让你怀疑人生。
完整的多平台发布流水线架构
把前面的内容串起来,一套成熟的.NET MAUI CI/CD流水线长这样:
- CI阶段:每次Push或PR触发 → 编译检查 + 单元测试
- Staging阶段:合并到develop → 自动构建并推送到内部测试轨道(Google Play Internal)和TestFlight
- Production阶段:推送版本标签(如v1.2.0)→ 构建签名包 → 上传到商店正式轨道(需人工审批)
name: Release Pipeline
on:
push:
tags:
- 'v*'
jobs:
run-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- run: dotnet test tests/MyMauiApp.Tests/ -c Release
deploy-android:
needs: run-tests
runs-on: ubuntu-latest
environment: production
steps:
# ... Android构建和发布步骤(参见上方Android CD工作流)
deploy-ios:
needs: run-tests
runs-on: macos-15
environment: production
steps:
# ... iOS构建和发布步骤(参见上方iOS CD工作流)
needs: run-tests确保测试通过才触发部署,environment: production要求有人审批才能执行。Android和iOS部署并行跑,效率拉满。
常见问题解答
GitHub Actions构建.NET MAUI应用要多久?
Android大概4到7分钟,iOS大概5到10分钟。第一次构建会慢一些,因为要下载安装工作负载。后面用上NuGet缓存和workload restore之后会快不少。多平台构建并行跑的话,整体流水线时间就取决于最慢的那个平台。
能不能在Linux上构建iOS应用?
不行,这是苹果的限制。iOS构建必须在macOS上跑,因为Xcode工具链和代码签名工具只有macOS版本。Android构建倒是没这个限制,Linux上就能搞定,而且更便宜。
密钥库丢了怎么办?
对于Android来说,丢了签名密钥库就没法用同一身份更新应用了。好在Google Play有个Play App Signing功能,建议一开始就启用——让Google帮你管App Signing Key,你只管Upload Key就行。万一Upload Key丢了,还可以联系Google Play支持团队重置。
怎么给不同分支配不同的部署目标?
用GitHub Actions的environment功能加上分支保护规则。比如develop分支自动部署到内部测试轨道,main分支通过标签触发正式发布。每个environment可以有自己独立的Secrets和审批流程。
macOS运行器费用怎么控制?
macOS运行器每分钟$0.08,Linux只要$0.008,差了10倍。省钱的办法有几个:只在Push标签时才触发iOS构建、用NuGet缓存缩短构建时间、用paths-ignore过滤非代码变更、或者直接上自托管运行器(self-hosted runners)彻底告别按分钟计费。