name: Build, Test, and Release
on:
push:
tags:
- 'v*'
branches:
- 'main'
permissions:
contents: write
jobs:
build-binaries:
name: Build Binaries
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Generate cache keys
id: cache-keys
run: |
# Create hash for Elixir code files
find lib mix.exs mix.lock -type f -name "*.ex" -o -name "*.exs" | sort | xargs cat | shasum -a 256 | cut -d ' ' -f1 > elixir_hash
# Create hash for MCP server code
shasum -a 256 index.ts | cut -d ' ' -f1 > mcp_hash
echo "elixir_hash=$(cat elixir_hash)" >> $GITHUB_OUTPUT
echo "mcp_hash=$(cat mcp_hash)" >> $GITHUB_OUTPUT
- name: Cache build artifacts
id: cache-build
uses: actions/cache@v4
with:
path: burrito_out
key: ${{ runner.os }}-burrito-${{ steps.cache-keys.outputs.elixir_hash }}-${{ steps.cache-keys.outputs.mcp_hash }}
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
- if: steps.cache-build.outputs.cache-hit != 'true' || startsWith(github.ref, 'refs/tags/')
uses: goto-bus-stop/setup-zig@v2
with:
version: 0.14.0
- if: steps.cache-build.outputs.cache-hit != 'true' || startsWith(github.ref, 'refs/tags/')
run: sudo apt-get -y install xz-utils
- if: steps.cache-build.outputs.cache-hit != 'true' || startsWith(github.ref, 'refs/tags/')
uses: erlef/setup-beam@v1
with:
otp-version: '27.2'
elixir-version: '1.18.2'
- if: steps.cache-build.outputs.cache-hit != 'true' || startsWith(github.ref, 'refs/tags/')
uses: actions/setup-node@v4
with:
node-version: '20'
- if: steps.cache-build.outputs.cache-hit != 'true' || startsWith(github.ref, 'refs/tags/')
name: Install dependencies
run: |
mix local.hex --force
mix local.rebar --force
mix deps.get
- if: steps.cache-build.outputs.cache-hit != 'true' || startsWith(github.ref, 'refs/tags/')
name: Build release with Burrito
run: |
mix deps.compile
MIX_ENV=prod mix release
- name: Generate checksums
run: |
cd burrito_out
node -e '
const crypto = require("crypto");
const fs = require("fs");
const files = ["hexdocs_mcp_windows.exe", "hexdocs_mcp_linux", "hexdocs_mcp_macos", "hexdocs_mcp_macos_arm"];
const checksums = files.map(file => {
try {
const hash = crypto.createHash("sha256");
const data = fs.readFileSync(file);
hash.update(data);
return `${hash.digest("hex")} ${file}`;
} catch (error) {
console.error(`Error processing ${file}:`, error);
process.exit(1);
}
});
fs.writeFileSync("SHA256SUMS", checksums.join("\n") + "\n");
'
cd ..
- name: Import GPG key
uses: crazy-max/ghaction-import-gpg@v6
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
- name: Export public key and sign checksums
run: |
cd burrito_out
# Export the public key
gpg --armor --export ${{ secrets.GPG_KEY_ID }} > SIGNING_KEY.asc
# Sign the checksums
gpg --batch --yes --detach-sign --armor SHA256SUMS
cd ..
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: binaries
path: |
burrito_out/hexdocs_mcp_windows.exe
burrito_out/hexdocs_mcp_linux
burrito_out/hexdocs_mcp_macos
burrito_out/hexdocs_mcp_macos_arm
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
files: |
burrito_out/hexdocs_mcp_windows.exe
burrito_out/hexdocs_mcp_linux
burrito_out/hexdocs_mcp_macos
burrito_out/hexdocs_mcp_macos_arm
burrito_out/SHA256SUMS
burrito_out/SHA256SUMS.asc
burrito_out/SIGNING_KEY.asc
if: startsWith(github.ref, 'refs/tags/')
test-binaries:
name: Test Binaries
needs: build-binaries
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
binary: hexdocs_mcp_linux
chmod: true
- os: macos-latest
binary: hexdocs_mcp_macos_arm
chmod: true
- os: windows-latest
binary: hexdocs_mcp_windows.exe
chmod: false
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
name: binaries
- name: Make binary executable
if: matrix.chmod
run: chmod +x ${{ matrix.binary }}
shell: bash
- name: Setup BEAM (Erlang/Elixir) for Linux/Windows
if: matrix.os == 'ubuntu-latest' || matrix.os == 'windows-latest'
uses: erlef/setup-beam@v1
with:
otp-version: '27.2'
elixir-version: '1.18.2'
- name: Install Elixir (macOS)
if: matrix.os == 'macos-latest'
run: |
brew install erlang elixir
mix local.hex --force
mix local.rebar --force
shell: bash
- name: Verify mix installation
run: mix --version
shell: bash
- name: Install Ollama (Linux)
if: matrix.os == 'ubuntu-latest'
run: |
curl -fsSL https://ollama.com/install.sh | sh
ollama serve &
sleep 10
ollama pull nomic-embed-text
if curl -s http://localhost:11434/api/tags > /dev/null; then
echo "Ollama is running and accessible"
else
echo "WARNING: Ollama API is not accessible"
ps aux | grep ollama
fi
shell: bash
- name: Set up Homebrew
if: matrix.os == 'macos-latest'
id: set-up-homebrew
uses: Homebrew/actions/setup-homebrew@master
- name: Install Ollama (macOS)
if: matrix.os == 'macos-latest'
run: |
brew install --cask ollama
ollama serve &
sleep 10
ollama pull nomic-embed-text
if curl -s http://localhost:11434/api/tags > /dev/null; then
echo "Ollama is running and accessible"
else
echo "WARNING: Ollama API is not accessible"
ps aux | grep ollama
fi
shell: bash
- name: Install Ollama (Windows)
if: matrix.os == 'windows-latest'
run: |
Write-Host "Downloading Ollama standalone CLI..."
Invoke-WebRequest -Uri "https://ollama.com/download/ollama-windows-amd64.zip" -OutFile "ollama.zip"
Expand-Archive -Path "ollama.zip" -DestinationPath "C:\ollama"
echo "C:\ollama" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
$env:PATH = "C:\ollama;$env:PATH"
Write-Host "Starting Ollama server in background..."
$ollamaProcess = Start-Process -FilePath "C:\ollama\ollama.exe" -ArgumentList "serve" -WindowStyle Hidden -PassThru
Write-Host "Started Ollama process with PID: $($ollamaProcess.Id)"
Write-Host "Waiting for Ollama to start..."
$attempts = 0
$maxAttempts = 30
while ($attempts -lt $maxAttempts) {
try {
$response = Invoke-RestMethod -Uri "http://localhost:11434/api/tags" -Method GET -ErrorAction Stop
Write-Host "Ollama is running and accessible!"
break
} catch {
$attempts++
if ($attempts -eq $maxAttempts) {
Write-Host "ERROR: Ollama failed to start after $maxAttempts attempts"
Get-Process -Name "ollama" -ErrorAction SilentlyContinue
exit 1
}
Write-Host "Attempt $attempts/$maxAttempts - Ollama not ready yet, waiting..."
Start-Sleep -Seconds 2
}
}
Write-Host "Testing Ollama embedding endpoint before model pull..."
try {
$testPayload = @{
model = "nomic-embed-text"
input = "test"
} | ConvertTo-Json
$embeddingResponse = Invoke-RestMethod -Uri "http://localhost:11434/api/embed" -Method POST -Body $testPayload -ContentType "application/json" -ErrorAction Stop
Write-Host "Note: Embedding endpoint accessible but model not yet downloaded"
} catch {
Write-Host "Expected: Model not yet available, pulling now..."
}
Write-Host "Pulling nomic-embed-text model..."
& C:\ollama\ollama.exe pull nomic-embed-text
if ($LASTEXITCODE -ne 0) {
Write-Host "ERROR: Failed to pull model"
exit 1
}
$models = & C:\ollama\ollama.exe list
Write-Host "Available models:"
Write-Host $models
Write-Host "Testing Ollama embedding endpoint after model pull..."
$retries = 0
$maxRetries = 5
while ($retries -lt $maxRetries) {
try {
$testPayload = @{
model = "nomic-embed-text"
input = "test"
} | ConvertTo-Json
$embeddingResponse = Invoke-RestMethod -Uri "http://localhost:11434/api/embed" -Method POST -Body $testPayload -ContentType "application/json" -ErrorAction Stop
Write-Host "SUCCESS: Ollama embedding endpoint is working!"
Write-Host "Embedding dimension: $($embeddingResponse.embeddings[0].Length)"
break
} catch {
$retries++
if ($retries -eq $maxRetries) {
Write-Host "ERROR: Failed to generate test embedding after $maxRetries attempts: $_"
Write-Host "Checking if Ollama is still running..."
Get-Process -Name "ollama" -ErrorAction SilentlyContinue
try {
$response = Invoke-RestMethod -Uri "http://localhost:11434/api/tags" -Method GET -ErrorAction Stop
Write-Host "Ollama API is still accessible but embedding generation fails"
} catch {
Write-Host "ERROR: Ollama API is no longer accessible"
}
exit 1
}
Write-Host "Retry $retries/$maxRetries - Waiting for model to be ready..."
Start-Sleep -Seconds 2
}
}
shell: pwsh
- name: Create test project (Linux/macOS)
if: matrix.os != 'windows-latest'
run: |
mkdir -p test_project
cd test_project
cat > mix.exs << 'EOF'
defmodule TestProject.MixProject do
use Mix.Project
def project do
[
app: :test_project,
version: "0.1.0",
deps: deps()
]
end
defp deps do
[
{:nimble_parsec, "~> 1.4"}
]
end
end
EOF
cd ..
shell: bash
- name: Create test project (Windows)
if: matrix.os == 'windows-latest'
run: |
mkdir test_project
cd test_project
@'
defmodule TestProject.MixProject do
use Mix.Project
def project do
[
app: :test_project,
version: "0.1.0",
deps: deps()
]
end
defp deps do
[
{:nimble_parsec, "~> 1.4"}
]
end
end
'@ | Out-File -FilePath mix.exs -Encoding utf8
cd ..
shell: pwsh
- name: Verify Ollama is still running (Windows)
if: matrix.os == 'windows-latest'
run: |
Write-Host "Checking if Ollama is still accessible..."
try {
$response = Invoke-RestMethod -Uri "http://localhost:11434/api/tags" -Method GET -ErrorAction Stop
Write-Host "✓ Ollama is accessible"
} catch {
Write-Host "✗ Ollama is not accessible: $_"
Get-Process -Name "ollama" -ErrorAction SilentlyContinue | Format-Table
exit 1
}
shell: pwsh
- name: Test help command
run: ./${{ matrix.binary }} --help
shell: bash
- name: Test fetch_docs command help
run: ./${{ matrix.binary }} fetch_docs --help
shell: bash
- name: Test semantic_search command help
run: ./${{ matrix.binary }} semantic_search --help
shell: bash
- name: Test hex_search command help
run: ./${{ matrix.binary }} hex_search --help
shell: bash
- name: Test fulltext_search command help
run: ./${{ matrix.binary }} fulltext_search --help
shell: bash
- name: Test check_embeddings command help
run: ./${{ matrix.binary }} check_embeddings --help
shell: bash
- name: Test model availability check - should fail with missing model error
run: |
echo "Testing that fetch_docs fails gracefully when mxbai-embed-large model is not available..."
output=$(./${{ matrix.binary }} fetch_docs jason 2>&1) || true
# Check if the output contains the expected error message
if echo "$output" | grep -q "Model 'mxbai-embed-large' not found"; then
echo "✓ PASS: Correct error message displayed for missing model"
echo "Error message output:"
echo "$output" | grep -A 10 -B 2 "Model.*not found"
else
echo "✗ FAIL: Expected error message not found"
echo "Full output:"
echo "$output"
exit 1
fi
# Check if it suggests the correct ollama pull command
if echo "$output" | grep -q "ollama pull mxbai-embed-large"; then
echo "✓ PASS: Correctly suggests pulling the required model"
else
echo "✗ FAIL: Does not suggest pulling the required model"
echo "Full output:"
echo "$output"
exit 1
fi
shell: bash
env:
OLLAMA_HOST: http://localhost:11434
- name: Pull mxbai-embed-large model for testing
run: |
echo "Pulling mxbai-embed-large model for subsequent tests..."
if [ "${{ matrix.os }}" = "windows-latest" ]; then
C:/ollama/ollama.exe pull mxbai-embed-large
else
ollama pull mxbai-embed-large
fi
echo "✓ Model pulled successfully"
shell: bash
- name: Test model availability check - should succeed with available model
run: |
echo "Testing that fetch_docs works when mxbai-embed-large model is available..."
output=$(./${{ matrix.binary }} fetch_docs jason 2>&1) || exit_code=$?
# The command might succeed or fail for other reasons (like missing packages),
# but it should NOT fail with "model not found" error
if echo "$output" | grep -q "Model 'mxbai-embed-large' not found"; then
echo "✗ FAIL: Model not found error still appears after pulling"
echo "Full output:"
echo "$output"
exit 1
else
echo "✓ PASS: No 'model not found' error - model availability check passed"
fi
shell: bash
env:
OLLAMA_HOST: http://localhost:11434
- name: Test default model behavior - should use mxbai-embed-large by default
run: |
echo "Testing that default model is mxbai-embed-large..."
# Try to fetch a small package with default model (should work now that we pulled mxbai-embed-large)
output=$(./${{ matrix.binary }} fetch_docs jason 2>&1) || exit_code=$?
# Should not complain about missing model since we have mxbai-embed-large
if echo "$output" | grep -q "Model.*not found"; then
echo "✗ FAIL: Default model seems to not be available"
echo "Full output:"
echo "$output"
exit 1
else
echo "✓ PASS: Default model works (mxbai-embed-large is being used)"
fi
shell: bash
env:
OLLAMA_HOST: http://localhost:11434
- name: Test fetch_docs specific package
run: ./${{ matrix.binary }} fetch_docs nimble_parsec
shell: bash
env:
OLLAMA_HOST: http://localhost:11434
- name: Test fetch_docs specific package with version
run: ./${{ matrix.binary }} fetch_docs nimble_parsec 1.4.0
shell: bash
env:
OLLAMA_HOST: http://localhost:11434
- name: Test fetch_docs with mxbai-embed-large
run: ./${{ matrix.binary }} fetch_docs jason
shell: bash
env:
OLLAMA_HOST: http://localhost:11434
- name: Test fetch_docs with force option
run: ./${{ matrix.binary }} fetch_docs nimble_parsec --force
shell: bash
env:
OLLAMA_HOST: http://localhost:11434
- name: Test fetch_docs from project
run: ./${{ matrix.binary }} fetch_docs --project test_project/mix.exs
shell: bash
env:
OLLAMA_HOST: http://localhost:11434
- name: Test fetch_docs specific package from project
run: ./${{ matrix.binary }} fetch_docs nimble_parsec --project test_project/mix.exs
shell: bash
env:
OLLAMA_HOST: http://localhost:11434
- name: Test semantic_search with query
run: ./${{ matrix.binary }} semantic_search -q "what is nimble_parsec?"
shell: bash
env:
OLLAMA_HOST: http://localhost:11434
- name: Test semantic_search in specific package
run: ./${{ matrix.binary }} semantic_search nimble_parsec -q "how to define a parser"
shell: bash
env:
OLLAMA_HOST: http://localhost:11434
- name: Test semantic_search in specific package version
run: ./${{ matrix.binary }} semantic_search nimble_parsec 1.4.0 -q "how to use combinators"
shell: bash
env:
OLLAMA_HOST: http://localhost:11434
- name: Test semantic_search with mxbai-embed-large
run: ./${{ matrix.binary }} semantic_search nimble_parsec -q "parser configuration"
shell: bash
env:
OLLAMA_HOST: http://localhost:11434
- name: Test semantic_search with custom limit
run: ./${{ matrix.binary }} semantic_search nimble_parsec -q "nimble parser" --limit 5
shell: bash
env:
OLLAMA_HOST: http://localhost:11434
- name: Test hex_search for packages
run: ./${{ matrix.binary }} hex_search --query "parser" --limit 3
shell: bash
- name: Test hex_search within package versions
run: ./${{ matrix.binary }} hex_search nimble_parsec --query "1." --limit 3
shell: bash
- name: Test hex_search specific version
run: ./${{ matrix.binary }} hex_search nimble_parsec 1.4.0 --query "info"
shell: bash
- name: Test fulltext_search across all packages
run: ./${{ matrix.binary }} fulltext_search --query "parser" --limit 3
shell: bash
- name: Test fulltext_search within package
run: ./${{ matrix.binary }} fulltext_search nimble_parsec --query "combinator" --limit 3
shell: bash
- name: Test check_embeddings for existing package
run: ./${{ matrix.binary }} check_embeddings nimble_parsec
shell: bash
- name: Test check_embeddings for existing package with version
run: ./${{ matrix.binary }} check_embeddings nimble_parsec 1.4.0
shell: bash
- name: Test check_embeddings for non-existent package
run: |
./${{ matrix.binary }} check_embeddings non_existent_package_xyz || echo "Expected error, command continued"
shell: bash
- name: Test invalid command handling
run: |
./${{ matrix.binary }} invalid_command || echo "Expected error, command continued"
shell: bash