dotnet-cli-release-pipeline
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)
# Tag a release
git tag -a v1.2.3 -m "Release v1.2.3"
git push origin v1.2.3
Version Flow
git tag v1.2.3
│
▼
GitHub Actions trigger (on push tags: v*)
│
▼
Extract version from tag: GITHUB_REF_NAME → v1.2.3 → 1.2.3
│
▼
Pass to dotnet publish /p:Version=1.2.3
│
▼
Embed in binary (--version output)
│
▼
Stamp in package manifests (Homebrew, winget, Scoop, NuGet)
Extracting Version from Tag
- name: Extract version from tag
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
# v1.2.3 → 1.2.3
Pre-release Versions
# Pre-release tag
git tag -a v1.3.0-rc.1 -m "Release candidate 1"
# CI detects pre-release and skips package manager submissions
# but still creates GitHub Release as pre-release
Unified GitHub Actions Workflow
Complete Workflow
name: Release
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+*" # v1.2.3, v1.2.3-rc.1
permissions:
contents: write # Create GitHub Releases
defaults:
run:
shell: bash
env:
PROJECT: src/MyCli/MyCli.csproj
DOTNET_VERSION: "8.0.x"
jobs:
build:
strategy:
matrix:
include:
- rid: linux-x64
os: ubuntu-latest
- rid: linux-arm64
os: ubuntu-latest
- rid: osx-arm64
os: macos-latest
- rid: win-x64
os: windows-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Extract version
id: version
shell: bash
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Publish
run: >-
dotnet publish ${{ env.PROJECT }}
-c Release
-r ${{ matrix.rid }}
-o ./publish
/p:Version=${{ steps.version.outputs.version }}
- name: Package (Unix)
if: runner.os != 'Windows'
run: |
set -euo pipefail
cd publish
tar -czf "$GITHUB_WORKSPACE/mytool-${{ steps.version.outputs.version }}-${{ matrix.rid }}.tar.gz" .
- name: Package (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
Compress-Archive -Path "publish/*" `
-DestinationPath "mytool-${{ steps.version.outputs.version }}-${{ matrix.rid }}.zip"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.rid }}
path: |
*.tar.gz
*.zip
release:
needs: build
runs-on: ubuntu-latest
steps:
- name: Extract version
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Generate checksums
working-directory: artifacts
run: |
set -euo pipefail
shasum -a 256 *.tar.gz *.zip > checksums-sha256.txt
cat checksums-sha256.txt
- name: Detect pre-release
id: prerelease
run: |
set -euo pipefail
if [[ "${{ steps.version.outputs.version }}" == *-* ]]; then
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
fi
# Pin third-party actions to a commit SHA in production for supply-chain security
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: v${{ steps.version.outputs.version }}
prerelease: ${{ steps.prerelease.outputs.is_prerelease }}
generate_release_notes: true
files: |
artifacts/*.tar.gz
artifacts/*.zip
artifacts/checksums-sha256.txt
publish-nuget:
needs: release
if: ${{ !contains(github.ref_name, '-') }} # Skip pre-releases
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Extract version
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Pack
run: >-
dotnet pack ${{ env.PROJECT }}
-c Release
/p:Version=${{ steps.version.outputs.version }}
-o ./nupkgs
- name: Push to NuGet
run: >-
dotnet nuget push ./nupkgs/*.nupkg
--source https://api.nuget.org/v3/index.json
--api-key ${{ secrets.NUGET_API_KEY }}
Build Matrix per RID
Matrix Strategy
The build matrix produces one artifact per RID. Each RID runs on the appropriate runner OS.
strategy:
matrix:
include:
- rid: linux-x64
os: ubuntu-latest
- rid: linux-arm64
os: ubuntu-latest # Cross-compile ARM64 on x64 runner
- rid: osx-arm64
os: macos-latest # Native ARM64 runner
- rid: win-x64
os: windows-latest
Cross-Compilation Notes
- linux-arm64 on ubuntu-latest: .NET supports cross-compilation for managed (non-AOT) builds.
dotnet publish -r linux-arm64on an x64 runner produces a valid ARM64 binary without QEMU. For Native AOT, cross-compiling ARM64 on an x64 runner requires the ARM64 cross-compilation toolchain (gcc-aarch64-linux-gnuor equivalent). See [skill:dotnet-native-aot] for cross-compile prerequisites. - osx-arm64: Use
macos-latest(which provides ARM64 runners) for native compilation. Cross-compiling macOS ARM64 from Linux is not supported. - win-x64 on windows-latest: Native compilation on Windows runner.
Extended Matrix (Optional)
strategy:
matrix:
include:
# Primary targets
- rid: linux-x64
os: ubuntu-latest
- rid: linux-arm64
os: ubuntu-latest
- rid: osx-arm64
os: macos-latest
- rid: win-x64
os: windows-latest
# Extended targets
- rid: osx-x64
os: macos-13 # Intel macOS runner
- rid: linux-musl-x64
os: ubuntu-latest # Alpine musl cross-compile
Artifact Staging
Upload Per-RID Artifacts
Each matrix job uploads its artifact with a RID-specific name:
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.rid }}
path: |
*.tar.gz
*.zip
retention-days: 1 # Short retention -- artifacts are published to GitHub Releases
Download in Release Job
The release job downloads all artifacts from the build matrix:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true # Merge all release-* artifacts into one directory
After download, artifacts/ contains:
artifacts/
mytool-1.2.3-linux-x64.tar.gz
mytool-1.2.3-linux-arm64.tar.gz
mytool-1.2.3-osx-arm64.tar.gz
mytool-1.2.3-win-x64.zip
GitHub Releases with Checksums
Checksum Generation
- name: Generate checksums
working-directory: artifacts
run: |
set -euo pipefail
shasum -a 256 *.tar.gz *.zip > checksums-sha256.txt
cat checksums-sha256.txt
Output format (checksums-sha256.txt):
abc123... mytool-1.2.3-linux-x64.tar.gz
def456... mytool-1.2.3-linux-arm64.tar.gz
ghi789... mytool-1.2.3-osx-arm64.tar.gz
jkl012... mytool-1.2.3-win-x64.zip
Creating the Release
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: v${{ steps.version.outputs.version }}
prerelease: ${{ steps.prerelease.outputs.is_prerelease }}
generate_release_notes: true
files: |
artifacts/*.tar.gz
artifacts/*.zip
artifacts/checksums-sha256.txt
generate_release_notes: true auto-generates release notes from merged PRs and commit messages since the last tag.
Automated Formula/Manifest PR Creation
Homebrew Formula Update
After the GitHub Release is published, update the Homebrew tap automatically:
update-homebrew:
needs: release
if: ${{ !contains(github.ref_name, '-') }}
runs-on: ubuntu-latest
steps:
- name: Extract version
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
with:
repository: myorg/homebrew-tap
token: ${{ secrets.TAP_GITHUB_TOKEN }}
- name: Download checksums
run: |
set -euo pipefail
curl -sL "https://github.com/myorg/mytool/releases/download/v${{ steps.version.outputs.version }}/checksums-sha256.txt" \
-o checksums.txt
- name: Update formula
run: |
set -euo pipefail
VERSION="${{ steps.version.outputs.version }}"
LINUX_X64_SHA=$(grep "linux-x64" checksums.txt | awk '{print $1}')
LINUX_ARM64_SHA=$(grep "linux-arm64" checksums.txt | awk '{print $1}')
OSX_ARM64_SHA=$(grep "osx-arm64" checksums.txt | awk '{print $1}')
# Use sed or a templating script to update Formula/mytool.rb
# with new version and SHA-256 values
python3 scripts/update-formula.py \
--version "$VERSION" \
--linux-x64-sha "$LINUX_X64_SHA" \
--linux-arm64-sha "$LINUX_ARM64_SHA" \
--osx-arm64-sha "$OSX_ARM64_SHA"
- name: Create PR
uses: peter-evans/create-pull-request@v6
with:
title: "mytool ${{ steps.version.outputs.version }}"
commit-message: "Update mytool to ${{ steps.version.outputs.version }}"
branch: "update-mytool-${{ steps.version.outputs.version }}"
body: |
Automated update for mytool v${{ steps.version.outputs.version }}
Release: https://github.com/myorg/mytool/releases/tag/v${{ steps.version.outputs.version }}
winget Manifest Update
update-winget:
needs: release
if: ${{ !contains(github.ref_name, '-') }}
runs-on: windows-latest
steps:
- name: Extract version
id: version
shell: bash
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Submit to winget-pkgs
uses: vedantmgoyal9/winget-releaser@main
with:
identifier: MyOrg.MyTool
version: ${{ steps.version.outputs.version }}
installers-regex: '\.zip$'
token: ${{ secrets.WINGET_GITHUB_TOKEN }}
Scoop Manifest Update
update-scoop:
needs: release
if: ${{ !contains(github.ref_name, '-') }}
runs-on: ubuntu-latest
steps:
- name: Extract version
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
with:
repository: myorg/scoop-mytool
token: ${{ secrets.SCOOP_GITHUB_TOKEN }}
- name: Download checksums
run: |
set -euo pipefail
curl -sL "https://github.com/myorg/mytool/releases/download/v${{ steps.version.outputs.version }}/checksums-sha256.txt" \
-o checksums.txt
- name: Update manifest
run: |
set -euo pipefail
VERSION="${{ steps.version.outputs.version }}"
WIN_X64_SHA=$(grep "win-x64" checksums.txt | awk '{print $1}')
# Update bucket/mytool.json with new version and hash
jq --arg v "$VERSION" --arg h "$WIN_X64_SHA" \
'.version = $v | .architecture."64bit".hash = $h |
.architecture."64bit".url = "https://github.com/myorg/mytool/releases/download/v\($v)/mytool-\($v)-win-x64.zip"' \
bucket/mytool.json > tmp.json && mv tmp.json bucket/mytool.json
- name: Create PR
uses: peter-evans/create-pull-request@v6
with:
title: "mytool ${{ steps.version.outputs.version }}"
commit-message: "Update mytool to ${{ steps.version.outputs.version }}"
branch: "update-mytool-${{ steps.version.outputs.version }}"
Versioning Strategy Details
SemVer for CLI Tools
| Change Type | Version Bump | Example |
|---|---|---|
| Breaking CLI flag rename/removal | Major | 1.x.x -> 2.0.0 |
| New command or option | Minor | x.1.x -> x.2.0 |
| Bug fix, performance improvement | Patch | x.x.1 -> x.x.2 |
| Release candidate | Pre-release suffix | x.x.x-rc.1 |
Version Embedding
The version flows from the git tag through dotnet publish into the binary:
<!-- .csproj -- Version is set at publish time via /p:Version -->
<PropertyGroup>
<!-- Fallback version for local development -->
<Version>0.0.0-dev</Version>
</PropertyGroup>
# --version output matches the git tag
$ mytool --version
1.2.3
Tagging Workflow
# 1. Update CHANGELOG.md (if applicable)
# 2. Commit the changelog
git commit -am "docs: update changelog for v1.2.3"
# 3. Tag the release
git tag -a v1.2.3 -m "Release v1.2.3"
# 4. Push tag -- triggers the release workflow
git push origin v1.2.3
Workflow Security
Secret Management
# Required repository secrets:
# NUGET_API_KEY - NuGet.org API key for package publishing
# TAP_GITHUB_TOKEN - PAT with repo scope for homebrew-tap
# WINGET_GITHUB_TOKEN - PAT with public_repo scope for winget-pkgs PRs
# SCOOP_GITHUB_TOKEN - PAT with repo scope for scoop bucket
# CHOCO_API_KEY - Chocolatey API key for package push
Permissions
permissions:
contents: write # Minimum: create GitHub Releases and upload assets
Use job-level permissions when different jobs need different scopes. Never grant write-all.
Agent Gotchas
- Do not use
set -ewithoutset -o pipefailin GitHub Actions bash steps. Withoutpipefail, a failing command piped toteeor another utility exits 0, masking the failure. Always useset -euo pipefail. - Do not hardcode the .NET version in the publish path. Use
dotnet publish -o ./publishto control the output directory explicitly. Hardcodingnet8.0in artifact paths breaks when upgrading to .NET 9+. - Do not skip the pre-release detection step. Package manager submissions (Homebrew, winget, Scoop, Chocolatey, NuGet) must be gated on stable versions. Publishing a
-rc.1to winget-pkgs or NuGet as stable causes user confusion. - Do not use
actions/upload-artifactv3 withmerge-multiple. Themerge-multipleparameter requiresactions/download-artifact@v4. Using v3 silently ignores the flag and creates nested directories. - Do not forget
retention-days: 1on intermediate build artifacts. Release artifacts are published to GitHub Releases (permanent). Workflow artifacts are temporary and should expire quickly to save storage. - Do not create GitHub Releases with
gh release createin a matrix job. Only the release job (after all builds complete) should create the release. Matrix jobs upload artifacts; the release job assembles them.
References
More from novotnyllc/dotnet-artisan
dotnet-csharp
Baseline C# skill loaded for every .NET code path. Guides language patterns (records, pattern matching, primary constructors, C# 8-15), coding standards, async/await, DI, LINQ, serialization, domain modeling, concurrency, Roslyn analyzers, globalization, native interop (P/Invoke, LibraryImport, ComWrappers), WASM interop (JSImport/JSExport), and type design. Spans 25 topics. Do not use for ASP.NET endpoint architecture, UI framework patterns, or CI/CD guidance.
128dotnet-ui
Builds .NET UI apps across Blazor (Server, WASM, Hybrid, Auto), MAUI (XAML, MVVM, Shell, Native AOT), Uno Platform (MVUX, Extensions, Toolkit), WPF (.NET 8+, Fluent theme), WinUI 3 (Windows App SDK, MSIX, Mica/Acrylic, adaptive layout), and WinForms (high-DPI, dark mode) with JS interop, accessibility (SemanticProperties, ARIA), localization (.resx, RTL), platform bindings (Java.Interop, ObjCRuntime), and framework selection. Spans 20 topic areas. Do not use for backend API design or CI/CD pipelines.
99dotnet-api
Builds ASP.NET Core APIs, EF Core data access, gRPC, SignalR, and backend services with middleware, security (OAuth, JWT, OWASP), resilience, messaging, OpenAPI, .NET Aspire, Semantic Kernel, HybridCache, YARP reverse proxy, output caching, Office documents (Excel, Word, PowerPoint), PDF, and architecture patterns. Spans 32 topic areas. Do not use for UI rendering patterns or CI/CD pipeline authoring.
90dotnet-testing
Defines .NET test strategy and implementation patterns across xUnit v3 (Facts, Theories, fixtures, IAsyncLifetime), integration testing (WebApplicationFactory, Testcontainers), Aspire testing (DistributedApplicationTestingBuilder), snapshot testing (Verify, scrubbing), Playwright E2E browser automation, BenchmarkDotNet microbenchmarks, code coverage (Coverlet), mutation testing (Stryker.NET), UI testing (page objects, selectors), and AOT WASM test compilation. Spans 13 topic areas. Do not use for production API architecture or CI workflow authoring.
86dotnet-advisor
Routes .NET/C# requests to the correct domain skill and loads coding standards as baseline for all code paths. Determines whether the task needs API, UI, testing, devops, tooling, or debugging guidance based on prompt analysis and project signals, then invokes skills in the right order. Always invoked after [skill:using-dotnet] detects .NET intent. Do not use for deep API, UI, testing, devops, tooling, or debugging implementation guidance.
60dotnet-debugging
Debugs Windows and Linux/macOS applications (native, .NET/CLR, mixed-mode) with WinDbg MCP (crash dumps, !analyze, !syncblk, !dlk, !runaway, !dumpheap, !gcroot, BSOD), dotnet-dump, lldb with SOS, createdump, and container diagnostics (Docker, Kubernetes). Hang/deadlock diagnosis, high CPU triage, memory leak investigation, kernel debugging, and dotnet-monitor for production. Spans 17 topic areas. Do not use for routine .NET SDK profiling, benchmark design, or CI test debugging.
57