From a58f6907273a703a1a77705d17ecd9abbeb931d1 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 6 Aug 2026 16:43:56 +0100 Subject: [PATCH 1/2] ci: gate merges on tests, core conformance and a built-image smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo had no pre-merge checks. notify-ci.yml fires on push to main — after the merge button — and only dispatches to the private CI repo; PR checks were CodeRabbit alone, and branch protection required a review but zero status checks. tests/test_antseed_node.py was consequently red for a month across #63/#68/#69/#70/#71 and every one merged. Three jobs: - tests: pytest against a UTF8 Postgres service (SQL_ASCII makes psycopg return TEXT as bytes, which silently voids settings overrides), submodules checked out so the engine-backed tests are real, node present for the sidecar suite. - core-tests: the Lua policy core's unit + golden conformance vectors. - images: build both images, then smoke the artifact. The image smoke is the one that would have caught #95. A green suite proves the REPO is consistent and says nothing about what COPY put in the image — which is exactly how a control.js requiring ./ids.js shipped without it, died at import, and took every wallet endpoint down with a 502. scripts/check_sidecar_modules.js resolves (never executes) each shipped module's local imports inside the built image; verified to reproduce that failure against the old COPY list. --- .github/workflows/ci.yml | 93 ++++++++++++++++++++++++++++++++ scripts/check_sidecar_modules.js | 51 ++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100755 scripts/check_sidecar_modules.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7528eee --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,93 @@ +name: ci + +# Pre-merge gate. Until this existed the repo had NO pre-merge checks at all — +# notify-ci.yml fires on push to main (i.e. after the merge button) and only +# dispatches to the private CI repo. The consequence was not theoretical: +# tests/test_antseed_node.py sat red for a month across #63/#68/#69/#70/#71, +# each PR body noting "1 pre-existing failure", and every one merged. +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ci-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + tests: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: test + # UTF8 explicitly: a SQL_ASCII cluster makes psycopg return TEXT as + # bytes, settings.reload() then silently drops every override and ~26 + # store-backed tests fail for a reason that looks like application code. + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" + POSTGRES_DB: hoststore_test + ports: ["5432:5432"] + options: >- + --health-cmd pg_isready --health-interval 5s + --health-timeout 5s --health-retries 10 + env: + DATABASE_URL: postgresql://postgres:test@localhost:5432/hoststore_test + steps: + - uses: actions/checkout@v4 + with: + # The Lua policy core is a submodule; without it the engine-backed + # tests import nothing and the suite is quietly meaningless. + submodules: recursive + - uses: actions/setup-python@v5 + with: + python-version: "3.12" # matches Dockerfile's python:3.12-slim + cache: pip + - uses: actions/setup-node@v4 + with: + node-version: "22" # tests/test_antseed_node.py shells out to `node --test` + - run: pip install -r requirements.txt -r requirements-dev.txt + - run: python -m pytest tests -q + + core-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: { submodules: recursive } + - run: sudo apt-get update && sudo apt-get install -y lua5.4 + - name: Policy core unit + golden conformance + working-directory: core + run: lua tests/run_lua.lua + + images: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: { submodules: recursive } + + - run: docker build -f Dockerfile -t unhardcoded:ci . + - run: docker build -f Dockerfile.antseed -t unhardcoded-antseed:ci . + + # The check that would have caught #95. A passing test suite proves the + # REPO is consistent; it says nothing about what the COPY actually put in + # the image. control.js gained require('./ids.js') while the Dockerfile + # still named files individually — build succeeded, tests stayed green, + # and the sidecar's control server died at import in prod with every + # wallet endpoint 502ing. Importing each shipped module inside the built + # image is the only place that divergence is observable. + - name: Every shipped sidecar module resolves its imports inside the image + run: | + docker run --rm \ + -v "$PWD/scripts/check_sidecar_modules.js:/tmp/check.js:ro" \ + --entrypoint node unhardcoded-antseed:ci /tmp/check.js + + - name: Router image starts and serves /healthz + run: | + docker run -d --name router-ci -p 18080:18080 --entrypoint sh unhardcoded:ci -c \ + "python serve.py --config config.live.lua --metrics metrics.live.lua \ + --default-profile default --host 0.0.0.0 --port 18080" || true + for i in $(seq 1 30); do + if curl -fsS localhost:18080/healthz >/dev/null 2>&1; then echo "router up"; exit 0; fi + sleep 2 + done + echo "router did not become healthy"; docker logs router-ci | tail -40; exit 1 diff --git a/scripts/check_sidecar_modules.js b/scripts/check_sidecar_modules.js new file mode 100755 index 0000000..5d00c55 --- /dev/null +++ b/scripts/check_sidecar_modules.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node +// Assert every shipped sidecar module can resolve its local imports. +// +// Run INSIDE the built antseed image (CI mounts this file in): +// docker run --rm -v "$PWD/scripts/check_sidecar_modules.js:/tmp/c.js" IMG node /tmp/c.js +// +// Why it has to run in the image rather than the repo: the repo is always +// self-consistent, so the Python and Node suites pass whether or not a file +// reaches the image. control.js gained `require('./ids.js')` while +// Dockerfile.antseed still named its COPY files individually; the build +// succeeded, every test stayed green, and the sidecar's control server died at +// import in production ("Cannot find module './ids.js'") with :8379 never +// binding and every wallet endpoint returning 502. The only place that +// divergence is observable is the built artifact. +// +// Resolution-only: never executes a module, so a checker run cannot start the +// control server or touch a wallet. +const fs = require("fs"); +const path = require("path"); + +const DIR = process.argv[2] || "/usr/local/lib/antseed"; +const LOCAL_IMPORT = /(?:require\(\s*|from\s+)['"](\.\/[^'"]+)['"]/g; + +const shipped = fs.readdirSync(DIR) + .filter((f) => (f.endsWith(".js") || f.endsWith(".mjs")) && !f.endsWith(".test.js")); + +if (shipped.length === 0) { + console.error(`no shipped modules found in ${DIR} — wrong path, or the COPY is broken`); + process.exit(1); +} + +const problems = []; +for (const name of shipped) { + const file = path.join(DIR, name); + const src = fs.readFileSync(file, "utf8"); + for (const m of src.matchAll(LOCAL_IMPORT)) { + const target = path.join(DIR, m[1]); + // .mjs imports carry the extension; require() may omit it. Accept either. + const candidates = path.extname(target) ? [target] : [target + ".js", target + ".mjs", target]; + if (!candidates.some((c) => fs.existsSync(c))) { + problems.push(`${name} imports ${m[1]} — not present in the image`); + } + } +} + +if (problems.length) { + console.error("sidecar image is missing modules its own code requires:"); + for (const p of problems) console.error(" " + p); + process.exit(1); +} +console.log(`ok — ${shipped.length} shipped modules, all local imports resolve`); From 0196b0c35cb082f21bfabe3cec91c02ee9452850 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 6 Aug 2026 16:53:19 +0100 Subject: [PATCH 2/2] ci: give the image smoke a database and fail fast on a dead container The router opens a host-store pool at startup, so booting it without Postgres proved only that it can fail to connect. Adds a postgres service to the images job and runs the container with --network host so it can reach it (a bridged container cannot see the runner's localhost). Also drops the '|| true' after docker run: a container that fails to start must fail the job immediately, not fall through to a curl loop that reports the same thing sixty seconds later. --- .github/workflows/ci.yml | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7528eee..b1175aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,19 @@ jobs: images: runs-on: ubuntu-latest + services: + # The router opens a host-store pool at startup, so a boot smoke needs a + # database or it proves only that the process can fail to connect. + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: test + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" + POSTGRES_DB: hoststore_ci + ports: ["5432:5432"] + options: >- + --health-cmd pg_isready --health-interval 5s + --health-timeout 5s --health-retries 10 steps: - uses: actions/checkout@v4 with: { submodules: recursive } @@ -81,13 +94,24 @@ jobs: -v "$PWD/scripts/check_sidecar_modules.js:/tmp/check.js:ro" \ --entrypoint node unhardcoded-antseed:ci /tmp/check.js - - name: Router image starts and serves /healthz + # --network host so the container reaches the runner's postgres service on + # localhost; a bridged container cannot. No `|| true` on the run: a + # container that fails to start must fail the job, not fall through to a + # curl loop that reports the same thing thirty seconds later. + - name: Router image boots and serves /healthz run: | - docker run -d --name router-ci -p 18080:18080 --entrypoint sh unhardcoded:ci -c \ - "python serve.py --config config.live.lua --metrics metrics.live.lua \ - --default-profile default --host 0.0.0.0 --port 18080" || true - for i in $(seq 1 30); do - if curl -fsS localhost:18080/healthz >/dev/null 2>&1; then echo "router up"; exit 0; fi + set -euo pipefail + docker run -d --name router-ci --network host \ + -e DATABASE_URL='postgresql://postgres:test@localhost:5432/hoststore_ci' \ + --entrypoint python unhardcoded:ci \ + serve.py --config config.live.lua --metrics metrics.live.lua \ + --default-profile default --host 0.0.0.0 --port 18080 + for _ in $(seq 1 30); do + if curl -fsS localhost:18080/healthz >/dev/null 2>&1; then + echo "router image boots and serves /healthz"; exit 0 + fi sleep 2 done - echo "router did not become healthy"; docker logs router-ci | tail -40; exit 1 + echo "::error::router image did not become healthy within 60s" + docker logs router-ci 2>&1 | tail -60 + exit 1