aicx 0.6.0

Operator CLI + MCP server: canonical corpus first, optional semantic index second (Claude Code, Codex, Gemini)
Documentation
name: Release

on:
  push:
    tags:
      - "v*"
  workflow_dispatch:
    inputs:
      tag:
        description: "Release tag to build (vX.Y.Z)"
        required: true
        type: string

permissions:
  contents: write

env:
  CARGO_TERM_COLOR: always
  PACKAGE_NAME: ai-contexters

jobs:
  verify:
    name: Verify release input
    runs-on: [self-hosted, ops-linux]
    outputs:
      release_tag: ${{ steps.meta.outputs.release_tag }}
      version: ${{ steps.meta.outputs.version }}

    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0

      - name: Ensure unzip on self-hosted Linux
        if: runner.os == 'Linux'
        run: |
          if command -v unzip >/dev/null 2>&1; then
            exit 0
          fi
          if command -v apt-get >/dev/null 2>&1; then
            sudo apt-get update
            sudo apt-get install --yes unzip
          else
            echo "unzip is required on Linux runners for setup-protoc" >&2
            exit 1
          fi

      - name: Install protoc
        uses: arduino/setup-protoc@v3
        with:
          repo-token: ${{ secrets.GITHUB_TOKEN }}

      - name: Resolve release tag
        id: meta
        env:
          EVENT_NAME: ${{ github.event_name }}
          INPUT_TAG: ${{ inputs.tag }}
          REF_NAME: ${{ github.ref_name }}
        run: |
          release_tag="${REF_NAME}"
          if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then
            release_tag="${INPUT_TAG}"
          fi
          if [[ -z "${release_tag}" ]]; then
            echo "release tag is required" >&2
            exit 1
          fi
          if [[ ! "${release_tag}" =~ ^v[0-9]+[.][0-9]+[.][0-9]+ ]]; then
            echo "release tag must look like vX.Y.Z" >&2
            exit 1
          fi
          echo "release_tag=${release_tag}" >> "${GITHUB_OUTPUT}"
          echo "version=${release_tag#v}" >> "${GITHUB_OUTPUT}"

      - name: Check git tag exists
        env:
          RELEASE_TAG: ${{ steps.meta.outputs.release_tag }}
        run: git rev-parse --verify "refs/tags/${RELEASE_TAG}" >/dev/null

      - name: Check Cargo.toml version matches tag
        env:
          RELEASE_VERSION: ${{ steps.meta.outputs.version }}
        run: |
          python - <<'PY'
          import os
          import sys
          import tomllib

          with open("Cargo.toml", "rb") as fh:
              cargo_version = tomllib.load(fh)["package"]["version"]

          release_version = os.environ["RELEASE_VERSION"]
          if cargo_version != release_version:
              print(
                  f"Cargo.toml version {cargo_version} does not match release tag v{release_version}",
                  file=sys.stderr,
              )
              raise SystemExit(1)
          PY

      - uses: actions/setup-python@v6
        with:
          python-version: '3.x'

      - name: semgrep
        run: |
          python -m pip install --upgrade pip semgrep
          semgrep --config auto --error --quiet

      - uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy, rustfmt

      - uses: actions/cache@v5
        with:
          path: |
            ~/.cargo/registry
            ~/.cargo/git
            target
          key: release-verify-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: |
            release-verify-

      - name: cargo clippy
        run: cargo clippy --locked --all-features --all-targets -- -D warnings

      - name: cargo test --bin aicx
        run: cargo test --locked --bin aicx

      - name: cargo test --bin aicx-mcp
        run: cargo test --locked --bin aicx-mcp

      - name: cargo fmt
        run: cargo fmt -- --check

      - name: cargo publish --dry-run
        run: cargo publish --locked --dry-run

  build:
    name: Build ${{ matrix.target }} on ${{ matrix.runner_name }}
    needs: verify
    runs-on: ${{ fromJSON(matrix.runner) }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - runner_name: ops-linux
            runner: '["self-hosted", "ops-linux"]'
            target: x86_64-unknown-linux-musl
            archive_ext: tar.gz
            binary_ext: ""
          - runner_name: dragon-macos
            runner: '["self-hosted", "dragon-macos"]'
            target: x86_64-apple-darwin
            archive_ext: tar.gz
            binary_ext: ""
          - runner_name: dragon-macos
            runner: '["self-hosted", "dragon-macos"]'
            target: aarch64-apple-darwin
            archive_ext: tar.gz
            binary_ext: ""

    steps:
      - uses: actions/checkout@v6

      - name: Ensure unzip on self-hosted Linux
        if: runner.os == 'Linux'
        run: |
          if command -v unzip >/dev/null 2>&1; then
            exit 0
          fi
          if command -v apt-get >/dev/null 2>&1; then
            sudo apt-get update
            sudo apt-get install --yes unzip
          else
            echo "unzip is required on Linux runners for setup-protoc" >&2
            exit 1
          fi

      - name: Install protoc
        uses: arduino/setup-protoc@v3
        with:
          repo-token: ${{ secrets.GITHUB_TOKEN }}

      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}

      - uses: actions/cache@v5
        with:
          path: |
            ~/.cargo/registry
            ~/.cargo/git
            target
          key: release-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: |
            release-${{ runner.os }}-${{ matrix.target }}-

      - name: Install musl tools
        if: runner.os == 'Linux'
        run: sudo apt-get update && sudo apt-get install --yes musl-tools

      - name: Build release binaries
        run: cargo build --locked --release --target ${{ matrix.target }} --bin aicx --bin aicx-mcp

      - name: Package release archive
        id: package
        shell: bash
        env:
          VERSION: ${{ needs.verify.outputs.version }}
          TARGET: ${{ matrix.target }}
          ARCHIVE_EXT: ${{ matrix.archive_ext }}
          BINARY_EXT: ${{ matrix.binary_ext }}
          PACKAGE_NAME: ${{ env.PACKAGE_NAME }}
        run: |
          python - <<'PY'
          import hashlib
          import os
          from pathlib import Path
          import shutil
          import tarfile
          import zipfile

          version = os.environ["VERSION"]
          target = os.environ["TARGET"]
          archive_ext = os.environ["ARCHIVE_EXT"]
          binary_ext = os.environ["BINARY_EXT"]
          package_name = os.environ["PACKAGE_NAME"]

          release_dir = Path("target") / target / "release"
          dist_dir = Path("dist")
          dist_dir.mkdir(exist_ok=True)

          bundle_name = f"{package_name}-v{version}-{target}"
          bundle_dir = dist_dir / bundle_name
          bundle_dir.mkdir()

          docs_dir = bundle_dir / "docs"
          docs_dir.mkdir()

          for binary in ("aicx", "aicx-mcp"):
              shutil.copy2(release_dir / f"{binary}{binary_ext}", bundle_dir / f"{binary}{binary_ext}")

          for source in ("LICENSE", "README.md"):
              shutil.copy2(source, bundle_dir / Path(source).name)

          for source in ("docs/COMMANDS.md", "docs/RELEASES.md"):
              shutil.copy2(source, docs_dir / Path(source).name)

          archive_path = dist_dir / f"{bundle_name}.{archive_ext}"
          if archive_ext == "tar.gz":
              with tarfile.open(archive_path, "w:gz") as archive:
                  archive.add(bundle_dir, arcname=bundle_name)
          else:
              with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
                  for path in bundle_dir.rglob("*"):
                      archive.write(path, path.relative_to(dist_dir))

          digest = hashlib.sha256(archive_path.read_bytes()).hexdigest()
          checksum_path = Path(f"{archive_path}.sha256")
          checksum_path.write_text(f"{digest}  {archive_path.name}\n", encoding="utf-8")

          output_path = Path(os.environ["GITHUB_OUTPUT"])
          with output_path.open("a", encoding="utf-8") as fh:
              fh.write(f"archive={archive_path.as_posix()}\n")
              fh.write(f"checksum={checksum_path.as_posix()}\n")
          PY

      - uses: actions/upload-artifact@v7
        with:
          name: release-${{ matrix.target }}
          path: |
            ${{ steps.package.outputs.archive }}
            ${{ steps.package.outputs.checksum }}
          if-no-files-found: error

  github-release:
    name: Publish GitHub Release
    needs: [verify, build]
    runs-on: [self-hosted, ops-linux]

    steps:
      - uses: actions/download-artifact@v8
        with:
          path: dist
          pattern: release-*
          merge-multiple: true

      - name: Create or update GitHub release
        env:
          GITHUB_TOKEN: ${{ github.token }}
          RELEASE_TAG: ${{ needs.verify.outputs.release_tag }}
        run: |
          if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
            gh release upload "${RELEASE_TAG}" dist/* --clobber
          else
            gh release create "${RELEASE_TAG}" dist/* --generate-notes
          fi