From 10552532bf4317fffa6b7127726d920a68fe2b0d Mon Sep 17 00:00:00 2001 From: paydii <193906237+paydii@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:14:07 +0200 Subject: [PATCH] Publish siGit Code to NuGet as a .NET tool Adds a sixth release channel: `dotnet tool install --global SiGit.Code`. A .NET tool is a single package rather than one artifact per platform, so nuget/sigit/ bundles all six native binaries under native/-/ and a small managed shim execs the right one. This is the pattern smbcloud-cli and onde-cli already use. The shim leaves stdin, stdout, and stderr unredirected so sigit's TTY check still picks TUI or ACP mode correctly. Two things worth knowing about it: The shim sets RollForward=Major. It targets net8.0, and without that a machine carrying only the .NET 10 runtime installs a tool that refuses to start with "You must install or update .NET to run this application". Bundling every target makes the package large, so the pack job fails if the .nupkg crosses nuget.org's 250 MB limit. At v1.5.1 it lands near 160 MB. If that check ever trips, split into RID-specific tool packages instead of dropping targets. Also sets strip = "symbols" on the release profile. That takes the macOS arm64 binary from 102 MB to 88 MB and shrinks every channel, not just this one. --- .agents/AGENTS.md | 22 +- .github/workflows/release-nuget.yml | 300 ++++++++++++++++++++++++++++ Cargo.toml | 8 + README.md | 1 + nuget/.gitignore | 5 + nuget/sigit/Program.cs | 114 +++++++++++ nuget/sigit/README.md | 65 ++++++ nuget/sigit/SiGit.Code.csproj | 41 ++++ 8 files changed, 553 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/release-nuget.yml create mode 100644 nuget/.gitignore create mode 100644 nuget/sigit/Program.cs create mode 100644 nuget/sigit/README.md create mode 100644 nuget/sigit/SiGit.Code.csproj diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index 1c89f6a..6aec68d 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -249,6 +249,22 @@ mutating tools; the escape hatch for clients without permission-request support) ## Releasing -Version lives in `Cargo.toml`. The binary is published to five registries via separate workflows -(`release-crates`, `release-github`, `release-homebrew`, `release-npm`, `release-pypi`); the -`npm/` and `pypi/` dirs hold the wrapper-package templates. Update `CHANGELOG.md` for releases. +Version lives in `Cargo.toml`. The binary is published to six registries via separate workflows +(`release-crates`, `release-github`, `release-homebrew`, `release-npm`, `release-nuget`, +`release-pypi`); the `npm/`, `nuget/`, and `pypi/` dirs hold the wrapper-package templates. Update +`CHANGELOG.md` for releases. + +`[profile.release]` sets `strip = "symbols"` because binary size is a distribution constraint, not +just a nicety — see the NuGet note below. + +The NuGet package (`SiGit.Code`, installed with `dotnet tool install --global SiGit.Code`) is the +odd one out: npm and PyPI publish one artifact per platform, but a .NET tool is a single package, +so `nuget/sigit/` bundles all six binaries under `native/-/` and a small managed shim +(`Program.cs`) execs the right one. That shim leaves stdin/stdout/stderr unredirected on purpose, +since siGit Code chooses TUI or ACP mode by testing whether stdin is a TTY. + +Bundling every target means the package is large, so `release-nuget.yml` fails the pack job if the +`.nupkg` crosses nuget.org's 250 MB limit. At v1.5.1 it lands around 160 MB. If a future release +trips that check, the fix is not to drop targets but to split into RID-specific tool packages +(.NET 10's `DotnetToolRidPackage`), which ship one binary per platform the way npm already does — +that also needs a fallback package for pre-.NET-10 SDKs. diff --git a/.github/workflows/release-nuget.yml b/.github/workflows/release-nuget.yml new file mode 100644 index 0000000..d3e0b3c --- /dev/null +++ b/.github/workflows/release-nuget.yml @@ -0,0 +1,300 @@ +name: NuGet Release + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + inputs: + tag: + description: "Release tag (e.g. v1.5.2)" + required: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + DOTNET_VERSION: 8.0.x + NUGET_PACKAGE_ID: SiGit.Code + +jobs: + build-native-binaries: + name: Build native binary (${{ matrix.build.NAME }}) + runs-on: ${{ matrix.build.OS }} + strategy: + fail-fast: false + matrix: + build: + - { + NAME: linux-x64, + OS: ubuntu-latest, + TARGET: x86_64-unknown-linux-gnu, + } + - { + NAME: linux-arm64, + OS: ubuntu-24.04-arm, + TARGET: aarch64-unknown-linux-gnu, + } + - { + NAME: windows-x64, + OS: windows-2022, + TARGET: x86_64-pc-windows-msvc, + } + - { + NAME: windows-arm64, + OS: windows-2022, + TARGET: aarch64-pc-windows-msvc, + } + - { NAME: darwin-x64, OS: macos-26, TARGET: x86_64-apple-darwin } + - { NAME: darwin-arm64, OS: macos-26, TARGET: aarch64-apple-darwin } + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - name: Read Rust toolchain + shell: bash + run: | + rust_toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n 1)" + if [ -z "$rust_toolchain" ]; then + echo "Failed to read Rust toolchain from rust-toolchain.toml" >&2 + exit 1 + fi + echo "RUST_TOOLCHAIN=${rust_toolchain}" >> "$GITHUB_ENV" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + + - name: Install Rust target + shell: bash + run: | + rustup target add ${{ matrix.build.TARGET }} --toolchain ${{ env.RUST_TOOLCHAIN }} + rustup target list --installed + + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + with: + key: nuget-${{ matrix.build.TARGET }} + + - name: Build binary + shell: bash + run: cargo build --locked --release --target ${{ matrix.build.TARGET }} + + - name: Prepare artifact + shell: bash + run: | + executable_name="sigit" + if [[ "${{ matrix.build.OS }}" == "windows-2022" ]]; then + executable_name="sigit.exe" + fi + + mkdir -p "nuget-artifacts/native/${{ matrix.build.NAME }}" + cp "target/${{ matrix.build.TARGET }}/release/${executable_name}" \ + "nuget-artifacts/native/${{ matrix.build.NAME }}/${executable_name}" + + - name: Upload native artifact + uses: actions/upload-artifact@v4 + with: + name: nuget-native-${{ matrix.build.NAME }} + path: nuget-artifacts/native + + pack-nuget: + name: Pack NuGet .NET tool + runs-on: ubuntu-latest + needs: build-native-binaries + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - name: Set the release version + shell: bash + run: | + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + release_version="${GITHUB_REF_NAME#v}" + else + release_version="${{ github.event.inputs.tag }}" + release_version="${release_version#v}" + fi + echo "RELEASE_VERSION=${release_version}" >> "$GITHUB_ENV" + + - name: Download native artifacts + uses: actions/download-artifact@v4 + with: + pattern: nuget-native-* + path: nuget/sigit/native + merge-multiple: true + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Pack .NET tool + shell: bash + run: | + dotnet pack nuget/sigit/SiGit.Code.csproj \ + --configuration Release \ + --output nuget/dist \ + -p:PackageVersion=${RELEASE_VERSION} + + # All six binaries ship in one package, so this sits closer to nuget.org's + # 250 MB ceiling than the single-platform npm and PyPI artifacts do. Fail + # here with a clear message rather than at `dotnet nuget push`, where the + # error is just "the package file exceeds the size limit". + - name: Check package size against the NuGet limit + shell: bash + run: | + package="$(ls nuget/dist/*.nupkg | head -n 1)" + size_bytes="$(wc -c < "${package}")" + limit_bytes=$((250 * 1024 * 1024)) + + echo "Package: ${package}" + echo "Size: $((size_bytes / 1024 / 1024)) MiB" + + if [ "${size_bytes}" -gt "${limit_bytes}" ]; then + echo "::error::${package} is over nuget.org's 250 MB package limit. Split the tool into RID-specific packages (.NET 10 DotnetToolRidPackage) instead of bundling every target." >&2 + exit 1 + fi + + - name: Upload package artifact + uses: actions/upload-artifact@v4 + with: + name: nuget-package + path: nuget/dist/*.nupkg + + smoke-test: + name: Smoke test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + needs: pack-nuget + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-2022 + - macos-26 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - name: Set the release version + shell: bash + run: | + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + release_version="${GITHUB_REF_NAME#v}" + else + release_version="${{ github.event.inputs.tag }}" + release_version="${release_version#v}" + fi + echo "RELEASE_VERSION=${release_version}" >> "$GITHUB_ENV" + + - name: Download package artifact + uses: actions/download-artifact@v4 + with: + name: nuget-package + path: nuget/dist + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Install tool from local package + shell: pwsh + run: | + $packageSource = (Resolve-Path "nuget/dist").Path + @" + + + + + + + + "@ | Set-Content -Path "nuget/NuGet.Config" + + dotnet tool install ` + --tool-path ".tool" ` + --configfile "nuget/NuGet.Config" ` + --version "$env:RELEASE_VERSION" ` + "$env:NUGET_PACKAGE_ID" + + - name: Verify bundled native binary + shell: pwsh + run: | + $runtimeIdentifier = if ($IsWindows) { + if ([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { "windows-arm64" } else { "windows-x64" } + } elseif ($IsMacOS) { + if ([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { "darwin-arm64" } else { "darwin-x64" } + } else { + if ([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { "linux-arm64" } else { "linux-x64" } + } + + $binaryName = if ($IsWindows) { "sigit.exe" } else { "sigit" } + $nativeBinary = Get-ChildItem ".tool/.store" -Recurse -File | Where-Object { + $_.FullName -like "*native*${runtimeIdentifier}*${binaryName}" + } | Select-Object -First 1 + + if (-not $nativeBinary) { + Write-Error "The sigit native binary for ${runtimeIdentifier} was not bundled in the installed .NET tool package." + } + + Write-Host "Found bundled native binary at $($nativeBinary.FullName)" + + publish-nuget: + name: Publish to NuGet + runs-on: ubuntu-latest + needs: smoke-test + steps: + - name: Set the release version + shell: bash + run: | + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + release_version="${GITHUB_REF_NAME#v}" + else + release_version="${{ github.event.inputs.tag }}" + release_version="${release_version#v}" + fi + echo "RELEASE_VERSION=${release_version}" >> "$GITHUB_ENV" + + - name: Download package artifact + uses: actions/download-artifact@v4 + with: + name: nuget-package + path: nuget/dist + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Check whether release already exists on NuGet + id: nuget-check + shell: bash + run: | + package_index_url="https://api.nuget.org/v3-flatcontainer/sigit.code/index.json" + if curl -fsS "${package_index_url}" | grep -F "\"${RELEASE_VERSION}\"" >/dev/null; then + echo "${NUGET_PACKAGE_ID} ${RELEASE_VERSION} already exists on NuGet, skipping publish" + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Publish package + if: steps.nuget-check.outputs.exists != 'true' + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + shell: bash + run: | + dotnet nuget push nuget/dist/*.nupkg \ + --api-key "${NUGET_API_KEY}" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate diff --git a/Cargo.toml b/Cargo.toml index 3c17c13..3af179d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,3 +54,11 @@ regex = "1" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls", "stream"] } uuid = { version = "1", features = ["v4"] } rpassword = "7" + +# Release binaries ship to five registries, and the NuGet package bundles all +# six targets in a single archive, so binary size is a distribution constraint +# rather than a nicety. Stripping symbols takes the macOS arm64 binary from +# ~102 MB to ~88 MB. The Homebrew tarball already stripped by hand in +# release-github.yml; this makes every channel match. +[profile.release] +strip = "symbols" diff --git a/README.md b/README.md index 8a0b8cd..6a31c78 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ cargo install sigit | pip | `pip install sigit-code` | | uv | `uvx --from sigit-code sigit` | | npm | `npm install -g @smbcloud/sigit` | +| NuGet | `dotnet tool install --global SiGit.Code` | ## First run diff --git a/nuget/.gitignore b/nuget/.gitignore new file mode 100644 index 0000000..6279b8e --- /dev/null +++ b/nuget/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +dist/ +native/ +NuGet.Config diff --git a/nuget/sigit/Program.cs b/nuget/sigit/Program.cs new file mode 100644 index 0000000..c043c2e --- /dev/null +++ b/nuget/sigit/Program.cs @@ -0,0 +1,114 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; + +internal static class Program +{ + private const string CommandName = "sigit"; + + public static async Task Main(string[] arguments) + { + try + { + string executablePath = ResolveExecutablePath(); + EnsureExecutablePermissions(executablePath); + return await RunAsync(executablePath, arguments); + } + catch (Exception exception) when ( + exception is FileNotFoundException or + PlatformNotSupportedException or + Win32Exception) + { + Console.Error.WriteLine($"{CommandName}: {exception.Message}"); + return 1; + } + } + + private static string ResolveExecutablePath() + { + string runtimeIdentifier = GetRuntimeIdentifier(); + string executableName = OperatingSystem.IsWindows() ? $"{CommandName}.exe" : CommandName; + string executablePath = Path.GetFullPath( + Path.Combine(AppContext.BaseDirectory, "native", runtimeIdentifier, executableName)); + + if (!File.Exists(executablePath)) + { + throw new FileNotFoundException( + $"The native {CommandName} executable for '{runtimeIdentifier}' is not bundled in this package.", + executablePath); + } + + return executablePath; + } + + private static string GetRuntimeIdentifier() + { + string operatingSystem = OperatingSystem.IsWindows() + ? "windows" + : OperatingSystem.IsMacOS() + ? "darwin" + : OperatingSystem.IsLinux() + ? "linux" + : throw new PlatformNotSupportedException( + $"{CommandName} does not support this operating system through the .NET tool package."); + + string architecture = RuntimeInformation.ProcessArchitecture switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "arm64", + _ => throw new PlatformNotSupportedException( + $"{CommandName} does not support the '{RuntimeInformation.ProcessArchitecture}' architecture through the .NET tool package."), + }; + + return $"{operatingSystem}-{architecture}"; + } + + private static void EnsureExecutablePermissions(string executablePath) + { + if (OperatingSystem.IsWindows()) + { + return; + } + + UnixFileMode currentMode = File.GetUnixFileMode(executablePath); + UnixFileMode requiredMode = UnixFileMode.UserRead | + UnixFileMode.UserWrite | + UnixFileMode.UserExecute | + UnixFileMode.GroupRead | + UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | + UnixFileMode.OtherExecute; + + if ((currentMode & requiredMode) == requiredMode) + { + return; + } + + File.SetUnixFileMode(executablePath, currentMode | requiredMode); + } + + private static async Task RunAsync(string executablePath, IReadOnlyList arguments) + { + // siGit Code picks its mode from whether stdin is a TTY: a terminal gets + // the ratatui chat UI, a pipe gets the ACP server. Leaving all three + // streams unredirected makes the child inherit this process's handles, + // so that detection sees the real terminal (or the real pipe from an + // editor) rather than something this launcher introduced. + ProcessStartInfo startInfo = new(executablePath) + { + UseShellExecute = false, + WorkingDirectory = Environment.CurrentDirectory, + }; + + foreach (string argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using Process process = Process.Start(startInfo) + ?? throw new Win32Exception($"Failed to start '{CommandName}'."); + + await process.WaitForExitAsync(); + return process.ExitCode; + } +} diff --git a/nuget/sigit/README.md b/nuget/sigit/README.md new file mode 100644 index 0000000..7d43699 --- /dev/null +++ b/nuget/sigit/README.md @@ -0,0 +1,65 @@ +# siGit Code for .NET + +`sigit` is the command-line interface for [siGit Code](https://sigit.si), an AI coding agent that +runs on your machine. + +## Install + +```sh +dotnet tool install --global SiGit.Code +``` + +## Update + +```sh +dotnet tool update --global SiGit.Code +``` + +## Run + +```sh +sigit +``` + +In a terminal that opens the chat UI. When stdin is a pipe, the same binary speaks the Agent +Client Protocol (ACP) over stdio instead, which is how editors such as Zed and VS Code drive it. + +First run downloads a GGUF model, so expect a wait of a gigabyte or two before the first reply. +On macOS the model cache is shared with the siGit Code desktop app, so a model either app has +already fetched is reused. + +## Platform support + +This .NET tool bundles native `sigit` binaries for: + +- macOS `arm64`, `x64` +- Linux `arm64`, `x64` (glibc) +- Windows `arm64`, `x64` + +The terminal chat UI is Unix-only. On Windows the binary runs in ACP mode, so use it through an +editor rather than directly. + +Because all six binaries ship in one package, the install is large. If that matters, the Homebrew, +npm, and PyPI packages each download only the binary for your platform. + +## Other installation methods + +- Cargo: `cargo install sigit` +- npm: `npm install -g @smbcloud/sigit` +- pip: `pip install sigit-code` +- Homebrew: `brew tap getsigit/tap && brew trust --tap getsigit/tap && brew install sigit` +- GitHub Releases: + +## Source + +- Repository: +- Website: +- Issues: + +## License + +[Apache 2.0](https://github.com/getsigit/sigit/blob/main/LICENSE) + +## Copyright + +© 2026 PT Sigit Mitra Bangun ([siGit Code & Deploy](https://sigit.si)). diff --git a/nuget/sigit/SiGit.Code.csproj b/nuget/sigit/SiGit.Code.csproj new file mode 100644 index 0000000..7be8d32 --- /dev/null +++ b/nuget/sigit/SiGit.Code.csproj @@ -0,0 +1,41 @@ + + + Exe + net8.0 + + Major + enable + enable + true + sigit + SiGit.Code + 0.0.0-local + $(PackageVersion) + Seto Elkahfi + siGit Code — ACP-compatible AI coding agent. Sí, git. + README.md + Apache-2.0 + https://sigit.si + https://github.com/getsigit/sigit + cli;sigit;ai;coding-agent;llm;acp;developer-tools;dotnet-tool + true + + + + + + + +