Skip to content

Commit 2f0ca15

Browse files
rustyconoverclaude
andcommitted
ci: run SQL E2E via standalone haybarn + signed vgi community extension
deps: resolve vgi-python from PyPI; track uv.lock Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0ac35e4 commit 2f0ca15

8 files changed

Lines changed: 782 additions & 46 deletions

File tree

.github/workflows/ci.yml

Lines changed: 91 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,28 @@
1+
# Copyright 2026 Query Farm LLC - https://query.farm
2+
#
3+
# Unit tests (pytest), lint, and the sqllogictest extension suite
4+
# (test/sql/*.test) run against the vgi-news worker through the real signed
5+
# `vgi` DuckDB community extension via a prebuilt standalone `haybarn-unittest`.
6+
# The SQL E2E redirects every provider at a local mock news API — no keys, no
7+
# cost, no live egress. See ci/README.md for the design.
18
name: CI
29

310
on:
411
push:
512
branches: [main]
6-
paths-ignore: ['README.md', 'CLAUDE.md']
13+
paths-ignore: ['README.md']
714
pull_request:
815
branches: [main]
9-
paths-ignore: ['README.md', 'CLAUDE.md']
16+
paths-ignore: ['README.md']
1017
workflow_dispatch:
1118

1219
permissions:
1320
contents: read
1421

22+
concurrency:
23+
group: ci-${{ github.workflow }}-${{ github.ref }}
24+
cancel-in-progress: true
25+
1526
jobs:
1627
test:
1728
name: Python ${{ matrix.python-version }} on ${{ matrix.os }}
@@ -21,60 +32,105 @@ jobs:
2132
matrix:
2233
os: [ubuntu-latest, macos-latest]
2334
python-version: ["3.13"]
35+
2436
steps:
25-
- uses: actions/checkout@v4
37+
- uses: actions/checkout@v6
2638

2739
- name: Install uv
28-
uses: astral-sh/setup-uv@v3
40+
uses: astral-sh/setup-uv@v8.2.0
2941

3042
- name: Set up Python ${{ matrix.python-version }}
3143
run: uv python install ${{ matrix.python-version }}
3244

33-
# Install from PyPI (not the local ../vgi-python path used for dev), so CI
34-
# is self-contained.
3545
- name: Install dependencies
36-
run: |
37-
uv venv
38-
uv pip install vgi-python "httpx>=0.27" "pyarrow>=16" pytest ruff mypy
39-
uv pip install -e . --no-deps
46+
run: uv sync --frozen --all-extras
4047

41-
- name: Lint (ruff + mypy)
42-
run: |
43-
uv run --no-sync ruff check .
44-
uv run --no-sync mypy vgi_news/ news_worker.py
45-
46-
# Unit + mock-server + worker-integration tests. Live (network) tests are
47-
# excluded by default (pyproject addopts: -m 'not live'), so CI never
48-
# touches a real news API.
48+
# The `live` marker (real GDELT over the network) is excluded by default
49+
# via the pyproject addopts.
4950
- name: Run tests
5051
run: uv run --no-sync pytest -q
5152

52-
e2e-sql:
53-
name: SQL end-to-end (haybarn, mocked)
53+
lint:
54+
name: Lint
5455
runs-on: ubuntu-latest
5556
steps:
56-
- uses: actions/checkout@v4
57+
- uses: actions/checkout@v6
5758

5859
- name: Install uv
59-
uses: astral-sh/setup-uv@v3
60+
uses: astral-sh/setup-uv@v8.2.0
61+
62+
- name: Install dependencies
63+
run: uv sync --frozen --all-extras
64+
65+
- name: Check linting
66+
run: uv run --no-sync ruff check .
67+
68+
- name: Type check (mypy)
69+
run: uv run --no-sync mypy vgi_news/ news_worker.py
70+
71+
# Resolve the latest published haybarn release once, so the whole matrix tests
72+
# the same version (and we never hardcode/pin it).
73+
resolve-haybarn:
74+
name: Resolve haybarn release
75+
runs-on: ubuntu-latest
76+
outputs:
77+
release: ${{ steps.r.outputs.release }}
78+
steps:
79+
- id: r
80+
run: |
81+
REL=$(gh release view --repo Query-farm-haybarn/haybarn --json tagName --jq .tagName)
82+
echo "release=$REL" >> "$GITHUB_OUTPUT"
83+
echo "Latest haybarn release: $REL"
84+
env:
85+
GH_TOKEN: ${{ github.token }}
86+
87+
integration:
88+
name: End-to-end SQL on ${{ matrix.os }}
89+
needs: resolve-haybarn
90+
strategy:
91+
fail-fast: false
92+
matrix:
93+
include:
94+
- { os: ubuntu-latest, asset: haybarn_unittest-linux-amd64.zip }
95+
- { os: macos-latest, asset: haybarn_unittest-osx-arm64.zip }
96+
runs-on: ${{ matrix.os }}
97+
defaults:
98+
run:
99+
shell: bash
100+
steps:
101+
- uses: actions/checkout@v6
102+
103+
- name: Install uv
104+
uses: astral-sh/setup-uv@v8.2.0
60105

