Skip to content

Commit 3a2249f

Browse files
committed
Provide matching native builds for SDK CI and reject empty test runs
1 parent 0444b1a commit 3a2249f

5 files changed

Lines changed: 287 additions & 1 deletion

File tree

‎.github/workflows/integration.yml‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ jobs:
6464
with:
6565
submodules: recursive
6666

67+
- name: Check native artifact selection
68+
run: python3 -m unittest discover -s test/scripts -p 'test_*.py'
69+
6770
- name: Install build toolchain
6871
run: |
6972
sudo apt-get update
@@ -122,6 +125,25 @@ jobs:
122125
- name: Build the extension (release)
123126
run: make release
124127

128+
# SDKs changing the wire contract need the matching development client
129+
# before that revision is published to the signed community channel.
130+
# Both executables statically link VGI and its core dependencies.
131+
- name: Package native SDK CI executables
132+
if: github.ref == 'refs/heads/main'
133+
run: |
134+
python3 scripts/ci_fetch_native_runner.py --pack \
135+
--source "$GITHUB_WORKSPACE" --build build/release \
136+
--output "$RUNNER_TEMP/vgi-native-runner"
137+
138+
- name: Upload native SDK CI executables
139+
if: github.ref == 'refs/heads/main'
140+
uses: actions/upload-artifact@v4
141+
with:
142+
name: vgi-native-runner-linux_amd64
143+
path: ${{ runner.temp }}/vgi-native-runner/
144+
if-no-files-found: error
145+
retention-days: 90
146+
125147
- name: Run Filter v2 producer tests
126148
run: make test_filter_v2
127149

‎docs/catalog_contents.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,15 @@ those package releases are pending. Package publication remains a separate step;
457457
normal Git pushes do not publish them. S3 adapters were checked against
458458
conditional HTTP storage fixtures, without live bucket credentials.
459459

460+
SDK development CI also pins a matching native VGI revision. The native
461+
integration workflow packages its statically linked test runner and CLI;
462+
`scripts/ci_fetch_native_runner.py` selects the exact revision's artifact and
463+
verifies its manifest and executable checksums. Missing artifacts fail the job.
464+
This lets a new wire contract run through CI before its public extension release.
465+
The development runner links VGI, HTTPFS, JSON, and Parquet; Spatial remains an
466+
explicitly reported unsupported dependency in that mode. The integration scripts
467+
retain their signed-community mode for release qualification and local runs.
468+
460469
To rerun the shared suite from a built native checkout, select a worker and use
461470
an unused temporary directory. The leading wildcard is necessary when the
462471
runner registers absolute test paths:

