ai-crew-sync 0.5.2

MCP server that lets a team's AI coding agents (Claude Code, Codex, Cursor or any MCP client) exchange messages, coordinate tasks, share presence and keep shared notes, backed by Postgres
Documentation
name: Release

on:
  push:
    tags: ["v*"]

permissions:
  contents: read
  packages: write

jobs:
  # A tag publishes nothing until the same gates that guard main have passed.
  gates:
    uses: ./.github/workflows/ci.yml

  # v0.4.0 shipped an image that could not start: it was built against a newer
  # glibc than its runtime carried, and CI never ran the binary. This job boots
  # the real image against a real database and speaks MCP to it BEFORE the
  # multi-arch push, so that class of break cannot reach a published tag again.
  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

  # Static musl binaries: nothing dynamic to mismatch. Building against glibc
  # on a current runner would link a version Debian 12 and RHEL 9 do not have —
  # the same class of break that made the v0.4.0 image unable to start, except
  # it would fail on the user's machine instead of ours.
  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

  # .deb and .rpm from the musl binaries, then INSTALLED AND RUN inside the
  # distributions they target. A package that installs and cannot execute is
  # the failure mode worth spending a job on.
  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

      # The gate: install the real package in the real distribution and run it.
      - 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

  # Everything verified: attach it to the release people actually download from.
  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"

  # Bumps the formula in joaquinbejar/homebrew-tap to this release. Needs a
  # HOMEBREW_TAP_TOKEN secret with contents:write on that repo; without it the
  # job says so and skips rather than failing the release.
  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