dotnet-cli-release-pipeline
Unified release CI/CD pipeline for .NET CLI tools: GitHub Actions workflow producing all distribution formats from a single version tag trigger, build matrix per Runtime Identifier (RID), artifact staging between jobs, GitHub Releases with SHA-256 checksums, automated Homebrew formula and winget manifest PR creation, and SemVer versioning strategy with git tags.
Version assumptions: .NET 8.0+ baseline. GitHub Actions workflow syntax v2. Patterns apply to any CI system but examples use GitHub Actions.
Scope
- Tag-triggered GitHub Actions release workflow
- Build matrix per Runtime Identifier (RID)
- Artifact staging between CI jobs
- GitHub Releases with SHA-256 checksums
- Automated Homebrew formula and winget manifest PR creation
- SemVer versioning with git tags
Out of scope
- General CI/CD patterns (branch strategies, matrix testing) -- see [skill:dotnet-gha-patterns] and [skill:dotnet-ado-patterns]
- Native AOT compilation configuration -- see [skill:dotnet-native-aot]
- Distribution strategy decisions -- see [skill:dotnet-cli-distribution]
- Package format details -- see [skill:dotnet-cli-packaging]
- Container image publishing -- see [skill:dotnet-containers]
Cross-references: [skill:dotnet-cli-distribution] for RID matrix and publish strategy, [skill:dotnet-cli-packaging] for package format authoring, [skill:dotnet-native-aot] for AOT publish configuration, [skill:dotnet-containers] for container-based distribution.
Versioning Strategy
SemVer + Git Tags
Use Semantic Versioning (SemVer) with git tags as the single source of truth for release versions.
Tag format: v{major}.{minor}.{patch} (e.g., v1.2.3)
bash1 2# Tag a release 3git tag -a v1.2.3 -m "Release v1.2.3" 4git push origin v1.2.3 5 6```bash 7 8### Version Flow 9 10```text 11 12git tag v1.2.3 13 │ 14 ▼ 15GitHub Actions trigger (on push tags: v*) 16 │ 17 ▼ 18Extract version from tag: GITHUB_REF_NAME → v1.2.3 → 1.2.3 19 │ 20 ▼ 21Pass to dotnet publish /p:Version=1.2.3 22 │ 23 ▼ 24Embed in binary (--version output) 25 │ 26 ▼ 27Stamp in package manifests (Homebrew, winget, Scoop, NuGet) 28 29```text 30 31### Extracting Version from Tag 32 33```yaml 34 35- name: Extract version from tag 36 id: version 37 run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" 38 # v1.2.3 → 1.2.3 39 40```text 41 42### Pre-release Versions 43 44```bash 45 46# Pre-release tag 47git tag -a v1.3.0-rc.1 -m "Release candidate 1" 48 49# CI detects pre-release and skips package manager submissions 50# but still creates GitHub Release as pre-release 51 52```text 53 54--- 55 56## Unified GitHub Actions Workflow 57 58### Complete Workflow 59 60```yaml 61 62name: Release 63 64on: 65 push: 66 tags: 67 - 'v[0-9]+.[0-9]+.[0-9]+*' # v1.2.3, v1.2.3-rc.1 68 69permissions: 70 contents: write # Create GitHub Releases 71 72defaults: 73 run: 74 shell: bash 75 76env: 77 PROJECT: src/MyCli/MyCli.csproj 78 DOTNET_VERSION: '8.0.x' 79 80jobs: 81 build: 82 strategy: 83 matrix: 84 include: 85 - rid: linux-x64 86 os: ubuntu-latest 87 - rid: linux-arm64 88 os: ubuntu-latest 89 - rid: osx-arm64 90 os: macos-latest 91 - rid: win-x64 92 os: windows-latest 93 runs-on: ${{ matrix.os }} 94 steps: 95 - uses: actions/checkout@v4 96 97 - uses: actions/setup-dotnet@v4 98 with: 99 dotnet-version: ${{ env.DOTNET_VERSION }} 100 101 - name: Extract version 102 id: version 103 shell: bash 104 run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" 105 106 - name: Publish 107 run: >- 108 dotnet publish ${{ env.PROJECT }} -c Release -r ${{ matrix.rid }} -o ./publish /p:Version=${{ 109 steps.version.outputs.version }} 110 111 - name: Package (Unix) 112 if: runner.os != 'Windows' 113 run: | 114 set -euo pipefail 115 cd publish 116 tar -czf "$GITHUB_WORKSPACE/mytool-${{ steps.version.outputs.version }}-${{ matrix.rid }}.tar.gz" . 117 118 - name: Package (Windows) 119 if: runner.os == 'Windows' 120 shell: pwsh 121 run: | 122 Compress-Archive -Path "publish/*" ` 123 -DestinationPath "mytool-${{ steps.version.outputs.version }}-${{ matrix.rid }}.zip" 124 125 - name: Upload artifact 126 uses: actions/upload-artifact@v4 127 with: 128 name: release-${{ matrix.rid }} 129 path: | 130 *.tar.gz 131 *.zip 132 133 release: 134 needs: build 135 runs-on: ubuntu-latest 136 steps: 137 - name: Extract version 138 id: version 139 run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" 140 141 - name: Download all artifacts 142 uses: actions/download-artifact@v4 143 with: 144 path: artifacts 145 merge-multiple: true 146 147 - name: Generate checksums 148 working-directory: artifacts 149 run: | 150 set -euo pipefail 151 shasum -a 256 *.tar.gz *.zip > checksums-sha256.txt 152 cat checksums-sha256.txt 153 154 - name: Detect pre-release 155 id: prerelease 156 run: | 157 set -euo pipefail 158 if [[ "${{ steps.version.outputs.version }}" == *-* ]]; then 159 echo "is_prerelease=true" >> "$GITHUB_OUTPUT" 160 else 161 echo "is_prerelease=false" >> "$GITHUB_OUTPUT" 162 fi 163 164 # Pin third-party actions to a commit SHA in production for supply-chain security 165 - name: Create GitHub Release 166 uses: softprops/action-gh-release@v2 167 with: 168 name: v${{ steps.version.outputs.version }} 169 prerelease: ${{ steps.prerelease.outputs.is_prerelease }} 170 generate_release_notes: true 171 files: | 172 artifacts/*.tar.gz 173 artifacts/*.zip 174 artifacts/checksums-sha256.txt 175 176 publish-nuget: 177 needs: release 178 if: ${{ !contains(github.ref_name, '-') }} # Skip pre-releases 179 runs-on: ubuntu-latest 180 steps: 181 - uses: actions/checkout@v4 182 183 - uses: actions/setup-dotnet@v4 184 with: 185 dotnet-version: ${{ env.DOTNET_VERSION }} 186 187 - name: Extract version 188 id: version 189 run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" 190 191 - name: Pack 192 run: >- 193 dotnet pack ${{ env.PROJECT }} -c Release /p:Version=${{ steps.version.outputs.version }} -o ./nupkgs 194 195 - name: Push to NuGet 196 run: >- 197 dotnet nuget push ./nupkgs/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ 198 secrets.NUGET_API_KEY }} 199 200```json 201 202--- 203 204## Build Matrix per RID 205 206### Matrix Strategy 207 208The build matrix produces one artifact per RID. Each RID runs on the appropriate runner OS. 209 210```yaml 211 212strategy: 213 matrix: 214 include: 215 - rid: linux-x64 216 os: ubuntu-latest 217 - rid: linux-arm64 218 os: ubuntu-latest # Cross-compile ARM64 on x64 runner 219 - rid: osx-arm64 220 os: macos-latest # Native ARM64 runner 221 - rid: win-x64 222 os: windows-latest 223 224```text 225 226### Cross-Compilation Notes 227 228- **linux-arm64 on ubuntu-latest:** .NET supports cross-compilation for managed (non-AOT) builds. 229 `dotnet publish -r linux-arm64` on an x64 runner produces a valid ARM64 binary without QEMU. For Native AOT, 230 cross-compiling ARM64 on an x64 runner requires the ARM64 cross-compilation toolchain (`gcc-aarch64-linux-gnu` or 231 equivalent). See [skill:dotnet-native-aot] for cross-compile prerequisites. 232- **osx-arm64:** Use `macos-latest` (which provides ARM64 runners) for native compilation. Cross-compiling macOS ARM64 233 from Linux is not supported. 234- **win-x64 on windows-latest:** Native compilation on Windows runner. 235 236### Extended Matrix (Optional) 237 238```yaml 239 240strategy: 241 matrix: 242 include: 243 # Primary targets 244 - rid: linux-x64 245 os: ubuntu-latest 246 - rid: linux-arm64 247 os: ubuntu-latest 248 - rid: osx-arm64 249 os: macos-latest 250 - rid: win-x64 251 os: windows-latest 252 # Extended targets 253 - rid: osx-x64 254 os: macos-13 # Intel macOS runner 255 - rid: linux-musl-x64 256 os: ubuntu-latest # Alpine musl cross-compile 257 258```text 259 260--- 261 262## Artifact Staging 263 264### Upload Per-RID Artifacts 265 266Each matrix job uploads its artifact with a RID-specific name: 267 268```yaml 269 270- name: Upload artifact 271 uses: actions/upload-artifact@v4 272 with: 273 name: release-${{ matrix.rid }} 274 path: | 275 *.tar.gz 276 *.zip 277 retention-days: 1 # Short retention -- artifacts are published to GitHub Releases 278 279```text 280 281### Download in Release Job 282 283The release job downloads all artifacts from the build matrix: 284 285```yaml 286 287- name: Download all artifacts 288 uses: actions/download-artifact@v4 289 with: 290 path: artifacts 291 merge-multiple: true # Merge all release-* artifacts into one directory 292 293```text 294 295After download, `artifacts/` contains: 296 297```text 298 299artifacts/ 300 mytool-1.2.3-linux-x64.tar.gz 301 mytool-1.2.3-linux-arm64.tar.gz 302 mytool-1.2.3-osx-arm64.tar.gz 303 mytool-1.2.3-win-x64.zip 304 305```text 306 307--- 308 309## GitHub Releases with Checksums 310 311### Checksum Generation 312 313```yaml 314 315- name: Generate checksums 316 working-directory: artifacts 317 run: | 318 set -euo pipefail 319 shasum -a 256 *.tar.gz *.zip > checksums-sha256.txt 320 cat checksums-sha256.txt 321 322```text 323 324**Output format (checksums-sha256.txt):** 325 326```text 327 328abc123... mytool-1.2.3-linux-x64.tar.gz 329def456... mytool-1.2.3-linux-arm64.tar.gz 330ghi789... mytool-1.2.3-osx-arm64.tar.gz 331jkl012... mytool-1.2.3-win-x64.zip 332 333```text 334 335### Creating the Release 336 337```yaml 338 339- name: Create GitHub Release 340 uses: softprops/action-gh-release@v2 341 with: 342 name: v${{ steps.version.outputs.version }} 343 prerelease: ${{ steps.prerelease.outputs.is_prerelease }} 344 generate_release_notes: true 345 files: | 346 artifacts/*.tar.gz 347 artifacts/*.zip 348 artifacts/checksums-sha256.txt 349 350```text 351 352`generate_release_notes: true` auto-generates release notes from merged PRs and commit messages since the last tag. 353 354--- 355 356## Automated Formula/Manifest PR Creation 357 358### Homebrew Formula Update 359 360After the GitHub Release is published, update the Homebrew tap automatically: 361 362```yaml 363 364update-homebrew: 365 needs: release 366 if: ${{ !contains(github.ref_name, '-') }} 367 runs-on: ubuntu-latest 368 steps: 369 - name: Extract version 370 id: version 371 run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" 372 373 - uses: actions/checkout@v4 374 with: 375 repository: myorg/homebrew-tap 376 token: ${{ secrets.TAP_GITHUB_TOKEN }} 377 378 - name: Download checksums 379 run: | 380 set -euo pipefail 381 curl -sL "https://github.com/myorg/mytool/releases/download/v${{ steps.version.outputs.version }}/checksums-sha256.txt" \ 382 -o checksums.txt 383 384 - name: Update formula 385 run: | 386 set -euo pipefail 387 VERSION="${{ steps.version.outputs.version }}" 388 LINUX_X64_SHA=$(grep "linux-x64" checksums.txt | awk '{print $1}') 389 LINUX_ARM64_SHA=$(grep "linux-arm64" checksums.txt | awk '{print $1}') 390 OSX_ARM64_SHA=$(grep "osx-arm64" checksums.txt | awk '{print $1}') 391 392 # Use sed or a templating script to update Formula/mytool.rb 393 # with new version and SHA-256 values 394 python3 scripts/update-formula.py \ 395 --version "$VERSION" \ 396 --linux-x64-sha "$LINUX_X64_SHA" \ 397 --linux-arm64-sha "$LINUX_ARM64_SHA" \ 398 --osx-arm64-sha "$OSX_ARM64_SHA" 399 400 - name: Create PR 401 uses: peter-evans/create-pull-request@v6 402 with: 403 title: 'mytool ${{ steps.version.outputs.version }}' 404 commit-message: 'Update mytool to ${{ steps.version.outputs.version }}' 405 branch: 'update-mytool-${{ steps.version.outputs.version }}' 406 body: | 407 Automated update for mytool v${{ steps.version.outputs.version }} 408 Release: https://github.com/myorg/mytool/releases/tag/v${{ steps.version.outputs.version }} 409 410```text 411 412### winget Manifest Update 413 414```yaml 415 416update-winget: 417 needs: release 418 if: ${{ !contains(github.ref_name, '-') }} 419 runs-on: windows-latest 420 steps: 421 - name: Extract version 422 id: version 423 shell: bash 424 run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" 425 426 - name: Submit to winget-pkgs 427 uses: vedantmgoyal9/winget-releaser@main 428 with: 429 identifier: MyOrg.MyTool 430 version: ${{ steps.version.outputs.version }} 431 installers-regex: '\.zip$' 432 token: ${{ secrets.WINGET_GITHUB_TOKEN }} 433 434```text 435 436### Scoop Manifest Update 437 438```yaml 439 440update-scoop: 441 needs: release 442 if: ${{ !contains(github.ref_name, '-') }} 443 runs-on: ubuntu-latest 444 steps: 445 - name: Extract version 446 id: version 447 run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" 448 449 - uses: actions/checkout@v4 450 with: 451 repository: myorg/scoop-mytool 452 token: ${{ secrets.SCOOP_GITHUB_TOKEN }} 453 454 - name: Download checksums 455 run: | 456 set -euo pipefail 457 curl -sL "https://github.com/myorg/mytool/releases/download/v${{ steps.version.outputs.version }}/checksums-sha256.txt" \ 458 -o checksums.txt 459 460 - name: Update manifest 461 run: | 462 set -euo pipefail 463 VERSION="${{ steps.version.outputs.version }}" 464 WIN_X64_SHA=$(grep "win-x64" checksums.txt | awk '{print $1}') 465 466 # Update bucket/mytool.json with new version and hash 467 jq --arg v "$VERSION" --arg h "$WIN_X64_SHA" \ 468 '.version = $v | .architecture."64bit".hash = $h | 469 .architecture."64bit".url = "https://github.com/myorg/mytool/releases/download/v\($v)/mytool-\($v)-win-x64.zip"' \ 470 bucket/mytool.json > tmp.json && mv tmp.json bucket/mytool.json 471 472 - name: Create PR 473 uses: peter-evans/create-pull-request@v6 474 with: 475 title: 'mytool ${{ steps.version.outputs.version }}' 476 commit-message: 'Update mytool to ${{ steps.version.outputs.version }}' 477 branch: 'update-mytool-${{ steps.version.outputs.version }}' 478 479```text 480 481--- 482 483## Versioning Strategy Details 484 485### SemVer for CLI Tools 486 487| Change Type | Version Bump | Example | 488| -------------------------------- | ------------------ | -------------- | 489| Breaking CLI flag rename/removal | Major | 1.x.x -> 2.0.0 | 490| New command or option | Minor | x.1.x -> x.2.0 | 491| Bug fix, performance improvement | Patch | x.x.1 -> x.x.2 | 492| Release candidate | Pre-release suffix | x.x.x-rc.1 | 493 494### Version Embedding 495 496The version flows from the git tag through `dotnet publish` into the binary: 497 498```xml 499 500<!-- .csproj -- Version is set at publish time via /p:Version --> 501<PropertyGroup> 502 <!-- Fallback version for local development --> 503 <Version>0.0.0-dev</Version> 504</PropertyGroup> 505 506```text 507 508```bash 509 510# --version output matches the git tag 511$ mytool --version 5121.2.3 513 514```bash 515 516### Tagging Workflow 517 518```bash 519 520# 1. Update CHANGELOG.md (if applicable) 521# 2. Commit the changelog 522git commit -am "docs: update changelog for v1.2.3" 523 524# 3. Tag the release 525git tag -a v1.2.3 -m "Release v1.2.3" 526 527# 4. Push tag -- triggers the release workflow 528git push origin v1.2.3 529 530```text 531 532--- 533 534## Workflow Security 535 536### Secret Management 537 538```yaml 539 540# Required repository secrets: 541# NUGET_API_KEY - NuGet.org API key for package publishing 542# TAP_GITHUB_TOKEN - PAT with repo scope for homebrew-tap 543# WINGET_GITHUB_TOKEN - PAT with public_repo scope for winget-pkgs PRs 544# SCOOP_GITHUB_TOKEN - PAT with repo scope for scoop bucket 545# CHOCO_API_KEY - Chocolatey API key for package push 546 547```text 548 549### Permissions 550 551```yaml 552 553permissions: 554 contents: write # Minimum: create GitHub Releases and upload assets 555 556```yaml 557 558Use job-level permissions when different jobs need different scopes. Never grant `write-all`. 559 560--- 561 562## Agent Gotchas 563 5641. **Do not use `set -e` without `set -o pipefail` in GitHub Actions bash steps.** Without `pipefail`, a failing command 565 piped to `tee` or another utility exits 0, masking the failure. Always use `set -euo pipefail`. 5662. **Do not hardcode the .NET version in the publish path.** Use `dotnet publish -o ./publish` to control the output 567 directory explicitly. Hardcoding `net8.0` in artifact paths breaks when upgrading to .NET 9+. 5683. **Do not skip the pre-release detection step.** Package manager submissions (Homebrew, winget, Scoop, Chocolatey, 569 NuGet) must be gated on stable versions. Publishing a `-rc.1` to winget-pkgs or NuGet as stable causes user 570 confusion. 5714. **Do not use `actions/upload-artifact` v3 with `merge-multiple`.** The `merge-multiple` parameter requires 572 `actions/download-artifact@v4`. Using v3 silently ignores the flag and creates nested directories. 5735. **Do not forget `retention-days: 1` on intermediate build artifacts.** Release artifacts are published to GitHub 574 Releases (permanent). Workflow artifacts are temporary and should expire quickly to save storage. 5756. **Do not create GitHub Releases with `gh release create` in a matrix job.** Only the release job (after all builds 576 complete) should create the release. Matrix jobs upload artifacts; the release job assembles them. 577 578--- 579 580## References 581 582- [GitHub Actions workflow syntax](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions) 583- [softprops/action-gh-release](https://github.com/softprops/action-gh-release) 584- [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) 585- [vedantmgoyal9/winget-releaser](https://github.com/vedantmgoyal9/winget-releaser) 586- [Semantic Versioning](https://semver.org/) 587- [.NET versioning](https://learn.microsoft.com/en-us/dotnet/core/versions/) 588- [GitHub Actions artifacts](https://docs.github.com/en/actions/using-workflows/storing-workflow-data-as-artifacts)