# .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

- .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

The self-update command requires these NuGet packages:

```xml
<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

Configure the `.csproj` file to package the application as a global .NET tool.

```xml
<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:

```xml
<ItemGroup>
  <None Include="../../README.md" Pack="true" PackagePath="/" />
</ItemGroup>
```

## GitHub Actions Workflow

Create `.github/workflows/release.yml` to build and publish releases when tags are pushed.

```yaml
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: true
```

The workflow:

1. Triggers on semantic version tags (`v1.0.0`, `v1.0.0-beta.1`)
2. Extracts and validates the version from the tag
3. Builds with the version embedded in the assembly
4. Creates a NuGet package
5. Generates SHA256 checksums for integrity verification
6. Creates a GitHub release with all artifacts

## Installation Script

Create `install.sh` at the repository root. This script handles installation for users.

```bash
#!/usr/bin/env bash
set -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"
    fi
else
    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)"
    fi
fi

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)"
    fi
fi

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/null
else
    info "Installing..."
    dotnet tool install -g --add-source "$TEMP_DIR" "$TOOL_NAME" >/dev/null
fi

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."
fi
```

The script:

1. Detects available download tools (prefers `gh` for private repos)
2. Fetches the latest version or uses a specified version
3. Downloads the package and checksum
4. Verifies SHA256 integrity before installation
5. Uses `dotnet tool install/update` from the local package
6. Provides PATH configuration instructions if needed

## Self-Update Command

Add an update command to your CLI following the `BaseCommand<TRequest>` pattern from the [CLI Design Playbook](./cli-design). The command delegates all update logic to `IUpdateService`.

### Service Interface

```csharp
// IUpdateService.cs
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

```csharp
// Request record - ALWAYS FIRST in file
public record UpdateRequest(
    bool CheckOnly,
    bool Force,
    GlobalOptions GlobalOptions) : RequestBase(GlobalOptions);

// Command class
public 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

`PerformUpdateAsync` downloads the `.nupkg`, verifies the checksum, and installs via `dotnet tool update -g`:

```csharp
// UpdateService.cs
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

**Version Detection** (implement as `GetCurrentVersion()` on `UpdateService`):

```csharp
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:

```csharp
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`):

```csharp
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:

```csharp
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:

```csharp
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

Register both the service and command in DI, then add the command from the service provider:

```csharp
// Program.cs - DI registration
services.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`:

```csharp
// Program.cs - before running pre-flight checks
bool 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

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

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:

```xml
<!-- 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

### Create a Test Release

```bash
# Tag and push
git tag v0.1.0
git push origin v0.1.0
```

### Verify the Workflow

1. Check the Actions tab in GitHub for workflow completion
2. Verify the release contains:
   - `mytool.0.1.0.nupkg`
   - `mytool.0.1.0.nupkg.sha256`
   - `install.sh`

### Test Installation

```bash
# Download and run install script
gh release download --repo your-org/mytool --pattern 'install.sh' --output - | bash

# Verify installation
mytool --version
```

### Test Self-Update

```bash
# Check for updates
mytool update --check

# Force reinstall current version
mytool update --force
```

## Private Repository Considerations

For private repositories:

1. Users must have `gh` installed and authenticated
2. The install script automatically uses `gh` when authenticated
3. Direct curl/wget downloads fail without authentication

Install instructions for private repos:

```bash
# Ensure gh is authenticated
gh auth login

# Then install
gh release download --repo your-org/mytool --pattern 'install.sh' --output - | bash
```