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: aicx
RUST_MEMEX_REF: 047e63bd9c89bb174fed6e69f9a3a9c203e04981
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
path: aicx
- name: Checkout rust-memex sibling
uses: actions/checkout@v6
with:
repository: Loctree/rust-memex
ref: ${{ env.RUST_MEMEX_REF }}
path: rust-memex
- 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
working-directory: aicx
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
working-directory: aicx
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
working-directory: aicx
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
key: release-verify-${{ hashFiles('aicx/Cargo.lock') }}
restore-keys: |
release-verify-
- name: cargo clippy default
working-directory: aicx
run: cargo clippy --locked -p aicx --all-targets -- -D warnings
- name: cargo clippy native GGUF
working-directory: aicx
run: |
cargo clippy --locked -p aicx-embeddings --features gguf -- -D warnings
cargo clippy --locked -p aicx --features native-embedder --all-targets -- -D warnings
- name: cargo test --bin aicx
working-directory: aicx
run: cargo test --locked --bin aicx
- name: cargo test --bin aicx-mcp
working-directory: aicx
run: cargo test --locked --bin aicx-mcp
- name: cargo test native GGUF
working-directory: aicx
run: |
cargo test --locked -p aicx-embeddings --features gguf
cargo test --locked -p aicx --features native-embedder --test native_embedder
- name: cargo fmt
working-directory: aicx
run: cargo fmt -- --check
- name: Clean Cargo artifacts
if: always()
working-directory: aicx
run: cargo clean
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: zip
binary_ext: ""
- runner_name: dragon-macos
runner: '["self-hosted", "dragon-macos"]'
target: aarch64-apple-darwin
archive_ext: zip
binary_ext: ""
steps:
- uses: actions/checkout@v6
with:
path: aicx
- name: Checkout rust-memex sibling
uses: actions/checkout@v6
with:
repository: Loctree/rust-memex
ref: ${{ env.RUST_MEMEX_REF }}
path: rust-memex
- 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
key: release-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('aicx/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
working-directory: aicx
run: cargo build --locked --release --target ${{ matrix.target }} --bin aicx --bin aicx-mcp
- name: Import macOS signing certificate
if: runner.os == 'macOS'
env:
MACOS_CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }}
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
MACOS_KEYCHAIN_PASSWORD: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }}
run: |
KEYCHAIN="$RUNNER_TEMP/aicx-release.keychain-db"
CERT_PATH="$RUNNER_TEMP/aicx-release-cert.p12"
CA_PATH="$RUNNER_TEMP/DeveloperIDG2CA.cer"
CERT_PATH="$CERT_PATH" python3 - <<'PY'
import base64
import os
from pathlib import Path
Path(os.environ["CERT_PATH"]).write_bytes(
base64.b64decode(os.environ["MACOS_CERT_P12_BASE64"])
)
PY
curl -fsSL "https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer" -o "$CA_PATH"
security create-keychain -p "$MACOS_KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$MACOS_KEYCHAIN_PASSWORD" "$KEYCHAIN"
security import "$CERT_PATH" -k "$KEYCHAIN" -P "$MACOS_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
security import "$CA_PATH" -k "$KEYCHAIN" -T /usr/bin/codesign -T /usr/bin/security >/dev/null
security list-keychains -d user -s "$KEYCHAIN" /Library/Keychains/System.keychain /System/Library/Keychains/SystemRootCertificates.keychain login.keychain-db
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_KEYCHAIN_PASSWORD" "$KEYCHAIN"
echo "MACOS_KEYCHAIN_PATH=$KEYCHAIN" >> "$GITHUB_ENV"
- name: Assemble release bundle
id: bundle
shell: bash
working-directory: aicx
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 os
from pathlib import Path
import shutil
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"
bundle_name = f"{package_name}-v{version}-{target}"
bundle_dir = Path(os.environ["RUNNER_TEMP"]) / bundle_name
if bundle_dir.exists():
shutil.rmtree(bundle_dir)
bundle_dir.mkdir()
docs_dir = bundle_dir / "docs"
docs_dir.mkdir()
for binary in ("aicx", "aicx-mcp"):
target_path = bundle_dir / f"{binary}{binary_ext}"
shutil.copy2(release_dir / f"{binary}{binary_ext}", target_path)
target_path.chmod(0o755)
for source in ("LICENSE", "README.md", "install.sh"):
target_path = bundle_dir / Path(source).name
shutil.copy2(source, target_path)
if target_path.name == "install.sh":
target_path.chmod(0o755)
for source in ("docs/COMMANDS.md", "docs/RELEASES.md"):
shutil.copy2(source, docs_dir / Path(source).name)
dist_dir = Path("dist")
dist_dir.mkdir(exist_ok=True)
output_path = Path(os.environ["GITHUB_OUTPUT"])
with output_path.open("a", encoding="utf-8") as fh:
fh.write(f"bundle_name={bundle_name}\n")
fh.write(f"bundle_dir={bundle_dir.as_posix()}\n")
fh.write(f"archive_path={(dist_dir / f'{bundle_name}.{archive_ext}').as_posix()}\n")
fh.write(f"checksum_path={(dist_dir / f'{bundle_name}.{archive_ext}.sha256').as_posix()}\n")
fh.write(f"notary_log_path={(dist_dir / f'{bundle_name}.notary.json').as_posix()}\n")
PY
- name: Sign and notarize macOS bundle
if: runner.os == 'macOS'
working-directory: aicx
env:
MACOS_DEVELOPER_ID_APPLICATION: ${{ secrets.MACOS_DEVELOPER_ID_APPLICATION }}
APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
MACOS_KEYCHAIN_PATH: ${{ env.MACOS_KEYCHAIN_PATH }}
run: |
bash tools/ci_macos_sign_and_notarize_bundle.sh \
"${{ steps.bundle.outputs.bundle_dir }}" \
"${{ steps.bundle.outputs.archive_path }}" \
"${{ steps.bundle.outputs.notary_log_path }}"
shasum -a 256 "${{ steps.bundle.outputs.archive_path }}" > "${{ steps.bundle.outputs.checksum_path }}"
- name: Package release archive
if: runner.os != 'macOS'
id: package
shell: bash
working-directory: aicx
env:
ARCHIVE_EXT: ${{ matrix.archive_ext }}
BUNDLE_NAME: ${{ steps.bundle.outputs.bundle_name }}
BUNDLE_DIR: ${{ steps.bundle.outputs.bundle_dir }}
ARCHIVE_PATH: ${{ steps.bundle.outputs.archive_path }}
CHECKSUM_PATH: ${{ steps.bundle.outputs.checksum_path }}
run: |
python - <<'PY'
import hashlib
import os
from pathlib import Path
import shutil
import tarfile
archive_ext = os.environ["ARCHIVE_EXT"]
bundle_name = os.environ["BUNDLE_NAME"]
bundle_dir = Path(os.environ["BUNDLE_DIR"])
archive_path = Path(os.environ["ARCHIVE_PATH"])
checksum_path = Path(os.environ["CHECKSUM_PATH"])
if archive_ext != "tar.gz":
raise SystemExit(f"Unsupported non-macOS archive format: {archive_ext}")
archive_path.parent.mkdir(exist_ok=True)
with tarfile.open(archive_path, "w:gz") as archive:
archive.add(bundle_dir, arcname=bundle_name)
digest = hashlib.sha256(archive_path.read_bytes()).hexdigest()
checksum_path.write_text(f"{digest} {archive_path.name}\n", encoding="utf-8")
PY
- uses: actions/upload-artifact@v7
with:
name: release-${{ matrix.target }}
path: aicx/dist/*
if-no-files-found: error
- name: Clean local build artifacts
if: always()
working-directory: aicx
env:
BUNDLE_DIR: ${{ steps.bundle.outputs.bundle_dir }}
ARCHIVE_PATH: ${{ steps.bundle.outputs.archive_path }}
CHECKSUM_PATH: ${{ steps.bundle.outputs.checksum_path }}
NOTARY_LOG_PATH: ${{ steps.bundle.outputs.notary_log_path }}
run: |
cargo clean --target "${{ matrix.target }}" || true
rm -rf dist
if [[ -n "${BUNDLE_DIR}" ]]; then
rm -rf "${BUNDLE_DIR}"
fi
if [[ -n "${ARCHIVE_PATH}" ]]; then
rm -f "${ARCHIVE_PATH}"
fi
if [[ -n "${CHECKSUM_PATH}" ]]; then
rm -f "${CHECKSUM_PATH}"
fi
if [[ -n "${NOTARY_LOG_PATH}" ]]; then
rm -f "${NOTARY_LOG_PATH}"
fi
github-release:
name: Publish GitHub Release
needs: [verify, build]
runs-on: [self-hosted, ops-linux]
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/download-artifact@v8
with:
path: dist
pattern: release-*
merge-multiple: true
- name: Generate release notes from CHANGELOG
env:
RELEASE_VERSION: ${{ needs.verify.outputs.version }}
RELEASE_NOTES_PATH: ${{ runner.temp }}/release-notes.md
run: |
python3 tools/release_sync.py notes "${RELEASE_VERSION}" --output "${RELEASE_NOTES_PATH}"
- name: Create or update GitHub release
env:
GITHUB_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.verify.outputs.release_tag }}
RELEASE_NOTES_PATH: ${{ runner.temp }}/release-notes.md
run: |
if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
gh release edit "${RELEASE_TAG}" --notes-file "${RELEASE_NOTES_PATH}"
gh release upload "${RELEASE_TAG}" dist/* --clobber
else
gh release create "${RELEASE_TAG}" dist/* --notes-file "${RELEASE_NOTES_PATH}"
fi
- name: Clean downloaded release artifacts
if: always()
run: |
rm -rf dist
rm -f "${RUNNER_TEMP}/release-notes.md"