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