ci: block repository media assets (#256)
* ci: block repository media assets * test: stabilize cascade scanner loop test
This commit is contained in:
35
.gitignore
vendored
35
.gitignore
vendored
@ -165,5 +165,40 @@ outputs/
|
||||
logs/
|
||||
nohup.out
|
||||
|
||||
# Repository media policy: do not commit images, videos, or asset/media folders.
|
||||
# Use external hosting, release artifacts, or approved storage and link from docs.
|
||||
asset/
|
||||
assets/
|
||||
image/
|
||||
images/
|
||||
img/
|
||||
media/
|
||||
video/
|
||||
videos/
|
||||
*.avif
|
||||
*.bmp
|
||||
*.gif
|
||||
*.heic
|
||||
*.heif
|
||||
*.icns
|
||||
*.ico
|
||||
*.jpeg
|
||||
*.jpg
|
||||
*.png
|
||||
*.svg
|
||||
*.tif
|
||||
*.tiff
|
||||
*.webp
|
||||
*.avi
|
||||
*.flv
|
||||
*.m4v
|
||||
*.mkv
|
||||
*.mov
|
||||
*.mp4
|
||||
*.mpeg
|
||||
*.mpg
|
||||
*.webm
|
||||
*.wmv
|
||||
|
||||
# Use-cases: exclude lock files to keep the repo lean
|
||||
use-cases/**/package-lock.json
|
||||
|
||||
@ -28,6 +28,14 @@ repos:
|
||||
- id: detect-private-key
|
||||
- id: check-merge-conflict
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: no-repo-assets
|
||||
name: block committed images, videos, and asset directories
|
||||
entry: python3 scripts/check_repo_assets.py
|
||||
language: system
|
||||
pass_filenames: false
|
||||
|
||||
- repo: https://github.com/jorisroovers/gitlint
|
||||
rev: v0.19.1
|
||||
hooks:
|
||||
|
||||
@ -42,9 +42,11 @@ Use the [feature request template](https://github.com/EverMind-AI/EverOS/issues/
|
||||
|
||||
1. Create a branch from `main`.
|
||||
2. Keep the change scoped to one purpose.
|
||||
3. Run `make ci` locally before requesting review.
|
||||
4. Use a Conventional Commit title, such as `fix(search): guard empty profile`.
|
||||
5. Open a pull request to `main` and fill out the PR template.
|
||||
3. Do not commit images, videos, or asset/media directories. Use external
|
||||
hosting, release artifacts, or approved storage and link from docs.
|
||||
4. Run `make ci` locally before requesting review.
|
||||
5. Use a Conventional Commit title, such as `fix(search): guard empty profile`.
|
||||
6. Open a pull request to `main` and fill out the PR template.
|
||||
|
||||
By submitting a pull request, you agree that your contribution is licensed under
|
||||
the project's [Apache-2.0](LICENSE) license.
|
||||
@ -90,7 +92,7 @@ git clone https://github.com/EverMind-AI/EverOS.git
|
||||
cd EverOS
|
||||
make install # deps + pre-commit hooks (one-stop dev setup)
|
||||
everos init # write ./.env, then fill in the API key slots
|
||||
make ci # verify: lint + unit + integration
|
||||
make ci # verify: lint + unit + integration + package
|
||||
```
|
||||
|
||||
### Code style
|
||||
@ -110,7 +112,7 @@ Highlights:
|
||||
|
||||
```bash
|
||||
make format # ruff fix + format
|
||||
make lint # ruff check + import-linter
|
||||
make lint # ruff check + import-linter + hard repo hygiene gates
|
||||
```
|
||||
|
||||
### Branch strategy
|
||||
|
||||
11
Makefile
11
Makefile
@ -1,12 +1,13 @@
|
||||
.PHONY: help install install-deps lint docs-check check-commits check-cjk check-datetime openapi check-openapi format test integration package cov ci clean
|
||||
.PHONY: help install install-deps lint docs-check check-commits check-assets check-cjk check-datetime openapi check-openapi format test integration package cov ci clean
|
||||
|
||||
help:
|
||||
@echo "Targets:"
|
||||
@echo " install Install deps + pre-commit hooks (full dev setup)"
|
||||
@echo " install-deps Install deps only (uv sync --frozen, used by CI)"
|
||||
@echo " lint ruff (check + format-check) + import-linter + datetime discipline + openapi drift"
|
||||
@echo " lint ruff + import-linter + repo asset/media + datetime discipline + openapi drift"
|
||||
@echo " docs-check Validate Markdown links, use-case banners, and issue template YAML"
|
||||
@echo " check-commits Validate Conventional Commit subjects for a git range"
|
||||
@echo " check-assets Block committed images, videos, and asset/media directories"
|
||||
@echo " check-cjk Scan for CJK outside the language-policy allowlist (advisory)"
|
||||
@echo " check-datetime Scan for code that bypasses component/utils/datetime (HARD gate, run via lint)"
|
||||
@echo " openapi Regenerate docs/openapi.json from the FastAPI app"
|
||||
@ -34,6 +35,7 @@ lint:
|
||||
uv run ruff check src tests
|
||||
uv run ruff format --check src tests
|
||||
uv run lint-imports
|
||||
uv run python scripts/check_repo_assets.py
|
||||
uv run python scripts/check_datetime_discipline.py
|
||||
uv run python scripts/dump_openapi.py --check
|
||||
|
||||
@ -44,6 +46,11 @@ docs-check:
|
||||
check-commits:
|
||||
python3 scripts/check_commit_messages.py $(RANGE)
|
||||
|
||||
# Repository media hygiene gate. Images/videos belong in external hosting,
|
||||
# release artifacts, or other approved storage, then linked from docs.
|
||||
check-assets:
|
||||
uv run python scripts/check_repo_assets.py
|
||||
|
||||
# Advisory CJK scan (see .claude/rules/language-policy.md). Deliberately NOT
|
||||
# wired into `lint` / `ci`: the policy is enforced by review and the rules
|
||||
# doc, not a hard gate. Run on demand when touching potentially-CJK files.
|
||||
|
||||
@ -69,11 +69,13 @@ Reasons this is documented separately:
|
||||
│ │ ├ check-yaml / check-toml │ │
|
||||
│ │ ├ check-added-large-files (≥1MB warn) │ │
|
||||
│ │ ├ detect-private-key │ │
|
||||
│ │ ├ no committed images/videos/assets │ │
|
||||
│ │ └ gitlint (commit-msg stage) │ │
|
||||
│ │ │ │
|
||||
│ │ ruff lint + format │ │
|
||||
│ │ (replaces black / isort / flake8) │ │
|
||||
│ │ import-linter DDD layer-direction enforcement │ │
|
||||
│ │ repo asset gate blocks images/videos/assets in git │ │
|
||||
│ │ pytest unit / integration │ │
|
||||
│ │ │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
@ -94,6 +96,7 @@ Reasons this is documented separately:
|
||||
│ ┌─ CI/CD (GitHub Actions) ───────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ CI: .github/workflows/ci.yml lint / test / integ │ │
|
||||
│ │ / package build │ │
|
||||
│ │ Docs: .github/workflows/docs.yml Markdown + YAML check │ │
|
||||
│ │ Gates invoke Makefile targets; the Makefile is the │ │
|
||||
│ │ single source of truth for commands. │ │
|
||||
@ -204,19 +207,21 @@ Stage 2: pre-commit (triggered by `git commit`)
|
||||
├ check-yaml, check-toml
|
||||
├ check-added-large-files (≥1MB)
|
||||
├ detect-private-key
|
||||
├ no-repo-assets (rejects images/videos/assets in git)
|
||||
└ gitlint (commit-msg stage; rejects malformed messages)
|
||||
|
||||
│
|
||||
▼
|
||||
Stage 3: local `make ci` (manual, before push)
|
||||
├ make lint (ruff check + ruff format --check + import-linter)
|
||||
├ make lint (ruff + import-linter + repo hygiene gates)
|
||||
├ make test (pytest tests/unit)
|
||||
└ make integration (pytest tests/integration)
|
||||
├ make integration (pytest tests/integration)
|
||||
└ make package (sdist/wheel build + import smoke test)
|
||||
|
||||
│
|
||||
▼
|
||||
Stage 4: CI (GitHub Actions, push + PR triggered)
|
||||
└ re-runs the same `make lint / test / integration` targets
|
||||
└ re-runs the same `make lint / test / integration / package` targets
|
||||
|
||||
│
|
||||
▼
|
||||
@ -272,7 +277,7 @@ dev = ["ruff", "pytest", "pytest-asyncio", "pytest-cov",
|
||||
make help list all targets
|
||||
make install uv sync --frozen
|
||||
make format ruff fix + format
|
||||
make lint ruff + import-linter + datetime discipline + openapi drift
|
||||
make lint ruff + import-linter + repo asset/media + datetime discipline + openapi drift
|
||||
make test pytest tests/unit
|
||||
make integration pytest tests/integration
|
||||
make package build sdist/wheel + smoke-test wheel import
|
||||
@ -338,6 +343,7 @@ Every key has a sensible default except the `API_KEY` fields, which you fill in.
|
||||
|---|---|---|
|
||||
| Lint | `make lint` (ruff check + ruff format --check) | any error |
|
||||
| Layer direction | `make lint` (lint-imports inside) | layer violation |
|
||||
| Repository media | `make lint` (check_repo_assets.py) | images/videos/assets committed |
|
||||
| Datetime discipline | `make lint` (check_datetime_discipline.py) | bypasses helper module |
|
||||
| OpenAPI drift | `make lint` (dump_openapi.py --check) | schema ≠ committed openapi.json |
|
||||
| Unit | `make test` (pytest tests/unit) | any failure |
|
||||
|
||||
122
scripts/check_repo_assets.py
Normal file
122
scripts/check_repo_assets.py
Normal file
@ -0,0 +1,122 @@
|
||||
"""Block committed image/video files and asset-style directories."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Iterable
|
||||
|
||||
BLOCKED_DIR_NAMES = frozenset(
|
||||
{
|
||||
"asset",
|
||||
"assets",
|
||||
"image",
|
||||
"images",
|
||||
"img",
|
||||
"media",
|
||||
"video",
|
||||
"videos",
|
||||
}
|
||||
)
|
||||
IMAGE_EXTENSIONS = frozenset(
|
||||
{
|
||||
".avif",
|
||||
".bmp",
|
||||
".gif",
|
||||
".heic",
|
||||
".heif",
|
||||
".icns",
|
||||
".ico",
|
||||
".jpeg",
|
||||
".jpg",
|
||||
".png",
|
||||
".svg",
|
||||
".tif",
|
||||
".tiff",
|
||||
".webp",
|
||||
}
|
||||
)
|
||||
VIDEO_EXTENSIONS = frozenset(
|
||||
{
|
||||
".avi",
|
||||
".flv",
|
||||
".m4v",
|
||||
".mkv",
|
||||
".mov",
|
||||
".mp4",
|
||||
".mpeg",
|
||||
".mpg",
|
||||
".webm",
|
||||
".wmv",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Violation:
|
||||
path: str
|
||||
reason: str
|
||||
|
||||
|
||||
def _normalise_path(path: str) -> PurePosixPath:
|
||||
return PurePosixPath(path.replace("\\", "/"))
|
||||
|
||||
|
||||
def _violation_reason(path: str) -> str | None:
|
||||
posix_path = _normalise_path(path)
|
||||
lower_parts = tuple(part.lower() for part in posix_path.parts)
|
||||
if any(part in BLOCKED_DIR_NAMES for part in lower_parts):
|
||||
return "asset/media directory"
|
||||
|
||||
suffix = posix_path.suffix.lower()
|
||||
if suffix in IMAGE_EXTENSIONS:
|
||||
return "image file"
|
||||
if suffix in VIDEO_EXTENSIONS:
|
||||
return "video file"
|
||||
return None
|
||||
|
||||
|
||||
def find_violations(paths: Iterable[str]) -> list[Violation]:
|
||||
violations: list[Violation] = []
|
||||
for path in paths:
|
||||
reason = _violation_reason(path)
|
||||
if reason is not None:
|
||||
violations.append(Violation(path=path, reason=reason))
|
||||
return violations
|
||||
|
||||
|
||||
def _tracked_paths() -> list[str]:
|
||||
result = subprocess.run(
|
||||
["git", "ls-files", "-z"],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
text=False,
|
||||
)
|
||||
return [
|
||||
raw.decode("utf-8")
|
||||
for raw in result.stdout.split(b"\0")
|
||||
if raw
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
violations = find_violations(_tracked_paths())
|
||||
if not violations:
|
||||
print("Repository asset/media check passed.")
|
||||
return 0
|
||||
|
||||
print(
|
||||
"Repository asset/media check failed.\n"
|
||||
"Do not commit images, videos, or asset/media directories. "
|
||||
"Host visual media externally, in release artifacts, or another "
|
||||
"approved storage location, then link to it from docs.\n"
|
||||
)
|
||||
for violation in violations:
|
||||
print(f"- {violation.path}: {violation.reason}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -13,6 +13,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from everos.core.persistence import MemoryRoot
|
||||
from everos.memory.cascade import scanner as scanner_module
|
||||
from everos.memory.cascade.scanner import CascadeScanner, _collect_scan_inputs
|
||||
|
||||
|
||||
@ -112,16 +113,26 @@ async def test_run_loop_swallows_scan_exception(
|
||||
scanner = CascadeScanner(mr, scan_interval_seconds=0.05)
|
||||
|
||||
call_count = {"n": 0}
|
||||
second_scan = asyncio.Event()
|
||||
logged_errors: list[str] = []
|
||||
|
||||
async def fake_scan() -> list: # type: ignore[type-arg]
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
raise RuntimeError("simulated scanner failure")
|
||||
second_scan.set()
|
||||
return []
|
||||
|
||||
def fake_exception(_event: str, *, error: str) -> None:
|
||||
logged_errors.append(error)
|
||||
|
||||
monkeypatch.setattr(scanner, "scan_once", fake_scan)
|
||||
monkeypatch.setattr(scanner_module.logger, "exception", fake_exception)
|
||||
await scanner.start()
|
||||
# Let the loop iterate at least twice (interval is 50ms).
|
||||
await asyncio.sleep(0.2)
|
||||
await scanner.stop()
|
||||
try:
|
||||
await asyncio.wait_for(second_scan.wait(), timeout=1.0)
|
||||
finally:
|
||||
await scanner.stop()
|
||||
|
||||
assert logged_errors == ["simulated scanner failure"]
|
||||
assert call_count["n"] >= 2 # second call ran despite first throwing
|
||||
|
||||
80
tests/unit/test_scripts/test_check_repo_assets.py
Normal file
80
tests/unit/test_scripts/test_check_repo_assets.py
Normal file
@ -0,0 +1,80 @@
|
||||
"""Self-tests for ``scripts/check_repo_assets.py``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
_CHECKER_PATH = _REPO_ROOT / "scripts" / "check_repo_assets.py"
|
||||
|
||||
|
||||
def _load_checker():
|
||||
assert _CHECKER_PATH.exists(), "repo asset checker should exist"
|
||||
spec = importlib.util.spec_from_file_location("_repo_asset_checker", _CHECKER_PATH)
|
||||
assert spec and spec.loader
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def test_clean_source_and_docs_paths_are_allowed() -> None:
|
||||
checker = _load_checker()
|
||||
|
||||
violations = checker.find_violations(
|
||||
[
|
||||
"README.md",
|
||||
"docs/engineering.md",
|
||||
"src/everos/__init__.py",
|
||||
"use-cases/claude-code-plugin/dashboard/dashboard.html",
|
||||
]
|
||||
)
|
||||
|
||||
assert violations == []
|
||||
|
||||
|
||||
def test_image_extensions_are_blocked() -> None:
|
||||
checker = _load_checker()
|
||||
|
||||
violations = checker.find_violations(["docs/banner.png", "icons/logo.svg"])
|
||||
|
||||
assert [violation.path for violation in violations] == [
|
||||
"docs/banner.png",
|
||||
"icons/logo.svg",
|
||||
]
|
||||
assert {violation.reason for violation in violations} == {"image file"}
|
||||
|
||||
|
||||
def test_video_extensions_are_blocked() -> None:
|
||||
checker = _load_checker()
|
||||
|
||||
violations = checker.find_violations(["demo/launch.mp4", "docs/clip.webm"])
|
||||
|
||||
assert [violation.path for violation in violations] == [
|
||||
"demo/launch.mp4",
|
||||
"docs/clip.webm",
|
||||
]
|
||||
assert {violation.reason for violation in violations} == {"video file"}
|
||||
|
||||
|
||||
def test_asset_and_media_directories_are_blocked() -> None:
|
||||
checker = _load_checker()
|
||||
|
||||
violations = checker.find_violations(
|
||||
[
|
||||
"assets/banner.txt",
|
||||
"docs/images/diagram.txt",
|
||||
"use-cases/example/media/story.md",
|
||||
"use-cases/example/videos/walkthrough.md",
|
||||
]
|
||||
)
|
||||
|
||||
assert [violation.path for violation in violations] == [
|
||||
"assets/banner.txt",
|
||||
"docs/images/diagram.txt",
|
||||
"use-cases/example/media/story.md",
|
||||
"use-cases/example/videos/walkthrough.md",
|
||||
]
|
||||
assert {violation.reason for violation in violations} == {"asset/media directory"}
|
||||
@ -889,7 +889,7 @@ function getGroupsForKey(keyId) {
|
||||
// Proxy forwards to upstream API with Authorization header
|
||||
```
|
||||
|
||||
### Dashboard (`assets/dashboard.html`)
|
||||
### Dashboard (`dashboard/dashboard.html`)
|
||||
|
||||
**Data Loading Flow:**
|
||||
|
||||
@ -1059,7 +1059,7 @@ evermem-plugin/
|
||||
│ ├── config.js # Configuration utilities
|
||||
│ ├── debug.js # Shared debug logging utility
|
||||
│ └── groups-store.js # Local groups persistence
|
||||
├── assets/
|
||||
├── dashboard/
|
||||
│ └── dashboard.html # Memory Hub dashboard
|
||||
├── server/
|
||||
│ └── proxy.js # Local proxy server for dashboard
|
||||
|
||||
Reference in New Issue
Block a user