ai-crew-sync 0.5.3

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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
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
        env:
          DOCKER_BUILD_RECORD_UPLOAD: false
        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
          # Ours only. A bare download also pulls the .dockerbuild build
          # record that build-push-action uploads, and extracting that one
          # fails the whole step.
          pattern: ai-crew-sync-*
      # An amd64 runner cannot execute an arm64 binary on its own, and an
      # arm64 package that installs but cannot run is exactly what this job
      # exists to catch.
      - uses: docker/setup-qemu-action@v3
      - 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"
          '

      # Same checks on the other architecture, through emulation. Slower, and
      # the arm64 package is the one nobody would notice was broken.
      - name: Verify the arm64 .deb on Debian 12
        run: |
          docker run --rm --platform linux/arm64 -v "$PWD/dist:/pkg" debian:12 sh -c '
            set -e
            dpkg -i /pkg/ai-crew-sync_arm64.deb
            ai-crew-sync --version
            test -f /etc/ai-crew-sync/ai-crew-sync.env
            id ai-crew-sync >/dev/null
            echo "arm64 deb: installs, runs, ships its unit and its user"
          '
      - name: Verify the aarch64 .rpm on Rocky Linux 9
        run: |
          docker run --rm --platform linux/arm64 -v "$PWD/dist:/pkg" rockylinux:9 sh -c '
            set -e
            rpm -i /pkg/ai-crew-sync.aarch64.rpm
            ai-crew-sync --version
            test -f /etc/ai-crew-sync/ai-crew-sync.env
            id ai-crew-sync >/dev/null
            echo "aarch64 rpm: installs, runs, ships its unit and its user"
          '

      - uses: actions/upload-artifact@v4
        with:
          # Shares the prefix the publish job filters on. Named 'os-packages'
          # it silently fell outside the pattern, and the release shipped
          # tarballs with no .deb or .rpm while every job reported success.
          name: ai-crew-sync-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
          pattern: ai-crew-sync-*
      - name: Upload
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -e
          # A tag push should produce a finished release without anyone
          # opening a browser. If the release does not exist yet, create it
          # with generated notes — a human can always rewrite the body, but
          # nobody should have to create the object before the assets have
          # somewhere to land.
          if ! gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
            echo "no release for $GITHUB_REF_NAME yet — creating it"
            gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" \
              --title "$GITHUB_REF_NAME" --generate-notes --latest
          fi

          # Assert the set is complete before uploading. The previous version
          # happily published eight tarballs and no packages, and every job
          # went green — a partial release is worse than a failed one, because
          # nobody goes looking.
          for want in '*.tar.gz' '*.sha256' '*.deb' '*.rpm'; do
            n=$(find dl -type f -name "$want" | wc -l)
            echo "  $want: $n"
            [ "$n" -gt 0 ] || { echo "no $want among the artifacts — refusing to publish a partial release"; exit 1; }
          done
          [ "$(find dl -type f -name '*.deb' | wc -l)" -ge 2 ] || { echo "expected both architectures of .deb"; exit 1; }
          [ "$(find dl -type f -name '*.rpm' | wc -l)" -ge 2 ] || { echo "expected both architectures of .rpm"; exit 1; }

          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