release #102
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: release | |
| # Streaming release: the GitHub release is created up front, then every job | |
| # uploads ITS artifact the moment it's built (gh release upload --clobber). | |
| # A slow leg (aarch64/i686 under QEMU, or the windows vcpkg build) never blocks | |
| # the release or the artifacts that are already done. | |
| # | |
| # Triggers: | |
| # • pushing a tag `vX.Y.Z` → builds that tag, publishes the release | |
| # • manual run (workflow_dispatch) → builds the chosen ref, publishes `tag` | |
| # | |
| # Artifacts: agentty-linux-{x86_64,aarch64,i686}, agentty-macos-{arm64,x86_64}, | |
| # agentty-windows-x86_64.exe, agentty-windows-x86_64.msi (signed installer), | |
| # agentty_<v>_{amd64,arm64}.deb, agentty-<v>-1.{x86_64,aarch64}.rpm, | |
| # agentty-bin-<v>-1-x86_64.pkg.tar.zst, agentty-<v>.tar.gz, SHA256SUMS. | |
| # | |
| # Downstream publishing (all gated on a real tag + a per-target secret; each | |
| # skips silently when its secret is absent): | |
| # winget → PR to microsoft/winget-pkgs (WINGET_TOKEN) | |
| # homebrew→ PR/commit to the 1ay1/homebrew-tap (TAP_TOKEN) | |
| # scoop → PR/commit to the 1ay1/scoop-bucket (SCOOP_TOKEN) | |
| # AUR → push agentty-bin to the AUR (AUR_SSH_KEY) | |
| # alpine → .apk built + version-pinned APKBUILD attached to the release | |
| # nix/snap/gentoo → version-pinned manifests attached to the release for | |
| # manual/overlay publishing (nixpkgs/snapcraft/overlay PRs) | |
| on: | |
| push: | |
| tags: | |
| - 'v*' | |
| workflow_dispatch: | |
| inputs: | |
| tag: | |
| description: 'Release tag (blank → v<CMake project version>)' | |
| required: false | |
| default: '' | |
| permissions: | |
| contents: write | |
| concurrency: | |
| group: release-${{ github.ref }} | |
| cancel-in-progress: false | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| jobs: | |
| # Create/refresh the release immediately + upload the source tarball. | |
| prepare: | |
| name: prepare release | |
| runs-on: ubuntu-latest | |
| outputs: | |
| version: ${{ steps.meta.outputs.version }} | |
| tag: ${{ steps.meta.outputs.tag }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Init submodules (HTTPS, race-tolerant) | |
| run: | | |
| git config --global url."https://github.com/".insteadOf "git@github.com:" | |
| for attempt in 1 2 3 4 5; do | |
| if git submodule update --init --recursive --force; then break; fi | |
| echo "submodule update failed (attempt $attempt) — full-fetching and retrying" | |
| git submodule sync --recursive | |
| git submodule foreach --recursive 'git fetch --prune origin || true' | |
| sleep $((attempt * 10)) | |
| done | |
| git submodule update --init --recursive --force | |
| - name: Resolve version + tag | |
| id: meta | |
| run: | | |
| V=$(grep -m1 'project(agentty' CMakeLists.txt | sed -E 's/.*VERSION ([0-9.]+).*/\1/') | |
| echo "version=$V" >> "$GITHUB_OUTPUT" | |
| if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then | |
| # Blank input → derive the tag from the CMake project version. | |
| TAG="${{ inputs.tag }}" | |
| [ -n "$TAG" ] || TAG="v$V" | |
| echo "tag=$TAG" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Ensure release exists + upload source tarball | |
| env: | |
| V: ${{ steps.meta.outputs.version }} | |
| TAG: ${{ steps.meta.outputs.tag }} | |
| run: | | |
| # Create the release if it doesn't exist. IMPORTANT: `gh release | |
| # view` succeeds on a DRAFT too — a draft left by a prior cancelled | |
| # run (or auto-created on tag push) would otherwise absorb every | |
| # upload while staying invisible (public download URLs 404, unauth | |
| # API omits it). So after ensuring it exists, ALWAYS force it | |
| # published + non-prerelease. This is the fix for "run succeeded but | |
| # assets 404 / release doesn't show up". | |
| gh release view "$TAG" >/dev/null 2>&1 \ | |
| || gh release create "$TAG" --title "$TAG" \ | |
| --notes "See [CHANGELOG.md](https://github.com/1ay1/agentty/blob/master/CHANGELOG.md)." | |
| gh release edit "$TAG" --draft=false --prerelease=false | |
| pipx run git-archive-all "agentty-${V}.tar.gz" | |
| gh release upload "$TAG" "agentty-${V}.tar.gz" --clobber | |
| # Linux static (musl) binaries — one job per arch, NOT a matrix. A matrix | |
| # job only reports complete when its slowest leg finishes (aarch64 under | |
| # QEMU, ~13 min), and `needs:` can't target a single matrix leg — so a | |
| # matrix would re-gate the fast packages behind the slow build, and a failed | |
| # aarch64 leg would block x86 packaging entirely. Separate jobs let the x86 | |
| # binary + packages publish in ~5 min while aarch64 streams in later, and | |
| # isolate each arch's failures from the others. | |
| build-linux-x86_64: | |
| name: linux x86_64 | |
| needs: prepare | |
| runs-on: ubuntu-latest | |
| env: | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Init submodules (HTTPS, race-tolerant) | |
| run: | | |
| git config --global url."https://github.com/".insteadOf "git@github.com:" | |
| for attempt in 1 2 3 4 5; do | |
| if git submodule update --init --recursive --force; then break; fi | |
| echo "submodule update failed (attempt $attempt) — full-fetching and retrying" | |
| git submodule sync --recursive | |
| git submodule foreach --recursive 'git fetch --prune origin || true' | |
| sleep $((attempt * 10)) | |
| done | |
| git submodule update --init --recursive --force | |
| - name: Build static binary (Alpine 3.21 / musl / GCC 14) | |
| run: | | |
| docker run --rm --platform linux/amd64 \ | |
| -e CMARCH='sse2' \ | |
| -v "$PWD":/src -w /src alpine:3.21 sh -c ' | |
| set -ex | |
| apk add --no-cache build-base cmake ninja git linux-headers pkgconfig \ | |
| openssl-dev openssl-libs-static nghttp2-dev nghttp2-static zlib-static | |
| git config --global --add safe.directory /src | |
| rm -rf build-rel | |
| cmake -S . -B build-rel -GNinja -DCMAKE_BUILD_TYPE=Release \ | |
| -DAGENTTY_STANDALONE=ON -DAGENTTY_FULLY_STATIC=ON \ | |
| -DAGENTTY_AUTO_PULL_MAYA=OFF -DAGENTTY_USE_MIMALLOC=OFF \ | |
| -DAGENTTY_ARCH="$CMARCH" | |
| cmake --build build-rel -j"$(nproc)" | |
| strip build-rel/agentty | |
| # ELF-shape guard: a correct static-PIE build is ET_DYN with no | |
| # PT_INTERP and no NEEDED. If the -static-pie link silently | |
| # downgraded (Debian #996326 spec bug) the binary keeps a | |
| # PT_INTERP / NEEDED libc and will not run on a glibc host — | |
| # exactly the v0.2.7 failure. Fail the build here rather than ship it. | |
| readelf -h build-rel/agentty | grep -E "Type:" | |
| if readelf -l build-rel/agentty | grep -q INTERP; then | |
| echo "FATAL: binary has PT_INTERP (dynamic link, not static-pie)"; exit 1; fi | |
| if readelf -d build-rel/agentty 2>/dev/null | grep -q NEEDED; then | |
| echo "FATAL: binary has NEEDED (libc not statically linked)"; exit 1; fi | |
| cp build-rel/agentty agentty-linux-x86_64 | |
| ' | |
| # Runtime smoke test: the ELF-shape checks above are necessary but not | |
| # sufficient — a musl static-PIE binary can pass every readelf check and | |
| # still crash at startup on a glibc host (TLS/relocation) or SIGILL on an | |
| # older CPU than the -march target. The ONLY proof it "runs everywhere" | |
| # is to run it on a foreign libc. Execute `--version` (no network, no | |
| # auth, exit 0) in a clean Debian (glibc) AND Alpine (musl) container; | |
| # fail the release if either can't launch it. | |
| - name: Smoke test (glibc + musl) | |
| run: | | |
| set -e | |
| expected="agentty ${{ needs.prepare.outputs.version }}" | |
| for img in debian:stable-slim alpine:3.21; do | |
| echo "== smoke test on $img ==" | |
| out=$(docker run --rm --platform linux/amd64 \ | |
| -v "$PWD/agentty-linux-x86_64":/agentty:ro "$img" /agentty --version) | |
| echo " got: $out" | |
| case "$out" in | |
| "$expected"*) echo " OK" ;; | |
| *) echo "FATAL: binary did not run on $img (got '$out', want '$expected*')"; exit 1 ;; | |
| esac | |
| done | |
| - name: Upload to release + stash for packaging | |
| run: gh release upload "$TAG" agentty-linux-x86_64 --clobber | |
| - uses: actions/upload-artifact@v4 | |
| with: | |
| name: bin-linux-x86_64 | |
| path: agentty-linux-x86_64 | |
| if-no-files-found: error | |
| build-linux-aarch64: | |
| name: linux aarch64 | |
| needs: prepare | |
| # Native ARM64 runner (GitHub-hosted, free for public repos) — NOT QEMU. | |
| # The previous `ubuntu-latest` + docker --platform linux/arm64 path ran | |
| # the whole C++26 compile under QEMU emulation (~10-20x slower, 1h+), | |
| # which stalled every downstream package that pins the arm64 binary. On | |
| # real ARM silicon this finishes in ~the same time as the x86_64 leg, so | |
| # aarch64 is a first-class fast arch again and nothing waits on it. | |
| runs-on: ubuntu-24.04-arm | |
| env: | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Init submodules (HTTPS, race-tolerant) | |
| run: | | |
| git config --global url."https://github.com/".insteadOf "git@github.com:" | |
| for attempt in 1 2 3 4 5; do | |
| if git submodule update --init --recursive --force; then break; fi | |
| echo "submodule update failed (attempt $attempt) — full-fetching and retrying" | |
| git submodule sync --recursive | |
| git submodule foreach --recursive 'git fetch --prune origin || true' | |
| sleep $((attempt * 10)) | |
| done | |
| git submodule update --init --recursive --force | |
| - name: Build static binary (Alpine 3.21 / musl / GCC 14) | |
| # Host is already arm64, so a plain alpine:3.21 container builds | |
| # natively — no --platform, no QEMU. | |
| run: | | |
| docker run --rm \ | |
| -e CMARCH='native' \ | |
| -v "$PWD":/src -w /src alpine:3.21 sh -c ' | |
| set -ex | |
| apk add --no-cache build-base cmake ninja git linux-headers pkgconfig \ | |
| openssl-dev openssl-libs-static nghttp2-dev nghttp2-static zlib-static | |
| git config --global --add safe.directory /src | |
| rm -rf build-rel | |
| cmake -S . -B build-rel -GNinja -DCMAKE_BUILD_TYPE=Release \ | |
| -DAGENTTY_STANDALONE=ON -DAGENTTY_FULLY_STATIC=ON \ | |
| -DAGENTTY_AUTO_PULL_MAYA=OFF -DAGENTTY_USE_MIMALLOC=OFF \ | |
| -DAGENTTY_ARCH="$CMARCH" | |
| cmake --build build-rel -j"$(nproc)" | |
| strip build-rel/agentty | |
| # Guard against the -static-pie spec bug (Debian #996326): a | |
| # broken link yields an ET_DYN that still has a PT_INTERP / | |
| # NEEDED libc and no self-relocating PHDR, which aborts on | |
| # Android/Bionic with "Could not find a PHDR". Write a diagnosis | |
| # file (uploaded even on failure) and fail the job here instead | |
| # of shipping an unrunnable aarch64 asset. | |
| { | |
| echo "### readelf -h"; readelf -h build-rel/agentty | |
| echo "### program headers (INTERP/PHDR)"; readelf -l build-rel/agentty | grep -E "Type|PHDR|INTERP|LOAD|DYNAMIC" | |
| echo "### dynamic (NEEDED)"; readelf -d build-rel/agentty 2>/dev/null | grep -E "NEEDED|FLAGS" || echo "(no dynamic section)" | |
| } > aarch64.diag.txt 2>&1 || true | |
| cat aarch64.diag.txt | |
| cp build-rel/agentty agentty-linux-aarch64 | |
| # The CMake POST_BUILD guard (cmake/assert_static_pie.cmake) is | |
| # the authoritative shape check and already ran during the build | |
| # above. These inline checks are a redundant backstop. The | |
| # binary is -static -no-pie (ET_EXEC) by default, so we do NOT | |
| # gate on ELF type at all (ET_EXEC and ET_DYN are both fine); the | |
| # only things that mean it will not run off the build image are a | |
| # PT_INTERP or a NEEDED entry (v0.2.7 shape). | |
| if readelf -l build-rel/agentty | grep -q INTERP; then echo "FATAL: has PT_INTERP (needs an external loader)"; exit 1; fi | |
| if readelf -d build-rel/agentty 2>/dev/null | grep -q NEEDED; then echo "FATAL: has NEEDED (libc not static)"; exit 1; fi | |
| ' | |
| # Runtime smoke test on real ARM silicon (this runner is arm64, so no | |
| # QEMU) — prove the binary launches on both a glibc and a musl distro. | |
| - name: Smoke test (glibc + musl) | |
| run: | | |
| set -e | |
| expected="agentty ${{ needs.prepare.outputs.version }}" | |
| for img in debian:stable-slim alpine:3.21; do | |
| echo "== smoke test on $img ==" | |
| out=$(docker run --rm \ | |
| -v "$PWD/agentty-linux-aarch64":/agentty:ro "$img" /agentty --version) | |
| echo " got: $out" | |
| case "$out" in | |
| "$expected"*) echo " OK" ;; | |
| *) echo "FATAL: binary did not run on $img (got '$out', want '$expected*')"; exit 1 ;; | |
| esac | |
| done | |
| - name: Upload to release + stash for packaging | |
| run: gh release upload "$TAG" agentty-linux-aarch64 aarch64.diag.txt --clobber | |
| - uses: actions/upload-artifact@v4 | |
| with: | |
| name: bin-linux-aarch64 | |
| path: agentty-linux-aarch64 | |
| if-no-files-found: error | |
| build-linux-i686: | |
| name: linux i686 | |
| needs: prepare | |
| runs-on: ubuntu-latest | |
| env: | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Init submodules (HTTPS, race-tolerant) | |
| run: | | |
| git config --global url."https://github.com/".insteadOf "git@github.com:" | |
| for attempt in 1 2 3 4 5; do | |
| if git submodule update --init --recursive --force; then break; fi | |
| echo "submodule update failed (attempt $attempt) — full-fetching and retrying" | |
| git submodule sync --recursive | |
| git submodule foreach --recursive 'git fetch --prune origin || true' | |
| sleep $((attempt * 10)) | |
| done | |
| git submodule update --init --recursive --force | |
| - name: Set up QEMU | |
| uses: docker/setup-qemu-action@v4 | |
| - name: Build static binary (Alpine 3.21 / musl / GCC 14) | |
| run: | | |
| docker run --rm --platform linux/386 \ | |
| -e CMARCH='sse2' \ | |
| -v "$PWD":/src -w /src alpine:3.21 sh -c ' | |
| set -ex | |
| apk add --no-cache build-base cmake ninja git linux-headers pkgconfig \ | |
| openssl-dev openssl-libs-static nghttp2-dev nghttp2-static zlib-static | |
| git config --global --add safe.directory /src | |
| rm -rf build-rel | |
| cmake -S . -B build-rel -GNinja -DCMAKE_BUILD_TYPE=Release \ | |
| -DAGENTTY_STANDALONE=ON -DAGENTTY_FULLY_STATIC=ON \ | |
| -DAGENTTY_AUTO_PULL_MAYA=OFF -DAGENTTY_USE_MIMALLOC=OFF \ | |
| -DAGENTTY_ARCH="$CMARCH" | |
| cmake --build build-rel -j"$(nproc)" | |
| strip build-rel/agentty | |
| if readelf -l build-rel/agentty | grep -q INTERP; then | |
| echo "FATAL: binary has PT_INTERP (dynamic link, not static-pie)"; exit 1; fi | |
| if readelf -d build-rel/agentty 2>/dev/null | grep -q NEEDED; then | |
| echo "FATAL: binary has NEEDED (libc not statically linked)"; exit 1; fi | |
| cp build-rel/agentty agentty-linux-i686 | |
| ' | |
| # Smoke test through QEMU (already set up above). A 32-bit static-PIE | |
| # binary must launch on both a glibc (i386 Debian) and musl (i386 | |
| # Alpine) userland. | |
| - name: Smoke test (glibc + musl) | |
| run: | | |
| set -e | |
| expected="agentty ${{ needs.prepare.outputs.version }}" | |
| for img in i386/debian:stable-slim i386/alpine:3.21; do | |
| echo "== smoke test on $img ==" | |
| out=$(docker run --rm --platform linux/386 \ | |
| -v "$PWD/agentty-linux-i686":/agentty:ro "$img" /agentty --version) | |
| echo " got: $out" | |
| case "$out" in | |
| "$expected"*) echo " OK" ;; | |
| *) echo "FATAL: binary did not run on $img (got '$out', want '$expected*')"; exit 1 ;; | |
| esac | |
| done | |
| - name: Upload to release | |
| run: gh release upload "$TAG" agentty-linux-i686 --clobber | |
| build-windows: | |
| name: windows x86_64 | |
| needs: prepare | |
| runs-on: windows-latest | |
| env: | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Init submodules (HTTPS, race-tolerant) | |
| shell: bash | |
| run: | | |
| git config --global url."https://github.com/".insteadOf "git@github.com:" | |
| for attempt in 1 2 3 4 5; do | |
| if git submodule update --init --recursive --force; then break; fi | |
| echo "submodule update failed (attempt $attempt) — full-fetching and retrying" | |
| git submodule sync --recursive | |
| git submodule foreach --recursive 'git fetch --prune origin || true' | |
| sleep $((attempt * 10)) | |
| done | |
| git submodule update --init --recursive --force | |
| - name: Install deps (vcpkg static) | |
| shell: pwsh | |
| run: | | |
| git -C "$env:VCPKG_INSTALLATION_ROOT" fetch --depth 1 origin master | |
| git -C "$env:VCPKG_INSTALLATION_ROOT" reset --hard FETCH_HEAD | |
| & "$env:VCPKG_INSTALLATION_ROOT\bootstrap-vcpkg.bat" | |
| & "$env:VCPKG_INSTALLATION_ROOT\vcpkg.exe" install openssl nghttp2 --triplet x64-windows-static | |
| - name: Configure + build | |
| shell: pwsh | |
| run: | | |
| cmake -B build -DCMAKE_BUILD_TYPE=Release ` | |
| -DAGENTTY_STANDALONE=ON -DAGENTTY_AUTO_PULL_MAYA=OFF ` | |
| -DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_INSTALLATION_ROOT/scripts/buildsystems/vcpkg.cmake" ` | |
| -DVCPKG_TARGET_TRIPLET=x64-windows-static | |
| cmake --build build --config Release -j | |
| - name: Stage binary | |
| shell: pwsh | |
| run: | | |
| $exe = Get-ChildItem -Recurse build -Filter agentty.exe | Select-Object -First 1 | |
| Copy-Item $exe.FullName agentty-windows-x86_64.exe | |
| - name: Install WiX toolset + UI extension | |
| shell: pwsh | |
| # build-msi.ps1 invokes `wix build ... -ext WixToolset.UI.wixext`, | |
| # which needs both the `wix` tool AND the UI extension registered. | |
| # | |
| # Pin to WiX **v5**: `dotnet tool install wix` (unpinned) pulls v7, | |
| # which is gated behind the Open Source Maintenance Fee EULA and | |
| # fails every invocation with "WIX7015: You must accept the OSMF | |
| # EULA". v5 is the last freely-usable major and reads the v4/wxs | |
| # schema in agentty.wxs without changes. Uninstall any preexisting | |
| # wix first so the version is deterministic regardless of what the | |
| # runner image preinstalled. | |
| run: | | |
| dotnet tool uninstall --global wix 2>$null | |
| dotnet tool install --global wix --version 5.* | |
| $env:PATH = "$env:USERPROFILE\.dotnet\tools;$env:PATH" | |
| "$env:USERPROFILE\.dotnet\tools" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 | |
| $wixVer = (wix --version).Trim().Split('+')[0] | |
| Write-Host "wix version: $wixVer" | |
| # Register the UI extension at the tool's exact version so the two | |
| # never skew. `wix extension add` is idempotent. | |
| wix extension add -g "WixToolset.UI.wixext/$wixVer" | |
| wix extension list -g | |
| - name: Build (and sign) MSI installer | |
| shell: pwsh | |
| env: | |
| VERSION: ${{ needs.prepare.outputs.version }} | |
| # Code signing is OPTIONAL. Two ways, pick whichever you have: | |
| # (a) any-CA cert: base64 your .pfx into WINDOWS_CERT_BASE64 (+ password) | |
| # (b) Azure Trusted Signing: set the TRUSTED_SIGNING_* secrets | |
| # No secrets → a valid UNSIGNED installer is still produced. | |
| WINDOWS_CERT_BASE64: ${{ secrets.WINDOWS_CERT_BASE64 }} | |
| WINDOWS_CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }} | |
| AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} | |
| AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} | |
| AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} | |
| TRUSTED_SIGNING_ENDPOINT: ${{ secrets.TRUSTED_SIGNING_ENDPOINT }} | |
| TRUSTED_SIGNING_ACCOUNT: ${{ secrets.TRUSTED_SIGNING_ACCOUNT }} | |
| TRUSTED_SIGNING_PROFILE: ${{ secrets.TRUSTED_SIGNING_PROFILE }} | |
| run: | | |
| # Splat a hashtable so the -Sign switch is present only when signing | |
| # inputs exist. An empty positional splat (@()) would bind '' as a | |
| # positional arg and fail ("positional parameter ... argument ''"). | |
| $params = @{ | |
| Version = "$env:VERSION" | |
| Exe = 'agentty-windows-x86_64.exe' | |
| Arch = 'x64' | |
| } | |
| if ($env:WINDOWS_CERT_BASE64 -or $env:TRUSTED_SIGNING_ENDPOINT) { $params.Sign = $true } | |
| ./packaging/windows/build-msi.ps1 @params | |
| - name: Upload to release | |
| shell: bash | |
| run: gh release upload "$TAG" agentty-windows-x86_64.exe agentty-windows-x86_64.msi --clobber | |
| build-macos: | |
| name: macos ${{ matrix.arch }} | |
| needs: prepare | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - arch: arm64 | |
| runner: macos-15 # Apple Silicon | |
| - arch: x86_64 | |
| runner: macos-15-intel # Intel (macos-13 retired) | |
| runs-on: ${{ matrix.runner }} | |
| env: | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Init submodules (HTTPS, race-tolerant) | |
| run: | | |
| git config --global url."https://github.com/".insteadOf "git@github.com:" | |
| for attempt in 1 2 3 4 5; do | |
| if git submodule update --init --recursive --force; then break; fi | |
| echo "submodule update failed (attempt $attempt) — full-fetching and retrying" | |
| git submodule sync --recursive | |
| git submodule foreach --recursive 'git fetch --prune origin || true' | |
| sleep $((attempt * 10)) | |
| done | |
| git submodule update --init --recursive --force | |
| - name: Install deps (Homebrew) | |
| # GCC, NOT AppleClang: agentty + maya require C++26 (std::expected, | |
| # std::format) requested via CMake's cxx_std_26 compile feature, which | |
| # AppleClang doesn't advertise (configure fails in maya). openssl@3 | |
| # ships a static .a; nghttp2 ships only the dylib, so it's built from | |
| # source below. | |
| run: brew install gcc openssl@3 ninja | |
| - name: Build static nghttp2 (Homebrew ships only the dylib) | |
| run: | | |
| ver=1.64.0 | |
| curl -fsSL "https://github.com/nghttp2/nghttp2/releases/download/v${ver}/nghttp2-${ver}.tar.gz" | tar xz | |
| cd "nghttp2-${ver}" | |
| ./configure --prefix="$PWD/../nghttp2-static" \ | |
| --enable-lib-only --enable-static --disable-shared CFLAGS="-O2" | |
| make -j"$(sysctl -n hw.ncpu)" | |
| make install | |
| - name: Configure + build (standalone) | |
| run: | | |
| gcc_pfx="$(brew --prefix gcc)" | |
| # Resolve the versioned compilers (gcc-15, g++-15 …) without pinning | |
| # a version Homebrew may bump out from under us. | |
| GCC="$(ls "$gcc_pfx"/bin/gcc-* | grep -E 'gcc-[0-9]+$' | sort -V | tail -1)" | |
| GXX="$(ls "$gcc_pfx"/bin/g++-* | grep -E 'g\+\+-[0-9]+$' | sort -V | tail -1)" | |
| # Fold GCC's runtime into the binary so it runs without Homebrew GCC | |
| # present: -static-libstdc++/-static-libgcc cover libstdc++/libgcc, | |
| # but libquadmath has no -static flag — link its archive by full path | |
| # (macOS ld picks the dylib otherwise). Result: only the system | |
| # frameworks + libSystem remain dynamic. | |
| QUADMATH="$gcc_pfx/lib/gcc/current/libquadmath.a" | |
| PKG_CONFIG_PATH="$PWD/nghttp2-static/lib/pkgconfig" \ | |
| CC="$GCC" CXX="$GXX" cmake -B build -GNinja -DCMAKE_BUILD_TYPE=Release \ | |
| -DAGENTTY_STANDALONE=ON -DAGENTTY_AUTO_PULL_MAYA=OFF \ | |
| -DAGENTTY_USE_MIMALLOC=OFF \ | |
| -DOPENSSL_ROOT_DIR="$(brew --prefix openssl@3)" \ | |
| -DCMAKE_EXE_LINKER_FLAGS="-static-libstdc++ -static-libgcc $QUADMATH" | |
| cmake --build build -j"$(sysctl -n hw.ncpu)" | |
| strip build/agentty | |
| cp build/agentty agentty-macos-${{ matrix.arch }} | |
| - name: Verify portability (no Homebrew dylibs) | |
| run: | | |
| otool -L agentty-macos-${{ matrix.arch }} | |
| if otool -L agentty-macos-${{ matrix.arch }} | grep -qE '/opt/homebrew|/usr/local'; then | |
| echo "::error::binary links Homebrew dylibs — not portable"; exit 1 | |
| fi | |
| - name: Upload to release | |
| run: gh release upload "$TAG" agentty-macos-${{ matrix.arch }} --clobber | |
| # x86_64 packages — deb (amd64) / rpm (x86_64) / Arch (x86_64). Needs only | |
| # the x86 build, so it publishes ~5 min in without waiting on aarch64. | |
| package-linux-x86_64: | |
| name: packages x86_64 | |
| needs: [prepare, build-linux-x86_64] | |
| runs-on: ubuntu-latest | |
| env: | |
| V: ${{ needs.prepare.outputs.version }} | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - uses: actions/download-artifact@v4 | |
| with: | |
| name: bin-linux-x86_64 | |
| path: bins | |
| - name: Tooling | |
| run: sudo apt-get update && sudo apt-get install -y --no-install-recommends rpm libarchive-tools zstd | |
| - name: Build + upload .deb / .rpm / .pkg.tar.zst | |
| run: | | |
| mkdir -p dist | |
| chmod +x bins/agentty-linux-* | |
| # deb/build.sh cd's into a tmpdir, so the output dir must be absolute. | |
| bash packaging/deb/build.sh "$V" amd64 bins/agentty-linux-x86_64 "$PWD/dist" | |
| bash packaging/rpm/build.sh "$V" x86_64 bins/agentty-linux-x86_64 "$PWD/dist" | |
| # Arch .pkg.tar.zst built by hand (bsdtar + zstd) — same philosophy as | |
| # the deb builder. Avoids makepkg's fakeroot, which is unreliable in | |
| # containers ("libfakeroot: payload not recognized"). | |
| aroot="$(mktemp -d)" | |
| install -Dm755 bins/agentty-linux-x86_64 "$aroot/usr/bin/agentty" | |
| asize=$(du -sb "$aroot" | cut -f1) | |
| { | |
| echo "pkgname = agentty-bin" | |
| echo "pkgbase = agentty-bin" | |
| echo "pkgver = $V-1" | |
| echo "pkgdesc = Blazing-fast Claude in your terminal (C++26, static binary)" | |
| echo "url = https://github.com/1ay1/agentty" | |
| echo "builddate = $(date +%s)" | |
| echo "packager = agentty CI <noreply@github.com>" | |
| echo "size = $asize" | |
| echo "arch = x86_64" | |
| echo "license = MIT" | |
| echo "provides = agentty" | |
| echo "conflict = agentty" | |
| } > "$aroot/.PKGINFO" | |
| bsdtar -C "$aroot" -czf "$aroot/.MTREE" --format=mtree \ | |
| --options='!all,use-set,type,uid,gid,mode,time,size,md5,sha256,link' \ | |
| .PKGINFO usr | |
| bsdtar -C "$aroot" -cf - .PKGINFO .MTREE usr \ | |
| | zstd -19 -T0 > "dist/agentty-bin-${V}-1-x86_64.pkg.tar.zst" | |
| gh release upload "$TAG" dist/*.deb dist/*.rpm dist/*.pkg.tar.zst --clobber | |
| # aarch64 packages — deb (arm64) / rpm (aarch64). Needs only the aarch64 | |
| # build, so it streams in when that QEMU build finishes (no Arch pkg: the | |
| # AUR agentty-bin is x86_64-only). | |
| package-linux-aarch64: | |
| name: packages aarch64 | |
| needs: [prepare, build-linux-aarch64] | |
| runs-on: ubuntu-latest | |
| env: | |
| V: ${{ needs.prepare.outputs.version }} | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - uses: actions/download-artifact@v4 | |
| with: | |
| name: bin-linux-aarch64 | |
| path: bins | |
| - name: Tooling | |
| run: sudo apt-get update && sudo apt-get install -y --no-install-recommends rpm | |
| - name: Build + upload .deb / .rpm | |
| run: | | |
| mkdir -p dist | |
| chmod +x bins/agentty-linux-* | |
| # deb/build.sh cd's into a tmpdir, so the output dir must be absolute. | |
| bash packaging/deb/build.sh "$V" arm64 bins/agentty-linux-aarch64 "$PWD/dist" | |
| bash packaging/rpm/build.sh "$V" aarch64 bins/agentty-linux-aarch64 "$PWD/dist" | |
| gh release upload "$TAG" dist/*.deb dist/*.rpm --clobber | |
| # SHA256SUMS over whatever is on the release. Runs TWICE by design: | |
| # * early — gated only on the Linux legs (all fast now: x86_64/aarch64 on | |
| # native runners, i686 via QEMU), so the checksum file covers every | |
| # Linux binary within ~8 min without waiting on the slower macos/windows | |
| # legs. This is what `install.sh` verifies against, so the primary | |
| # install target must be checksummed ASAP. | |
| # * late — a second job (checksums-final) gated on ALL builds refreshes | |
| # it once macos/windows finish, so the final file covers every asset. | |
| # Both use `always()` + hash `*`, so a slow or failed leg never blocks the | |
| # file for the assets that DID land. This matters because a consumer (e.g. | |
| # an AUR PKGBUILD verifying against SHA256SUMS) 404s if the file is missing | |
| # while the slow build drags on. | |
| checksums: | |
| name: checksums (early) | |
| needs: | |
| - prepare | |
| - build-linux-x86_64 | |
| - build-linux-aarch64 | |
| - build-linux-i686 | |
| - package-linux-x86_64 | |
| if: always() | |
| runs-on: ubuntu-latest | |
| env: | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| steps: | |
| - uses: actions/checkout@v5 # gh needs a git repo context for release ops | |
| - name: Download release assets, hash, re-upload | |
| run: | | |
| mkdir -p dist && cd dist | |
| gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern '*' --skip-existing || true | |
| # Never hash the checksum file itself into itself. | |
| rm -f SHA256SUMS | |
| sha256sum -- * > SHA256SUMS | |
| # --clobber alone races with checksums-final on the same asset name | |
| # (both delete+create SHA256SUMS): the loser hits | |
| # "HTTP 422 ReleaseAsset.name already exists". Delete first, then retry. | |
| for attempt in 1 2 3; do | |
| gh release delete-asset "$TAG" SHA256SUMS --yes 2>/dev/null || true | |
| if gh release upload "$TAG" SHA256SUMS --clobber; then | |
| exit 0 | |
| fi | |
| echo "upload attempt $attempt failed (asset race); retrying in 5s" >&2 | |
| sleep 5 | |
| done | |
| echo "::error::failed to upload SHA256SUMS after 3 attempts"; exit 1 | |
| # Late refresh once the slow QEMU arches land, so SHA256SUMS covers them too. | |
| checksums-final: | |
| name: checksums (final) | |
| needs: | |
| - prepare | |
| - checksums | |
| - build-linux-x86_64 | |
| - build-linux-aarch64 | |
| - build-linux-i686 | |
| - build-macos | |
| - build-windows | |
| - package-linux-x86_64 | |
| - package-linux-aarch64 | |
| if: always() | |
| runs-on: ubuntu-latest | |
| env: | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Download release assets, hash, re-upload | |
| run: | | |
| mkdir -p dist && cd dist | |
| gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern '*' --skip-existing || true | |
| # Never hash the checksum file itself into itself. | |
| rm -f SHA256SUMS | |
| sha256sum -- * > SHA256SUMS | |
| # Delete-then-upload with retry, so a transient asset-name collision | |
| # ("HTTP 422 ReleaseAsset.name already exists") self-heals. | |
| for attempt in 1 2 3; do | |
| gh release delete-asset "$TAG" SHA256SUMS --yes 2>/dev/null || true | |
| if gh release upload "$TAG" SHA256SUMS --clobber; then | |
| exit 0 | |
| fi | |
| echo "upload attempt $attempt failed (asset race); retrying in 5s" >&2 | |
| sleep 5 | |
| done | |
| echo "::error::failed to upload SHA256SUMS after 3 attempts"; exit 1 | |
| # Alpine .apk — built inside an Alpine container with abuild, attached to the | |
| # release so users can `apk add ./agentty-*.apk` before the aports APKBUILD is | |
| # merged upstream. x86_64 only in CI (aarch64 would need QEMU + abuild). | |
| package-alpine: | |
| name: package alpine (.apk) | |
| needs: [prepare, build-linux-x86_64] | |
| runs-on: ubuntu-latest | |
| env: | |
| V: ${{ needs.prepare.outputs.version }} | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - uses: actions/download-artifact@v4 | |
| with: | |
| name: bin-linux-x86_64 | |
| path: bins | |
| - name: Build .apk (Alpine 3.21 / abuild) | |
| run: | | |
| chmod +x bins/agentty-linux-x86_64 | |
| docker run --rm -v "$PWD":/src -w /src -e V="$V" alpine:3.21 sh -c ' | |
| set -ex | |
| apk add --no-cache alpine-sdk abuild | |
| adduser -D builder && addgroup builder abuild | |
| su builder -c "abuild-keygen -a -n" | |
| work=/home/builder/agentty; mkdir -p "$work" | |
| # Central version: @VERSION@ -> $V. Point source at the local binary | |
| # and drop the aarch64 line so a single-arch x86_64 build succeeds. | |
| sed -e "s/^pkgver=@VERSION@/pkgver=$V/" \ | |
| -e "s|^\tagentty-linux-x86_64::.*|\tagentty-linux-x86_64::agentty-linux-x86_64|" \ | |
| -e "/agentty-linux-aarch64::/d" \ | |
| packaging/alpine/APKBUILD > "$work/APKBUILD" | |
| cp bins/agentty-linux-x86_64 "$work/agentty-linux-x86_64" | |
| chown -R builder:builder "$work" | |
| su builder -c "cd $work && abuild checksum && abuild -r -P /home/builder/pkgs" || true | |
| find /home/builder/pkgs -name "agentty-*.apk" -exec cp {} /src/ \; || true | |
| ' | |
| ls -la agentty-*.apk 2>/dev/null || echo "no .apk produced (non-fatal)" | |
| - name: Upload .apk to release | |
| run: | | |
| if ls agentty-*.apk >/dev/null 2>&1; then | |
| gh release upload "$TAG" agentty-*.apk --clobber | |
| fi | |
| # Version-pinned downstream manifests (nix/snap/gentoo/alpine) rendered from | |
| # the release binaries and attached to the release. Same sed pinning as | |
| # scripts/release.sh — CMake project VERSION stays the single source of truth. | |
| # Pins from SHA256SUMS + verifies the bytes match before attaching. | |
| publish-manifests: | |
| name: publish manifests (nix/snap/gentoo) | |
| needs: [prepare, checksums-final, build-linux-x86_64, build-linux-aarch64] | |
| if: always() && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') | |
| runs-on: ubuntu-latest | |
| env: | |
| V: ${{ needs.prepare.outputs.version }} | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Pin + attach manifests | |
| run: | | |
| set -eu | |
| mkdir -p out | |
| # Authoritative hashes from SHA256SUMS; verify bytes still match so a | |
| # re-run never renders a manifest that drifted from the release. | |
| gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ | |
| --pattern 'agentty-linux-x86_64' --pattern 'agentty-linux-aarch64' \ | |
| --pattern 'SHA256SUMS' --skip-existing | |
| sum_for() { | |
| want=$(awk -v f="$1" '$2==f {print $1}' SHA256SUMS) | |
| [ -n "$want" ] || { echo "::error::$1 missing from SHA256SUMS"; exit 1; } | |
| got=$(sha256sum "$1" | awk '{print $1}') | |
| [ "$want" = "$got" ] || { echo "::error::$1 hash drift: SHA256SUMS=$want bytes=$got"; exit 1; } | |
| printf '%s' "$want" | |
| } | |
| x64=$(sum_for agentty-linux-x86_64) | |
| arm=$(sum_for agentty-linux-aarch64) | |
| sed -e "s/@VERSION@/$V/g" \ | |
| -e "s|@LINUX_X86_64_SHA256@|$x64|g" \ | |
| -e "s|@LINUX_AARCH64_SHA256@|$arm|g" \ | |
| packaging/nix/default.nix > out/default.nix | |
| sed -e "s/@VERSION@/$V/g" packaging/snap/snapcraft.yaml.in > out/snapcraft.yaml | |
| cp packaging/gentoo/agentty-9999.ebuild "out/agentty-$V.ebuild" | |
| gh release upload "$TAG" out/default.nix out/snapcraft.yaml out/agentty-*.ebuild --clobber | |
| # Homebrew tap: bump version + pin all four (linux/macos × arch) sha256, then | |
| # push the formula to 1ay1/homebrew-tap. Needs TAP_TOKEN (PAT, repo scope). | |
| # Pins from the release's SHA256SUMS (single source of truth) + verifies the | |
| # downloaded bytes match, so a re-run can never publish a stale hash. | |
| publish-homebrew: | |
| name: publish to homebrew tap | |
| needs: [prepare, checksums-final, build-linux-x86_64, build-linux-aarch64, build-macos] | |
| if: always() && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') | |
| runs-on: ubuntu-latest | |
| env: | |
| V: ${{ needs.prepare.outputs.version }} | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| TAP_TOKEN: ${{ secrets.TAP_TOKEN }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Render + push formula | |
| if: ${{ env.TAP_TOKEN != '' }} | |
| run: | | |
| set -eu | |
| gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ | |
| --pattern 'agentty-linux-x86_64' --pattern 'agentty-linux-aarch64' \ | |
| --pattern 'agentty-macos-x86_64' --pattern 'agentty-macos-arm64' \ | |
| --pattern 'SHA256SUMS' --skip-existing | |
| # SHA256SUMS on the release is the single source of truth. Read the | |
| # hash from it, and verify the downloaded bytes still match — so a | |
| # re-run can never pin a hash that has drifted from the release. | |
| sum_for() { | |
| want=$(awk -v f="$1" '$2==f {print $1}' SHA256SUMS) | |
| [ -n "$want" ] || { echo "::error::$1 missing from SHA256SUMS"; exit 1; } | |
| got=$(sha256sum "$1" | awk '{print $1}') | |
| [ "$want" = "$got" ] || { echo "::error::$1 hash drift: SHA256SUMS=$want bytes=$got — release was re-clobbered; refusing to pin a stale hash"; exit 1; } | |
| printf '%s' "$want" | |
| } | |
| sed -e "s/^ version \".*\"/ version \"$V\"/" \ | |
| -e "s|@LINUX_X86_64_SHA256@|$(sum_for agentty-linux-x86_64)|g" \ | |
| -e "s|@LINUX_AARCH64_SHA256@|$(sum_for agentty-linux-aarch64)|g" \ | |
| -e "s|@MACOS_X86_64_SHA256@|$(sum_for agentty-macos-x86_64)|g" \ | |
| -e "s|@MACOS_ARM64_SHA256@|$(sum_for agentty-macos-arm64)|g" \ | |
| packaging/homebrew/agentty.rb > agentty.rb | |
| git clone "https://x-access-token:${TAP_TOKEN}@github.com/1ay1/homebrew-tap.git" tap | |
| mkdir -p tap/Formula && cp agentty.rb tap/Formula/agentty.rb | |
| cd tap | |
| git config user.name agentty-ci; git config user.email noreply@github.com | |
| git add Formula/agentty.rb | |
| git commit -m "agentty $V" || { echo "no change"; exit 0; } | |
| git push origin HEAD:master | |
| # Scoop bucket: bump version + pin win sha256, push to 1ay1/scoop-bucket. | |
| # Needs SCOOP_TOKEN (PAT, repo scope). Skipped when absent. Depends only on | |
| # the windows build — fires without waiting on any linux/macos leg. | |
| publish-scoop: | |
| name: publish to scoop bucket | |
| needs: [prepare, checksums-final, build-windows] | |
| if: always() && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') | |
| runs-on: ubuntu-latest | |
| env: | |
| V: ${{ needs.prepare.outputs.version }} | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| SCOOP_TOKEN: ${{ secrets.SCOOP_TOKEN }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Render + push manifest | |
| if: ${{ env.SCOOP_TOKEN != '' }} | |
| run: | | |
| set -eu | |
| gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ | |
| --pattern 'agentty-windows-x86_64.exe' --pattern 'SHA256SUMS' --skip-existing | |
| # Authoritative hash from SHA256SUMS; verify bytes still match so a | |
| # re-run never pins a hash that drifted from the release. | |
| win=$(awk '$2=="agentty-windows-x86_64.exe" {print $1}' SHA256SUMS) | |
| [ -n "$win" ] || { echo "::error::windows .exe missing from SHA256SUMS"; exit 1; } | |
| got=$(sha256sum agentty-windows-x86_64.exe | awk '{print $1}') | |
| [ "$win" = "$got" ] || { echo "::error::windows .exe hash drift: SHA256SUMS=$win bytes=$got"; exit 1; } | |
| sed -e "s/\"version\": \".*\"/\"version\": \"$V\"/" \ | |
| -e "s|/download/v[0-9.]*/|/download/v$V/|g" \ | |
| -e "s|@WINDOWS_X86_64_SHA256@|$win|g" \ | |
| packaging/scoop/agentty.json > agentty.json | |
| git clone "https://x-access-token:${SCOOP_TOKEN}@github.com/1ay1/scoop-bucket.git" bucket | |
| mkdir -p bucket/bucket && cp agentty.json bucket/bucket/agentty.json | |
| cd bucket | |
| git config user.name agentty-ci; git config user.email noreply@github.com | |
| git add bucket/agentty.json | |
| git commit -m "agentty $V" || { echo "no change"; exit 0; } | |
| git push origin HEAD:master | |
| # AUR: push the version-pinned PKGBUILD (agentty-bin). Needs AUR_SSH_KEY, an | |
| # SSH private key registered with the AUR account. Skipped when absent. | |
| # Pins from SHA256SUMS + verifies the bytes match before pushing. | |
| publish-aur: | |
| name: publish to AUR | |
| needs: [prepare, checksums-final, build-linux-x86_64, build-linux-aarch64] | |
| if: always() && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') | |
| runs-on: ubuntu-latest | |
| env: | |
| V: ${{ needs.prepare.outputs.version }} | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Render + push PKGBUILD | |
| if: ${{ env.AUR_SSH_KEY != '' }} | |
| run: | | |
| set -eu | |
| gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ | |
| --pattern 'agentty-linux-x86_64' --pattern 'agentty-linux-aarch64' \ | |
| --pattern 'SHA256SUMS' --skip-existing | |
| # Authoritative hashes from SHA256SUMS; verify bytes still match. | |
| sum_for() { | |
| want=$(awk -v f="$1" '$2==f {print $1}' SHA256SUMS) | |
| [ -n "$want" ] || { echo "::error::$1 missing from SHA256SUMS"; exit 1; } | |
| got=$(sha256sum "$1" | awk '{print $1}') | |
| [ "$want" = "$got" ] || { echo "::error::$1 hash drift: SHA256SUMS=$want bytes=$got"; exit 1; } | |
| printf '%s' "$want" | |
| } | |
| x64=$(sum_for agentty-linux-x86_64) | |
| arm=$(sum_for agentty-linux-aarch64) | |
| sed -e "s/^pkgver=.*/pkgver=$V/" \ | |
| -e "s/sha256sums_x86_64=.*/sha256sums_x86_64=('$x64')/" \ | |
| -e "s/sha256sums_aarch64=.*/sha256sums_aarch64=('$arm')/" \ | |
| packaging/arch/PKGBUILD > PKGBUILD | |
| mkdir -p ~/.ssh | |
| printf '%s\n' "$AUR_SSH_KEY" > ~/.ssh/aur; chmod 600 ~/.ssh/aur | |
| ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts 2>/dev/null | |
| export GIT_SSH_COMMAND="ssh -i ~/.ssh/aur" | |
| git clone ssh://aur@aur.archlinux.org/agentty-bin.git aur | |
| cp PKGBUILD aur/PKGBUILD | |
| cd aur | |
| # .SRCINFO must mirror the PKGBUILD; generate it via makepkg if the | |
| # runner has it, else hand-write the minimal fields the AUR requires. | |
| if command -v makepkg >/dev/null 2>&1; then | |
| makepkg --printsrcinfo > .SRCINFO | |
| else | |
| printf 'pkgbase = agentty-bin\n\tpkgdesc = Blazing-fast Claude in your terminal\n\tpkgver = %s\n\tpkgrel = 1\n\turl = https://github.com/1ay1/agentty\n\tarch = x86_64\n\tarch = aarch64\n\tlicense = MIT\n\tprovides = agentty\n\tconflicts = agentty\n\tsource_x86_64 = agentty-%s-x86_64::https://github.com/1ay1/agentty/releases/download/v%s/agentty-linux-x86_64\n\tsha256sums_x86_64 = %s\n\tsource_aarch64 = agentty-%s-aarch64::https://github.com/1ay1/agentty/releases/download/v%s/agentty-linux-aarch64\n\tsha256sums_aarch64 = %s\n\npkgname = agentty-bin\n' \ | |
| "$V" "$V" "$V" "$x64" "$V" "$V" "$arm" > .SRCINFO | |
| fi | |
| git config user.name agentty-ci; git config user.email noreply@github.com | |
| git add PKGBUILD .SRCINFO | |
| git commit -m "agentty $V" || { echo "no change"; exit 0; } | |
| git push origin HEAD:master | |
| # Submit the Windows .msi to the winget community repo. winget is the | |
| # zero-cost, no-SmartScreen-warning install path: `winget install agentty`. | |
| # | |
| # Fully hands-off across BOTH cases: | |
| # * package already in winget-pkgs -> vedantmgoyal9/winget-releaser opens | |
| # the version-bump PR (update path). | |
| # * package NOT yet in winget-pkgs -> that action refuses ("add at least | |
| # one version first"), so we fall back to a first-submission step that | |
| # renders packaging/winget/*.yaml from the CMake version + the built | |
| # .msi's real SHA256/ProductCode and opens the initial PR via the API. | |
| # Either way the submitted version is the central CMake project VERSION. | |
| # Requires WINGET_TOKEN (classic PAT, public_repo scope); skipped if absent. | |
| publish-winget: | |
| name: publish to winget | |
| needs: [prepare, build-windows] | |
| if: ${{ startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' }} | |
| runs-on: ubuntu-latest | |
| env: | |
| V: ${{ needs.prepare.outputs.version }} | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} | |
| PKGID: agentty.agentty | |
| steps: | |
| - uses: actions/checkout@v5 | |
| # Does agentty.agentty already have a manifest folder upstream? Drives | |
| # which of the two paths below actually runs. | |
| - name: Check if package exists in winget-pkgs | |
| id: exists | |
| if: ${{ env.WINGET_TOKEN != '' }} | |
| run: | | |
| if gh api repos/microsoft/winget-pkgs/contents/manifests/a/agentty/agentty >/dev/null 2>&1; then | |
| echo "present=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "present=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| env: | |
| GH_TOKEN: ${{ secrets.WINGET_TOKEN }} | |
| # UPDATE path — package already exists upstream. | |
| - name: Update manifest (winget-releaser) | |
| if: ${{ env.WINGET_TOKEN != '' && steps.exists.outputs.present == 'true' }} | |
| # Non-fatal: winget is publish-once-per-version. A manual re-dispatch | |
| # for an already-submitted version makes komac error on the | |
| # duplicate; that's a no-op, not a release-breaking failure. The | |
| # tagged-release path submits each version exactly once. | |
| continue-on-error: true | |
| uses: vedantmgoyal9/winget-releaser@main | |
| with: | |
| identifier: agentty.agentty | |
| version: ${{ needs.prepare.outputs.version }} | |
| # Explicit tag: the action's default is | |
| # `github.event.release.tag_name || github.ref_name`, which on a | |
| # workflow_dispatch from master resolves to "master" — komac then | |
| # queries releases/tags/master (404), gets no asset URLs, and | |
| # fails. prepare.outputs.tag is the real vX.Y.Z on both the | |
| # tagged-release and manual-dispatch paths. | |
| release-tag: ${{ needs.prepare.outputs.tag }} | |
| installers-regex: 'agentty-windows-x86_64\.msi$' | |
| token: ${{ secrets.WINGET_TOKEN }} | |
| # FIRST-SUBMISSION path — package not yet upstream. Render the templates | |
| # from the central version + the exact built .msi and open the initial PR. | |
| - name: First submission (render + PR via API) | |
| if: ${{ env.WINGET_TOKEN != '' && steps.exists.outputs.present == 'false' }} | |
| env: | |
| GH_TOKEN: ${{ secrets.WINGET_TOKEN }} | |
| run: | | |
| set -eu | |
| sudo apt-get update && sudo apt-get install -y --no-install-recommends msitools >/dev/null | |
| # 1. Fetch the built .msi; compute SHA256 + ProductCode from it. | |
| gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ | |
| --pattern 'agentty-windows-x86_64.msi' --output a.msi | |
| MSI_SHA=$(sha256sum a.msi | awk '{print toupper($1)}') | |
| # msiinfo dumps the Property table; ProductCode is a GUID in braces. | |
| PC=$(msiinfo export a.msi Property 2>/dev/null \ | |
| | awk -F'\t' '$1=="ProductCode"{print $2}') | |
| [ -n "$PC" ] || { echo "::error::could not read ProductCode from MSI"; exit 1; } | |
| echo "msi sha=$MSI_SHA productcode=$PC" | |
| # 2. Render the three templates. Keep the schema line (needed by | |
| # winget) but drop the template-only notes, which we mark with a | |
| # leading '#!' so they're trivially strippable. | |
| mkdir -p out | |
| for f in agentty.agentty.installer.yaml agentty.agentty.locale.en-US.yaml agentty.agentty.yaml; do | |
| sed -e "s/@VERSION@/$V/g" \ | |
| -e "s|@MSI_SHA256@|$MSI_SHA|g" \ | |
| -e "s|@MSI_PRODUCT_CODE@|$PC|g" \ | |
| "packaging/winget/$f" \ | |
| | grep -v '^#!' > "out/$f" | |
| done | |
| # 3. Fork winget-pkgs (default branch only; no-op if it exists). | |
| gh api repos/microsoft/winget-pkgs/forks -X POST -f default_branch_only=true >/dev/null || true | |
| me=$(gh api user -q .login) | |
| # Wait for the fork to become queryable. | |
| for i in $(seq 1 30); do | |
| if gh api "repos/$me/winget-pkgs/git/ref/heads/master" >/dev/null 2>&1; then break; fi | |
| sleep 4 | |
| done | |
| # 4. Build a commit (blobs -> tree -> commit -> branch) via the API so | |
| # we never clone the multi-GB winget-pkgs repo. | |
| branch="agentty-agentty-$V" | |
| dir="manifests/a/agentty/agentty/$V" | |
| base_sha=$(gh api "repos/$me/winget-pkgs/git/ref/heads/master" -q '.object.sha') | |
| base_tree=$(gh api "repos/$me/winget-pkgs/git/commits/$base_sha" -q '.tree.sha') | |
| items='[]' | |
| for f in agentty.agentty.installer.yaml agentty.agentty.locale.en-US.yaml agentty.agentty.yaml; do | |
| b64=$(base64 -w0 "out/$f") | |
| blob=$(jq -n --arg c "$b64" '{content:$c,encoding:"base64"}' \ | |
| | gh api "repos/$me/winget-pkgs/git/blobs" --input - -q '.sha') | |
| items=$(echo "$items" | jq --arg p "$dir/$f" --arg s "$blob" \ | |
| '. + [{path:$p,mode:"100644",type:"blob",sha:$s}]') | |
| done | |
| new_tree=$(jq -n --arg bt "$base_tree" --argjson t "$items" '{base_tree:$bt,tree:$t}' \ | |
| | gh api "repos/$me/winget-pkgs/git/trees" --input - -q '.sha') | |
| new_commit=$(jq -n --arg m "New version: agentty.agentty version $V" --arg t "$new_tree" --arg p "$base_sha" \ | |
| '{message:$m,tree:$t,parents:[$p]}' \ | |
| | gh api "repos/$me/winget-pkgs/git/commits" --input - -q '.sha') | |
| # Recreate the branch idempotently. | |
| gh api -X DELETE "repos/$me/winget-pkgs/git/refs/heads/$branch" >/dev/null 2>&1 || true | |
| jq -n --arg r "refs/heads/$branch" --arg s "$new_commit" '{ref:$r,sha:$s}' \ | |
| | gh api "repos/$me/winget-pkgs/git/refs" --input - >/dev/null | |
| # 5. Open the PR against microsoft/winget-pkgs. | |
| body="First-time submission of agentty.agentty ($V). agentty is a native C++26 terminal coding agent: a single static binary, sandboxed by default, one-command SSH air-gap. https://github.com/1ay1/agentty" | |
| jq -n --arg t "New version: agentty.agentty version $V" --arg h "$me:$branch" --arg b "$body" \ | |
| '{title:$t,head:$h,base:"master",body:$b}' \ | |
| | gh api repos/microsoft/winget-pkgs/pulls --input - -q '.html_url' | |
| # Fedora COPR: submit a source build so `dnf/yum/zypper install agentty` works | |
| # from the project's own COPR repo. This is the cheapest RPM channel — no | |
| # distro-review wait, builds for every enabled Fedora/EL/openSUSE chroot. | |
| # | |
| # One-time setup (you): | |
| # 1. Create the project at https://copr.fedorainfracloud.org (name it | |
| # `agentty`; enable the fedora-*/epel-*/opensuse-* chroots you want). | |
| # 2. Copr → API → copy the whole `[copr-cli]` token block. | |
| # 3. gh secret set COPR_CONFIG --repo 1ay1/agentty < the-token-file | |
| # 4. gh secret set COPR_PROJECT --repo 1ay1/agentty --body '<user>/agentty' | |
| # Users then: dnf copr enable <user>/agentty && dnf install agentty | |
| # | |
| # We hand COPR the version-pinned .spec + the prebuilt binary as an SRPM so | |
| # COPR's builders don't recompile C++26 (matching every other channel). The | |
| # version is the central CMake project VERSION (via prepare.outputs.version). | |
| publish-copr: | |
| name: publish to Fedora COPR | |
| needs: [prepare, build-linux-x86_64] | |
| if: always() && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') | |
| runs-on: ubuntu-latest | |
| container: fedora:41 | |
| env: | |
| V: ${{ needs.prepare.outputs.version }} | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| COPR_CONFIG: ${{ secrets.COPR_CONFIG }} | |
| COPR_PROJECT: ${{ secrets.COPR_PROJECT }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Build SRPM + submit to COPR | |
| if: ${{ env.COPR_CONFIG != '' && env.COPR_PROJECT != '' }} | |
| run: | | |
| set -eu | |
| dnf install -y --setopt=install_weak_deps=False \ | |
| rpm-build rpmdevtools copr-cli gh tar >/dev/null | |
| # Central version -> .spec. The spec packages the prebuilt static | |
| # binary fetched from the release (Source0), so no compile on COPR. | |
| rpmdev-setuptree | |
| gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ | |
| --pattern 'agentty-linux-x86_64' --output ~/rpmbuild/SOURCES/agentty-linux-x86_64 | |
| sed -e "s/@VERSION@/$V/g" \ | |
| -e "s/@RPM_ARCH@/x86_64/g" \ | |
| -e "s/@DATE@/$(LC_ALL=C date '+%a %b %d %Y')/g" \ | |
| packaging/rpm/agentty.spec.in > ~/rpmbuild/SPECS/agentty.spec | |
| # Build a SOURCE rpm only (-bs) — COPR rebuilds it in each chroot. | |
| rpmbuild -bs ~/rpmbuild/SPECS/agentty.spec | |
| srpm=$(ls ~/rpmbuild/SRPMS/agentty-*.src.rpm | head -1) | |
| # copr-cli reads ~/.config/copr for auth. | |
| mkdir -p ~/.config | |
| printf '%s\n' "$COPR_CONFIG" > ~/.config/copr | |
| chmod 600 ~/.config/copr | |
| copr-cli build "$COPR_PROJECT" "$srpm" | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| # Self-hosted APT repo on the `apt-repo` branch (served via GitHub Pages) so | |
| # `apt-get install agentty` works without Debian's multi-month review. Rebuilds | |
| # the Packages/Release index over ALL .deb versions each run, GPG-signs the | |
| # Release, and force-pushes the branch. | |
| # | |
| # One-time setup (you): | |
| # 1. Generate a GPG key: gpg --full-generate-key (RSA, no passphrase) | |
| # 2. Export it: gpg --armor --export-secret-keys <KEYID> > apt-signing.asc | |
| # 3. gh secret set APT_GPG_KEY --repo 1ay1/agentty < apt-signing.asc | |
| # 4. Settings → Pages → deploy from branch `apt-repo` (root). | |
| # Users then: | |
| # curl -fsSL https://1ay1.github.io/agentty/apt/public.key \ | |
| # | sudo gpg --dearmor -o /usr/share/keyrings/agentty.gpg | |
| # echo "deb [signed-by=/usr/share/keyrings/agentty.gpg] \ | |
| # https://1ay1.github.io/agentty/apt stable main" \ | |
| # | sudo tee /etc/apt/sources.list.d/agentty.list | |
| # sudo apt-get update && sudo apt-get install agentty | |
| publish-apt-repo: | |
| name: publish APT repo | |
| needs: [prepare, package-linux-x86_64, package-linux-aarch64] | |
| if: always() && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') | |
| runs-on: ubuntu-latest | |
| env: | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| APT_GPG_KEY: ${{ secrets.APT_GPG_KEY }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| with: | |
| fetch-depth: 0 | |
| - name: Build + sign + push APT repo | |
| if: ${{ env.APT_GPG_KEY != '' }} | |
| run: | | |
| set -eu | |
| sudo apt-get update | |
| sudo apt-get install -y --no-install-recommends dpkg-dev apt-utils gnupg | |
| # Pull EVERY published .deb (all versions, both arches) so the index | |
| # covers the full history, not just this release. | |
| mkdir -p work/pool | |
| for tag in $(gh release list --repo "$GITHUB_REPOSITORY" --limit 100 \ | |
| --json tagName -q '.[].tagName'); do | |
| gh release download "$tag" --repo "$GITHUB_REPOSITORY" \ | |
| --pattern 'agentty_*_*.deb' --dir work/pool --skip-existing || true | |
| done | |
| ls work/pool/*.deb | |
| # Import the signing key; grab its id + export the public half. | |
| printf '%s\n' "$APT_GPG_KEY" | gpg --batch --import | |
| KEYID=$(gpg --list-secret-keys --with-colons | awk -F: '/^sec:/{print $5; exit}') | |
| # Build a flat-ish repo: dists/stable/main/binary-{amd64,arm64}. | |
| cd work | |
| for arch in amd64 arm64; do | |
| d="dists/stable/main/binary-$arch"; mkdir -p "$d" | |
| dpkg-scanpackages --arch "$arch" pool /dev/null > "$d/Packages" | |
| gzip -9c "$d/Packages" > "$d/Packages.gz" | |
| done | |
| apt-ftparchive release dists/stable > dists/stable/Release | |
| gpg --default-key "$KEYID" -abs -o dists/stable/Release.gpg dists/stable/Release | |
| gpg --default-key "$KEYID" --clearsign -o dists/stable/InRelease dists/stable/Release | |
| gpg --armor --export "$KEYID" > public.key | |
| # Force-push the assembled repo to the apt-repo branch under apt/. | |
| cd .. | |
| git config user.name agentty-ci; git config user.email noreply@github.com | |
| git checkout --orphan apt-repo | |
| git rm -rf . >/dev/null 2>&1 || true | |
| mkdir -p apt | |
| cp -r work/dists work/pool work/public.key apt/ | |
| git add apt | |
| git commit -m "apt repo @ $TAG" | |
| git push -f origin apt-repo | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |