From 94ac2170c7bf2938b176761be38feaf97a0b4e93 Mon Sep 17 00:00:00 2001 From: Ryan Anderson Date: Fri, 12 Jun 2026 15:16:39 -0700 Subject: [PATCH] ci: add DinoLab V2 gate --- .github/workflows/ci.yml | 36 ++++++ README.md | 6 +- docs/V2_UPGRADE_PLAN.md | 28 +++++ tools/validate_static_app.py | 231 +++++++++++++++++++++++++++++++++++ 4 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 docs/V2_UPGRADE_PLAN.md create mode 100644 tools/validate_static_app.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..74234d0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + static-app: + name: Static app gate + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Validate static app and licensed assets + run: python tools/validate_static_app.py + + - name: Check JavaScript syntax + run: | + node --check app.js + node --check sw.js diff --git a/README.md b/README.md index 14b4b5a..91ca0f4 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ ## Image Gallery -The app includes **27 copyright-free images** from Wikimedia Commons: +The app includes **33 open-license images** from Wikimedia Commons: - Dinosaur fossils & museum reconstructions - Prehistoric marine & flying reptiles - Ice Age megafauna (mammoth, smilodon) @@ -49,10 +49,10 @@ The app includes **27 copyright-free images** from Wikimedia Commons: ## Asset Folder Structure - `assets/master/` for original licensed files (JPEG/PNG) -- `assets/web/` for optimized runtime files (WebP) +- `assets/web/` for optimized runtime files (currently JPEG/PNG copies) - `data/assets-manifest.json` for attribution + license tracking -Do not ship production assets unless `status` is updated from `todo-source` to a verified licensed state. +Do not ship production assets unless `status` is `ready` and attribution/license metadata remains complete. ## Goal diff --git a/docs/V2_UPGRADE_PLAN.md b/docs/V2_UPGRADE_PLAN.md new file mode 100644 index 0000000..2fc4815 --- /dev/null +++ b/docs/V2_UPGRADE_PLAN.md @@ -0,0 +1,28 @@ +# DinoLab Mashup V2 Upgrade Plan + +## Current Baseline + +- Static PWA app shell with offline service worker, manifest, local state, and no server dependency. +- Asset manifest tracks 33 local Wikimedia-sourced assets with creator, license, source URL, alt text, and master/runtime file paths. +- CI now validates the app shell, web manifest, service-worker cache list, asset metadata, local asset file presence, and JavaScript syntax. + +## Ship-Blocking Guardrails + +- Keep every production image at `status: ready` with source URL, creator, license, and alt text before release. +- Keep `assets/master/` and `assets/web/` in sync with `data/assets-manifest.json`; no unreferenced or missing image files. +- Keep the app boot sequence intact: manifest, stylesheet, app script, asset manifest fetch, discovery gallery render, and service-worker registration. + +## V2 Work Queue + +1. Add installable PWA icon and screenshot assets to `manifest.webmanifest`. +2. Add a browser smoke test that opens the app, verifies the gallery renders from `data/assets-manifest.json`, and confirms service-worker fallback behavior. +3. Add an in-app attribution view that exposes title, creator, license, and source URL for each asset. +4. Convert runtime images to responsive WebP/AVIF variants while preserving master originals. +5. Add parent-facing privacy copy for local-only saves and poster downloads. +6. Add lightweight accessibility checks for touch targets, keyboard navigation, contrast, and image alt coverage. + +## Deployment Notes + +- No package install is required for local static serving. +- Local smoke: `python3 -m http.server 8080`, then open `http://localhost:8080`. +- CI smoke: `python tools/validate_static_app.py`, `node --check app.js`, and `node --check sw.js`. diff --git a/tools/validate_static_app.py b/tools/validate_static_app.py new file mode 100644 index 0000000..75c481a --- /dev/null +++ b/tools/validate_static_app.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import sys +from pathlib import Path +from urllib.parse import urlparse + + +ROOT = Path(__file__).resolve().parents[1] +REQUIRED_APP_FILES = [ + "index.html", + "styles.css", + "app.js", + "sw.js", + "manifest.webmanifest", + "data/assets-manifest.json", +] +REQUIRED_WEB_MANIFEST_FIELDS = [ + "name", + "short_name", + "description", + "start_url", + "display", + "background_color", + "theme_color", +] +REQUIRED_SERVICE_WORKER_ASSETS = [ + "./", + "./index.html", + "./styles.css", + "./app.js", + "./manifest.webmanifest", + "./data/assets-manifest.json", +] +IMAGE_EXTENSIONS = {".avif", ".jpeg", ".jpg", ".png", ".webp"} +UNVERIFIED_LICENSE_VALUES = {"", "todo", "todo-source", "unknown", "unverified"} + + +def load_json(path: Path, errors: list[str]) -> object: + try: + with path.open(encoding="utf-8") as handle: + return json.load(handle) + except Exception as exc: + errors.append(f"{path.relative_to(ROOT)} is not valid JSON: {exc}") + return {} + + +def require_text_marker(path: str, text: str, marker: str, errors: list[str]) -> None: + if marker not in text: + errors.append(f"{path} is missing expected marker: {marker}") + + +def read_text(path: Path, errors: list[str]) -> str: + try: + return path.read_text(encoding="utf-8") + except Exception as exc: + errors.append(f"{path.relative_to(ROOT)} could not be read: {exc}") + return "" + + +def safe_repo_path(value: str, errors: list[str], asset_id: str, field: str) -> Path | None: + rel = Path(value) + if rel.is_absolute() or ".." in rel.parts: + errors.append(f"{asset_id}.{field} must be a safe repo-relative path: {value}") + return None + return ROOT / rel + + +def has_http_url(value: str) -> bool: + parsed = urlparse(value) + return parsed.scheme in {"http", "https"} and bool(parsed.netloc) + + +def validate_app_shell(errors: list[str]) -> None: + for rel_path in REQUIRED_APP_FILES: + path = ROOT / rel_path + if not path.is_file(): + errors.append(f"Missing required app file: {rel_path}") + + index_text = read_text(ROOT / "index.html", errors) + app_text = read_text(ROOT / "app.js", errors) + sw_text = read_text(ROOT / "sw.js", errors) + + for marker in [ + ' None: + manifest = load_json(ROOT / "manifest.webmanifest", errors) + if not isinstance(manifest, dict): + errors.append("manifest.webmanifest must be a JSON object") + return + + for field in REQUIRED_WEB_MANIFEST_FIELDS: + value = manifest.get(field) + if not isinstance(value, str) or not value.strip(): + errors.append(f"manifest.webmanifest requires non-empty string field: {field}") + + if manifest.get("display") != "standalone": + errors.append("manifest.webmanifest display must stay set to standalone") + + icons = manifest.get("icons") + if icons is not None and not isinstance(icons, list): + errors.append("manifest.webmanifest icons must be a list when present") + + +def validate_asset_manifest(errors: list[str]) -> None: + manifest_path = ROOT / "data/assets-manifest.json" + manifest = load_json(manifest_path, errors) + if not isinstance(manifest, dict): + errors.append("data/assets-manifest.json must be a JSON object") + return + + policy = manifest.get("policy") + if not isinstance(policy, dict): + errors.append("data/assets-manifest.json must include a policy object") + return + + required_fields = policy.get("requiredFields") + if not isinstance(required_fields, list) or not required_fields: + errors.append("policy.requiredFields must be a non-empty list") + return + + assets = manifest.get("assets") + if not isinstance(assets, list) or not assets: + errors.append("assets must be a non-empty list") + return + + seen_ids: set[str] = set() + referenced_files: set[Path] = set() + + for index, asset in enumerate(assets): + if not isinstance(asset, dict): + errors.append(f"assets[{index}] must be an object") + continue + + asset_id = asset.get("id") + if not isinstance(asset_id, str) or not asset_id.strip(): + errors.append(f"assets[{index}] is missing a non-empty id") + asset_id = f"assets[{index}]" + elif asset_id in seen_ids: + errors.append(f"Duplicate asset id: {asset_id}") + else: + seen_ids.add(asset_id) + + for field in required_fields: + if field not in asset: + errors.append(f"{asset_id} is missing required field: {field}") + continue + if field == "formats": + if not isinstance(asset[field], dict): + errors.append(f"{asset_id}.formats must be an object") + elif not isinstance(asset[field], str) or not asset[field].strip(): + errors.append(f"{asset_id}.{field} must be a non-empty string") + + status = str(asset.get("status", "")).strip().lower() + if status != "ready": + errors.append(f"{asset_id}.status must be ready before shipping") + + license_value = str(asset.get("license", "")).strip().lower() + if license_value in UNVERIFIED_LICENSE_VALUES: + errors.append(f"{asset_id}.license must be verified, not {asset.get('license')!r}") + + source_url = asset.get("sourceUrl") + if not isinstance(source_url, str) or not has_http_url(source_url): + errors.append(f"{asset_id}.sourceUrl must be an absolute HTTP(S) URL") + + formats = asset.get("formats") + if not isinstance(formats, dict): + continue + + for field in ("master", "web"): + value = formats.get(field) + if not isinstance(value, str) or not value.strip(): + errors.append(f"{asset_id}.formats.{field} must be a non-empty string") + continue + + file_path = safe_repo_path(value, errors, asset_id, f"formats.{field}") + if file_path is None: + continue + if not file_path.is_file(): + errors.append(f"{asset_id}.formats.{field} file does not exist: {value}") + continue + if file_path.stat().st_size == 0: + errors.append(f"{asset_id}.formats.{field} file is empty: {value}") + referenced_files.add(file_path.resolve()) + + for directory in ("assets/master", "assets/web"): + directory_path = ROOT / directory + if not directory_path.is_dir(): + errors.append(f"Missing asset directory: {directory}") + continue + for image_path in directory_path.iterdir(): + if image_path.suffix.lower() in IMAGE_EXTENSIONS and image_path.resolve() not in referenced_files: + errors.append(f"Unreferenced asset file: {image_path.relative_to(ROOT)}") + + +def main() -> int: + errors: list[str] = [] + validate_app_shell(errors) + validate_web_manifest(errors) + validate_asset_manifest(errors) + + if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + print("DinoLab static app validation passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())