brain-edge 0.2.0

The HTTP/JSON edge for the Brain memory database — a self-hostable binary and an embeddable library that speaks the same API the Arc cloud hosts.
Documentation
name: Release

# Publishes brain-edge when a version tag is pushed, to two places:
#   • crates.io      — the library + `brain-edge` binary, for `cargo install`
#   • ghcr.io        — the self-host container image, how most people run it
#
# The tag normally comes from `release-please.yml`, not from a shell: merging
# the Release PR it maintains bumps the version in Cargo.toml, writes the
# CHANGELOG, and creates the tag. Tagging by hand still works and is the
# escape hatch:
#
#   git tag v0.1.0 && git push origin v0.1.0
#
# The version check below is what makes both paths safe — a tag that disagrees
# with the manifest cannot publish.
#
# Auth: crates.io is OIDC **Trusted Publishing** (no long-lived token in this
# repo); GHCR uses the workflow's own `GITHUB_TOKEN` with `packages: write`,
# so there is no registry secret to rotate for either.
#
# ONE-TIME registry setup (done on crates.io, not here) — register this repo +
# this workflow as a Trusted Publisher:
#   crates.io → crate settings → Trusted Publishing → GitHub:
#     owner arc-labs-ai, repo brain-edge, workflow release.yml, env release
# Until that is configured the crates.io job fails closed (it never falls back
# to a token). GHCR needs no setup beyond the package being owned by the org.
#
# ORDERING: brain-edge depends on `brain-db-sdk`, which must already be on
# crates.io at a matching version or `cargo publish` fails — crates.io refuses a
# crate whose dependencies it cannot resolve. brain-db-sdk 0.1.0 is published,
# and Cargo.toml now depends on it by version rather than by path, so both jobs
# resolve it from the registry like any other dependency.
#
# The two jobs are independent — crates.io failing does not block the image,
# and vice versa. Both registries reject re-publishing the same version, so
# re-running after a partial success is a no-op for whichever already landed.
#
# The `release` environment gates both publishes; require reviewers on it
# (Settings → Environments → release) to add a manual approval before anything
# goes public.

on:
  push:
    tags:
      - "v*"
  # Escape hatch. A re-run replays the workflow as it existed AT THE TAG, so a
  # workflow bug fixed afterwards cannot be recovered by re-running — v0.1.0's
  # image published with no provenance because the job lacked
  # `attestations: write`, and re-running would have replayed the same missing
  # permission. A dispatch runs the workflow from the default branch against an
  # existing tag, so fixes apply.
  #
  # Safe to re-run: both registries reject a version they already hold, so this
  # is a no-op for whatever already published.
  workflow_dispatch:
    inputs:
      tag:
        description: "Existing version tag to publish (e.g. v0.1.0)"
        required: true
        type: string

# No repo-wide write; each job opts into exactly the scope it needs.
permissions:
  contents: read

env:
  CARGO_TERM_COLOR: always
  RUST_BACKTRACE: 1
  # Resolved once, read by every job. On a tag push github.ref_name is already
  # `vX.Y.Z`; on a dispatch it would be the branch the hatch was fired from,
  # which would make each job compare a branch name against a crate version.
  RELEASE_TAG: ${{ inputs.tag || github.ref_name }}