‎scripts/ci_fetch_native_runner.py‎

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
#!/usr/bin/env python3
2+
"""Package or fetch the native CI executables for an exact VGI main revision.
3+
4+
SDK development CI must use a client with the same wire contract as its workers.
5+
The signed community extension is released separately and can still implement
6+
the previous contract. This helper never falls back to another revision.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import argparse
12+
import hashlib
13+
import json
14+
import os
15+
from pathlib import Path
16+
import platform
17+
import re
18+
import shutil
19+
import subprocess
20+
import tempfile
21+
import time
22+
23+
24+
REPOSITORY = "Query-farm/vgi"
25+
26+
27+
def command(*args: str) -> str:
28+
return subprocess.check_output(args, text=True).strip()
29+
30+
31+
def platform_name() -> str:
32+
machines = {"x86_64": "amd64", "aarch64": "arm64", "arm64": "arm64"}
33+
if platform.system() != "Linux" or platform.machine() not in machines:
34+
raise RuntimeError("native SDK CI artifacts require Linux amd64 or arm64")
35+
return "linux_" + machines[platform.machine()]
36+
37+
38+
def digest(path: Path) -> str:
39+
result = hashlib.sha256()
40+
with path.open("rb") as source:
41+
for chunk in iter(lambda: source.read(1024 * 1024), b""):
42+
result.update(chunk)
43+
return result.hexdigest()
44+
45+
46+
def package(source: Path, build: Path, output: Path, revision: str) -> None:
47+
cli = build / "haybarn"
48+
if not cli.is_file():
49+
cli = build / "duckdb"
50+
binaries = {"unittest": build / "test/unittest", "haybarn": cli}
51+
output.mkdir(parents=True, exist_ok=True)
52+
hashes = {}
53+
for name, binary in binaries.items():
54+
if not binary.is_file():
55+
raise RuntimeError(f"missing native build executable: {binary}")
56+
shutil.copy2(binary, output / name)
57+
hashes[name] = digest(output / name)
58+
header = (source / "src/generated/vgi_protocol_version.hpp").read_text()
59+
version = re.search(r'VGI_PROTOCOL_VERSION\s*=\s*"([^"]+)"', header)
60+
if version is None:
61+
raise RuntimeError("generated protocol version is missing")
62+
manifest = {
63+
"revision": revision,
64+
"engine_revision": command("git", "-C", str(source), "rev-parse", "HEAD:duckdb"),
65+
"platform": platform_name(),
66+
"protocol_version": version.group(1),
67+
"sha256": hashes,
68+
}
69+
(output / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
70+
print(json.dumps(manifest, indent=2), flush=True)
71+
72+
73+
def api(path: str) -> dict:
74+
return json.loads(command("gh", "api", path))
75+
76+
77+
def fetch(revision: str, output: Path, timeout: int) -> None:
78+
artifact_name = "vgi-native-runner-" + platform_name()
79+
deadline = time.monotonic() + timeout
80+
run_id = None
81+
while True:
82+
runs = api(
83+
f"repos/{REPOSITORY}/actions/workflows/integration.yml/runs"
84+
f"?branch=main&head_sha={revision}&per_page=10"
85+
)["workflow_runs"]
86+
for run in runs:
87+
# Never consume a pull-request merge build or a different revision.
88+
if run["head_sha"] != revision or run["head_branch"] != "main":
89+
continue
90+
if run["event"] not in ("push", "workflow_dispatch"):
91+
continue
92+
artifacts = api(f"repos/{REPOSITORY}/actions/runs/{run['id']}/artifacts")["artifacts"]
93+
if any(item["name"] == artifact_name and not item["expired"] for item in artifacts):
94+
run_id = run["id"]
95+
break
96+
if run_id is not None:
97+
break
98+
if time.monotonic() >= deadline:
99+
raise RuntimeError(
100+
f"no {artifact_name} artifact for VGI {revision}; "
101+
"check that revision's native Integration suite build"
102+
)
103+
print(f"Waiting for {artifact_name} at VGI {revision} ...", flush=True)
104+
time.sleep(min(15, max(0, deadline - time.monotonic())))
105+
106+
with tempfile.TemporaryDirectory(prefix="vgi-native-download-") as directory:
107+
staging = Path(directory)
108+
command(
109+
"gh", "run", "download", str(run_id), "--repo", REPOSITORY,
110+
"--name", artifact_name, "--dir", str(staging),
111+
)
112+
manifest = json.loads((staging / "manifest.json").read_text())
113+
if manifest["revision"] != revision or manifest["platform"] != platform_name():
114+
raise RuntimeError("native artifact provenance does not match the requested revision/platform")
115+
for name in ("unittest", "haybarn"):
116+
if digest(staging / name) != manifest["sha256"][name]:
117+
raise RuntimeError(f"native artifact checksum mismatch: {name}")
118+
output.mkdir(parents=True, exist_ok=True)
119+
for name in ("unittest", "haybarn", "manifest.json"):
120+
shutil.copy2(staging / name, output / name)
121+
for name in ("unittest", "haybarn"):
122+
(output / name).chmod(0o755)
123+
124+
values = {
125+
"HAYBARN_UNITTEST": str(output / "unittest"),
126+
"HAYBARN_CLI": str(output / "haybarn"),
127+
"RUNNER_LINKS_EXTENSIONS": "1",
128+
}
129+
environment = os.environ.get("GITHUB_ENV")
130+
if environment:
131+
with Path(environment).open("a") as target:
132+
for name, value in values.items():
133+
if "\n" in value or "\r" in value:
134+
raise RuntimeError("native output path cannot contain newlines")
135+
target.write(f"{name}={value}\n")
136+
print(f"Using native VGI {revision}, protocol {manifest['protocol_version']}, run {run_id}", flush=True)
137+
for name, value in values.items():
138+
print(f"{name}={value}", flush=True)
139+
140+
141+
def main() -> None:
142+
parser = argparse.ArgumentParser(description=__doc__)
143+
parser.add_argument("--source", required=True, type=Path)
144+
parser.add_argument("--output", required=True, type=Path)
145+
parser.add_argument("--pack", action="store_true")
146+
parser.add_argument("--build", type=Path, default=Path("build/release"))
147+
parser.add_argument("--timeout", type=int, default=1800)
148+
args = parser.parse_args()
149+
source = args.source.resolve()
150+
output = args.output.resolve()
151+
revision = command("git", "-C", str(source), "rev-parse", "HEAD")
152+
if args.pack:
153+
build = args.build if args.build.is_absolute() else source / args.build
154+
package(source, build, output, revision)
155+
else:
156+
fetch(revision, output, args.timeout)
157+
158+
159+
if __name__ == "__main__":
160+
main()

‎scripts/run_tests.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,9 @@ def run_one(
139139
t0 = time.monotonic()
140140
try:
141141
proc = subprocess.Popen(
142-
[str(unittest), *extra_args, str(test_path)],
142+
# CMake can register extension tests with absolute names. Match the
143+
# same file whether the runner registered a relative or full path.
144+
[str(unittest), *extra_args, "*" + str(test_path)],
143145
env=env,
144146
stdout=subprocess.PIPE,
145147
stderr=subprocess.PIPE,
@@ -162,6 +164,9 @@ def run_one(
162164
finally:
163165
_LIVE.discard(proc.pid)
164166
exit_code = proc.returncode
167+
if exit_code == 0 and ("No test cases matched" in stdout or "No tests ran" in stdout):
168+
exit_code = 1
169+
error = "the runner matched no test cases for this file"
165170
except (OSError, subprocess.SubprocessError) as exc:
166171
# Treat infrastructural failures (binary missing, exec failure, etc.)
167172
# as test failures so we surface them in the summary rather than
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""CI artifacts must match the requested source revision and verified contents."""
2+
3+
import importlib.util
4+
import json
5+
import os
6+
from pathlib import Path
7+
import tempfile
8+
import unittest
9+
from unittest.mock import patch
10+
11+
12+
SCRIPT = Path(__file__).resolve().parents[2] / "scripts/ci_fetch_native_runner.py"
13+
SPEC = importlib.util.spec_from_file_location("ci_native_runner", SCRIPT)
14+
runner = importlib.util.module_from_spec(SPEC)
15+
SPEC.loader.exec_module(runner)
16+
17+
18+
class NativeArtifactTests(unittest.TestCase):
19+
def setUp(self):
20+
self.temp = tempfile.TemporaryDirectory()
21+
self.addCleanup(self.temp.cleanup)
22+
self.root = Path(self.temp.name)
23+
self.revision = "a" * 40
24+
self.run = {"id": 7, "head_sha": self.revision, "head_branch": "main", "event": "push"}
25+
self.artifact = {"name": "vgi-native-runner-linux_amd64", "expired": False}
26+
self.bad_revision = False
27+
self.corrupt = False
28+
29+
def api(self, path):
30+
if path.endswith("/artifacts"):
31+
return {"artifacts": [self.artifact]}
32+
return {"workflow_runs": [self.run]}
33+
34+
def download(self, *args):
35+
staging = Path(args[args.index("--dir") + 1])
36+
for name in ("unittest", "haybarn"):
37+
(staging / name).write_bytes(b"built executable")
38+
manifest = {
39+
"revision": "b" * 40 if self.bad_revision else self.revision,
40+
"platform": "linux_amd64",
41+
"protocol_version": "2.2.0",
42+
"sha256": {name: runner.digest(staging / name) for name in ("unittest", "haybarn")},
43+
}
44+
(staging / "manifest.json").write_text(json.dumps(manifest))
45+
if self.corrupt:
46+
(staging / "unittest").write_bytes(b"corrupt executable")
47+
return ""
48+
49+
def fetch(self):
50+
with (
51+
patch.object(runner, "api", side_effect=self.api),
52+
patch.object(runner, "command", side_effect=self.download),
53+
patch.object(runner, "platform_name", return_value="linux_amd64"),
54+
patch.dict(os.environ, {"GITHUB_ENV": str(self.root / "environment")}),
55+
):
56+
runner.fetch(self.revision, self.root / "output", timeout=0)
57+
58+
def test_matching_build_exports_executable_paths(self):
59+
self.fetch()
60+
environment = (self.root / "environment").read_text()
61+
self.assertIn("RUNNER_LINKS_EXTENSIONS=1\n", environment)
62+
self.assertIn(f"HAYBARN_UNITTEST={self.root}/output/unittest\n", environment)
63+
self.assertTrue(os.access(self.root / "output/unittest", os.X_OK))
64+
65+
def test_mismatched_manifest_is_rejected(self):
66+
self.bad_revision = True
67+
with self.assertRaisesRegex(RuntimeError, "provenance"):
68+
self.fetch()
69+
self.assertFalse((self.root / "environment").exists())
70+
71+
def test_corrupt_binary_is_rejected(self):
72+
self.corrupt = True
73+
with self.assertRaisesRegex(RuntimeError, "checksum"):
74+
self.fetch()
75+
self.assertFalse((self.root / "output").exists())
76+
77+
def test_other_revision_never_falls_back(self):
78+
self.run["head_sha"] = "c" * 40
79+
with self.assertRaisesRegex(RuntimeError, "no vgi-native-runner"):
80+
self.fetch()
81+
82+
def test_pull_request_build_is_not_used(self):
83+
self.run["event"] = "pull_request"
84+
with self.assertRaisesRegex(RuntimeError, "no vgi-native-runner"):
85+
self.fetch()
86+
87+
def test_expired_artifact_is_not_used(self):
88+
self.artifact["expired"] = True
89+
with self.assertRaisesRegex(RuntimeError, "no vgi-native-runner"):
90+
self.fetch()

0 commit comments

Comments
 (0)