From 06851432e1daec2458d1ad36263c0fc1673290b9 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 1/3] 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 8d79539..3b5cfc1 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 + + + + + + + + From ccfaae9ac098353ef79c1bf72f701ea3f1c92766 Mon Sep 17 00:00:00 2001 From: paydii <193906237+paydii@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:39:53 +0200 Subject: [PATCH 2/3] Add winget, Scoop, AUR, deb, and rpm release channels Covers the OS package managers sigit had no presence in. Windows users get winget and Scoop, Arch users get sigit-bin from the AUR, and Debian and Fedora users get a .deb and .rpm attached to each GitHub release. All four of the new channels read checksums off the GitHub release, so release-github.yml now writes a .sha256 next to every asset rather than only the macOS Homebrew tarball, and dispatches the packaging workflows once the release exists. A dispatch that fails is a warning, not a failed release, so a channel nobody has configured yet cannot block the others. The .deb and .rpm are built with nfpm from the binary the matrix already produced, so each Linux target still compiles once. One config covers both formats. AUR publishes sigit-bin rather than a source package. Building sigit from source pulls in the whole on-device inference stack, which is a lot to ask of someone installing a CLI. Scoop and the AUR follow the existing Homebrew tap shape: generate the manifest, push it to a sibling repo. winget is different because its manifests live in microsoft/winget-pkgs, so that workflow submits a PR through wingetcreate and only handles updates. The first submission has to be made by hand, since wingetcreate cannot update a package that does not exist yet. Still needs credentials before any of it publishes: a getsigit/scoop-bucket repo with SCOOP_BUCKET_TOKEN, WINGET_TOKEN, and the three AUR_* secrets. --- .agents/AGENTS.md | 47 ++++++--- .github/workflows/release-aur.yml | 99 +++++++++++++++++++ .github/workflows/release-github.yml | 87 +++++++++++++++-- .github/workflows/release-scoop.yml | 140 +++++++++++++++++++++++++++ .github/workflows/release-winget.yml | 71 ++++++++++++++ README.md | 5 + packaging/aur/PKGBUILD.in | 31 ++++++ packaging/nfpm.yaml | 37 +++++++ 8 files changed, 496 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/release-aur.yml create mode 100644 .github/workflows/release-scoop.yml create mode 100644 .github/workflows/release-winget.yml create mode 100644 packaging/aur/PKGBUILD.in create mode 100644 packaging/nfpm.yaml diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index 6aec68d..0ab6fc6 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -249,22 +249,47 @@ mutating tools; the escape hatch for clients without permission-request support) ## Releasing -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. +Version lives in `Cargo.toml`. Update `CHANGELOG.md` for releases. + +Publishing splits into two groups. **Language registries** each get their own tag-triggered +workflow: `release-crates`, `release-npm`, `release-nuget`, `release-pypi`. The `npm/`, `nuget/`, +and `pypi/` dirs hold their wrapper-package templates. + +**OS package managers** all publish somewhere outside this repo and all need checksums from the +GitHub release, so `release-github` builds the binaries, attaches the assets, and then dispatches +`release-homebrew`, `release-scoop`, `release-winget`, and `release-aur`. A dispatch failure in one +is logged as a warning rather than failing the others, so an unconfigured channel does not block a +release. Their inputs live in `packaging/`. + +Every release asset now carries a `.sha256` sidecar, not just the macOS Homebrew tarball. Scoop, +winget, and the AUR PKGBUILD each need one, and they consume the raw binaries rather than the +tarball. `release-github` also builds a `.deb` and `.rpm` per Linux target with nfpm +(`packaging/nfpm.yaml`), packaging the already-built binary rather than re-invoking cargo. + +Three of these need credentials or a one-time manual step before they work: + +- Scoop needs a `getsigit/scoop-bucket` repo and a `SCOOP_BUCKET_TOKEN` secret, mirroring the + Homebrew tap setup. +- winget needs a `WINGET_TOKEN` (PAT with `public_repo`) so `wingetcreate` can fork + `microsoft/winget-pkgs`. The workflow only handles *updates*; the first submission has to be made + by hand with `wingetcreate new`, since a package must exist before it can be updated. +- The AUR needs `AUR_USERNAME`, `AUR_EMAIL`, and `AUR_SSH_PRIVATE_KEY`. It publishes `sigit-bin` + (a prebuilt binary) so Arch users are not compiling the on-device inference stack to install a + CLI. `[profile.release]` sets `strip = "symbols"` because binary size is a distribution constraint, not -just a nicety — see the NuGet note below. +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. +odd one out among the language registries: 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. It also 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. 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. +(.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-aur.yml b/.github/workflows/release-aur.yml new file mode 100644 index 0000000..4392ef0 --- /dev/null +++ b/.github/workflows/release-aur.yml @@ -0,0 +1,99 @@ +name: AUR Release + +# Dispatched by release-github.yml once the release and its checksums exist. +# +# Publishes the `sigit-bin` package: a prebuilt binary rather than a source +# build, so Arch users are not compiling the whole dependency tree (and the +# on-device inference stack in particular) to install a CLI. +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag (e.g. v1.5.2)" + required: true + +permissions: + contents: read + +env: + REPO: getsigit/sigit + +jobs: + publish-aur-package: + name: Publish sigit-bin to the AUR + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.inputs.tag }} + + - name: Resolve tag and version + id: release + shell: bash + run: | + TAG="${{ github.event.inputs.tag }}" + VERSION="${TAG#v}" + + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Read SHA256 checksums from release + id: sha + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + mkdir -p artifacts + + gh release download "${{ steps.release.outputs.tag }}" \ + --repo "${{ env.REPO }}" \ + --pattern "sigit-linux-*.sha256" \ + --dir artifacts/ + + X86_64_SHA=$(cat artifacts/sigit-linux-amd64.sha256) + AARCH64_SHA=$(cat artifacts/sigit-linux-arm64.sha256) + + echo "x86_64=${X86_64_SHA}" >> "$GITHUB_OUTPUT" + echo "aarch64=${AARCH64_SHA}" >> "$GITHUB_OUTPUT" + + echo "x86_64 SHA256: ${X86_64_SHA}" + echo "aarch64 SHA256: ${AARCH64_SHA}" + + - name: Render PKGBUILD + shell: bash + run: | + mkdir -p aur-build + + sed \ + -e "s/@VERSION@/${{ steps.release.outputs.version }}/g" \ + -e "s/@SHA256_X86_64@/${{ steps.sha.outputs.x86_64 }}/g" \ + -e "s/@SHA256_AARCH64@/${{ steps.sha.outputs.aarch64 }}/g" \ + packaging/aur/PKGBUILD.in > aur-build/PKGBUILD + + if grep -q '@[A-Z0-9_]*@' aur-build/PKGBUILD; then + echo "::error::PKGBUILD still contains unsubstituted placeholders" >&2 + grep -n '@[A-Z0-9_]*@' aur-build/PKGBUILD >&2 + exit 1 + fi + + # Catches an unbalanced quote or paren before it reaches the AUR. + bash -n aur-build/PKGBUILD + + echo "Rendered PKGBUILD:" + cat aur-build/PKGBUILD + + # The action runs makepkg in an Arch container to generate .SRCINFO and + # pushes over SSH. Generating .SRCINFO by hand is possible but drifts + # from the PKGBUILD the moment a field is added. + - name: Publish to the AUR + uses: KSXGitHub/github-actions-deploy-aur@v3 + with: + pkgname: sigit-bin + pkgbuild: aur-build/PKGBUILD + commit_username: ${{ secrets.AUR_USERNAME }} + commit_email: ${{ secrets.AUR_EMAIL }} + ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + commit_message: "Update sigit-bin to ${{ steps.release.outputs.version }}" + updpkgsums: false diff --git a/.github/workflows/release-github.yml b/.github/workflows/release-github.yml index 2e75a56..727b617 100644 --- a/.github/workflows/release-github.yml +++ b/.github/workflows/release-github.yml @@ -119,6 +119,54 @@ jobs: echo "Archive: ${ARCHIVE_NAME}" echo "SHA256: $(cat "${ARCHIVE_NAME}.sha256")" + # Debian and RPM packages are built from the binary staged above rather + # than by re-invoking cargo, so each Linux target compiles once and gets + # packaged twice. + - name: Build Linux packages + if: contains(matrix.target, 'linux') + shell: bash + env: + NFPM_VERSION: "2.43.0" + run: | + case "$(uname -m)" in + x86_64) nfpm_arch="x86_64"; export PKG_ARCH="amd64" ;; + aarch64) nfpm_arch="arm64"; export PKG_ARCH="arm64" ;; + *) echo "Unsupported Linux build host $(uname -m)" >&2; exit 1 ;; + esac + + curl -sSfL \ + "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_Linux_${nfpm_arch}.tar.gz" \ + | tar -xz -C /tmp nfpm + + export PKG_VERSION="${RELEASE_VERSION}" + export PKG_BINARY="./release/${PROJECT_NAME}-${{ matrix.name }}" + + /tmp/nfpm package --config packaging/nfpm.yaml --packager deb --target ./release/ + /tmp/nfpm package --config packaging/nfpm.yaml --packager rpm --target ./release/ + + ls -la ./release/ + + # Homebrew has always had a checksum because the tap needs one. Scoop, + # winget, and the AUR PKGBUILD each need one too, and they consume the + # raw binaries rather than the macOS tarball, so every asset gets a + # sidecar. Windows runners use bash from Git for Windows, which has + # sha256sum; macOS only has shasum. + - name: Checksum release assets + shell: bash + run: | + cd release + for asset in *; do + case "${asset}" in *.sha256) continue ;; esac + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "${asset}" | awk '{print $1}' > "${asset}.sha256" + else + shasum -a 256 "${asset}" | awk '{print $1}' > "${asset}.sha256" + fi + + echo "${asset}: $(cat "${asset}.sha256")" + done + - name: Upload binary artifact uses: actions/upload-artifact@v4 with: @@ -171,17 +219,36 @@ jobs: tag_name: ${{ steps.tag.outputs.tag }} files: release/* - - name: Trigger Homebrew release + # These channels all publish somewhere outside this repo (a tap, a Scoop + # bucket, microsoft/winget-pkgs, the AUR) and every one of them reads + # checksums off the release created above, so they hang off this job + # rather than firing on the tag directly. + - name: Trigger OS package manager releases uses: actions/github-script@v7 with: script: | - await github.rest.actions.createWorkflowDispatch({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'release-homebrew.yml', - ref: 'main', - inputs: { - tag: '${{ steps.tag.outputs.tag }}' + const tag = '${{ steps.tag.outputs.tag }}' + const workflows = [ + 'release-homebrew.yml', + 'release-scoop.yml', + 'release-winget.yml', + 'release-aur.yml', + ] + + for (const workflow_id of workflows) { + try { + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id, + ref: 'main', + inputs: { tag }, + }) + console.log(`Dispatched ${workflow_id} for tag ${tag}`) + } catch (error) { + // One packaging channel being unconfigured (a missing secret, + // a bucket repo that does not exist yet) should not take the + // rest of the fan-out down with it. + core.warning(`Failed to dispatch ${workflow_id}: ${error.message}`) } - }) - console.log('Dispatched release-homebrew.yml for tag ${{ steps.tag.outputs.tag }}') + } diff --git a/.github/workflows/release-scoop.yml b/.github/workflows/release-scoop.yml new file mode 100644 index 0000000..a3bbee9 --- /dev/null +++ b/.github/workflows/release-scoop.yml @@ -0,0 +1,140 @@ +name: Scoop Release + +# Dispatched by release-github.yml once the release and its checksums exist. +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag (e.g. v1.5.2)" + required: true + +permissions: + contents: read + +env: + REPO: getsigit/sigit + +jobs: + update-scoop-bucket: + name: Update Scoop bucket + runs-on: ubuntu-latest + + steps: + - name: Resolve tag and version + id: release + shell: bash + run: | + TAG="${{ github.event.inputs.tag }}" + VERSION="${TAG#v}" + + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Read SHA256 checksums from release + id: sha + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + mkdir -p artifacts + + gh release download "${{ steps.release.outputs.tag }}" \ + --repo "${{ env.REPO }}" \ + --pattern "sigit-win-*.exe.sha256" \ + --dir artifacts/ + + AMD64_SHA=$(cat artifacts/sigit-win-amd64.exe.sha256) + ARM64_SHA=$(cat artifacts/sigit-win-arm64.exe.sha256) + + echo "amd64=${AMD64_SHA}" >> "$GITHUB_OUTPUT" + echo "arm64=${ARM64_SHA}" >> "$GITHUB_OUTPUT" + + echo "AMD64 SHA256: ${AMD64_SHA}" + echo "ARM64 SHA256: ${ARM64_SHA}" + + - name: Checkout Scoop bucket + uses: actions/checkout@v6 + with: + repository: getsigit/scoop-bucket + token: ${{ secrets.SCOOP_BUCKET_TOKEN }} + path: scoop-bucket + + - name: Generate manifest + shell: bash + run: | + VERSION="${{ steps.release.outputs.version }}" + TAG="${{ steps.release.outputs.tag }}" + AMD64_SHA="${{ steps.sha.outputs.amd64 }}" + ARM64_SHA="${{ steps.sha.outputs.arm64 }}" + + mkdir -p scoop-bucket/bucket + + # The `#/sigit.exe` URL fragment is Scoop's rename-on-download + # syntax: the release asset is sigit-win-amd64.exe, but the shim has + # to end up as sigit.exe for `sigit` to work on PATH. + cat > scoop-bucket/bucket/sigit.json <&2 + exit 1 + fi + + for field in version bin architecture; do + if [ "$(jq -r "has(\"${field}\")" scoop-bucket/bucket/sigit.json)" != "true" ]; then + echo "::error::Scoop manifest is missing the '${field}' field" >&2 + exit 1 + fi + done + + - name: Commit and push + shell: bash + run: | + VERSION="${{ steps.release.outputs.version }}" + + cd scoop-bucket + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add bucket/sigit.json + git diff --cached --quiet && echo "No changes to commit" && exit 0 + git commit -m "Update sigit to ${VERSION}" + git push diff --git a/.github/workflows/release-winget.yml b/.github/workflows/release-winget.yml new file mode 100644 index 0000000..81bda9a --- /dev/null +++ b/.github/workflows/release-winget.yml @@ -0,0 +1,71 @@ +name: winget Release + +# Dispatched by release-github.yml once the release and its checksums exist. +# +# This workflow only handles *updates*. winget manifests live in +# microsoft/winget-pkgs, and a package has to exist there before it can be +# updated, so the very first submission is a one-time manual step: +# +# wingetcreate new https://github.com/getsigit/sigit/releases/download/vX.Y.Z/sigit-win-amd64.exe +# +# answering `portable` for the installer type and `smbCloud.siGitCode` for the +# package identifier. Every release after that is automated here. +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag (e.g. v1.5.2)" + required: true + +permissions: + contents: read + +env: + REPO: getsigit/sigit + PACKAGE_IDENTIFIER: smbCloud.siGitCode + +jobs: + submit-winget-manifest: + name: Submit winget manifest + # wingetcreate is a Windows-only tool. + runs-on: windows-latest + + steps: + - name: Resolve tag and version + id: release + shell: bash + run: | + TAG="${{ github.event.inputs.tag }}" + VERSION="${TAG#v}" + + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Submit manifest update + shell: pwsh + env: + WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} + run: | + if (-not $env:WINGET_TOKEN) { + Write-Error "WINGET_TOKEN is not set. It needs a PAT with public_repo scope so wingetcreate can fork microsoft/winget-pkgs and open the manifest PR." + } + + $tag = "${{ steps.release.outputs.tag }}" + $version = "${{ steps.release.outputs.version }}" + $base = "https://github.com/${env:REPO}/releases/download/$tag" + + Invoke-WebRequest -Uri "https://aka.ms/wingetcreate/latest" -OutFile wingetcreate.exe + + # The trailing |x64 and |arm64 tell wingetcreate which installer + # entry each URL replaces. Without them it guesses from the file + # name, and "sigit-win-amd64.exe" is not a spelling it recognises. + .\wingetcreate.exe update $env:PACKAGE_IDENTIFIER ` + --version $version ` + --urls "$base/sigit-win-amd64.exe|x64" "$base/sigit-win-arm64.exe|arm64" ` + --release-notes-url "https://github.com/${env:REPO}/releases/tag/$tag" ` + --submit ` + --token $env:WINGET_TOKEN + + if ($LASTEXITCODE -ne 0) { + Write-Error "wingetcreate failed with exit code $LASTEXITCODE" + } diff --git a/README.md b/README.md index 6a31c78..720e63d 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,11 @@ cargo install sigit | uv | `uvx --from sigit-code sigit` | | npm | `npm install -g @smbcloud/sigit` | | NuGet | `dotnet tool install --global SiGit.Code` | +| winget | `winget install smbCloud.siGitCode` | +| Scoop | `scoop bucket add getsigit https://github.com/getsigit/scoop-bucket && scoop install sigit` | +| AUR | `yay -S sigit-bin` | +| Debian/Ubuntu | download the `.deb` from [releases](https://github.com/getsigit/sigit/releases), then `sudo dpkg -i sigit_*.deb` | +| Fedora/RHEL | download the `.rpm` from [releases](https://github.com/getsigit/sigit/releases), then `sudo rpm -i sigit-*.rpm` | ## First run diff --git a/packaging/aur/PKGBUILD.in b/packaging/aur/PKGBUILD.in new file mode 100644 index 0000000..18cff25 --- /dev/null +++ b/packaging/aur/PKGBUILD.in @@ -0,0 +1,31 @@ +# Maintainer: smbCloud +# +# Template for the sigit-bin AUR package. release-aur.yml substitutes the +# at-sign-delimited placeholders below and pushes the result. The +# $pkgver/${CARCH} references are makepkg's own and must survive substitution +# untouched, which is why this lives in a file rather than a heredoc in the +# workflow. Keep placeholder-shaped text out of the comments: the workflow +# greps for leftovers and treats any hit as an unsubstituted value. + +pkgname=sigit-bin +pkgver=@VERSION@ +pkgrel=1 +pkgdesc="siGit Code, an AI coding agent that runs on your machine" +arch=('x86_64' 'aarch64') +url="https://github.com/getsigit/sigit" +license=('Apache-2.0') +depends=('glibc' 'gcc-libs') +provides=('sigit') +conflicts=('sigit') +# The released binary is already stripped by the release profile, and makepkg +# should not rewrite a vendor binary it did not build. +options=(!strip !debug) + +source_x86_64=("sigit-${pkgver}-x86_64::${url}/releases/download/v${pkgver}/sigit-linux-amd64") +source_aarch64=("sigit-${pkgver}-aarch64::${url}/releases/download/v${pkgver}/sigit-linux-arm64") +sha256sums_x86_64=('@SHA256_X86_64@') +sha256sums_aarch64=('@SHA256_AARCH64@') + +package() { + install -Dm755 "${srcdir}/sigit-${pkgver}-${CARCH}" "${pkgdir}/usr/bin/sigit" +} diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml new file mode 100644 index 0000000..63751ba --- /dev/null +++ b/packaging/nfpm.yaml @@ -0,0 +1,37 @@ +# nfpm builds the .deb and .rpm attached to each GitHub release. +# +# One config covers both formats. nfpm takes an already-built binary rather +# than invoking cargo, so the release matrix compiles each Linux target once +# and packages it twice. `arch` is a Go arch name (amd64/arm64) that nfpm +# translates per format, so the rpm comes out tagged x86_64 without a second +# config. +# +# PKG_ARCH and PKG_VERSION are set by release-github.yml; PKG_BINARY points at +# the renamed binary already staged in release/. +name: sigit +arch: ${PKG_ARCH} +platform: linux +version: ${PKG_VERSION} +section: devel +priority: optional +maintainer: smbCloud +vendor: PT Sigit Mitra Bangun +homepage: https://sigit.si +license: Apache-2.0 +description: | + siGit Code, an AI coding agent that runs on your machine. + Speaks the Agent Client Protocol for editor integration, and runs inference + either on-device or against a hosted endpoint. +contents: + - src: ${PKG_BINARY} + dst: /usr/bin/sigit + file_info: + mode: 0755 + - src: ./LICENSE + dst: /usr/share/doc/sigit/LICENSE + file_info: + mode: 0644 + - src: ./README.md + dst: /usr/share/doc/sigit/README.md + file_info: + mode: 0644 From c75bf635307d1ff42e008d08c6c3560a8454116e Mon Sep 17 00:00:00 2001 From: paydii <193906237+paydii@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:39:53 +0200 Subject: [PATCH 3/3] Add winget, Scoop, AUR, deb, and rpm release channels Covers the OS package managers sigit had no presence in. Windows users get winget and Scoop, Arch users get sigit-bin from the AUR, and Debian and Fedora users get a .deb and .rpm attached to each GitHub release. All four of the new channels read checksums off the GitHub release, so release-github.yml now writes a .sha256 next to every asset rather than only the macOS Homebrew tarball, and dispatches the packaging workflows once the release exists. A dispatch that fails is a warning, not a failed release, so a channel nobody has configured yet cannot block the others. The .deb and .rpm are built with nfpm from the binary the matrix already produced, so each Linux target still compiles once. One config covers both formats. AUR publishes sigit-bin rather than a source package. Building sigit from source pulls in the whole on-device inference stack, which is a lot to ask of someone installing a CLI. Scoop and the AUR follow the existing Homebrew tap shape: generate the manifest, push it to a sibling repo. winget is different because its manifests live in microsoft/winget-pkgs, so that workflow submits a PR through wingetcreate and only handles updates. The first submission has to be made by hand, since wingetcreate cannot update a package that does not exist yet. Still needs credentials before any of it publishes: a getsigit/scoop-bucket repo with SCOOP_BUCKET_TOKEN, WINGET_TOKEN, and the three AUR_* secrets. --- .agents/AGENTS.md | 47 ++++++--- .github/workflows/release-aur.yml | 99 +++++++++++++++++++ .github/workflows/release-github.yml | 87 +++++++++++++++-- .github/workflows/release-scoop.yml | 140 +++++++++++++++++++++++++++ .github/workflows/release-winget.yml | 71 ++++++++++++++ README.md | 5 + packaging/aur/PKGBUILD.in | 31 ++++++ packaging/nfpm.yaml | 37 +++++++ 8 files changed, 496 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/release-aur.yml create mode 100644 .github/workflows/release-scoop.yml create mode 100644 .github/workflows/release-winget.yml create mode 100644 packaging/aur/PKGBUILD.in create mode 100644 packaging/nfpm.yaml diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index 6aec68d..0ab6fc6 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -249,22 +249,47 @@ mutating tools; the escape hatch for clients without permission-request support) ## Releasing -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. +Version lives in `Cargo.toml`. Update `CHANGELOG.md` for releases. + +Publishing splits into two groups. **Language registries** each get their own tag-triggered +workflow: `release-crates`, `release-npm`, `release-nuget`, `release-pypi`. The `npm/`, `nuget/`, +and `pypi/` dirs hold their wrapper-package templates. + +**OS package managers** all publish somewhere outside this repo and all need checksums from the +GitHub release, so `release-github` builds the binaries, attaches the assets, and then dispatches +`release-homebrew`, `release-scoop`, `release-winget`, and `release-aur`. A dispatch failure in one +is logged as a warning rather than failing the others, so an unconfigured channel does not block a +release. Their inputs live in `packaging/`. + +Every release asset now carries a `.sha256` sidecar, not just the macOS Homebrew tarball. Scoop, +winget, and the AUR PKGBUILD each need one, and they consume the raw binaries rather than the +tarball. `release-github` also builds a `.deb` and `.rpm` per Linux target with nfpm +(`packaging/nfpm.yaml`), packaging the already-built binary rather than re-invoking cargo. + +Three of these need credentials or a one-time manual step before they work: + +- Scoop needs a `getsigit/scoop-bucket` repo and a `SCOOP_BUCKET_TOKEN` secret, mirroring the + Homebrew tap setup. +- winget needs a `WINGET_TOKEN` (PAT with `public_repo`) so `wingetcreate` can fork + `microsoft/winget-pkgs`. The workflow only handles *updates*; the first submission has to be made + by hand with `wingetcreate new`, since a package must exist before it can be updated. +- The AUR needs `AUR_USERNAME`, `AUR_EMAIL`, and `AUR_SSH_PRIVATE_KEY`. It publishes `sigit-bin` + (a prebuilt binary) so Arch users are not compiling the on-device inference stack to install a + CLI. `[profile.release]` sets `strip = "symbols"` because binary size is a distribution constraint, not -just a nicety — see the NuGet note below. +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. +odd one out among the language registries: 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. It also 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. 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. +(.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-aur.yml b/.github/workflows/release-aur.yml new file mode 100644 index 0000000..4392ef0 --- /dev/null +++ b/.github/workflows/release-aur.yml @@ -0,0 +1,99 @@ +name: AUR Release + +# Dispatched by release-github.yml once the release and its checksums exist. +# +# Publishes the `sigit-bin` package: a prebuilt binary rather than a source +# build, so Arch users are not compiling the whole dependency tree (and the +# on-device inference stack in particular) to install a CLI. +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag (e.g. v1.5.2)" + required: true + +permissions: + contents: read + +env: + REPO: getsigit/sigit + +jobs: + publish-aur-package: + name: Publish sigit-bin to the AUR + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.inputs.tag }} + + - name: Resolve tag and version + id: release + shell: bash + run: | + TAG="${{ github.event.inputs.tag }}" + VERSION="${TAG#v}" + + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Read SHA256 checksums from release + id: sha + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + mkdir -p artifacts + + gh release download "${{ steps.release.outputs.tag }}" \ + --repo "${{ env.REPO }}" \ + --pattern "sigit-linux-*.sha256" \ + --dir artifacts/ + + X86_64_SHA=$(cat artifacts/sigit-linux-amd64.sha256) + AARCH64_SHA=$(cat artifacts/sigit-linux-arm64.sha256) + + echo "x86_64=${X86_64_SHA}" >> "$GITHUB_OUTPUT" + echo "aarch64=${AARCH64_SHA}" >> "$GITHUB_OUTPUT" + + echo "x86_64 SHA256: ${X86_64_SHA}" + echo "aarch64 SHA256: ${AARCH64_SHA}" + + - name: Render PKGBUILD + shell: bash + run: | + mkdir -p aur-build + + sed \ + -e "s/@VERSION@/${{ steps.release.outputs.version }}/g" \ + -e "s/@SHA256_X86_64@/${{ steps.sha.outputs.x86_64 }}/g" \ + -e "s/@SHA256_AARCH64@/${{ steps.sha.outputs.aarch64 }}/g" \ + packaging/aur/PKGBUILD.in > aur-build/PKGBUILD + + if grep -q '@[A-Z0-9_]*@' aur-build/PKGBUILD; then + echo "::error::PKGBUILD still contains unsubstituted placeholders" >&2 + grep -n '@[A-Z0-9_]*@' aur-build/PKGBUILD >&2 + exit 1 + fi + + # Catches an unbalanced quote or paren before it reaches the AUR. + bash -n aur-build/PKGBUILD + + echo "Rendered PKGBUILD:" + cat aur-build/PKGBUILD + + # The action runs makepkg in an Arch container to generate .SRCINFO and + # pushes over SSH. Generating .SRCINFO by hand is possible but drifts + # from the PKGBUILD the moment a field is added. + - name: Publish to the AUR + uses: KSXGitHub/github-actions-deploy-aur@v3 + with: + pkgname: sigit-bin + pkgbuild: aur-build/PKGBUILD + commit_username: ${{ secrets.AUR_USERNAME }} + commit_email: ${{ secrets.AUR_EMAIL }} + ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + commit_message: "Update sigit-bin to ${{ steps.release.outputs.version }}" + updpkgsums: false diff --git a/.github/workflows/release-github.yml b/.github/workflows/release-github.yml index 2e75a56..727b617 100644 --- a/.github/workflows/release-github.yml +++ b/.github/workflows/release-github.yml @@ -119,6 +119,54 @@ jobs: echo "Archive: ${ARCHIVE_NAME}" echo "SHA256: $(cat "${ARCHIVE_NAME}.sha256")" + # Debian and RPM packages are built from the binary staged above rather + # than by re-invoking cargo, so each Linux target compiles once and gets + # packaged twice. + - name: Build Linux packages + if: contains(matrix.target, 'linux') + shell: bash + env: + NFPM_VERSION: "2.43.0" + run: | + case "$(uname -m)" in + x86_64) nfpm_arch="x86_64"; export PKG_ARCH="amd64" ;; + aarch64) nfpm_arch="arm64"; export PKG_ARCH="arm64" ;; + *) echo "Unsupported Linux build host $(uname -m)" >&2; exit 1 ;; + esac + + curl -sSfL \ + "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_Linux_${nfpm_arch}.tar.gz" \ + | tar -xz -C /tmp nfpm + + export PKG_VERSION="${RELEASE_VERSION}" + export PKG_BINARY="./release/${PROJECT_NAME}-${{ matrix.name }}" + + /tmp/nfpm package --config packaging/nfpm.yaml --packager deb --target ./release/ + /tmp/nfpm package --config packaging/nfpm.yaml --packager rpm --target ./release/ + + ls -la ./release/ + + # Homebrew has always had a checksum because the tap needs one. Scoop, + # winget, and the AUR PKGBUILD each need one too, and they consume the + # raw binaries rather than the macOS tarball, so every asset gets a + # sidecar. Windows runners use bash from Git for Windows, which has + # sha256sum; macOS only has shasum. + - name: Checksum release assets + shell: bash + run: | + cd release + for asset in *; do + case "${asset}" in *.sha256) continue ;; esac + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "${asset}" | awk '{print $1}' > "${asset}.sha256" + else + shasum -a 256 "${asset}" | awk '{print $1}' > "${asset}.sha256" + fi + + echo "${asset}: $(cat "${asset}.sha256")" + done + - name: Upload binary artifact uses: actions/upload-artifact@v4 with: @@ -171,17 +219,36 @@ jobs: tag_name: ${{ steps.tag.outputs.tag }} files: release/* - - name: Trigger Homebrew release + # These channels all publish somewhere outside this repo (a tap, a Scoop + # bucket, microsoft/winget-pkgs, the AUR) and every one of them reads + # checksums off the release created above, so they hang off this job + # rather than firing on the tag directly. + - name: Trigger OS package manager releases uses: actions/github-script@v7 with: script: | - await github.rest.actions.createWorkflowDispatch({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'release-homebrew.yml', - ref: 'main', - inputs: { - tag: '${{ steps.tag.outputs.tag }}' + const tag = '${{ steps.tag.outputs.tag }}' + const workflows = [ + 'release-homebrew.yml', + 'release-scoop.yml', + 'release-winget.yml', + 'release-aur.yml', + ] + + for (const workflow_id of workflows) { + try { + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id, + ref: 'main', + inputs: { tag }, + }) + console.log(`Dispatched ${workflow_id} for tag ${tag}`) + } catch (error) { + // One packaging channel being unconfigured (a missing secret, + // a bucket repo that does not exist yet) should not take the + // rest of the fan-out down with it. + core.warning(`Failed to dispatch ${workflow_id}: ${error.message}`) } - }) - console.log('Dispatched release-homebrew.yml for tag ${{ steps.tag.outputs.tag }}') + } diff --git a/.github/workflows/release-scoop.yml b/.github/workflows/release-scoop.yml new file mode 100644 index 0000000..a3bbee9 --- /dev/null +++ b/.github/workflows/release-scoop.yml @@ -0,0 +1,140 @@ +name: Scoop Release + +# Dispatched by release-github.yml once the release and its checksums exist. +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag (e.g. v1.5.2)" + required: true + +permissions: + contents: read + +env: + REPO: getsigit/sigit + +jobs: + update-scoop-bucket: + name: Update Scoop bucket + runs-on: ubuntu-latest + + steps: + - name: Resolve tag and version + id: release + shell: bash + run: | + TAG="${{ github.event.inputs.tag }}" + VERSION="${TAG#v}" + + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Read SHA256 checksums from release + id: sha + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + mkdir -p artifacts + + gh release download "${{ steps.release.outputs.tag }}" \ + --repo "${{ env.REPO }}" \ + --pattern "sigit-win-*.exe.sha256" \ + --dir artifacts/ + + AMD64_SHA=$(cat artifacts/sigit-win-amd64.exe.sha256) + ARM64_SHA=$(cat artifacts/sigit-win-arm64.exe.sha256) + + echo "amd64=${AMD64_SHA}" >> "$GITHUB_OUTPUT" + echo "arm64=${ARM64_SHA}" >> "$GITHUB_OUTPUT" + + echo "AMD64 SHA256: ${AMD64_SHA}" + echo "ARM64 SHA256: ${ARM64_SHA}" + + - name: Checkout Scoop bucket + uses: actions/checkout@v6 + with: + repository: getsigit/scoop-bucket + token: ${{ secrets.SCOOP_BUCKET_TOKEN }} + path: scoop-bucket + + - name: Generate manifest + shell: bash + run: | + VERSION="${{ steps.release.outputs.version }}" + TAG="${{ steps.release.outputs.tag }}" + AMD64_SHA="${{ steps.sha.outputs.amd64 }}" + ARM64_SHA="${{ steps.sha.outputs.arm64 }}" + + mkdir -p scoop-bucket/bucket + + # The `#/sigit.exe` URL fragment is Scoop's rename-on-download + # syntax: the release asset is sigit-win-amd64.exe, but the shim has + # to end up as sigit.exe for `sigit` to work on PATH. + cat > scoop-bucket/bucket/sigit.json <&2 + exit 1 + fi + + for field in version bin architecture; do + if [ "$(jq -r "has(\"${field}\")" scoop-bucket/bucket/sigit.json)" != "true" ]; then + echo "::error::Scoop manifest is missing the '${field}' field" >&2 + exit 1 + fi + done + + - name: Commit and push + shell: bash + run: | + VERSION="${{ steps.release.outputs.version }}" + + cd scoop-bucket + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add bucket/sigit.json + git diff --cached --quiet && echo "No changes to commit" && exit 0 + git commit -m "Update sigit to ${VERSION}" + git push diff --git a/.github/workflows/release-winget.yml b/.github/workflows/release-winget.yml new file mode 100644 index 0000000..81bda9a --- /dev/null +++ b/.github/workflows/release-winget.yml @@ -0,0 +1,71 @@ +name: winget Release + +# Dispatched by release-github.yml once the release and its checksums exist. +# +# This workflow only handles *updates*. winget manifests live in +# microsoft/winget-pkgs, and a package has to exist there before it can be +# updated, so the very first submission is a one-time manual step: +# +# wingetcreate new https://github.com/getsigit/sigit/releases/download/vX.Y.Z/sigit-win-amd64.exe +# +# answering `portable` for the installer type and `smbCloud.siGitCode` for the +# package identifier. Every release after that is automated here. +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag (e.g. v1.5.2)" + required: true + +permissions: + contents: read + +env: + REPO: getsigit/sigit + PACKAGE_IDENTIFIER: smbCloud.siGitCode + +jobs: + submit-winget-manifest: + name: Submit winget manifest + # wingetcreate is a Windows-only tool. + runs-on: windows-latest + + steps: + - name: Resolve tag and version + id: release + shell: bash + run: | + TAG="${{ github.event.inputs.tag }}" + VERSION="${TAG#v}" + + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Submit manifest update + shell: pwsh + env: + WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} + run: | + if (-not $env:WINGET_TOKEN) { + Write-Error "WINGET_TOKEN is not set. It needs a PAT with public_repo scope so wingetcreate can fork microsoft/winget-pkgs and open the manifest PR." + } + + $tag = "${{ steps.release.outputs.tag }}" + $version = "${{ steps.release.outputs.version }}" + $base = "https://github.com/${env:REPO}/releases/download/$tag" + + Invoke-WebRequest -Uri "https://aka.ms/wingetcreate/latest" -OutFile wingetcreate.exe + + # The trailing |x64 and |arm64 tell wingetcreate which installer + # entry each URL replaces. Without them it guesses from the file + # name, and "sigit-win-amd64.exe" is not a spelling it recognises. + .\wingetcreate.exe update $env:PACKAGE_IDENTIFIER ` + --version $version ` + --urls "$base/sigit-win-amd64.exe|x64" "$base/sigit-win-arm64.exe|arm64" ` + --release-notes-url "https://github.com/${env:REPO}/releases/tag/$tag" ` + --submit ` + --token $env:WINGET_TOKEN + + if ($LASTEXITCODE -ne 0) { + Write-Error "wingetcreate failed with exit code $LASTEXITCODE" + } diff --git a/README.md b/README.md index 6a31c78..720e63d 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,11 @@ cargo install sigit | uv | `uvx --from sigit-code sigit` | | npm | `npm install -g @smbcloud/sigit` | | NuGet | `dotnet tool install --global SiGit.Code` | +| winget | `winget install smbCloud.siGitCode` | +| Scoop | `scoop bucket add getsigit https://github.com/getsigit/scoop-bucket && scoop install sigit` | +| AUR | `yay -S sigit-bin` | +| Debian/Ubuntu | download the `.deb` from [releases](https://github.com/getsigit/sigit/releases), then `sudo dpkg -i sigit_*.deb` | +| Fedora/RHEL | download the `.rpm` from [releases](https://github.com/getsigit/sigit/releases), then `sudo rpm -i sigit-*.rpm` | ## First run diff --git a/packaging/aur/PKGBUILD.in b/packaging/aur/PKGBUILD.in new file mode 100644 index 0000000..18cff25 --- /dev/null +++ b/packaging/aur/PKGBUILD.in @@ -0,0 +1,31 @@ +# Maintainer: smbCloud +# +# Template for the sigit-bin AUR package. release-aur.yml substitutes the +# at-sign-delimited placeholders below and pushes the result. The +# $pkgver/${CARCH} references are makepkg's own and must survive substitution +# untouched, which is why this lives in a file rather than a heredoc in the +# workflow. Keep placeholder-shaped text out of the comments: the workflow +# greps for leftovers and treats any hit as an unsubstituted value. + +pkgname=sigit-bin +pkgver=@VERSION@ +pkgrel=1 +pkgdesc="siGit Code, an AI coding agent that runs on your machine" +arch=('x86_64' 'aarch64') +url="https://github.com/getsigit/sigit" +license=('Apache-2.0') +depends=('glibc' 'gcc-libs') +provides=('sigit') +conflicts=('sigit') +# The released binary is already stripped by the release profile, and makepkg +# should not rewrite a vendor binary it did not build. +options=(!strip !debug) + +source_x86_64=("sigit-${pkgver}-x86_64::${url}/releases/download/v${pkgver}/sigit-linux-amd64") +source_aarch64=("sigit-${pkgver}-aarch64::${url}/releases/download/v${pkgver}/sigit-linux-arm64") +sha256sums_x86_64=('@SHA256_X86_64@') +sha256sums_aarch64=('@SHA256_AARCH64@') + +package() { + install -Dm755 "${srcdir}/sigit-${pkgver}-${CARCH}" "${pkgdir}/usr/bin/sigit" +} diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml new file mode 100644 index 0000000..63751ba --- /dev/null +++ b/packaging/nfpm.yaml @@ -0,0 +1,37 @@ +# nfpm builds the .deb and .rpm attached to each GitHub release. +# +# One config covers both formats. nfpm takes an already-built binary rather +# than invoking cargo, so the release matrix compiles each Linux target once +# and packages it twice. `arch` is a Go arch name (amd64/arm64) that nfpm +# translates per format, so the rpm comes out tagged x86_64 without a second +# config. +# +# PKG_ARCH and PKG_VERSION are set by release-github.yml; PKG_BINARY points at +# the renamed binary already staged in release/. +name: sigit +arch: ${PKG_ARCH} +platform: linux +version: ${PKG_VERSION} +section: devel +priority: optional +maintainer: smbCloud +vendor: PT Sigit Mitra Bangun +homepage: https://sigit.si +license: Apache-2.0 +description: | + siGit Code, an AI coding agent that runs on your machine. + Speaks the Agent Client Protocol for editor integration, and runs inference + either on-device or against a hosted endpoint. +contents: + - src: ${PKG_BINARY} + dst: /usr/bin/sigit + file_info: + mode: 0755 + - src: ./LICENSE + dst: /usr/share/doc/sigit/LICENSE + file_info: + mode: 0644 + - src: ./README.md + dst: /usr/share/doc/sigit/README.md + file_info: + mode: 0644