61106
- name: Set up Python 3.13
62107
run: uv python install 3.13
63108

64-
- name: Install worker dependencies
109+
- name: Install the worker (from the lockfile)
110+
run: uv sync --frozen --python 3.13
111+
112+
- name: Download haybarn-unittest
65113
run: |
66-
uv venv
67-
uv pip install vgi-python "httpx>=0.27" "pyarrow>=16"
68-
uv pip install -e . --no-deps
114+
gh release download "$HAYBARN_RELEASE" \
115+
--repo Query-farm-haybarn/haybarn \
116+
--pattern '${{ matrix.asset }}' \
117+
--output haybarn-unittest.zip --clobber
118+
mkdir -p hb && unzip -o -q haybarn-unittest.zip -d hb
119+
env:
120+
GH_TOKEN: ${{ github.token }}
121+
HAYBARN_RELEASE: ${{ needs.resolve-haybarn.outputs.release }}
69122

70-
- name: Install haybarn-unittest
123+
- name: Resolve runner + worker paths
71124
run: |
72-
uv tool install haybarn-unittest
73-
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
125+
# Absolute paths: run-integration.sh cd's into a staging dir before
126+
# invoking the runner, so relative paths would not resolve. The worker
127+
# runs from the synced .venv (deps resolved from PyPI via the lockfile);
128+
# plain `.venv/bin/python` ignores the PEP 723 header. run-integration.sh
129+
# starts the mock news API and redirects both providers at it.
130+
UNITTEST="$PWD/$(find hb -name 'haybarn-unittest' -type f | head -1)"
131+
chmod +x "$UNITTEST"
132+
echo "HAYBARN_UNITTEST=$UNITTEST" >> "$GITHUB_ENV"
133+
echo "VGI_NEWS_WORKER=$PWD/.venv/bin/python $PWD/news_worker.py" >> "$GITHUB_ENV"
74134
75-
# Drives the worker (as a DuckDB subprocess) against a local mock news API.
76-
# No network egress to GDELT/NewsAPI, no API keys.
77-
- name: Run SQL end-to-end tests
78-
env:
79-
VGI_NEWS_WORKER: .venv/bin/python news_worker.py
80-
run: uv run --no-sync python scripts/run_sql_e2e.py
135+
- name: Run extension integration suite
136+
run: ci/run-integration.sh

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,3 @@ vendor/
99
duckdb_unittest_tempdir/
1010
*.coverage
1111
.coverage
12-
uv.lock