jobs:
  crates-io:
    name: Publish → crates.io
    runs-on: ubuntu-26.04
    environment: release
    permissions:
      contents: read
      id-token: write # OIDC → crates.io trusted publishing
    steps:
      - uses: actions/checkout@v7
        with:
          ref: ${{ inputs.tag || github.ref }}

      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
        with:
          workspaces: brain-edge

      - name: Verify tag matches crate version
        run: |
          TAG="${RELEASE_TAG#v}"
          CRATE="$(grep -m1 '^version' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
          echo "tag=$TAG crate=$CRATE"
          test "$TAG" = "$CRATE" || { echo "::error::tag $TAG != crate version $CRATE"; exit 1; }

      - name: Test
        run: cargo test --all-targets

      # Exchanges the GitHub OIDC token for a short-lived crates.io token.
      - name: Authenticate to crates.io (OIDC)
        id: auth
        uses: rust-lang/crates-io-auth-action@v1

      - name: Publish
        run: cargo publish
        env:
          CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}

  # Each platform builds on its own NATIVE runner rather than cross-building
  # arm64 under QEMU on one machine. v0.1.0 spent 21 minutes in a single
  # emulated build; brain-db splits the same way and documents why — a Rust
  # workspace with native deps compiled under QEMU goes from minutes to hours.
  # ubuntu-26.04-arm is free for public repos.
  #
  # Each job pushes BY DIGEST only; `merge` assembles the two digests into one
  # manifest list, applies the real tags, and attests the result.
  image-build:
    name: Build image (${{ matrix.platform }})
    runs-on: ${{ matrix.runner }}
    environment: release
    timeout-minutes: 45
    strategy:
      fail-fast: false
      matrix:
        include:
          - platform: linux/amd64
            runner: ubuntu-26.04
          - platform: linux/arm64
            runner: ubuntu-26.04-arm
    permissions:
      contents: read
      packages: write # push to ghcr.io
    steps:
      - uses: actions/checkout@v7
        with:
          ref: ${{ inputs.tag || github.ref }}

      - name: Verify tag matches crate version
        run: |
          TAG="${RELEASE_TAG#v}"
          CRATE="$(grep -m1 '^version' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
          echo "tag=$TAG crate=$CRATE"
          test "$TAG" = "$CRATE" || { echo "::error::tag $TAG != crate version $CRATE"; exit 1; }

      - name: Derive platform tag pair
        run: |
          platform="${{ matrix.platform }}"
          echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"

      - uses: docker/setup-buildx-action@v4

      - name: Log in to ghcr.io
        uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # Labels are a per-image-config property, so they are applied here rather
      # than only on the manifest list. brain-db shipped a release with empty
      # version/revision/source because they were set only in the merge job.
      - name: Derive image labels
        id: meta
        uses: docker/metadata-action@v6
        with:
          images: ghcr.io/arc-labs-ai/brain-edge
          tags: type=semver,pattern={{version}},value=${{ env.RELEASE_TAG }}
          labels: |
            org.opencontainers.image.title=brain-edge
            org.opencontainers.image.description=The HTTP/JSON edge for the Brain memory database
            org.opencontainers.image.licenses=Apache-2.0

      - name: Build and push by digest
        id: build
        uses: docker/build-push-action@v7
        with:
          context: .
          platforms: ${{ matrix.platform }}
          labels: ${{ steps.meta.outputs.labels }}
          outputs: type=image,name=ghcr.io/arc-labs-ai/brain-edge,push-by-digest=true,name-canonical=true,push=true
          cache-from: type=gha,scope=${{ matrix.platform }}
          cache-to: type=gha,mode=max,scope=${{ matrix.platform }}

      # The native runner means the just-pushed digest can be started here. CI
      # already smoke-tests the image it builds; this does the same for the one
      # that actually ships. /health/live is the static liveness route, so it
      # needs no Brain behind it.
      - name: Smoke-test the pushed image
        run: |
          set -euo pipefail
          IMG="ghcr.io/arc-labs-ai/brain-edge@${{ steps.build.outputs.digest }}"
          docker run -d --name smoke -p 8080:8080 "$IMG"
          for i in $(seq 1 30); do
            if curl -fsS http://127.0.0.1:8080/health/live >/dev/null 2>&1; then
              echo "edge is live"; docker rm -f smoke; exit 0
            fi
            if [ "$(docker inspect -f '{{.State.Status}}' smoke)" != "running" ]; then
              echo "::error title=Smoke test failed::container exited"
              docker logs smoke; docker rm -f smoke; exit 1
            fi
            sleep 2
          done
          echo "::error title=Smoke test failed::/health/live never answered"
          docker logs smoke; docker rm -f smoke; exit 1

      - name: Export digest
        run: |
          mkdir -p /tmp/digests
          digest="${{ steps.build.outputs.digest }}"
          touch "/tmp/digests/${digest#sha256:}"

      - name: Upload digest
        uses: actions/upload-artifact@v7
        with:
          name: digests-${{ env.PLATFORM_PAIR }}
          path: /tmp/digests/*
          if-no-files-found: error
          retention-days: 1

  image:
    name: Publish image → ghcr.io
    needs: image-build
    runs-on: ubuntu-26.04
    environment: release
    timeout-minutes: 15
    permissions:
      contents: read
      packages: write # push to ghcr.io
      id-token: write # OIDC identity for the provenance attestation
      # Writing the attestation itself needs its own scope. Without it the
      # publish succeeds and the attest step fails with "Failed to persist
      # attestation: Resource not accessible by integration", which fails the
      # whole job AFTER the image is already live — v0.1.0 published that way.
      attestations: write
    steps:
      - name: Download digests
        uses: actions/download-artifact@v7
        with:
          path: /tmp/digests
          pattern: digests-*
          merge-multiple: true

      # From tag v1.2.3 this produces :1.2.3, :1.2, :1 and :latest. The
      # floating tags let a self-hoster track patches without editing compose
      # files; :1.2.3 is the one to pin in production.
      - name: Derive image tags
        id: meta
        uses: docker/metadata-action@v6
        with:
          images: ghcr.io/arc-labs-ai/brain-edge
          # `latest` is not listed: flavor defaults to latest=auto, which adds it
          # for any non-prerelease semver tag. Listing it as well is what gave
          # brain-db a duplicate `latest` in its tag set.
          #
          # The bare major tag is guarded. metadata-action emits `0` for a 0.x
          # release, and under semver every 0.x MINOR may break — so a consumer
          # pinned to `:0` would be carried from 0.1.0 straight through 0.2.0's
          # breaking changes. The guard turns it on by itself at 1.0, where a
          # floating major is the point.
          tags: |
            type=semver,pattern={{version}},value=${{ env.RELEASE_TAG }}
            type=semver,pattern={{major}}.{{minor}},value=${{ env.RELEASE_TAG }}
            type=semver,pattern={{major}},value=${{ env.RELEASE_TAG }},enable=${{ !startsWith(env.RELEASE_TAG, 'v0.') }}

      - uses: docker/setup-buildx-action@v4

      - name: Log in to ghcr.io
        uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # The image name is substituted at the workflow-expression level, leaving
      # exactly one `%s` for printf to cycle across each digest filename that
      # `*` glob-expands. Passing it as a second printf argument instead would
      # add a second %s and misalign every pair after the first digest.
      - name: Create manifest list and push
        working-directory: /tmp/digests
        run: |
          docker buildx imagetools create \
            $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
            $(printf 'ghcr.io/arc-labs-ai/brain-edge@sha256:%s ' *)
        env:
          DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }}

      # Attestation must name the MANIFEST LIST digest, not a per-platform one:
      # that is what a consumer pulls and what `${IMAGE}@${DIGEST}` pins.
      - name: Resolve the manifest-list digest
        id: inspect
        run: |
          digest="$(docker buildx imagetools inspect \
            "ghcr.io/arc-labs-ai/brain-edge:${{ steps.meta.outputs.version }}" \
            --format '{{json .Manifest.Digest}}' | tr -d '"')"
          echo "digest=${digest}" >> "$GITHUB_OUTPUT"

      # Signed provenance linking the image digest back to this workflow run,
      # so a consumer can verify where the image came from.
      - name: Attest build provenance
        uses: actions/attest-build-provenance@v4
        with:
          subject-name: ghcr.io/arc-labs-ai/brain-edge
          subject-digest: ${{ steps.inspect.outputs.digest }}
          push-to-registry: true