.NET CLI Tool Releases
This guide explains how to make a .NET CLI tool installable via GitHub releases using tag-based builds and a self-update command. The approach uses NuGet packages distributed through GitHub releases (not NuGet.org), with checksum verification for security.
Prerequisites
Section titled “Prerequisites”- .NET SDK that matches your
TargetFramework(e.g., net8.0 -> 8.0.x, net9.0 -> 9.0.x) - GitHub repository
- GitHub CLI (recommended for private repos)
Dependencies
Section titled “Dependencies”The self-update command requires these NuGet packages:
<ItemGroup> <PackageReference Include="Spectre.Console" Version="0.49.1" /> <PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" /></ItemGroup>| Package | Purpose |
|---|---|
Spectre.Console | Colored console output (AnsiConsole.MarkupLine) |
System.CommandLine | Command-line parsing and subcommand structure |
Project Configuration
Section titled “Project Configuration”Configure the .csproj file to package the application as a global .NET tool.
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net9.0</TargetFramework>
<!-- Tool Configuration --> <PackAsTool>true</PackAsTool> <ToolCommandName>mytool</ToolCommandName>
<!-- Package Metadata --> <PackageId>mytool</PackageId> <Title>My Tool CLI</Title> <Description>Description of my tool</Description> <Authors>Your Name</Authors> <PackageProjectUrl>https://github.com/your-org/mytool</PackageProjectUrl> <RepositoryUrl>https://github.com/your-org/mytool</RepositoryUrl> <RepositoryType>git</RepositoryType> <PackageLicenseExpression>MIT</PackageLicenseExpression>
<!-- Version: Overridden by CI, defaults to 0.0.1 for local dev --> <Version>0.0.1</Version> <IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion> </PropertyGroup></Project>Key settings:
| Property | Purpose |
|---|---|
PackAsTool | Enables packaging as a global tool |
ToolCommandName | The command users type to run the tool |
PackageId | The package identifier (usually matches ToolCommandName) |
Version | Default version for local builds; CI overrides this |
The IncludeSourceRevisionInInformationalVersion setting prevents git commit hashes from being appended to version strings.
If you set PackageReadmeFile, you must also include the file in the package:
<ItemGroup> <None Include="../../README.md" Pack="true" PackagePath="/" /></ItemGroup>GitHub Actions Workflow
Section titled “GitHub Actions Workflow”Create .github/workflows/release.yml to build and publish releases when tags are pushed.
name: Release
on: push: tags: - 'v*.*.*' - 'v*.*.*-*'
permissions: contents: write packages: write
env: DOTNET_VERSION: '9.0.x' # Use the SDK that matches your TargetFramework PROJECT_PATH: 'src/MyTool/MyTool.csproj'
jobs: release: runs-on: ubuntu-latest
steps: - name: Checkout uses: actions/checkout@v4
- name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Extract version from tag id: version run: | VERSION=${GITHUB_REF_NAME#v} if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then echo "Error: Tag '$GITHUB_REF_NAME' does not contain a valid semantic version" >&2 exit 1 fi echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Restore run: dotnet restore ${{ env.PROJECT_PATH }}
- name: Build run: dotnet build ${{ env.PROJECT_PATH }} -c Release --no-restore -p:Version=${{ steps.version.outputs.version }}
- name: Pack run: dotnet pack ${{ env.PROJECT_PATH }} -c Release --no-build -o ./artifacts -p:Version=${{ steps.version.outputs.version }}
- name: Generate checksums run: | cd ./artifacts for file in *.nupkg; do sha256sum "$file" > "$file.sha256" done
- name: Create GitHub Release uses: softprops/action-gh-release@v2 with: name: v${{ steps.version.outputs.version }} body: | ## Install Requires a .NET SDK that matches your target framework (e.g., 8.0 or 9.0).
```bash gh release download --repo your-org/mytool --pattern 'install.sh' --output - | bash ```
## Update ```bash mytool update ``` files: | ./artifacts/mytool.${{ steps.version.outputs.version }}.nupkg ./artifacts/mytool.${{ steps.version.outputs.version }}.nupkg.sha256 install.sh generate_release_notes: trueThe workflow:
- Triggers on semantic version tags (
v1.0.0,v1.0.0-beta.1) - Extracts and validates the version from the tag
- Builds with the version embedded in the assembly
- Creates a NuGet package
- Generates SHA256 checksums for integrity verification
- Creates a GitHub release with all artifacts
Installation Script
Section titled “Installation Script”Create install.sh at the repository root. This script handles installation for users.
#!/usr/bin/env bashset -euo pipefail
REPO="your-org/mytool"TOOL_NAME="mytool"
RED='\033[0;31m'GREEN='\033[0;32m'NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $1"; }error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
command -v dotnet >/dev/null 2>&1 || error ".NET SDK not found. Install from https://dotnet.microsoft.com/download"
VERSION="${1:-latest}"
if [ "$VERSION" = "latest" ]; then info "Fetching latest release..." if command -v gh >/dev/null 2>&1; then VERSION=$(gh release view --repo "$REPO" --json tagName -q '.tagName' 2>/dev/null | sed 's/^v//') || \ VERSION=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name"' | sed -E 's/.*"v?([^"]+)".*/\1/') else VERSION=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name"' | sed -E 's/.*"v?([^"]+)".*/\1/') fi
if [ -z "$VERSION" ] || ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then error "Failed to determine latest version" fielse if [ -z "$VERSION" ] || ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then error "Invalid version format. Expected semantic version (e.g., 1.2.3)" fifi
info "Installing $TOOL_NAME v${VERSION}..."
TEMP_DIR=$(mktemp -d)trap 'rm -rf "$TEMP_DIR"' EXIT
ASSET_NAME="${TOOL_NAME}.${VERSION}.nupkg"CHECKSUM_NAME="${ASSET_NAME}.sha256"info "Downloading ${ASSET_NAME} and checksum..."
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then gh release download "v${VERSION}" --repo "$REPO" --pattern "$ASSET_NAME" --pattern "$CHECKSUM_NAME" --dir "$TEMP_DIR" || \ error "Failed to download v${VERSION}"else DOWNLOAD_URL="https://github.com/$REPO/releases/download/v${VERSION}/${ASSET_NAME}" CHECKSUM_URL="https://github.com/$REPO/releases/download/v${VERSION}/${CHECKSUM_NAME}" if command -v curl >/dev/null 2>&1; then curl -fsSL "$DOWNLOAD_URL" -o "$TEMP_DIR/$ASSET_NAME" || error "Failed to download $ASSET_NAME" curl -fsSL "$CHECKSUM_URL" -o "$TEMP_DIR/$CHECKSUM_NAME" || error "Failed to download checksum" elif command -v wget >/dev/null 2>&1; then wget -q "$DOWNLOAD_URL" -O "$TEMP_DIR/$ASSET_NAME" || error "Failed to download $ASSET_NAME" wget -q "$CHECKSUM_URL" -O "$TEMP_DIR/$CHECKSUM_NAME" || error "Failed to download checksum" else error "No download tool found (gh, curl, or wget)" fifi
info "Verifying package integrity..."EXPECTED_CHECKSUM=$(cat "$TEMP_DIR/$CHECKSUM_NAME" | awk '{print $1}')
if command -v sha256sum >/dev/null 2>&1; then ACTUAL_CHECKSUM=$(sha256sum "$TEMP_DIR/$ASSET_NAME" | awk '{print $1}')elif command -v shasum >/dev/null 2>&1; then ACTUAL_CHECKSUM=$(shasum -a 256 "$TEMP_DIR/$ASSET_NAME" | awk '{print $1}')else error "No SHA256 tool found (sha256sum or shasum)"fi
if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then error "Checksum verification failed"fi
if dotnet tool list -g | grep -q "^$TOOL_NAME "; then info "Updating existing installation..." dotnet tool update -g --add-source "$TEMP_DIR" "$TOOL_NAME" >/dev/nullelse info "Installing..." dotnet tool install -g --add-source "$TEMP_DIR" "$TOOL_NAME" >/dev/nullfi
TOOLS_PATH="$HOME/.dotnet/tools"
if [[ ":$PATH:" != *":$TOOLS_PATH:"* ]]; then echo "" echo -e "${RED}ACTION REQUIRED:${NC} Add .NET tools to your PATH" echo "" echo "Run this command, then restart your terminal:" echo "" if [[ "$SHELL" == *"zsh"* ]]; then echo " echo 'export PATH=\"\$PATH:\$HOME/.dotnet/tools\"' >> ~/.zshrc" elif [[ "$SHELL" == *"bash"* ]]; then echo " echo 'export PATH=\"\$PATH:\$HOME/.dotnet/tools\"' >> ~/.bashrc" else echo " echo 'export PATH=\"\$PATH:\$HOME/.dotnet/tools\"' >> ~/.profile" fi echo ""else info "Done! Run '$TOOL_NAME --help' to get started."fiThe script:
- Detects available download tools (prefers
ghfor private repos) - Fetches the latest version or uses a specified version
- Downloads the package and checksum
- Verifies SHA256 integrity before installation
- Uses
dotnet tool install/updatefrom the local package - Provides PATH configuration instructions if needed
Self-Update Command
Section titled “Self-Update Command”Add an update command to your CLI following the BaseCommand<TRequest> pattern from the CLI Design Playbook. The command delegates all update logic to IUpdateService.
Service Interface
Section titled “Service Interface”public interface IUpdateService{ /// <summary>Gets the current installed version from assembly metadata.</summary> string GetCurrentVersion();
/// <summary>Validates that prerequisites for update operations are met.</summary> Task<Result<bool>> CheckPrerequisitesAsync(CancellationToken ct = default);
/// <summary>Fetches the latest release version from GitHub.</summary> Task<Result<string>> GetLatestVersionAsync(CancellationToken ct = default);
/// <summary>Downloads and installs the specified version via dotnet tool update.</summary> Task<Result<bool>> PerformUpdateAsync( string version, bool dryRun = false, CancellationToken ct = default);}Command Structure
Section titled “Command Structure”// Request record - ALWAYS FIRST in filepublic record UpdateRequest( bool CheckOnly, bool Force, GlobalOptions GlobalOptions) : RequestBase(GlobalOptions);
// Command classpublic class UpdateCommand : BaseCommand<UpdateRequest>{ private readonly IUpdateService updateService;
private Option<bool> checkOption = null!; private Option<bool> forceOption = null!;
public UpdateCommand(IUpdateService updateService) : base("update", "Check for and install updates") { this.updateService = updateService; }
protected override void RegisterArgumentsAndOptions() { checkOption = new( aliases: ["--check", "-c"], description: "Check for updates without installing", getDefaultValue: () => false);
forceOption = new( aliases: ["--force", "-f"], description: "Force reinstall even if already on latest version", getDefaultValue: () => false);
AddOption(checkOption); AddOption(forceOption); }
protected override Task<UpdateRequest> ParseRequestAsync(InvocationContext context) { return Task.FromResult(new UpdateRequest( CheckOnly: context.ParseResult.GetValueForOption(checkOption), Force: context.ParseResult.GetValueForOption(forceOption), GlobalOptions: GlobalOptions.Parse(context))); }
protected override Task<int> ValidateAsync(UpdateRequest request, CancellationToken ct) { if (request.CheckOnly && request.Force) { AnsiConsole.MarkupLine("[red]Error:[/] --check and --force cannot be used together."); return Task.FromResult(1); }
return Task.FromResult(0); }
protected override async Task<int> HandleAsync(UpdateRequest request, CancellationToken ct) { string currentVersion = updateService.GetCurrentVersion(); AnsiConsole.MarkupLine($"[dim]Current version:[/] v{currentVersion}");
Result<bool> prereqs = await updateService.CheckPrerequisitesAsync(ct); if (prereqs.IsError) { AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(prereqs.ErrorMessage!)}"); return 1; }
Result<string> latestResult = await updateService.GetLatestVersionAsync(ct); if (latestResult.IsError) { AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(latestResult.ErrorMessage!)}"); return 1; }
string latestVersion = latestResult.Value!; AnsiConsole.MarkupLine($"[dim]Latest version:[/] v{latestVersion}");
bool isUpToDate = CompareSemanticVersions(currentVersion, latestVersion) >= 0;
if (isUpToDate && !request.Force) { AnsiConsole.MarkupLine("[green]Already up to date.[/]"); return 0; }
if (request.CheckOnly) { AnsiConsole.MarkupLine($"[yellow]Update available:[/] v{currentVersion} → v{latestVersion}"); AnsiConsole.MarkupLine("[dim]Run 'mytool update' to install.[/]"); return 0; }
if (request.DryRun) { AnsiConsole.MarkupLine($"[dim]Dry-run: Would install v{latestVersion}[/]"); return 0; }
Result<bool> updateResult = await updateService.PerformUpdateAsync(latestVersion, dryRun: false, ct); if (updateResult.IsError) { AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(updateResult.ErrorMessage!)}"); return 1; }
AnsiConsole.MarkupLine($"[green]Successfully updated to v{latestVersion}![/]"); AnsiConsole.MarkupLine("[dim]Restart the tool to use the new version.[/]"); return 0; }}Service Implementation
Section titled “Service Implementation”PerformUpdateAsync downloads the .nupkg, verifies the checksum, and installs via dotnet tool update -g:
public class UpdateService : IUpdateService{ private const string GitHubRepo = "your-org/mytool"; private const string ToolName = "mytool";
private readonly IShellService shell;
public UpdateService(IShellService shell) { this.shell = shell; }
public async Task<Result<bool>> PerformUpdateAsync( string version, bool dryRun = false, CancellationToken ct = default) { if (dryRun) return Result<bool>.Success(true);
string tempDir = Path.Combine(Path.GetTempPath(), $"{ToolName}-update-{Guid.NewGuid():N}"); string assetName = $"{ToolName}.{version}.nupkg"; string checksumName = $"{assetName}.sha256";
try { Directory.CreateDirectory(tempDir);
if (!DownloadRelease(version, assetName, tempDir)) return Result<bool>.Error($"Failed to download {assetName}.");
if (!DownloadRelease(version, checksumName, tempDir)) return Result<bool>.Error($"Failed to download checksum.");
if (!VerifyChecksum(tempDir, assetName, checksumName)) return Result<bool>.Error("Checksum verification failed.");
bool isInstalled = IsToolInstalled(ToolName); string verb = isInstalled ? "update" : "install";
ShellResult result = await shell.ExecuteAsync( "dotnet", $"tool {verb} -g --add-source \"{tempDir}\" {ToolName}", ct: ct);
if (!result.Success) return Result<bool>.Error($"dotnet tool {verb} failed: {result.StandardError}");
return Result<bool>.Success(true); } finally { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, recursive: true); } }}Key Implementation Details
Section titled “Key Implementation Details”Version Detection (implement as GetCurrentVersion() on UpdateService):
public string GetCurrentVersion(){ Assembly assembly = Assembly.GetExecutingAssembly(); AssemblyInformationalVersionAttribute? infoVersion = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
if (infoVersion?.InformationalVersion is not null) { string version = infoVersion.InformationalVersion; // Remove +metadata suffix int plusIndex = version.IndexOf('+'); if (plusIndex >= 0) { version = version[..plusIndex]; } return version; }
return assembly.GetName().Version?.ToString(3) ?? "0.0.0";}Validate tag strings before shelling out:
If you accept a version or tag name from GitHub (or user input), validate it before passing it to any shell commands to avoid injection or unexpected argument parsing. A simple SemVer regex is enough:
private static string? ValidateAndExtractVersion(string versionString){ // Called before passing any GitHub tag/version string to shell commands string version = versionString.StartsWith('v') ? versionString[1..] : versionString; if (!Regex.IsMatch(version, @"^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$")) { return null; }
int plusIndex = version.IndexOf('+'); return plusIndex >= 0 ? version[..plusIndex] : version;}Semantic Version Comparison:
Compare versions properly, accounting for pre-release versions (e.g., 1.0.0-beta.1 < 1.0.0):
private static int CompareSemanticVersions(string version1, string version2){ // Parse base versions and pre-release identifiers (Version? baseVer1, string? preRelease1) = ParseSemanticVersion(version1); (Version? baseVer2, string? preRelease2) = ParseSemanticVersion(version2);
// Compare base versions first int baseComparison = baseVer1.CompareTo(baseVer2); if (baseComparison != 0) return baseComparison;
// Stable release > pre-release if (preRelease1 is null && preRelease2 is null) return 0; if (preRelease1 is null) return 1; if (preRelease2 is null) return -1;
// Compare pre-release identifiers lexically return ComparePreReleaseIdentifiers(preRelease1, preRelease2);}Download with Fallback:
Support multiple download tools with graceful fallback:
private static bool DownloadRelease(string version, string assetName, string tempDir){ // Try gh first if authenticated if (CommandExists("gh") && IsGitHubCliAuthenticated()) { // Use gh release download... if (success) return true; }
// Fallback to curl if (CommandExists("curl")) { // Use curl -fsSL... if (success) return true; }
// Fallback to wget if (CommandExists("wget")) { // Use wget -q... if (success) return true; }
return false;}Checksum Verification:
Always verify package integrity before installation:
private static bool VerifyChecksum(string directory, string fileName, string checksumFileName){ string checksumContent = File.ReadAllText(checksumPath).Trim(); string expectedChecksum = checksumContent.Split(' ')[0].ToLowerInvariant();
using SHA256 sha256 = SHA256.Create(); using FileStream stream = File.OpenRead(filePath); byte[] hashBytes = sha256.ComputeHash(stream); string actualChecksum = BitConverter.ToString(hashBytes) .Replace("-", "").ToLowerInvariant();
return actualChecksum == expectedChecksum;}Wiring Up the Command
Section titled “Wiring Up the Command”Register both the service and command in DI, then add the command from the service provider:
// Program.cs - DI registrationservices.AddSingleton<IUpdateService, UpdateService>();services.AddTransient<UpdateCommand>();
// ... build service provider ...
rootCommand.AddCommand(serviceProvider.GetRequiredService<UpdateCommand>());Pre-flight check bypass: The update command should run even if pre-flight checks fail (e.g., a missing config file the update itself might fix). Skip pre-flight when the user is running update:
// Program.cs - before running pre-flight checksbool isUpdateCommand = args.Length > 0 && args[0] == "update";
if (!isUpdateCommand){ IPreflightCheckService preflight = serviceProvider.GetRequiredService<IPreflightCheckService>(); if (!await preflight.RunChecksAsync(CancellationToken.None)) { await serviceProvider.DisposeAsync(); return 1; }}Complete Implementation Reference
Section titled “Complete Implementation Reference”The snippets above omit helper methods for brevity. Key helpers to implement:
| Helper | Purpose |
|---|---|
CommandExists(string) | Check if a CLI tool is on PATH |
IsGitHubCliAuthenticated() | Check if gh auth status succeeds |
IsToolInstalled(string) | Check dotnet tool list -g output |
GetLatestVersionAsync() | Call gh release view --json tagName (or GitHub API via curl) |
DownloadRelease(version, assetName, dir) | Download with gh → curl → wget fallback chain |
VerifyChecksum(dir, file, checksumFile) | SHA256 verify using System.Security.Cryptography.SHA256 |
CompareSemanticVersions(v1, v2) | Full SemVer comparison including pre-release identifiers |
CheckPrerequisitesAsync should verify that dotnet is available and at least one download tool is present (gh with auth, or curl, or wget). Unlike most commands, the update command should not run pre-flight checks — it may be the fix for a broken installation.
Synchronization Points
Section titled “Synchronization Points”These values must stay synchronized across files:
| Value | Locations |
|---|---|
| Tool name | .csproj (ToolCommandName, PackageId), install.sh (TOOL_NAME), UpdateCommand.cs (ToolName) |
| Repository | install.sh (REPO), UpdateCommand.cs (Repo), .csproj (RepositoryUrl) |
Add a comment in the .csproj as a reminder:
<!-- NOTE: PackageId and ToolCommandName must match TOOL_NAME in UpdateCommand.cs and install.sh. Update all locations if changing this value. --><PackageId>mytool</PackageId>Testing
Section titled “Testing”Create a Test Release
Section titled “Create a Test Release”# Tag and pushgit tag v0.1.0git push origin v0.1.0Verify the Workflow
Section titled “Verify the Workflow”- Check the Actions tab in GitHub for workflow completion
- Verify the release contains:
mytool.0.1.0.nupkgmytool.0.1.0.nupkg.sha256install.sh
Test Installation
Section titled “Test Installation”# Download and run install scriptgh release download --repo your-org/mytool --pattern 'install.sh' --output - | bash
# Verify installationmytool --versionTest Self-Update
Section titled “Test Self-Update”# Check for updatesmytool update --check
# Force reinstall current versionmytool update --forcePrivate Repository Considerations
Section titled “Private Repository Considerations”For private repositories:
- Users must have
ghinstalled and authenticated - The install script automatically uses
ghwhen authenticated - Direct curl/wget downloads fail without authentication
Install instructions for private repos:
# Ensure gh is authenticatedgh auth login
# Then installgh release download --repo your-org/mytool --pattern 'install.sh' --output - | bash