Initial layout: apps/fileshare/ (seeded from daniel/furtka apps/), CI (JSON + manifest validator + shellcheck), release pipeline (tag-driven, mirrors core repo), vendored manifest schema for offline validation. The core repo (daniel/furtka) at 26.6-alpha keeps apps/fileshare as a seed so offline first-boot still has an installable app; this catalog becomes authoritative once a box has synced at least once. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
124 lines
4.4 KiB
Python
Executable file
124 lines
4.4 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""CI guardrail: validate every app in ./apps/ before cutting a release.
|
|
|
|
For each `apps/<name>/`:
|
|
- manifest.json must parse via furtka.manifest.load_manifest (same schema
|
|
the on-box reconciler expects — fields, settings rules, icon name).
|
|
- docker-compose.yaml must exist and reference every manifest-declared
|
|
volume as `external: true` (the reconciler creates the volumes; compose
|
|
must not try to manage their lifecycle).
|
|
- `docker compose config` must parse the compose file (catches YAML typos
|
|
+ missing env refs).
|
|
|
|
The `furtka` package is vendored in `scripts/vendor/` — lifted verbatim from
|
|
daniel/furtka's `furtka/manifest.py`. Keeping the copy local avoids dragging
|
|
the core repo's whole pyproject into this repo's CI. If the core manifest
|
|
schema evolves we bump the vendored copy with a conventional commit.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
sys.path.insert(0, str(HERE / "vendor"))
|
|
|
|
from furtka_manifest import ManifestError, load_manifest # noqa: E402
|
|
|
|
APPS_ROOT = HERE.parent / "apps"
|
|
|
|
|
|
def check_app(app_dir: Path) -> list[str]:
|
|
errors: list[str] = []
|
|
manifest_path = app_dir / "manifest.json"
|
|
if not manifest_path.is_file():
|
|
return [f"{app_dir.name}: manifest.json missing"]
|
|
|
|
try:
|
|
m = load_manifest(manifest_path, expected_name=app_dir.name)
|
|
except ManifestError as e:
|
|
return [f"{app_dir.name}: invalid manifest: {e}"]
|
|
|
|
compose_path = app_dir / "docker-compose.yaml"
|
|
if not compose_path.is_file():
|
|
errors.append(f"{app_dir.name}: docker-compose.yaml missing")
|
|
return errors
|
|
|
|
compose_text = compose_path.read_text()
|
|
# Cheap text search — pyyaml would be cleaner but we stay stdlib-only.
|
|
# The reconciler also works on a text basis: volume must be referenced
|
|
# at all, and must carry `external: true` in its definition block.
|
|
for volume in m.volumes:
|
|
namespaced = f"furtka_{m.name}_{volume}"
|
|
if namespaced not in compose_text:
|
|
errors.append(
|
|
f"{app_dir.name}: docker-compose.yaml doesn't reference namespaced "
|
|
f"volume {namespaced!r}"
|
|
)
|
|
# very forgiving pattern — accept `external: true` anywhere after the
|
|
# namespaced name appears. A more rigorous YAML walk can come later.
|
|
tail = compose_text.split(namespaced, 1)[-1]
|
|
if "external: true" not in tail and "external:\n true" not in tail:
|
|
errors.append(
|
|
f"{app_dir.name}: volume {namespaced!r} must be declared with "
|
|
f"external: true (reconciler creates the volume before compose up)"
|
|
)
|
|
|
|
# `docker compose config` parses the file and resolves env substitutions.
|
|
# Skip the check if docker isn't installed in the environment — locally
|
|
# that's fine, and CI images should have it.
|
|
docker = subprocess.run(
|
|
["which", "docker"], capture_output=True, text=True, check=False
|
|
)
|
|
if docker.returncode == 0:
|
|
result = subprocess.run(
|
|
["docker", "compose", "-f", str(compose_path), "config"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
cwd=app_dir,
|
|
# Mask any env-var substitution errors — we don't have the real
|
|
# .env on a catalog validation pass; `docker compose config`
|
|
# returns the stderr line "WARN[0000] The X variable is not set"
|
|
# which is fine for syntax check.
|
|
)
|
|
if result.returncode != 0:
|
|
errors.append(
|
|
f"{app_dir.name}: `docker compose config` failed:\n"
|
|
+ (result.stderr or result.stdout).strip()
|
|
)
|
|
|
|
return errors
|
|
|
|
|
|
def main() -> int:
|
|
if not APPS_ROOT.is_dir():
|
|
print(f"no apps/ directory at {APPS_ROOT}", file=sys.stderr)
|
|
return 2
|
|
|
|
all_errors: list[str] = []
|
|
apps = sorted(p for p in APPS_ROOT.iterdir() if p.is_dir())
|
|
if not apps:
|
|
print("no apps to validate", file=sys.stderr)
|
|
return 2
|
|
|
|
for app_dir in apps:
|
|
errors = check_app(app_dir)
|
|
if errors:
|
|
all_errors.extend(errors)
|
|
else:
|
|
print(f"OK {app_dir.name}")
|
|
|
|
if all_errors:
|
|
print("\nFAIL:")
|
|
for e in all_errors:
|
|
print(f" - {e}")
|
|
return 1
|
|
print(f"\nValidated {len(apps)} app(s).")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|