name: Release
on:
push:
tags: ["v*"]
permissions:
contents: read
packages: write
jobs:
gates:
uses: ./.github/workflows/ci.yml
smoke:
name: release image boots and serves MCP
needs: gates
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18-alpine
env:
POSTGRES_USER: bus
POSTGRES_PASSWORD: bus
POSTGRES_DB: bus
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U bus"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgres://bus:bus@localhost:5432/bus
IMAGE: ai-crew-sync:smoke
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build the runner's architecture and load it locally
uses: docker/build-push-action@v6
with:
context: .
file: Docker/Dockerfile
platforms: linux/amd64
load: true
tags: ${{ env.IMAGE }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Start the server (it migrates on startup)
run: |
docker run -d --name bus --network host \
-e DATABASE_URL="$DATABASE_URL" -e BUS_BIND=0.0.0.0:8787 \
"$IMAGE" serve
for _ in $(seq 1 30); do
curl -fsS http://localhost:8787/health >/dev/null 2>&1 && exit 0
sleep 2
done
echo "server never became healthy:"; docker logs bus; exit 1
- name: Health endpoint reports a live database
run: |
body=$(curl -fsS http://localhost:8787/health)
echo "$body"
echo "$body" | grep -q '"database":"up"'
- name: An authenticated MCP call round-trips
run: |
docker run --rm --network host -e DATABASE_URL="$DATABASE_URL" \
"$IMAGE" team create --slug smoke --name Smoke
token=$(docker run --rm --network host -e DATABASE_URL="$DATABASE_URL" \
"$IMAGE" agent add --team smoke --name ci | grep -oE 'acs_[0-9a-f]+' | head -1)
[ -n "$token" ] || { echo "no token issued"; exit 1; }
agent=$(curl -fsS -X POST http://localhost:8787/mcp \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"whoami","arguments":{}}}' \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["result"]["structuredContent"]["agent"])')
echo "whoami -> $agent"
[ "$agent" = "ci" ] || { echo "unexpected identity"; exit 1; }
- name: Server logs (always)
if: always()
run: docker logs bus || true
binaries:
name: build ${{ matrix.target }}
needs: gates
runs-on: ${{ matrix.os }}
permissions:
contents: read
strategy:
matrix:
include:
- { target: x86_64-unknown-linux-musl, os: ubuntu-latest, cross: true }
- { target: aarch64-unknown-linux-musl, os: ubuntu-latest, cross: true }
- { target: x86_64-apple-darwin, os: macos-latest, cross: false }
- { target: aarch64-apple-darwin, os: macos-latest, cross: false }
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: Build
run: |
if [ "${{ matrix.cross }}" = "true" ]; then
cargo install cross --git https://github.com/cross-rs/cross --locked
cross build --release --target ${{ matrix.target }}
else
cargo build --release --target ${{ matrix.target }}
fi
- name: Archive
id: archive
run: |
name="ai-crew-sync-${GITHUB_REF_NAME}-${{ matrix.target }}"
mkdir -p "dist/$name"
cp "target/${{ matrix.target }}/release/ai-crew-sync" "dist/$name/"
cp README.md LICENSE .env.example "dist/$name/"
tar -C dist -czf "dist/$name.tar.gz" "$name"
# Homebrew verifies this, and so should anyone downloading by hand.
(cd dist && shasum -a 256 "$name.tar.gz" > "$name.tar.gz.sha256")
echo "name=$name" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
with:
name: ${{ steps.archive.outputs.name }}
path: |
dist/*.tar.gz
dist/*.sha256
retention-days: 1
packages:
name: build and verify os packages
needs: binaries
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-musl, aarch64-unknown-linux-musl
- uses: actions/download-artifact@v4
with:
path: dl
- name: Install packaging tools
run: cargo install cargo-deb cargo-generate-rpm --locked
- name: Package
run: |
set -e
mkdir -p dist
for pair in "x86_64-unknown-linux-musl amd64 x86_64" \
"aarch64-unknown-linux-musl arm64 aarch64"; do
set -- $pair; target=$1; deb_arch=$2; rpm_arch=$3
# --target is load-bearing: without it both tools label the package
# for the machine doing the build, so the arm64 package would ship
# claiming amd64 and refuse to install where it belongs.
mkdir -p "target/$target/release"
tar -xzf dl/ai-crew-sync-*-"$target"/*.tar.gz -C /tmp
cp /tmp/ai-crew-sync-*-"$target"/ai-crew-sync "target/$target/release/ai-crew-sync"
chmod +x "target/$target/release/ai-crew-sync"
cargo deb --no-build --no-strip --target "$target" -o dist/
cargo generate-rpm --payload-compress none --target "$target" \
--arch "$rpm_arch" -o "dist/ai-crew-sync.${rpm_arch}.rpm"
done
# Version-free aliases so `releases/latest/download/<name>` keeps
# working across releases — a URL with the version in it 404s the
# moment the next one ships.
for f in dist/ai-crew-sync_*_amd64.deb; do cp "$f" dist/ai-crew-sync_amd64.deb; done
for f in dist/ai-crew-sync_*_arm64.deb; do cp "$f" dist/ai-crew-sync_arm64.deb; done
ls -la dist
- name: Architectures are labelled for the binary, not the builder
run: |
set -e
for pair in "dist/ai-crew-sync_amd64.deb amd64" "dist/ai-crew-sync_arm64.deb arm64"; do
set -- $pair
got=$(dpkg-deb -f "$1" Architecture)
[ "$got" = "$2" ] || { echo "$1 claims $got, expected $2"; exit 1; }
echo " $1 -> $got"
done
- name: Verify the .deb on Debian 12
run: |
docker run --rm -v "$PWD/dist:/pkg" debian:12 sh -c '
set -e
dpkg -i /pkg/ai-crew-sync_amd64.deb
ai-crew-sync --version
test -f /etc/ai-crew-sync/ai-crew-sync.env
test -f /lib/systemd/system/ai-crew-sync.service
id ai-crew-sync >/dev/null
echo "deb: installs, runs, ships its unit and its user"
'
- name: Verify the .rpm on Rocky Linux 9
run: |
docker run --rm -v "$PWD/dist:/pkg" rockylinux:9 sh -c '
set -e
rpm -i /pkg/ai-crew-sync.x86_64.rpm
ai-crew-sync --version
test -f /etc/ai-crew-sync/ai-crew-sync.env
test -f /usr/lib/systemd/system/ai-crew-sync.service
id ai-crew-sync >/dev/null
echo "rpm: installs, runs, ships its unit and its user"
'
- uses: actions/upload-artifact@v4
with:
name: os-packages
path: dist/*
retention-days: 1
publish-assets:
name: attach assets to the release
needs: packages
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
path: dl
- name: Upload
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
find dl -type f \( -name '*.tar.gz' -o -name '*.sha256' \
-o -name '*.deb' -o -name '*.rpm' \) -print0 \
| xargs -0 gh release upload "$GITHUB_REF_NAME" --clobber --repo "$GITHUB_REPOSITORY"
homebrew:
name: update the homebrew tap
needs: publish-assets
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check for the tap token
id: token
run: |
if [ -n "${{ secrets.HOMEBREW_TAP_TOKEN }}" ]; then
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
echo "::notice::HOMEBREW_TAP_TOKEN is not set — skipping the tap update. Add it to bump the formula automatically."
fi
- name: Update the formula
if: steps.token.outputs.present == 'true'
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
set -e
version="${TAG#v}"
base="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}"
# Checksums come from the release itself, so the formula can only
# ever point at bytes that were actually published.
declare -A sums
for t in aarch64-apple-darwin x86_64-apple-darwin \
aarch64-unknown-linux-musl x86_64-unknown-linux-musl; do
sums[$t]=$(curl -fsSL "$base/ai-crew-sync-${TAG}-${t}.tar.gz.sha256" | awk '{print $1}')
[ -n "${sums[$t]}" ] || { echo "no checksum published for $t"; exit 1; }
done
git clone --depth 1 "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY_OWNER}/homebrew-tap.git" tap
cd tap
f=Formula/ai-crew-sync.rb
sed -i -E "s|version \"[^\"]+\"|version \"$version\"|" "$f"
sed -i -E "s|releases/download/v[^/]+/ai-crew-sync-v[^-]+-|releases/download/${TAG}/ai-crew-sync-${TAG}-|g" "$f"
python3 - "$f" <<PY
import re, sys
path = sys.argv[1]
s = open(path).read()
for target, digest in {
"aarch64-apple-darwin": "${sums[aarch64-apple-darwin]}",
"x86_64-apple-darwin": "${sums[x86_64-apple-darwin]}",
"aarch64-unknown-linux-musl": "${sums[aarch64-unknown-linux-musl]}",
"x86_64-unknown-linux-musl": "${sums[x86_64-unknown-linux-musl]}",
}.items():
# Replace the sha256 that follows this target's url.
s = re.sub(
r'(' + re.escape(target) + r'\.tar\.gz"\s*\n\s*sha256 ")[0-9a-f]{64}',
r'\g<1>' + digest, s)
open(path, "w").write(s)
PY
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git commit -am "ai-crew-sync $version" && git push
docker:
name: publish multi-arch image
needs: [gates, smoke]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- uses: docker/build-push-action@v6
with:
context: .
file: Docker/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max