Skip to content

Commit 1a0bbde

Browse files
committed
ci: add release hardening quality gates
1 parent d71cf3f commit 1a0bbde

5 files changed

Lines changed: 315 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
name: Python ${{ matrix.python-version }} on ${{ matrix.os }}
11+
runs-on: ${{ matrix.os }}
12+
strategy:
13+
fail-fast: false
14+
matrix:
15+
os: [ubuntu-latest, windows-latest, macos-latest]
16+
python-version: ["3.11", "3.12"]
17+
18+
steps:
19+
- name: Checkout
20+
uses: actions/checkout@v4
21+
22+
- name: Set up Python
23+
uses: actions/setup-python@v5
24+
with:
25+
python-version: ${{ matrix.python-version }}
26+
27+
- name: Install package
28+
run: python -m pip install -e ".[dev]"
29+
30+
- name: Compile sources
31+
run: python -m compileall -q minicode tests
32+
33+
- name: Run packaging smoke tests
34+
run: python -m pytest tests/test_packaging.py -q
35+
36+
- name: Run test suite
37+
run: python -m pytest -q

conftest.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Pytest collection controls for repository-local legacy smoke scripts."""
2+
3+
from __future__ import annotations
4+
5+
6+
# These root-level scripts are manual smoke/integration utilities from earlier
7+
# development rounds. Normal pytest coverage lives under tests/.
8+
collect_ignore = [
9+
"smoke_test.py",
10+
"test_chinese_input.py",
11+
"test_integration.py",
12+
"test_optim.py",
13+
"test_run.py",
14+
"test_state_integration.py",
15+
"visual_test.py",
16+
]
17+
18+
collect_ignore_glob = [
19+
"benchmarks/*.py",
20+
]

minicode/cron_runner.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Scheduled headless task runner for MiniCode.
2+
3+
Config format:
4+
5+
{
6+
"tasks": [
7+
{"name": "daily-check", "prompt": "Summarize the repository status"}
8+
]
9+
}
10+
11+
Without a config file, the runner exits cleanly with an explanatory message.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import argparse
17+
import json
18+
import os
19+
import time
20+
from pathlib import Path
21+
from typing import Any
22+
23+
24+
def _default_config_path() -> Path:
25+
return Path(os.environ.get("MINI_CODE_CRON_CONFIG", ".mini-code/cron.json"))
26+
27+
28+
def load_cron_config(path: str | Path | None = None) -> dict[str, Any]:
29+
config_path = Path(path) if path is not None else _default_config_path()
30+
if not config_path.exists():
31+
return {"tasks": []}
32+
data = json.loads(config_path.read_text(encoding="utf-8"))
33+
if not isinstance(data, dict):
34+
raise ValueError("cron config must be a JSON object")
35+
tasks = data.get("tasks", [])
36+
if not isinstance(tasks, list):
37+
raise ValueError("cron config field 'tasks' must be a list")
38+
return {"tasks": tasks}
39+
40+
41+
def run_configured_tasks(config: dict[str, Any], *, dry_run: bool = False) -> list[dict[str, Any]]:
42+
from minicode.headless import run_headless
43+
44+
results: list[dict[str, Any]] = []
45+
for index, task in enumerate(config.get("tasks", [])):
46+
if not isinstance(task, dict):
47+
results.append({"index": index, "ok": False, "error": "task must be an object"})
48+
continue
49+
prompt = str(task.get("prompt", "")).strip()
50+
name = str(task.get("name") or f"task-{index + 1}")
51+
if not prompt:
52+
results.append({"name": name, "ok": False, "error": "prompt is required"})
53+
continue
54+
if dry_run:
55+
results.append({"name": name, "ok": True, "dryRun": True})
56+
continue
57+
results.append({"name": name, "ok": True, "response": run_headless(prompt)})
58+
return results
59+
60+
61+
def main(argv: list[str] | None = None) -> None:
62+
parser = argparse.ArgumentParser(description="Run MiniCode scheduled headless tasks.")
63+
parser.add_argument("--config", default=None, help="Path to cron JSON config.")
64+
parser.add_argument("--once", action="store_true", help="Run tasks once and exit.")
65+
parser.add_argument("--dry-run", action="store_true", help="Validate tasks without executing prompts.")
66+
parser.add_argument("--interval", type=float, default=60.0, help="Polling interval in seconds.")
67+
args = parser.parse_args(argv)
68+
69+
while True:
70+
config = load_cron_config(args.config)
71+
if not config["tasks"]:
72+
print(f"No cron tasks configured in {args.config or _default_config_path()}.", flush=True)
73+
else:
74+
for result in run_configured_tasks(config, dry_run=args.dry_run):
75+
print(json.dumps(result, ensure_ascii=False), flush=True)
76+
if args.once or not config["tasks"]:
77+
return
78+
time.sleep(max(args.interval, 1.0))
79+
80+
81+
if __name__ == "__main__":
82+
main()

minicode/gateway.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Minimal HTTP gateway for MiniCode.
2+
3+
The gateway intentionally uses only the standard library so the Docker and
4+
console entry points remain zero-dependency. It exposes a health endpoint and a
5+
small headless execution endpoint for platform bridges to build on.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
import os
12+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
13+
from typing import Any
14+
15+
16+
def _json_bytes(payload: dict[str, Any], status: int = 200) -> tuple[int, bytes]:
17+
return status, json.dumps(payload, ensure_ascii=False).encode("utf-8")
18+
19+
20+
class MiniCodeGatewayHandler(BaseHTTPRequestHandler):
21+
server_version = "MiniCodeGateway/0.1"
22+
23+
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
24+
if os.environ.get("MINI_CODE_GATEWAY_ACCESS_LOG") == "1":
25+
super().log_message(format, *args)
26+
27+
def _send_json(self, payload: dict[str, Any], status: int = 200) -> None:
28+
status_code, body = _json_bytes(payload, status)
29+
self.send_response(status_code)
30+
self.send_header("Content-Type", "application/json; charset=utf-8")
31+
self.send_header("Content-Length", str(len(body)))
32+
self.end_headers()
33+
self.wfile.write(body)
34+
35+
def do_GET(self) -> None: # noqa: N802
36+
if self.path in {"/", "/health"}:
37+
self._send_json({"ok": True, "service": "minicode-gateway"})
38+
return
39+
self._send_json({"ok": False, "error": "not found"}, status=404)
40+
41+
def do_POST(self) -> None: # noqa: N802
42+
if self.path != "/run":
43+
self._send_json({"ok": False, "error": "not found"}, status=404)
44+
return
45+
46+
try:
47+
length = int(self.headers.get("Content-Length", "0"))
48+
raw = self.rfile.read(length).decode("utf-8")
49+
data = json.loads(raw) if raw.strip() else {}
50+
prompt = str(data.get("prompt", "")).strip()
51+
if not prompt:
52+
self._send_json({"ok": False, "error": "prompt is required"}, status=400)
53+
return
54+
55+
from minicode.headless import run_headless
56+
57+
self._send_json({"ok": True, "response": run_headless(prompt)})
58+
except Exception as exc: # noqa: BLE001
59+
self._send_json({"ok": False, "error": str(exc)}, status=500)
60+
61+
62+
def run_gateway() -> None:
63+
host = os.environ.get("MINI_CODE_GATEWAY_HOST", "127.0.0.1")
64+
port = int(os.environ.get("MINI_CODE_GATEWAY_PORT", "8080"))
65+
server = ThreadingHTTPServer((host, port), MiniCodeGatewayHandler)
66+
print(f"MiniCode gateway listening on http://{host}:{port}", flush=True)
67+
try:
68+
server.serve_forever()
69+
except KeyboardInterrupt:
70+
pass
71+
finally:
72+
server.server_close()
73+
74+
75+
if __name__ == "__main__":
76+
run_gateway()

tests/test_packaging.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from __future__ import annotations
2+
3+
import importlib
4+
import json
5+
import subprocess
6+
import sys
7+
import threading
8+
import tomllib
9+
import urllib.request
10+
from pathlib import Path
11+
12+
13+
ROOT = Path(__file__).resolve().parent.parent
14+
15+
16+
def test_console_script_entry_points_import() -> None:
17+
pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))
18+
19+
failures = []
20+
for name, target in pyproject["project"]["scripts"].items():
21+
module_name, _, attr_name = target.partition(":")
22+
try:
23+
module = importlib.import_module(module_name)
24+
except Exception as exc: # noqa: BLE001
25+
failures.append(f"{name}: cannot import {module_name}: {exc}")
26+
continue
27+
if not hasattr(module, attr_name):
28+
failures.append(f"{name}: {module_name}.{attr_name} does not exist")
29+
30+
assert failures == []
31+
32+
33+
def test_legacy_root_smoke_scripts_are_not_pytest_collected() -> None:
34+
import conftest
35+
36+
root_smoke_scripts = {
37+
path.name
38+
for pattern in ("test_*.py", "*_test.py")
39+
for path in ROOT.glob(pattern)
40+
}
41+
42+
assert root_smoke_scripts
43+
assert root_smoke_scripts.issubset(set(conftest.collect_ignore))
44+
assert "benchmarks/*.py" in conftest.collect_ignore_glob
45+
46+
47+
def test_ci_workflow_runs_release_quality_gates() -> None:
48+
workflow = ROOT / ".github" / "workflows" / "ci.yml"
49+
50+
assert workflow.exists()
51+
content = workflow.read_text(encoding="utf-8")
52+
assert "python -m compileall -q minicode tests" in content
53+
assert "python -m pytest -q" in content
54+
assert "tests/test_packaging.py" in content
55+
56+
57+
def test_cron_runner_empty_config_exits_cleanly(tmp_path: Path) -> None:
58+
missing_config = tmp_path / "missing-cron.json"
59+
60+
completed = subprocess.run(
61+
[
62+
sys.executable,
63+
"-m",
64+
"minicode.cron_runner",
65+
"--once",
66+
"--dry-run",
67+
"--config",
68+
str(missing_config),
69+
],
70+
cwd=ROOT,
71+
text=True,
72+
encoding="utf-8",
73+
errors="replace",
74+
stdout=subprocess.PIPE,
75+
stderr=subprocess.PIPE,
76+
timeout=20,
77+
check=False,
78+
)
79+
80+
assert completed.returncode == 0, completed.stderr
81+
assert "No cron tasks configured" in completed.stdout
82+
83+
84+
def test_gateway_health_endpoint_responds() -> None:
85+
from http.server import ThreadingHTTPServer
86+
87+
from minicode.gateway import MiniCodeGatewayHandler
88+
89+
server = ThreadingHTTPServer(("127.0.0.1", 0), MiniCodeGatewayHandler)
90+
thread = threading.Thread(target=server.serve_forever, daemon=True)
91+
thread.start()
92+
try:
93+
port = server.server_address[1]
94+
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=5) as response:
95+
payload = json.loads(response.read().decode("utf-8"))
96+
assert payload == {"ok": True, "service": "minicode-gateway"}
97+
finally:
98+
server.shutdown()
99+
server.server_close()
100+
thread.join(timeout=5)

0 commit comments

Comments
 (0)