diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b1175aa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,117 @@ +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 + 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 } + + - 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 + + # --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: | + 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 "::error::router image did not become healthy within 60s" + docker logs router-ci 2>&1 | tail -60 + 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`);