ci/README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# CI: the vgi-calendar worker integration suite
2+
3+
[`.github/workflows/ci.yml`](../.github/workflows/ci.yml) runs the unit tests
4+
and this repo's sqllogictest suite (`test/sql/*.test`) against the vgi-calendar
5+
VGI worker through the **real DuckDB `vgi` extension** on every push / PR.
6+
7+
## How it works (no C++ build)
8+
9+
Rather than building the vgi DuckDB extension from source, CI drives a
10+
**prebuilt** standalone `haybarn-unittest` (the DuckDB/Haybarn sqllogictest
11+
runner, published in Haybarn's releases) and installs the **signed** `vgi`
12+
extension from the Haybarn community channel:
13+
14+
1. **Install the worker**`uv sync --frozen` into a venv. `calendar_worker.py`
15+
is a self-contained PEP 723 stdio worker the extension can spawn via
16+
`uv run calendar_worker.py`.
17+
2. **Download the runner** — the matching `haybarn_unittest-*` asset per
18+
platform from the latest Haybarn release.
19+
3. **Preprocess** — the standalone runner links none of the extensions the
20+
tests gate on, so [`preprocess-require.awk`](preprocess-require.awk) rewrites
21+
each `require <ext>` into an explicit signed `INSTALL <ext> FROM
22+
{community,core}; LOAD <ext>;`. These tests skip `require vgi` (haybarn
23+
silently SKIPs it) and `LOAD vgi;` directly, so the awk also injects an
24+
`INSTALL vgi FROM community;` right before each bare `LOAD vgi;`. `require-env`
25+
and everything else pass through untouched.
26+
4. **Run**[`run-integration.sh`](run-integration.sh) stages the preprocessed
27+
tree, points `VGI_CALENDAR_WORKER` at `uv run calendar_worker.py`, warms the
28+
extension cache once, then runs the suite in a single `haybarn-unittest`
29+
invocation. Any failed assertion exits non-zero and fails the job.
30+
31+
## Run it locally
32+
33+
```bash
34+
uv sync --python 3.13 # install the worker + deps
35+
# point HAYBARN_UNITTEST at a haybarn-unittest binary (or a local DuckDB
36+
# `unittest` built with the vgi extension), and the worker at the stdio command:
37+
HAYBARN_UNITTEST=/path/to/haybarn-unittest \
38+
VGI_CALENDAR_WORKER="uv run --python 3.13 calendar_worker.py" \
39+
ci/run-integration.sh
40+
```
41+
42+
Or use the Makefile target `make test-sql`, which installs `haybarn-unittest`
43+
as a uv tool and points the worker at `uv run --python 3.13 calendar_worker.py`.

ci/preprocess-require.awk

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Copyright 2026 Query Farm LLC - https://query.farm
2+
#
3+
# Rewrite each `require <ext>` gate (and the bare `LOAD vgi;` these tests use
4+
# instead — haybarn skips `require vgi`, so the .test files LOAD it directly)
5+
# in this repo's sqllogictest files into an explicit signed INSTALL+LOAD, so the
6+
# prebuilt standalone `haybarn-unittest` (which links none of these extensions)
7+
# can run the suite. The vgi extension comes from the signed community channel;
8+
# httpfs/json/parquet/spatial from the signed core channel. `require-env` and
9+
# every other directive pass through untouched. See ci/README.md.
10+
/^require[ \t]+vgi[ \t]*$/ {
11+
print "statement ok"; print "INSTALL vgi FROM community;"; print "";
12+
print "statement ok"; print "LOAD vgi;"; next
13+
}
14+
/^require[ \t]+(httpfs|json|parquet|spatial)[ \t]*$/ {
15+
ext = $2
16+
print "statement ok"; print "INSTALL " ext " FROM core;"; print "";
17+
print "statement ok"; print "LOAD " ext ";"; next
18+
}
19+
# These tests gate the worker with `require-env` and then `LOAD vgi;` directly
20+
# (haybarn silently SKIPs `require vgi`). Inject a signed community INSTALL right
21+
# before that bare LOAD so a clean runner can resolve the extension.
22+
/^[ \t]*LOAD[ \t]+vgi[ \t]*;[ \t]*$/ {
23+
print "INSTALL vgi FROM community;"; print $0; next
24+
}
25+
{ print }

ci/run-integration.sh

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
#!/usr/bin/env bash
2+
# Copyright 2026 Query Farm LLC - https://query.farm
3+
#
4+
# Run this repo's sqllogictest suite (test/sql/*.test) against the vgi-news
5+
# VGI worker, using a prebuilt standalone `haybarn-unittest` and the signed
6+
# community `vgi` extension — no C++ build from source. See ci/README.md.
7+
#
8+
# The GDELT / NewsAPI providers are redirected at a local canned-response mock
9+
# HTTP server (the same routes scripts/run_sql_e2e.py serves) via the
10+
# VGI_NEWS_GDELT_BASE_URL / VGI_NEWS_NEWSAPI_BASE_URL env vars, so the suite is
11+
# deterministic and never egresses to a live API.
12+
#
13+
# Required environment:
14+
# HAYBARN_UNITTEST path to the haybarn-unittest binary
15+
# VGI_NEWS_WORKER worker LOCATION the .test files ATTACH (a stdio command)
16+
# Optional:
17+
# STAGE scratch dir for the preprocessed test tree (default: mktemp)
18+
set -euo pipefail
19+
20+
: "${HAYBARN_UNITTEST:?path to the haybarn-unittest binary}"
21+
: "${VGI_NEWS_WORKER:?worker LOCATION (stdio command or http:// URL)}"
22+
23+
HERE="$(cd "$(dirname "$0")" && pwd)"
24+
REPO="$(cd "$HERE/.." && pwd)"
25+
STAGE="${STAGE:-$(mktemp -d)}"
26+
27+
echo "Staging preprocessed tests into $STAGE ..."
28+
mkdir -p "$STAGE/test/sql"
29+
for f in "$REPO"/test/sql/*.test; do
30+
awk -f "$HERE/preprocess-require.awk" "$f" > "$STAGE/test/sql/$(basename "$f")"
31+
done
32+
33+
# Start the canned-response mock news server — reuse the exact routes/fixtures
34+
# from scripts/run_sql_e2e.py. It prints `URL:<base>` once bound and then blocks;
35+
# we read the URL, redirect both providers at it, and kill it on exit.
36+
MOCK_OUT="$(mktemp)"
37+
( cd "$REPO" && REPO="$REPO" uv run --no-sync python - >"$MOCK_OUT" 2>/dev/null <<'PY' ) &
38+
import importlib.util, os, threading, time
39+
from http.server import HTTPServer
40+
41+
spec = importlib.util.spec_from_file_location(
42+
"news_sql_e2e", os.path.join(os.environ["REPO"], "scripts", "run_sql_e2e.py")
43+
)
44+
mod = importlib.util.module_from_spec(spec)
45+
spec.loader.exec_module(mod)
46+
47+
srv = HTTPServer(("127.0.0.1", 0), mod._Handler)
48+
threading.Thread(target=srv.serve_forever, daemon=True).start()
49+
host, port = srv.server_address
50+
print(f"URL:http://{host}:{port}", flush=True)
51+
while True:
52+
time.sleep(3600)
53+
PY
54+
MOCK_PID=$!
55+
cleanup() { kill "$MOCK_PID" 2>/dev/null || true; rm -f "$MOCK_OUT"; }
56+
trap cleanup EXIT
57+
58+
BASE=""
59+
for _ in $(seq 1 100); do
60+
BASE="$(sed -n 's/^URL:\(.*\)$/\1/p' "$MOCK_OUT" | head -n1)"
61+
[ -n "$BASE" ] && break
62+
sleep 0.1
63+
done
64+
if [ -z "$BASE" ]; then
65+
echo "ERROR: mock news server did not report a URL" >&2
66+
exit 1
67+
fi
68+
echo "Mock news server at $BASE"
69+
export VGI_NEWS_GDELT_BASE_URL="$BASE/doc"
70+
export VGI_NEWS_NEWSAPI_BASE_URL="$BASE/everything"
71+
export VGI_NEWS_TIMEOUT="10"
72+
73+
cd "$STAGE"
74+
75+
# Warm the extension cache once: vgi from the signed community channel. A miss
76+
# here is only a warning — the per-test INSTALL/LOAD (injected by
77+
# preprocess-require.awk) is what actually gates each file.
78+
echo "Warming the extension cache (vgi from community) ..."
79+
mkdir -p "$STAGE/test"
80+
cat > "$STAGE/test/_warm.test" <<'EOF'
81+
# name: test/_warm.test
82+
# group: [warm]
83+
statement ok
84+
INSTALL vgi FROM community;
85+
EOF
86+
"$HAYBARN_UNITTEST" "test/_warm.test" >/dev/null 2>&1 || echo "::warning::extension warm step did not fully succeed"
87+
rm -f "$STAGE/test/_warm.test"
88+
89+
# Run the whole suite in one invocation, streaming the runner's native
90+
# sqllogictest report. Any failed assertion exits non-zero and fails the job.
91+
echo "Running suite (worker: $VGI_NEWS_WORKER) ..."
92+
"$HAYBARN_UNITTEST" "test/sql/*"

news_worker.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
# /// script
22
# requires-python = ">=3.13"
33
# dependencies = [
4-
# "vgi-python",
4+
# "vgi-python>=0.8.3",
55
# "httpx>=0.27",
66
# ]
7-
#
8-
# [tool.uv.sources]
9-
# vgi-python = { path = "../vgi-python" }
107
# ///
118
"""VGI worker exposing global news search to DuckDB/SQL.
129

pyproject.toml

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ license = { file = "LICENSE" }
88
authors = [{ name = "Query Farm" }]
99
keywords = ["vgi", "duckdb", "news", "gdelt", "newsapi", "search"]
1010
dependencies = [
11-
"vgi-python",
11+
"vgi-python>=0.8.3",
1212
"httpx>=0.27",
1313
"pyarrow>=16",
1414
]
@@ -20,11 +20,6 @@ dev = [
2020
"mypy>=1.10",
2121
]
2222

23-
# vgi-python is distributed on PyPI as `vgi-python`. For local development
24-
# against an in-tree checkout, uncomment the source override below.
25-
[tool.uv.sources]
26-
vgi-python = { path = "../vgi-python", editable = true }
27-
2823
[build-system]
2924
requires = ["hatchling"]
3025
build-backend = "hatchling.build"

0 commit comments

Comments
 (0)