diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 279dbc1..12a8b86 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -115,6 +115,11 @@ jobs: echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" echo "Will build and push: ${IMAGE}" + - name: Touch HTML placeholder files + # El binary regenerates these at startup via fs_write. They must exist + # in the build context for Dockerfile COPY to succeed. + run: touch src/index.html src/about.html src/terms.html src/enterprise-terms.html + - name: Build image (build-stage.sh) if: steps.changetype.outputs.asset_only != 'true' env: diff --git a/.gitea/workflows/dev.yaml b/.gitea/workflows/dev.yaml new file mode 100644 index 0000000..ae0353e --- /dev/null +++ b/.gitea/workflows/dev.yaml @@ -0,0 +1,111 @@ +name: Dev — Build & local smoke test + +# Validates that the build compiles and the server starts cleanly. +# No GCP deployment — this is the inner dev loop gate. +# Merge to stage when you want a real environment. + +on: + push: + branches: [dev] + paths: + - 'src/**' + - 'dist/**' + - 'runtime/**' + - 'Dockerfile.stage' + - 'build-stage.sh' + - '.gitea/workflows/dev.yaml' + + workflow_dispatch: + +jobs: + build-smoke: + runs-on: ubuntu-latest + timeout-minutes: 30 + + permissions: + contents: read + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Clone el (provides elc compiler) + env: + CHECKOUT_TOKEN: ${{ secrets.CHECKOUT_TOKEN }} + run: | + set -euo pipefail + DEST="${{ github.workspace }}/../foundation-el" + rm -rf "$DEST" + git clone --depth 1 \ + "https://will:${CHECKOUT_TOKEN}@git.neuralplatform.ai/neuron-technologies/el.git" \ + "$DEST" + echo "EL_HOME=$DEST" >> "$GITHUB_ENV" + + - name: Authenticate to GCP + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCP_SA_KEY }} + + - name: Set up gcloud SDK + uses: google-github-actions/setup-gcloud@v2 + with: + project_id: neuron-785695 + + - name: Configure docker auth for Artifact Registry + run: gcloud auth configure-docker us-central1-docker.pkg.dev --quiet + + - name: Get elc (pre-built linux/amd64) + run: | + set -euo pipefail + curl -fL -o "$EL_HOME/dist/platform/elc" \ + https://git.neuralplatform.ai/neuron-technologies/el/releases/download/v1.2.1/elc-linux-amd64 + chmod +x "$EL_HOME/dist/platform/elc" + + - name: Compute image tag + id: tag + run: echo "tag=dev-${GITHUB_SHA:0:8}" >> "$GITHUB_OUTPUT" + + - name: Touch HTML placeholder files + # El binary regenerates these at startup via fs_write. They must exist + # in the build context for Dockerfile COPY to succeed. touch is + # idempotent if the files already exist from a prior run. + run: touch src/index.html src/about.html src/terms.html src/enterprise-terms.html + + - name: Build image (local only — no push) + env: + EXTRACT_JS: '1' + run: ./build-stage.sh "${{ steps.tag.outputs.tag }}" + + - name: Local smoke test + run: | + set -euo pipefail + IMAGE="marketing:${{ steps.tag.outputs.tag }}" + + docker run -d --name dev-smoke \ + -p 8080:8080 \ + -e PORT=8080 \ + -e NODE_ENV=production \ + -e LANDING_ROOT=/srv/landing \ + "$IMAGE" + + # entrypoint.sh sleeps 4s for soul-demo to load before starting neuron-web. + # Poll up to 45s total. + for i in $(seq 1 15); do + STATUS=$(curl -sSo /dev/null -w "%{http_code}" --max-time 5 http://localhost:8080/ || echo "000") + echo "Attempt $i/15: HTTP $STATUS" + if [ "$STATUS" = "200" ]; then + echo "Dev smoke test PASSED" + docker stop dev-smoke && docker rm dev-smoke + exit 0 + fi + sleep 3 + done + + echo "--- container logs ---" + docker logs dev-smoke || true + docker stop dev-smoke && docker rm dev-smoke || true + echo "Dev smoke test FAILED" + exit 1 diff --git a/.gitea/workflows/stage.yaml b/.gitea/workflows/stage.yaml new file mode 100644 index 0000000..632a5d8 --- /dev/null +++ b/.gitea/workflows/stage.yaml @@ -0,0 +1,189 @@ +name: Stage — Build, push & deploy to marketing-stage + +# Pipeline: build → push → deploy marketing-stage → smoke test. +# STOPS HERE. No prod deploy. Merge to main when stage looks good. + +on: + push: + branches: [stage] + paths: + - 'src/**' + - 'dist/**' + - 'runtime/**' + - 'Dockerfile.stage' + - 'build-stage.sh' + - '.gitea/workflows/stage.yaml' + + workflow_dispatch: + inputs: + tag: + description: 'Image tag to build and deploy (defaults to short SHA)' + required: false + type: string + +jobs: + deploy-stage: + runs-on: ubuntu-latest + timeout-minutes: 45 + + permissions: + contents: read + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Detect change type + id: changetype + run: | + set -euo pipefail + CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || git diff --name-only HEAD 2>/dev/null || echo "unknown") + echo "Changed files:" + echo "$CHANGED" + NON_ASSET=$(echo "$CHANGED" | grep -v '^src/assets/' | grep -v '^src/shares/' | grep -v '^src/index\.html' | grep -v '^src/about\.html' | grep -v '^src/terms\.html' | grep -v '^src/enterprise-terms\.html' | grep -v '^src/llms\.txt' | grep -v '^$' || true) + if [ -z "$NON_ASSET" ] && [ "$CHANGED" != "unknown" ]; then + echo "asset_only=true" >> "$GITHUB_OUTPUT" + echo "=> Asset-only change detected, will use fast path" + else + echo "asset_only=false" >> "$GITHUB_OUTPUT" + echo "=> Full build required" + fi + + - name: Clone el (provides elc compiler) + if: steps.changetype.outputs.asset_only != 'true' + env: + CHECKOUT_TOKEN: ${{ secrets.CHECKOUT_TOKEN }} + run: | + set -euo pipefail + DEST="${{ github.workspace }}/../foundation-el" + rm -rf "$DEST" + git clone --depth 1 \ + "https://will:${CHECKOUT_TOKEN}@git.neuralplatform.ai/neuron-technologies/el.git" \ + "$DEST" + echo "EL_HOME=$DEST" >> "$GITHUB_ENV" + + - name: Authenticate to GCP + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCP_SA_KEY }} + + - name: Set up gcloud SDK + uses: google-github-actions/setup-gcloud@v2 + with: + project_id: neuron-785695 + + - name: Configure docker auth for Artifact Registry + run: gcloud auth configure-docker us-central1-docker.pkg.dev --quiet + + - name: Get elc (pre-built linux/amd64) + if: steps.changetype.outputs.asset_only != 'true' + run: | + set -euo pipefail + curl -fL -o "$EL_HOME/dist/platform/elc" \ + https://git.neuralplatform.ai/neuron-technologies/el/releases/download/v1.2.1/elc-linux-amd64 + chmod +x "$EL_HOME/dist/platform/elc" + + - name: Compute image tag + id: tag + run: | + TAG="${{ inputs.tag }}" + if [ -z "$TAG" ]; then TAG="stage-${GITHUB_SHA:0:8}"; fi + IMAGE="us-central1-docker.pkg.dev/neuron-785695/neuron-marketing/marketing:${TAG}" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" + echo "Will build and push: ${IMAGE}" + + - name: Touch HTML placeholder files + # El binary regenerates these at startup via fs_write. They must exist + # in the build context for Dockerfile COPY to succeed. + run: touch src/index.html src/about.html src/terms.html src/enterprise-terms.html + + - name: Build image (build-stage.sh) + if: steps.changetype.outputs.asset_only != 'true' + env: + EXTRACT_JS: '1' + run: | + ./build-stage.sh "${{ steps.tag.outputs.tag }}" + docker tag "marketing:${{ steps.tag.outputs.tag }}" "${{ steps.tag.outputs.image }}" + docker tag "marketing:${{ steps.tag.outputs.tag }}" "us-central1-docker.pkg.dev/neuron-785695/neuron-marketing/marketing:stage-latest" + + - name: Push image + if: steps.changetype.outputs.asset_only != 'true' + run: | + docker push "${{ steps.tag.outputs.image }}" + docker push "us-central1-docker.pkg.dev/neuron-785695/neuron-marketing/marketing:stage-latest" + + - name: Asset-only fast build + if: steps.changetype.outputs.asset_only == 'true' + env: + IMAGE: ${{ steps.tag.outputs.image }} + run: | + set -euo pipefail + LATEST="us-central1-docker.pkg.dev/neuron-785695/neuron-marketing/marketing:stage-latest" + docker pull "$LATEST" + cat > /tmp/Dockerfile.assets <<'EOF' + ARG BASE + FROM ${BASE} + COPY src/assets /srv/landing/assets + COPY src/shares /srv/landing/shares + EOF + docker build \ + --build-arg BASE="$LATEST" \ + -f /tmp/Dockerfile.assets \ + -t "marketing:${{ steps.tag.outputs.tag }}" \ + -t "$IMAGE" \ + -t "${LATEST%:*}:stage-latest" \ + . + docker push "$IMAGE" + docker push "${LATEST%:*}:stage-latest" + echo "Fast asset build complete" + + - name: Deploy to marketing-stage + id: deploy-stage + env: + IMAGE: ${{ steps.tag.outputs.image }} + run: | + set -euo pipefail + + gcloud run deploy marketing-stage \ + --image "$IMAGE" \ + --region us-central1 \ + --project neuron-785695 \ + --service-account neuron-marketing-sa@neuron-785695.iam.gserviceaccount.com \ + --update-env-vars "NODE_ENV=production,STRIPE_PUBLISHABLE_KEY=pk_test_51TPoHnJg9Fv1D3AUp1FEMcy4MGlKRZqs4scW66kjQFQjWofmNc2rottzXzDaXekHvuw1OQpyp2WCIsc7O5fXIG0G00HQQrkdGX,GCS_SHARE_BUCKET=neuron-shares-prod,SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9jb2pzZ2hhb25sdHVuaWRrenB3Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3Nzc2NDIxNjgsImV4cCI6MjA5MzIxODE2OH0.e0FVFw1aahnrBVvnkR5R8a-RxCx095U8o_gsk7Quq3E,NEURON_LLM_0_FORMAT=anthropic,NEURON_LLM_0_MODEL=claude-sonnet-4-5,NEURON_LLM_0_URL=https://api.anthropic.com/v1/messages" \ + --update-secrets "SUPABASE_SERVICE_KEY=supabase-service-key:latest,NEURON_LLM_0_KEY=anthropic-api-key:latest,ANTHROPIC_API_KEY=anthropic-api-key:latest,STRIPE_SECRET_KEY=stripe-secret-key-stage:latest,STRIPE_WEBHOOK_SECRET=stripe-webhook-secret-stage:latest,STRIPE_PRICE_PROFESSIONAL=stripe-price-professional-stage:latest,STRIPE_PRICE_FOUNDING=stripe-price-founding-stage:latest,STRIPE_PRICE_FAMILY_CHILD=stripe-price-family-child:latest,RESEND_API_KEY=resend-api-key:latest,DOCUSEAL_WEBHOOK_TOKEN=docuseal-webhook-token:latest" \ + --allow-unauthenticated \ + --quiet + + STAGE_URL=$(gcloud run services describe marketing-stage \ + --region us-central1 --project neuron-785695 \ + --format 'value(status.url)') + echo "stage_url=${STAGE_URL}" >> "$GITHUB_OUTPUT" + echo "Stage URL: ${STAGE_URL}" + + gcloud run services update marketing-stage \ + --region us-central1 --project neuron-785695 \ + --update-env-vars "NEURON_ORIGIN=${STAGE_URL}" \ + --quiet + + - name: Smoke test stage + run: | + set -euo pipefail + STAGE_URL="${{ steps.deploy-stage.outputs.stage_url }}" + echo "Smoke testing stage: ${STAGE_URL}" + + for i in $(seq 1 18); do + STATUS=$(curl -sSo /dev/null -w "%{http_code}" --max-time 15 "${STAGE_URL}/" || echo "000") + echo "Attempt ${i}/18: HTTP ${STATUS}" + if [ "$STATUS" = "200" ]; then + echo "Stage smoke test PASSED — merge to main when ready" + exit 0 + fi + sleep 5 + done + + echo "Stage smoke test FAILED" + exit 1 diff --git a/runtime/el_runtime.c b/runtime/el_runtime.c index 8f8a602..0ba1432 100644 --- a/runtime/el_runtime.c +++ b/runtime/el_runtime.c @@ -38,6 +38,7 @@ #include #include /* dlsym for http_set_handler fallback */ #include +#include #include #include #include @@ -1853,6 +1854,84 @@ el_val_t fs_write_bytes(el_val_t pathv, el_val_t bytesv, el_val_t lengthv) { return 1; } +// exec_command — run a shell command, return exit code (0 = success). +// Used by elb and other El tooling to invoke subprocesses. +el_val_t exec_command(el_val_t cmdv) { + const char* cmd = EL_CSTR(cmdv); + if (!cmd) return (el_val_t)(int64_t)-1; + int ret = system(cmd); + return (el_val_t)(int64_t)ret; +} + +// exec_capture — run a shell command, capture stdout, return as String. +// Returns "" on failure. +el_val_t exec_capture(el_val_t cmdv) { + const char* cmd = EL_CSTR(cmdv); + if (!cmd) return el_wrap_str(el_strdup("")); + FILE* f = popen(cmd, "r"); + if (!f) return el_wrap_str(el_strdup("")); + JsonBuf b; jb_init(&b); + char buf[4096]; + while (fgets(buf, sizeof(buf), f)) jb_puts(&b, buf); + pclose(f); + return el_wrap_str(b.buf); +} + +// exec — run a shell command via /bin/sh, capture stdout, return as String. +// Times out after 30 seconds. Returns "" on any error. +// El name: exec(cmd) -> String +el_val_t exec(el_val_t cmdv) { + const char* cmd = EL_CSTR(cmdv); + if (!cmd || !*cmd) return el_wrap_str(el_strdup("")); + /* Build a time-limited command: wrap with timeout(1) if available, + * otherwise rely on the 30s read loop guard below. We use the simple + * popen approach with a deadline measured by wall clock so the caller + * is never blocked indefinitely. */ + FILE* f = popen(cmd, "r"); + if (!f) return el_wrap_str(el_strdup("")); + JsonBuf b; jb_init(&b); + char buf[4096]; + /* 30-second wall-clock deadline */ + time_t deadline = time(NULL) + 30; + while (time(NULL) < deadline) { + if (fgets(buf, sizeof(buf), f) == NULL) break; + jb_puts(&b, buf); + } + pclose(f); + return el_wrap_str(b.buf); +} + +// exec_bg — run a shell command in background, return PID as String. +// The child process runs independently; the caller is not blocked. +// Returns "" on fork failure. +// El name: exec_bg(cmd) -> String +el_val_t exec_bg(el_val_t cmdv) { + const char* cmd = EL_CSTR(cmdv); + if (!cmd || !*cmd) return el_wrap_str(el_strdup("")); + pid_t pid = fork(); + if (pid < 0) { + /* fork failed */ + return el_wrap_str(el_strdup("")); + } + if (pid == 0) { + /* child: detach from parent's stdio, exec via shell */ + setsid(); + int devnull = open("/dev/null", O_RDWR); + if (devnull >= 0) { + dup2(devnull, STDIN_FILENO); + dup2(devnull, STDOUT_FILENO); + dup2(devnull, STDERR_FILENO); + close(devnull); + } + execl("/bin/sh", "sh", "-c", cmd, (char*)NULL); + _exit(127); + } + /* parent: convert pid to string and return immediately */ + char pidbuf[32]; + snprintf(pidbuf, sizeof(pidbuf), "%d", (int)pid); + return el_wrap_str(el_strdup(pidbuf)); +} + el_val_t fs_list(el_val_t pathv) { const char* path = EL_CSTR(pathv); el_val_t lst = el_list_empty(); @@ -2916,8 +2995,13 @@ static int looks_like_string(el_val_t v) { const unsigned char* s = (const unsigned char*)p; for (int i = 0; i < 16; i++) { unsigned char c = s[i]; - if (c == '\0') return i > 0; /* terminated string */ - if (c < 0x09 || (c > 0x0d && c < 0x20) || c >= 0x7f) return 0; + if (c == '\0') return 1; /* terminated string (empty string is still a valid string) */ + /* Reject C0 control chars (non-whitespace), allow UTF-8 high bytes. + * 0x09-0x0d = tab/newline/cr/vt/ff (whitespace, OK) + * 0x20-0x7e = printable ASCII (OK) + * 0x7f = DEL (reject) + * 0x80-0xff = UTF-8 continuation/lead bytes (OK for multi-byte chars) */ + if (c < 0x09 || (c > 0x0d && c < 0x20) || c == 0x7f) return 0; } return 1; /* 16+ printable bytes — call it a string */ } diff --git a/runtime/el_runtime.h b/runtime/el_runtime.h index 68bae8b..72bbf4b 100644 --- a/runtime/el_runtime.h +++ b/runtime/el_runtime.h @@ -739,6 +739,12 @@ el_val_t map_set(el_val_t map, el_val_t key, el_val_t value); /* el_map_set */ /* See bottom of el_runtime.c for the implementation. * Configured by env vars OTLP_ENDPOINT, OTEL_SERVICE_NAME, OTEL_SERVICE_VERSION. * No-op when OTLP_ENDPOINT is unset. Drop-on-failure semantics. */ +/* ── Subprocess execution ────────────────────────────────────────────────── */ +el_val_t exec_command(el_val_t cmd); /* run shell command, return exit code */ +el_val_t exec_capture(el_val_t cmd); /* run shell command, capture stdout */ +el_val_t exec(el_val_t cmd); /* exec(cmd) → stdout String (30s timeout) */ +el_val_t exec_bg(el_val_t cmd); /* exec_bg(cmd) → PID String (non-blocking) */ + el_val_t emit_log(el_val_t level, el_val_t msg, el_val_t fields_json); el_val_t emit_metric(el_val_t name, el_val_t value, el_val_t tags_json); el_val_t trace_span_start(el_val_t name); diff --git a/src/assets/js/manifest.json b/src/assets/js/manifest.json index 05f414f..be19ba2 100644 --- a/src/assets/js/manifest.json +++ b/src/assets/js/manifest.json @@ -15,7 +15,8 @@ "hash": "7eac0621cbca", "asset": "/assets/js/7eac0621cbca.js", "size": 2583, - "interpolated": [] + "interpolated": [], + "note": "carried from prior run" }, { "file": "checkout.el",