192b277229
Run 3388's gate failed with every route returning "el-runtime: no http handler registered". The runtime resolves handle_request (and the tool handlers) by name via dlsym(RTLD_DEFAULT, ...). On glibc/Linux a symbol is only visible to dlsym if it is in the dynamic symbol table, so the stripped CI binary booted but served nothing. macOS exports these freely, which is why the local build passed and masked it. Add -rdynamic to the cc link (mirrors the Windows build's --export-all-symbols). strip -s keeps .dynsym, so the handler still resolves after stripping. This fixes both the gate AND the actual deployed soul — without it the Linux/GKE soul is a server that answers nothing.
368 lines
15 KiB
YAML
368 lines
15 KiB
YAML
name: Neuron Soul CI
|
|
|
|
on:
|
|
push:
|
|
branches:
|
|
- main
|
|
pull_request:
|
|
branches:
|
|
- main
|
|
workflow_dispatch:
|
|
|
|
# Serialize all activity on the single GCE runner.
|
|
# With build+deploy in the same workflow, a new push queues a single
|
|
# workflow instance — not two competing ones — so the deploy job is
|
|
# never orphaned by a cancellation race.
|
|
concurrency:
|
|
group: neuron-runner
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
|
|
steps:
|
|
- name: Free disk space
|
|
run: |
|
|
df -h /
|
|
docker system prune -af --volumes 2>/dev/null || true
|
|
df -h /
|
|
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Install build dependencies
|
|
run: |
|
|
apt-get update -qq
|
|
apt-get install -y gcc curl libcurl4-openssl-dev apt-transport-https ca-certificates
|
|
echo "deb [trusted=yes] https://packages.cloud.google.com/apt cloud-sdk main" \
|
|
> /etc/apt/sources.list.d/google-cloud-sdk.list
|
|
apt-get update -qq && apt-get install -y google-cloud-cli
|
|
|
|
- name: Download El runtime from Artifact Registry
|
|
env:
|
|
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
|
|
run: |
|
|
echo "${GCP_SA_KEY}" > /tmp/gcp-key.json
|
|
gcloud auth activate-service-account --key-file=/tmp/gcp-key.json
|
|
gcloud config set project neuron-785695
|
|
|
|
rm -rf /opt/el/runtime
|
|
mkdir -p /opt/el/runtime
|
|
|
|
# Get latest version of each runtime package (elc/elb not needed — we compile
|
|
# dist/soul.c directly; running elb on Linux OOM-kills the runner, and we
|
|
# always use the repo's pre-built soul.c anyway).
|
|
get_latest() {
|
|
gcloud artifacts versions list \
|
|
--repository=foundation-prod \
|
|
--location=us-central1 \
|
|
--project=neuron-785695 \
|
|
--package="$1" \
|
|
--sort-by="~createTime" \
|
|
--limit=1 \
|
|
--format="value(name)" 2>/dev/null | awk -F/ '{print $NF}'
|
|
}
|
|
|
|
RC_VER=$(get_latest el-runtime-c)
|
|
RH_VER=$(get_latest el-runtime-h)
|
|
|
|
echo "Downloading runtime@${RC_VER}"
|
|
|
|
gcloud artifacts generic download \
|
|
--repository=foundation-prod --location=us-central1 --project=neuron-785695 \
|
|
--package=el-runtime-c --version="${RC_VER}" \
|
|
--destination=/opt/el/runtime/
|
|
|
|
gcloud artifacts generic download \
|
|
--repository=foundation-prod --location=us-central1 --project=neuron-785695 \
|
|
--package=el-runtime-h --version="${RH_VER}" \
|
|
--destination=/opt/el/runtime/
|
|
|
|
mv /opt/el/runtime/el_runtime.c* /opt/el/runtime/el_runtime.c 2>/dev/null || true
|
|
mv /opt/el/runtime/el_runtime.h* /opt/el/runtime/el_runtime.h 2>/dev/null || true
|
|
echo "El runtime ready: $(ls /opt/el/runtime/)"
|
|
|
|
- name: Build neuron soul binary
|
|
run: |
|
|
RUNTIME=/opt/el/runtime
|
|
|
|
# Compile the self-contained translation unit directly from dist/soul.c.
|
|
# dist/soul.c is the authoritative combined unit maintained in the repo —
|
|
# regenerated on macOS by running elb (which succeeds on arm64/macOS ld but
|
|
# fails on Linux due to duplicate strong symbols). We skip the elb step here
|
|
# entirely: elb on Linux would OOM the runner (elc uses 24GB+ virtual memory
|
|
# on a 16GB host) and we always restore from the repo's soul.c anyway.
|
|
mkdir -p dist
|
|
# -rdynamic: the el runtime resolves the HTTP request handler (and the
|
|
# tool handlers) by NAME via dlsym(RTLD_DEFAULT, "handle_request").
|
|
# macOS exports these symbols freely, but glibc/Linux only makes symbols
|
|
# visible to dlsym if they are in the dynamic symbol table — so without
|
|
# -rdynamic the stripped Linux binary boots but returns "el-runtime: no
|
|
# http handler registered" for EVERY route (i.e. a soul that serves
|
|
# nothing). Same reason the Windows build links -Wl,--export-all-symbols.
|
|
cc -O2 -DHAVE_CURL -rdynamic \
|
|
-I$RUNTIME \
|
|
dist/soul.c \
|
|
$RUNTIME/el_runtime.c \
|
|
-lssl -lcrypto -lcurl -lpthread -lm \
|
|
-o dist/neuron
|
|
|
|
# -s strips .symtab + debug for size. .dynsym (which -rdynamic populated
|
|
# with the dlsym-resolved handlers) is preserved, so the handler still
|
|
# resolves after stripping.
|
|
strip -s dist/neuron
|
|
ls -lh dist/neuron
|
|
|
|
- name: Soul contract gate (HARD BLOCK — no destructive/stale soul publishes)
|
|
run: |
|
|
# Boots dist/neuron on a throwaway port with a throwaway HOME/engram/cgi
|
|
# (never touches ~/.neuron or any live service) and fails the build if any
|
|
# app-contract route is unanswered (PRESENCE) or any engram write route
|
|
# hard-deletes instead of tombstoning/superseding (IMMUTABILITY). Non-zero
|
|
# here blocks Publish -> Artifact Registry -> GKE deploy, so a stale or
|
|
# memory-destroying soul can never reach prod.
|
|
chmod +x dist/neuron scripts/verify-soul-contract.sh
|
|
bash scripts/verify-soul-contract.sh dist/neuron 7796
|
|
|
|
- name: Smoke test
|
|
run: |
|
|
file dist/neuron
|
|
timeout 3 dist/neuron --help 2>&1 || true
|
|
echo "smoke test complete"
|
|
|
|
- name: Publish neuron binary
|
|
if: github.event_name == 'push'
|
|
env:
|
|
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
|
|
run: |
|
|
VERSION="${GITHUB_SHA:0:8}"
|
|
|
|
gcloud artifacts generic upload \
|
|
--repository=foundation-prod \
|
|
--location=us-central1 \
|
|
--project=neuron-785695 \
|
|
--package=neuron-soul \
|
|
--version="${VERSION}" \
|
|
--source=dist/neuron
|
|
|
|
echo "Published neuron-soul@${VERSION}"
|
|
rm -f /tmp/gcp-key.json
|
|
|
|
deploy:
|
|
runs-on: ubuntu-latest
|
|
needs: build
|
|
# Only deploy on push to main, not on PRs or manual workflow_dispatch without intent.
|
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
|
|
env:
|
|
USE_GKE_GCLOUD_AUTH_PLUGIN: "True"
|
|
|
|
steps:
|
|
- name: Free disk space
|
|
run: |
|
|
df -h /
|
|
docker system prune -af --volumes 2>/dev/null || true
|
|
rm -rf /tmp/.act-* /tmp/act-* 2>/dev/null || true
|
|
df -h /
|
|
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Install dependencies
|
|
run: |
|
|
apt-get update -qq
|
|
apt-get install -y --no-install-recommends \
|
|
ca-certificates curl apt-transport-https kubectl
|
|
echo "deb [trusted=yes] https://packages.cloud.google.com/apt cloud-sdk main" \
|
|
> /etc/apt/sources.list.d/google-cloud-sdk.list
|
|
apt-get update -qq && apt-get install -y google-cloud-cli google-cloud-cli-gke-gcloud-auth-plugin
|
|
|
|
- name: Authenticate to GCP
|
|
env:
|
|
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
|
|
run: |
|
|
echo "${GCP_SA_KEY}" > /tmp/gcp-key.json
|
|
gcloud auth activate-service-account --key-file=/tmp/gcp-key.json
|
|
gcloud config set project neuron-785695
|
|
gcloud auth configure-docker us-central1-docker.pkg.dev --quiet
|
|
|
|
- name: Get GKE credentials
|
|
run: |
|
|
gcloud container clusters get-credentials neuron-platform \
|
|
--region=us-central1 \
|
|
--project=neuron-785695
|
|
|
|
- name: Determine image tag and slot
|
|
id: vars
|
|
run: |
|
|
# GITEA_SHA is set by the Gitea runner; fall back to GITHUB_SHA for
|
|
# compatibility with older Forgejo/Gitea versions.
|
|
RAW_SHA="${GITEA_SHA:-${GITHUB_SHA:-}}"
|
|
SHA="${RAW_SHA:0:8}"
|
|
if [ -z "$SHA" ]; then
|
|
# Last resort: read from git directly
|
|
SHA=$(git rev-parse --short=8 HEAD 2>/dev/null || echo "unknown")
|
|
fi
|
|
IMAGE="us-central1-docker.pkg.dev/neuron-785695/neuron-api/neuron-soul:${SHA}"
|
|
echo "sha=${SHA}" >> "$GITEA_OUTPUT"
|
|
echo "image=${IMAGE}" >> "$GITEA_OUTPUT"
|
|
|
|
# Determine which slot is currently idle (0 replicas = idle slot)
|
|
# If both are at 0 (fresh deploy), default to blue
|
|
BLUE_REPLICAS=$(kubectl get deployment/neuron-mcp-blue \
|
|
-n neuron-prod \
|
|
-o jsonpath='{.spec.replicas}' 2>/dev/null || echo "0")
|
|
GREEN_REPLICAS=$(kubectl get deployment/neuron-mcp-green \
|
|
-n neuron-prod \
|
|
-o jsonpath='{.spec.replicas}' 2>/dev/null || echo "0")
|
|
|
|
echo " Blue replicas: ${BLUE_REPLICAS}"
|
|
echo " Green replicas: ${GREEN_REPLICAS}"
|
|
|
|
if [ "${GREEN_REPLICAS}" -eq 0 ] && [ "${BLUE_REPLICAS}" -gt 0 ]; then
|
|
SLOT="green"
|
|
elif [ "${BLUE_REPLICAS}" -eq 0 ] && [ "${GREEN_REPLICAS}" -gt 0 ]; then
|
|
SLOT="blue"
|
|
else
|
|
# Fresh cluster or both idle — deploy to blue first
|
|
SLOT="blue"
|
|
fi
|
|
|
|
echo "slot=${SLOT}" >> "$GITEA_OUTPUT"
|
|
echo " Deploying to slot: ${SLOT}"
|
|
|
|
- name: Prepare build artifacts
|
|
run: |
|
|
# Pre-download soul binary and El SDK so the Dockerfile can COPY them
|
|
# from the build context instead of authenticating inside the build.
|
|
mkdir -p build-artifacts
|
|
|
|
# ── soul binary ────────────────────────────────────────────────────────
|
|
# The build job (same workflow run) just published this version.
|
|
SOUL_VER=$(gcloud artifacts versions list \
|
|
--repository=foundation-prod \
|
|
--location=us-central1 \
|
|
--project=neuron-785695 \
|
|
--package=neuron-soul \
|
|
--sort-by="~createTime" \
|
|
--limit=1 \
|
|
--format="value(name)" 2>/dev/null | awk -F/ '{print $NF}')
|
|
echo "Downloading neuron-soul@${SOUL_VER}"
|
|
gcloud artifacts generic download \
|
|
--repository=foundation-prod \
|
|
--location=us-central1 \
|
|
--project=neuron-785695 \
|
|
--package=neuron-soul \
|
|
--version="${SOUL_VER}" \
|
|
--destination=build-artifacts/
|
|
mv build-artifacts/neuron* build-artifacts/neuron 2>/dev/null || true
|
|
chmod +x build-artifacts/neuron
|
|
|
|
# ── El SDK (for engram source compilation inside the Docker build) ────
|
|
ELC_VER=$(gcloud artifacts versions list \
|
|
--repository=foundation-prod --location=us-central1 --project=neuron-785695 \
|
|
--package=el-elc --sort-by="~createTime" --limit=1 \
|
|
--format="value(name)" 2>/dev/null | awk -F/ '{print $NF}')
|
|
gcloud artifacts generic download \
|
|
--repository=foundation-prod --location=us-central1 --project=neuron-785695 \
|
|
--package=el-elc --version="${ELC_VER}" --destination=build-artifacts/
|
|
mv build-artifacts/elc* build-artifacts/elc 2>/dev/null || true
|
|
chmod +x build-artifacts/elc
|
|
|
|
RC_VER=$(gcloud artifacts versions list \
|
|
--repository=foundation-prod --location=us-central1 --project=neuron-785695 \
|
|
--package=el-runtime-c --sort-by="~createTime" --limit=1 \
|
|
--format="value(name)" 2>/dev/null | awk -F/ '{print $NF}')
|
|
gcloud artifacts generic download \
|
|
--repository=foundation-prod --location=us-central1 --project=neuron-785695 \
|
|
--package=el-runtime-c --version="${RC_VER}" --destination=build-artifacts/
|
|
mv build-artifacts/el_runtime.c* build-artifacts/el_runtime.c 2>/dev/null || true
|
|
|
|
RH_VER=$(gcloud artifacts versions list \
|
|
--repository=foundation-prod --location=us-central1 --project=neuron-785695 \
|
|
--package=el-runtime-h --sort-by="~createTime" --limit=1 \
|
|
--format="value(name)" 2>/dev/null | awk -F/ '{print $NF}')
|
|
gcloud artifacts generic download \
|
|
--repository=foundation-prod --location=us-central1 --project=neuron-785695 \
|
|
--package=el-runtime-h --version="${RH_VER}" --destination=build-artifacts/
|
|
mv build-artifacts/el_runtime.h* build-artifacts/el_runtime.h 2>/dev/null || true
|
|
|
|
echo "Build artifacts ready:"
|
|
ls -lh build-artifacts/
|
|
|
|
- name: Clone engram source for Docker build context
|
|
run: |
|
|
# The Dockerfile builds engram from source (no published AR package).
|
|
# Clone the engram repo into ./engram/ so it's available in the build context.
|
|
git clone http://34.31.145.131/neuron-technologies/engram.git \
|
|
--depth=1 --branch=main \
|
|
engram
|
|
echo "Engram source ready at ./engram/src/server.el"
|
|
|
|
- name: Build and push Docker image
|
|
run: |
|
|
IMAGE="${{ steps.vars.outputs.image }}"
|
|
|
|
echo "Building ${IMAGE}..."
|
|
docker build \
|
|
--tag "${IMAGE}" \
|
|
--tag "us-central1-docker.pkg.dev/neuron-785695/neuron-api/neuron-soul:latest" \
|
|
.
|
|
|
|
echo "Pushing ${IMAGE}..."
|
|
docker push "${IMAGE}"
|
|
docker push "us-central1-docker.pkg.dev/neuron-785695/neuron-api/neuron-soul:latest"
|
|
|
|
- name: Blue-green deploy to GKE
|
|
run: |
|
|
chmod +x scripts/blue-green-deploy.sh
|
|
scripts/blue-green-deploy.sh \
|
|
--image "${{ steps.vars.outputs.image }}" \
|
|
--slot "${{ steps.vars.outputs.slot }}"
|
|
|
|
- name: Update infrastructure manifests
|
|
if: success()
|
|
env:
|
|
INFRA_GIT_TOKEN: ${{ secrets.INFRA_GIT_TOKEN }}
|
|
run: |
|
|
SLOT="${{ steps.vars.outputs.slot }}"
|
|
if [ "$SLOT" = "blue" ]; then IDLE="green"; else IDLE="blue"; fi
|
|
|
|
git clone "http://${INFRA_GIT_TOKEN}@34.31.145.131/neuron-technologies/infrastructure.git" \
|
|
--depth=1 --branch=main /tmp/infra-update
|
|
|
|
cd /tmp/infra-update
|
|
|
|
DEPLOY_DIR="platform/k8s/neuron-mcp"
|
|
sed -i "s/^ replicas: .*/ replicas: 1/" "${DEPLOY_DIR}/deployment-${SLOT}.yaml"
|
|
sed -i "s/^ replicas: .*/ replicas: 0/" "${DEPLOY_DIR}/deployment-${IDLE}.yaml"
|
|
echo " deployment-${SLOT}.yaml: replicas set to 1"
|
|
echo " deployment-${IDLE}.yaml: replicas set to 0"
|
|
|
|
git config user.email "ci@neurontechnologies.ai"
|
|
git config user.name "Neuron CI"
|
|
git add "${DEPLOY_DIR}/deployment-blue.yaml" "${DEPLOY_DIR}/deployment-green.yaml"
|
|
git diff --staged --quiet && { echo "No manifest changes needed"; exit 0; }
|
|
git commit -m "ci: neuron-mcp replica sync after blue-green swap to ${SLOT}"
|
|
git push origin main
|
|
echo "Infrastructure manifests updated: ${SLOT}=1, ${IDLE}=0"
|
|
|
|
- name: Verify deployment
|
|
run: |
|
|
SLOT="${{ steps.vars.outputs.slot }}"
|
|
echo "Verifying neuron-mcp-${SLOT} is healthy..."
|
|
kubectl rollout status deployment/"neuron-mcp-${SLOT}" \
|
|
--namespace=neuron-prod \
|
|
--timeout=8m
|
|
|
|
echo "Active service endpoints:"
|
|
kubectl get endpoints neuron-mcp -n neuron-prod
|
|
|
|
echo "Pod status:"
|
|
kubectl get pods -n neuron-prod -l app=neuron-mcp
|
|
|
|
- name: Cleanup
|
|
if: always()
|
|
run: rm -f /tmp/gcp-key.json
|