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: Install protoc
shell: bash
run: |
if command -v protoc >/dev/null 2>&1; then
echo "protoc already installed: $(protoc --version)"
exit 0
fi
if [[ "$RUNNER_OS" == "Linux" ]]; then
sudo apt-get update -qq
sudo apt-get install -y -qq protobuf-compiler
elif [[ "$RUNNER_OS" == "macOS" ]]; then
brew install protobuf
else
echo "::error::Unsupported OS for protoc install: $RUNNER_OS"
exit 1
fi
protoc --version
- 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
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Check release channel versions
working-directory: aicx
run: bash tools/release-channel-check.sh
- 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
- 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
- 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-gnu
- runner_name: dragon-macos
runner: '["self-hosted", "dragon-macos"]'
target: aarch64-apple-darwin
- runner_name: windows-pc
runner: '["self-hosted", "windows-pc"]'
target: x86_64-pc-windows-gnu
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: Install protoc
shell: bash
run: |
if command -v protoc >/dev/null 2>&1; then
echo "protoc already installed: $(protoc --version)"
exit 0
fi
if [[ "$RUNNER_OS" == "Linux" ]]; then
sudo apt-get update -qq
sudo apt-get install -y -qq protobuf-compiler
elif [[ "$RUNNER_OS" == "macOS" ]]; then
brew install protobuf
else
echo "::error::Unsupported OS for protoc install: $RUNNER_OS"
exit 1
fi
protoc --version
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Build signed + notarized macOS bundle (dragon-macos)
if: matrix.runner_name == 'dragon-macos'
shell: bash
working-directory: aicx
env:
TARGET: ${{ matrix.target }}
DIST_DIR: ${{ github.workspace }}/aicx/dist
AICX_BUNDLE_FLAVOR: slim
AICX_CLEAN_AFTER_BUILD: "0"
PACKAGE_NAME: ${{ env.PACKAGE_NAME }}
LOCTREE_GPG_KEY_ID: ${{ vars.LOCTREE_GPG_KEY_ID }}
run: |
# KEYS dir + .notary.env live on the dragon runner under $HOME/.keys.
KEYS="$HOME/.keys" ./tools/release_bundle.sh
- name: Build binaries-only release (ops-linux, GPG-detached)
if: matrix.runner_name == 'ops-linux'
shell: bash
working-directory: aicx
env:
TARGET: ${{ matrix.target }}
DIST_DIR: ${{ github.workspace }}/aicx/dist
AICX_RELEASE_BUNDLE_ONLY_BINARIES: "1"
AICX_BUNDLE_FLAVOR: slim
AICX_CLEAN_AFTER_BUILD: "0"
PACKAGE_NAME: ${{ env.PACKAGE_NAME }}
LOCTREE_GPG_KEY_ID: ${{ vars.LOCTREE_GPG_KEY_ID }}
run: |
./tools/release_bundle.sh
- name: Build binaries-only release (windows-pc, GPG-detached .zip)
if: matrix.runner_name == 'windows-pc'
shell: bash
working-directory: aicx
env:
TARGET: ${{ matrix.target }}
DIST_DIR: ${{ github.workspace }}/aicx/dist
AICX_RELEASE_BUNDLE_ONLY_BINARIES: "1"
AICX_BUNDLE_FLAVOR: slim
AICX_CLEAN_AFTER_BUILD: "0"
PACKAGE_NAME: ${{ env.PACKAGE_NAME }}
LOCTREE_GPG_KEY_ID: ${{ vars.LOCTREE_GPG_KEY_ID }}
PATH: "C:\\Strawberry\\c\\bin;C:\\Program Files (x86)\\GnuPG\\bin;C:\\Program Files\\Git\\usr\\bin;${{ env.PATH }}"
run: |
./tools/release_bundle.sh
- name: Build .deb (linux only)
if: contains(matrix.target, 'linux')
shell: bash
working-directory: aicx
env:
TARGET: ${{ matrix.target }}
DIST_DIR: ${{ github.workspace }}/aicx/dist
LOCTREE_GPG_KEY_ID: ${{ vars.LOCTREE_GPG_KEY_ID }}
run: |
cargo install --quiet cargo-deb --locked || cargo install --quiet cargo-deb
cargo deb --target "${TARGET}" --no-build --output "${DIST_DIR}/"
PASSPHRASE_FILE="${HOME}/.keys/.gpg.passphrase"
if [[ -z "${LOCTREE_GPG_KEY_ID}" ]]; then
echo "::error::vars.LOCTREE_GPG_KEY_ID empty — refusing to publish unsigned .deb." >&2
exit 1
fi
if [[ ! -r "${PASSPHRASE_FILE}" ]]; then
echo "::error::Passphrase file ${PASSPHRASE_FILE} not readable on runner." >&2
exit 1
fi
# Checksum and detached-sign every deb produced for this target.
for deb in "${DIST_DIR}"/*.deb; do
[ -f "${deb}" ] || continue
( cd "${DIST_DIR}" && shasum -a 256 "$(basename "${deb}")" ) > "${deb}.sha256"
gpg --batch --yes --armor --pinentry-mode loopback \
--passphrase-file "${PASSPHRASE_FILE}" \
--sig-notation "apple-developer-team-id@loctree.io=MW223P3NPX" \
--sig-notation "release-source@loctree.io=Loctree/aicx" \
--detach-sign --local-user "${LOCTREE_GPG_KEY_ID}" \
"${deb}"
done
- uses: actions/upload-artifact@v7
with:
name: release-${{ matrix.target }}
path: |
aicx/dist/*.tar.gz
aicx/dist/*.tar.gz.sha256
aicx/dist/*.tar.gz.asc
aicx/dist/*.zip
aicx/dist/*.zip.sha256
aicx/dist/*.zip.asc
aicx/dist/*.notary.json
aicx/dist/*.deb
aicx/dist/*.deb.sha256
aicx/dist/*.deb.asc
if-no-files-found: error
- name: Clean local build artifacts
if: always()
working-directory: aicx
run: |
cargo clean --target "${{ matrix.target }}" || true
rm -rf dist
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
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- 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: GPG detached-sign release archives
env:
LOCTREE_GPG_KEY_ID: ${{ vars.LOCTREE_GPG_KEY_ID }}
run: |
if [[ -z "${LOCTREE_GPG_KEY_ID}" ]]; then
echo "::error::vars.LOCTREE_GPG_KEY_ID is empty — refusing to publish unsigned release archives." >&2
exit 1
fi
PASSPHRASE_FILE="${HOME}/.keys/.gpg.passphrase"
if [[ ! -r "${PASSPHRASE_FILE}" ]]; then
echo "::error::Passphrase file ${PASSPHRASE_FILE} not readable on runner." >&2
exit 1
fi
if ! command -v gpg >/dev/null 2>&1; then
echo "::error::gpg not available on runner — cannot sign release archives." >&2
exit 1
fi
if ! gpg --list-secret-keys "${LOCTREE_GPG_KEY_ID}" >/dev/null 2>&1; then
echo "::error::GPG secret key ${LOCTREE_GPG_KEY_ID} is not imported on this runner." >&2
exit 1
fi
shopt -s nullglob
for asset in dist/*.tar.gz dist/*.zip; do
gpg --batch --yes --armor --pinentry-mode loopback \
--passphrase-file "${PASSPHRASE_FILE}" \
--sig-notation "apple-developer-team-id@loctree.io=MW223P3NPX" \
--sig-notation "release-source@loctree.io=Loctree/aicx" \
--detach-sign --local-user "${LOCTREE_GPG_KEY_ID}" \
"${asset}"
done
# Export pubkey as release asset so consumers can verify without keyservers.
gpg --export --armor "${LOCTREE_GPG_KEY_ID}" > dist/loctree-release-pubkey.asc
- 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: |
# Selective glob — never upload directories (gh release upload bails
# on dirs with "is a directory"; this killed every CI release prior).
shopt -s nullglob
assets=( dist/*.tar.gz dist/*.tar.gz.sha256 dist/*.tar.gz.asc \
dist/*.zip dist/*.zip.sha256 dist/*.zip.asc \
dist/*.notary.json \
dist/*.deb dist/*.deb.sha256 dist/*.deb.asc \
dist/loctree-release-pubkey.asc )
if [ "${#assets[@]}" -eq 0 ]; then
echo "::error::No release assets matched — refusing to create empty release." >&2
exit 1
fi
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}" "${assets[@]}" --clobber
else
gh release create "${RELEASE_TAG}" "${assets[@]}" --notes-file "${RELEASE_NOTES_PATH}"
fi
- name: Clean downloaded release artifacts
if: always()
run: |
rm -rf dist
rm -f "${RUNNER_TEMP}/release-notes.md"