Skip to content

Commit 3c1d2d7

Browse files
committed
Add full GitHub Actions CI gates for pure hypercluster clones.
Mirror agent-challenge/prism quality (ruff, mypy, format, no-verda, pytest+cov, docker build/publish, tag releases), install base from the release wheel URL so uv sync works without a sibling platform checkout, and apply ruff format so the format job passes on existing sources.
1 parent 32501f9 commit 3c1d2d7

80 files changed

Lines changed: 818 additions & 819 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches:
6+
- "**"
7+
tags:
8+
- "v*.*.*"
9+
pull_request:
10+
workflow_dispatch:
11+
inputs:
12+
confirm_publish:
13+
description: "Type true to publish the Docker image to GHCR"
14+
required: true
15+
default: "false"
16+
17+
permissions:
18+
contents: read
19+
20+
env:
21+
IMAGE_NAME: ghcr.io/baseintelligence/hypercluster
22+
# Pin matches pyproject dependencies (release wheel). staging script re-reads pyproject.
23+
PYTHON_VERSION: "3.12"
24+
25+
jobs:
26+
lint:
27+
runs-on: ubuntu-latest
28+
steps:
29+
- uses: actions/checkout@v4
30+
- name: Install uv
31+
uses: astral-sh/setup-uv@v5
32+
with:
33+
enable-cache: true
34+
- name: Set up Python
35+
run: uv python install ${{ env.PYTHON_VERSION }}
36+
- name: Install dependencies
37+
run: uv sync --extra dev
38+
- name: Ruff lint
39+
run: uv run ruff check .
40+
- name: Mypy type check
41+
run: uv run mypy
42+
43+
format:
44+
runs-on: ubuntu-latest
45+
steps:
46+
- uses: actions/checkout@v4
47+
- name: Install uv
48+
uses: astral-sh/setup-uv@v5
49+
with:
50+
enable-cache: true
51+
- name: Set up Python
52+
run: uv python install ${{ env.PYTHON_VERSION }}
53+
- name: Install dependencies
54+
run: uv sync --extra dev
55+
- name: Ruff format check
56+
run: uv run ruff format --check .
57+
58+
no-verda:
59+
runs-on: ubuntu-latest
60+
steps:
61+
- uses: actions/checkout@v4
62+
- name: Install uv
63+
uses: astral-sh/setup-uv@v5
64+
with:
65+
enable-cache: true
66+
- name: Set up Python
67+
run: uv python install ${{ env.PYTHON_VERSION }}
68+
- name: Install dependencies
69+
run: uv sync --extra dev
70+
- name: Product path no-verda fence
71+
run: uv run python scripts/check_no_verda.py
72+
- name: Module entrypoint no-verda audit
73+
run: |
74+
uv run python -c "
75+
from pathlib import Path
76+
from hypercluster.no_verda import run_product_verda_audit
77+
report = run_product_verda_audit(Path('.').resolve())
78+
print('\n'.join(report.summary_lines()))
79+
raise SystemExit(0 if report.ok else 1)
80+
"
81+
82+
test:
83+
runs-on: ubuntu-latest
84+
timeout-minutes: 30
85+
steps:
86+
- uses: actions/checkout@v4
87+
- name: Install uv
88+
uses: astral-sh/setup-uv@v5
89+
with:
90+
enable-cache: true
91+
- name: Set up Python
92+
run: uv python install ${{ env.PYTHON_VERSION }}
93+
- name: Install dependencies
94+
run: uv sync --extra dev
95+
- name: Pytest with coverage
96+
# live_verda + integration stay opt-in (real cloud / SSH). Default addopts
97+
# already deselect them; restate markers for job clarity.
98+
run: >-
99+
uv run pytest
100+
-m "not live_verda and not integration"
101+
--cov=hypercluster
102+
--cov-report=term-missing
103+
--cov-fail-under=70
104+
105+
docker-build:
106+
needs:
107+
- lint
108+
- format
109+
- no-verda
110+
- test
111+
runs-on: ubuntu-latest
112+
steps:
113+
- uses: actions/checkout@v4
114+
- name: Stage Base SDK wheel into docker/vendor
115+
run: bash scripts/stage_base_wheel.sh
116+
- uses: docker/setup-buildx-action@v3
117+
- name: Build hypercluster runtime image
118+
uses: docker/build-push-action@v6
119+
with:
120+
context: .
121+
file: Dockerfile
122+
target: runtime
123+
push: false
124+
tags: ${{ env.IMAGE_NAME }}:ci-${{ github.sha }}
125+
126+
docker-publish:
127+
if: >-
128+
github.event_name != 'pull_request' &&
129+
(github.ref == 'refs/heads/main' ||
130+
startsWith(github.ref, 'refs/tags/v') ||
131+
(github.event_name == 'workflow_dispatch' && inputs.confirm_publish == 'true'))
132+
needs:
133+
- docker-build
134+
runs-on: ubuntu-latest
135+
permissions:
136+
contents: read
137+
packages: write
138+
steps:
139+
- uses: actions/checkout@v4
140+
- name: Stage Base SDK wheel into docker/vendor
141+
run: bash scripts/stage_base_wheel.sh
142+
- uses: docker/setup-buildx-action@v3
143+
- name: Log in to GHCR
144+
uses: docker/login-action@v3
145+
with:
146+
registry: ghcr.io
147+
username: ${{ github.actor }}
148+
password: ${{ secrets.GITHUB_TOKEN }}
149+
- name: Generate Docker metadata
150+
id: meta
151+
uses: docker/metadata-action@v5
152+
with:
153+
images: ${{ env.IMAGE_NAME }}
154+
tags: |
155+
type=ref,event=branch
156+
type=semver,pattern={{version}}
157+
type=semver,pattern={{raw}}
158+
type=sha,prefix=sha-
159+
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
160+
- name: Build and publish image
161+
uses: docker/build-push-action@v6
162+
with:
163+
context: .
164+
file: Dockerfile
165+
target: runtime
166+
push: true
167+
tags: ${{ steps.meta.outputs.tags }}
168+
labels: ${{ steps.meta.outputs.labels }}
169+
170+
github-release:
171+
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
172+
needs:
173+
- docker-publish
174+
runs-on: ubuntu-latest
175+
permissions:
176+
contents: write
177+
steps:
178+
- name: Prepare release metadata
179+
id: release
180+
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
181+
- name: Create GitHub release
182+
uses: softprops/action-gh-release@v2
183+
with:
184+
tag_name: ${{ github.ref_name }}
185+
name: Hypercluster ${{ steps.release.outputs.version }}
186+
generate_release_notes: true
187+
append_body: true
188+
draft: false
189+
prerelease: ${{ contains(github.ref_name, '-') }}
190+
make_latest: ${{ !contains(github.ref_name, '-') }}
191+
body: |
192+
## Container Image
193+
194+
- `ghcr.io/baseintelligence/hypercluster:${{ steps.release.outputs.version }}`
195+
- `ghcr.io/baseintelligence/hypercluster:${{ github.ref_name }}`
196+
- `ghcr.io/baseintelligence/hypercluster:sha-${{ github.sha }}`
197+
198+
## Deployment Notes
199+
200+
BASE master deployments should pin the SemVer image tag plus the
201+
immutable `@sha256` digest. The `latest` tag is published only from
202+
`main`, not from release tags.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ docker/vendor/*.whl
2222
!docker/vendor/.gitkeep
2323
# Local documentation evidence (never publish)
2424
.docs-evidence/
25+
.coverage

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
1414
[![Python](https://img.shields.io/badge/python-%E2%89%A53.12-blue.svg)](pyproject.toml)
1515
[![Base SDK](https://img.shields.io/badge/base%20sdk-3.1.2-informational.svg)](https://github.com/BaseIntelligence/base)
16+
[![CI](https://github.com/BaseIntelligence/hypercluster/actions/workflows/ci.yml/badge.svg)](https://github.com/BaseIntelligence/hypercluster/actions/workflows/ci.yml)
1617

1718
</div>
1819

@@ -118,14 +119,18 @@ Copy `.env.example` for `CHALLENGE_*` / `HYPER_*` knobs. Never commit tokens.
118119

119120
## Validation quick reference
120121

121-
Default gates are local only (unit, integration, sim). They never auto-load commercial cloud credentials.
122+
Default gates are local only (unit, integration, sim). They never auto-load commercial cloud credentials. GitHub Actions runs the same quality bar (lint, format, mypy, no-verda fence, pytest with coverage, Docker build/publish) on every push; see [`.github/workflows/ci.yml`](.github/workflows/ci.yml).
122123

123124
```bash
124125
uv run ruff check .
126+
uv run ruff format --check .
125127
uv run mypy
126-
uv run pytest -q
128+
uv run python scripts/check_no_verda.py
129+
uv run pytest -q -m "not live_verda and not integration" --cov=hypercluster --cov-fail-under=70
127130
uv run hypercluster sim doctor --offline
128131
uv run hypercluster sim run-scenario --name smoke --url http://127.0.0.1:3200
132+
# Offline-friendly image builds: stage the Base wheel first
133+
bash scripts/stage_base_wheel.sh && docker build -t hypercluster:local .
129134
```
130135

131136
Canonical sim scenario order: `smoke``marketplace``nccl``tee-offline``weights`.

pyproject.toml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description = "Hypercluster compute power challenge for BASE Intelligence"
55
requires-python = ">=3.12"
66
dependencies = [
77
"aiosqlite>=0.20.0",
8-
# Ship pin (matches prism / release wheel). Local `uv` overrides via tool.uv.sources.
8+
# Ship pin (matches prism / release wheel). Pure-clone CI uses this URL (no sibling path).
99
"base @ https://github.com/BaseIntelligence/base/releases/download/v3.1.2/base-3.1.2-py3-none-any.whl#sha256=3a61c2d3a343ed6de55e80215486e3de0c9639276443d08f2ed316bc807f2ff0",
1010
"fastapi>=0.115.0",
1111
"httpx>=0.27.0",
@@ -22,6 +22,7 @@ dev = [
2222
"mypy>=1.10.0",
2323
"pytest>=8.3.0",
2424
"pytest-asyncio>=0.23.0",
25+
"pytest-cov>=5.0.0",
2526
"ruff>=0.6.0",
2627
]
2728

@@ -38,8 +39,11 @@ packages = ["src/hypercluster"]
3839
[tool.hatch.metadata]
3940
allow-direct-references = true
4041

41-
[tool.uv.sources]
42-
base = { path = "../platform", editable = true }
42+
# Do NOT pin base to a sibling monorepo path here. The release wheel URL in
43+
# project.dependencies is the source of truth for pure clones and GitHub Actions.
44+
# Local monorepo developers who need an editable platform check-out can run:
45+
# uv add --editable ../platform
46+
# or pass a temporary override file when resolving.
4347

4448
[tool.pytest.ini_options]
4549
asyncio_mode = "auto"
@@ -48,6 +52,7 @@ pythonpath = ["src"]
4852
# Live commercial-cloud rentals (Verda ops QA) are opt-in only (VAL-LIVE-010).
4953
# Default `uv run pytest` never selects the live_verda marker and never auto-loads
5054
# external ops secret files. Never add that mark to default addopts.
55+
# CI jobs deselect with: -m "not live_verda and not integration".
5156
markers = [
5257
"live_verda: external Verda/single-GPU ops QA (opt-in; never default CI)",
5358
"tee_live: optional live TEE hardware path (skip unless HYPER_TEE_LIVE)",

scripts/check_no_verda.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
#!/usr/bin/env python3
2+
"""CI / ops guard: fail if product tree depends on Verda (VAL-LIVE-001/002).
3+
4+
Thin wrapper around ``hypercluster.no_verda`` so GitHub Actions can run a
5+
dedicated no-verda fence job without pulling the full pytest suite.
6+
7+
Exit codes:
8+
0 — product + docs audit clean
9+
1 — findings or module load error
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import sys
15+
from pathlib import Path
16+
17+
REPO_ROOT = Path(__file__).resolve().parent.parent
18+
19+
20+
def main() -> int:
21+
# Prefer installed package; fall back to src layout when run pre-install.
22+
src = REPO_ROOT / "src"
23+
if str(src) not in sys.path:
24+
sys.path.insert(0, str(src))
25+
26+
try:
27+
from hypercluster.no_verda import run_docs_verda_audit, run_product_verda_audit
28+
except ImportError as exc: # pragma: no cover - environment misconfig
29+
print(f"check_no_verda: import failed: {exc}", file=sys.stderr)
30+
return 1
31+
32+
product = run_product_verda_audit(REPO_ROOT)
33+
docs = run_docs_verda_audit(REPO_ROOT)
34+
35+
# Docs: only fail on forced-Verda language (same policy as unit tests).
36+
docs_bad = [f for f in docs.findings if "forces Verda" in f.detail or "matched" in f.detail]
37+
38+
ok = product.ok and not docs_bad
39+
if product.ok:
40+
print("check_no_verda: product audit clean")
41+
else:
42+
print("check_no_verda: product audit FAILED", file=sys.stderr)
43+
for line in product.summary_lines():
44+
print(f" {line}", file=sys.stderr)
45+
46+
if docs_bad:
47+
print("check_no_verda: docs audit FAILED", file=sys.stderr)
48+
for finding in docs_bad:
49+
print(f" {finding.path}: {finding.detail}", file=sys.stderr)
50+
else:
51+
print("check_no_verda: docs audit clean")
52+
53+
return 0 if ok else 1
54+
55+
56+
if __name__ == "__main__":
57+
raise SystemExit(main())

scripts/qa/host_gpu_probe.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -252,9 +252,7 @@ def run_host_gpu_probe(
252252
)
253253
from typing import Literal
254254

255-
mode_norm: Literal["full", "quick"] = (
256-
"quick" if str(mode).lower() == "quick" else "full"
257-
)
255+
mode_norm: Literal["full", "quick"] = "quick" if str(mode).lower() == "quick" else "full"
258256
config = GpuProbeConfig(
259257
mode=mode_norm,
260258
require_docker_runtime=bool(require_docker),

scripts/qa/product_path.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -523,9 +523,7 @@ def run_product_gpu_probe(
523523
)
524524
payload = _safe_json(resp)
525525
if resp.status_code != 200:
526-
raise RuntimeError(
527-
f"product gpu probe HTTP {resp.status_code}: {payload}"
528-
)
526+
raise RuntimeError(f"product gpu probe HTTP {resp.status_code}: {payload}")
529527
evidence_id = None
530528
if isinstance(payload, dict):
531529
evidence_id = payload.get("evidence_id") or payload.get("id")
@@ -535,9 +533,7 @@ def run_product_gpu_probe(
535533
latest = client.get(f"{base}/v1/nodes/{node_id}/probes/gpu/latest")
536534
latest_body = _safe_json(latest)
537535
if latest.status_code != 200:
538-
raise RuntimeError(
539-
f"GET latest evidence HTTP {latest.status_code}: {latest_body}"
540-
)
536+
raise RuntimeError(f"GET latest evidence HTTP {latest.status_code}: {latest_body}")
541537
latest_id = None
542538
if isinstance(latest_body, dict):
543539
latest_id = latest_body.get("evidence_id") or latest_body.get("id")
@@ -630,9 +626,7 @@ def attach_host_probe_evidence(
630626
)
631627
payload = _safe_json(resp)
632628
if resp.status_code != 200:
633-
raise RuntimeError(
634-
f"attach external evidence HTTP {resp.status_code}: {payload}"
635-
)
629+
raise RuntimeError(f"attach external evidence HTTP {resp.status_code}: {payload}")
636630
evidence_id = None
637631
if isinstance(payload, dict):
638632
evidence_id = payload.get("evidence_id") or payload.get("id")

scripts/qa/verda_single_gpu_smoke.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
2727
Never commit tokens. Never leave instances running. Never set_weights.
2828
"""
29+
2930
from __future__ import annotations
3031

3132
import argparse
@@ -482,8 +483,7 @@ def main(argv: list[str] | None = None) -> int:
482483
# Recompute class match vs original catalog claim; also stamp registered claim.
483484
class_ok = bool(models_match(original_claim, measured_name)) or bool(
484485
normalize_gpu_model(original_claim)
485-
and normalize_gpu_model(original_claim)
486-
== normalize_gpu_model(measured_name)
486+
and normalize_gpu_model(original_claim) == normalize_gpu_model(measured_name)
487487
)
488488
host_probe["claim_model_class_match"] = class_ok
489489
host_probe["claimed_for_register"] = {

0 commit comments

Comments
 (0)