stage: metabolomics/v1 (initial dogfooding cut) #19
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # validate.yml -- runs on every PR | |
| # Enforces CI gates per section 14 of ASB-Skills Release Design Doc v2. | |
| # | |
| # Gates implemented here: | |
| # 1. LinkML schema validation (collection.yaml, tools/*.yaml) | |
| # 2. No orphan skills (DOI resolution sample) | |
| # 5. Description discipline lint (leading phrase, length, no marketing) | |
| # 6. EDAM IRI resolution | |
| # 8. RO-Crate validation (Workflow Run Profile 0.5) | |
| # 9. Indicium round-trip (verify-claims CLI from indicium-adapters) | |
| # 10. Plugin manifest validation | |
| name: Validate | |
| on: | |
| pull_request: | |
| branches: [main] | |
| push: | |
| branches: [main] | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| jobs: | |
| validate: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Set up Python 3.12 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| - name: Install dependencies | |
| run: | | |
| pip install --upgrade pip | |
| pip install pyyaml jsonschema requests | |
| pip install linkml linkml-runtime || echo "WARNING: linkml install failed" | |
| pip install rocrate || echo "WARNING: rocrate install failed" | |
| # indicium-adapters provides the verify-claims console script (Plan 1c) | |
| pip install indicium-adapters || echo "WARNING: indicium-adapters not yet available; gate 9 will warn-only" | |
| # -- Gate 10: Plugin manifest validates -------------------------------- | |
| - name: Validate .claude-plugin/marketplace.json | |
| run: | | |
| python - <<'EOF' | |
| import json, pathlib, sys | |
| p = pathlib.Path(".claude-plugin/marketplace.json") | |
| if not p.exists(): | |
| print("SKIP: no marketplace.json found"); sys.exit(0) | |
| data = json.loads(p.read_text()) | |
| required = ["schema_version", "plugins"] | |
| missing = [k for k in required if k not in data] | |
| if missing: | |
| print(f"FAIL: marketplace.json missing keys: {missing}"); sys.exit(1) | |
| if not isinstance(data["plugins"], list): | |
| print("FAIL: plugins must be a list"); sys.exit(1) | |
| print(f"PASS: marketplace.json valid ({len(data['plugins'])} plugins)") | |
| EOF | |
| # -- Gate 5: Description discipline lint -------------------------------- | |
| - name: Lint skill descriptions | |
| run: | | |
| python - <<'EOF' | |
| import sys, pathlib, yaml | |
| APPROVED_PREFIXES = ( | |
| "Use when", "Reference for", "Explains", "Decision support for" | |
| ) | |
| MIN_LEN = 50 | |
| MAX_LEN = 300 | |
| MARKETING_TERMS = ["best", "state-of-the-art", "revolutionary", "leading", "superior"] | |
| failures = [] | |
| skill_files = list(pathlib.Path("collections").rglob("SKILL.md")) | |
| skill_files += list(pathlib.Path("staged-collections").rglob("SKILL.md")) | |
| for skill_md in skill_files: | |
| text = skill_md.read_text() | |
| if not text.startswith("---"): | |
| continue | |
| try: | |
| parts = text.split("---", 2) | |
| fm = yaml.safe_load(parts[1]) | |
| except Exception: | |
| continue | |
| desc = (fm.get("description") or "").strip() | |
| if not desc: | |
| failures.append(f"{skill_md}: missing description") | |
| continue | |
| if not any(desc.startswith(p) for p in APPROVED_PREFIXES): | |
| failures.append( | |
| f"{skill_md}: description must start with one of {APPROVED_PREFIXES}" | |
| ) | |
| if len(desc) < MIN_LEN: | |
| failures.append( | |
| f"{skill_md}: description too short ({len(desc)} < {MIN_LEN})" | |
| ) | |
| if len(desc) > MAX_LEN: | |
| failures.append( | |
| f"{skill_md}: description too long ({len(desc)} > {MAX_LEN})" | |
| ) | |
| for term in MARKETING_TERMS: | |
| if term.lower() in desc.lower(): | |
| failures.append( | |
| f"{skill_md}: marketing term '{term}' in description" | |
| ) | |
| if failures: | |
| print("FAIL: description discipline violations:") | |
| for f in failures: | |
| print(f" - {f}") | |
| sys.exit(1) | |
| print(f"PASS: description discipline OK ({len(skill_files)} skill files checked)") | |
| EOF | |
| # -- Gate 2: No orphan skills (DOI resolution sample) ------------------ | |
| - name: Check derived_from DOIs resolve (sample) | |
| run: | | |
| python - <<'EOF' | |
| import sys, pathlib, yaml, urllib.request, urllib.error | |
| failures = [] | |
| checked = 0 | |
| skill_files = list(pathlib.Path("collections").rglob("SKILL.md")) | |
| skill_files += list(pathlib.Path("staged-collections").rglob("SKILL.md")) | |
| # Skip auto-generated router skills — they aggregate, not derive | |
| # from a single paper. | |
| skill_files = [s for s in skill_files if s.parent.name != "_router"] | |
| for skill_md in skill_files[:10]: | |
| text = skill_md.read_text() | |
| if not text.startswith("---"): | |
| continue | |
| try: | |
| fm = yaml.safe_load(text.split("---", 2)[1]) | |
| except Exception: | |
| continue | |
| # Accept both canonical derived_from and the upstream | |
| # provenance.source_papers shape used by the ASB pipeline. | |
| derived = fm.get("derived_from") or [] | |
| if not derived: | |
| prov = fm.get("provenance") or {} | |
| if isinstance(prov, dict): | |
| src = prov.get("source_papers") or [] | |
| derived = [s for s in src if isinstance(s, dict) and s.get("doi")] | |
| if not derived: | |
| failures.append(f"{skill_md}: no derived_from DOIs") | |
| continue | |
| # Sample: check first DOI only to keep CI fast. | |
| # Use doi.org's content-negotiation API (Accept: citeproc+json) | |
| # to validate the DOI exists, bypassing publisher-side | |
| # firewalls that 403 generic HEAD requests. | |
| entry = derived[0] | |
| doi = entry.get("doi") if isinstance(entry, dict) else entry | |
| url = f"https://doi.org/{doi}" | |
| try: | |
| req = urllib.request.Request( | |
| url, | |
| headers={ | |
| "Accept": "application/citeproc+json", | |
| "User-Agent": "asb-skill-collections/0.1 (mailto:louisfelix.nothias@gmail.com)", | |
| }, | |
| ) | |
| with urllib.request.urlopen(req, timeout=15) as resp: | |
| # 200 with JSON body confirms the DOI exists in the | |
| # Crossref/DataCite registry. | |
| if resp.status == 200: | |
| checked += 1 | |
| else: | |
| failures.append( | |
| f"{skill_md}: DOI {doi} returned status {resp.status}" | |
| ) | |
| except urllib.error.HTTPError as e: | |
| if e.code == 404: | |
| failures.append(f"{skill_md}: DOI {doi} not found (404)") | |
| else: | |
| # Treat 4xx (excluding 404) and 5xx as soft-fail — | |
| # doi.org's content-neg API is occasionally rate-limited | |
| # or down; non-404 errors shouldn't block the release. | |
| print(f"WARN: {skill_md}: DOI {doi} soft-fail HTTP {e.code} (not blocking)") | |
| checked += 1 | |
| except Exception as e: | |
| print(f"WARN: {skill_md}: DOI {doi} soft-fail {type(e).__name__} (not blocking)") | |
| checked += 1 | |
| if failures: | |
| print("FAIL: orphan skill / DOI resolution failures:") | |
| for f in failures: | |
| print(f" - {f}") | |
| sys.exit(1) | |
| print(f"PASS: DOI resolution OK ({checked} DOIs checked)") | |
| EOF | |
| # -- Gate 6: EDAM IRI resolution ---------------------------------------- | |
| - name: Check EDAM IRIs | |
| run: | | |
| python - <<'EOF' | |
| import sys, pathlib, yaml | |
| EDAM_BASE = "http://edamontology.org/" | |
| failures = [] | |
| checked = set() | |
| skill_files = list(pathlib.Path("collections").rglob("SKILL.md")) | |
| skill_files += list(pathlib.Path("staged-collections").rglob("SKILL.md")) | |
| for skill_md in skill_files: | |
| text = skill_md.read_text() | |
| if not text.startswith("---"): | |
| continue | |
| try: | |
| fm = yaml.safe_load(text.split("---", 2)[1]) | |
| except Exception: | |
| continue | |
| meta = fm.get("metadata") or {} | |
| iris = [] | |
| if meta.get("edam_operation"): | |
| iris.append(meta["edam_operation"]) | |
| iris.extend(meta.get("edam_topics") or []) | |
| for iri in iris: | |
| if iri in checked: | |
| continue | |
| checked.add(iri) | |
| if not iri.startswith(EDAM_BASE): | |
| failures.append( | |
| f"{skill_md}: EDAM IRI {iri} does not start with {EDAM_BASE}" | |
| ) | |
| if failures: | |
| print("FAIL: EDAM IRI violations:") | |
| for f in failures: | |
| print(f" - {f}") | |
| sys.exit(1) | |
| print(f"PASS: EDAM IRIs OK ({len(checked)} unique IRIs validated)") | |
| EOF | |
| # -- Gate 8: RO-Crate validation ---------------------------------------- | |
| - name: Validate RO-Crate metadata | |
| run: | | |
| python - <<'EOF' | |
| import sys, json, pathlib | |
| crate_files = list(pathlib.Path("collections").rglob("ro-crate-metadata.json")) | |
| crate_files += list(pathlib.Path("staged-collections").rglob("ro-crate-metadata.json")) | |
| failures = [] | |
| for crate_file in crate_files: | |
| try: | |
| data = json.loads(crate_file.read_text()) | |
| if "@context" not in data: | |
| failures.append(f"{crate_file}: missing @context") | |
| if "@graph" not in data: | |
| failures.append(f"{crate_file}: missing @graph") | |
| graph = data.get("@graph", []) | |
| root_ids = {"./", "."} | |
| root_entities = [e for e in graph if e.get("@id") in root_ids] | |
| if not root_entities: | |
| failures.append( | |
| f"{crate_file}: no root dataset entity (id ./ or .)" | |
| ) | |
| except json.JSONDecodeError as e: | |
| failures.append(f"{crate_file}: JSON parse error: {e}") | |
| if failures: | |
| print("FAIL: RO-Crate validation failures:") | |
| for f in failures: | |
| print(f" - {f}") | |
| sys.exit(1) | |
| print(f"PASS: RO-Crate validation OK ({len(crate_files)} crates checked)") | |
| EOF | |
| # -- Gate 9: indicium round-trip (verify-claims) ------------------------- | |
| - name: verify-claims round-trip | |
| run: | | |
| if ! command -v verify-claims &> /dev/null; then | |
| echo "WARNING: verify-claims CLI not found." | |
| echo "Install indicium-adapters to enable gate 9: pip install indicium-adapters" | |
| echo "Skipping gate 9 (non-blocking until indicium-adapters is published)" | |
| exit 0 | |
| fi | |
| EXIT=0 | |
| for collection_dir in collections/*/v*; do | |
| [ -d "$collection_dir" ] || continue | |
| echo "Running verify-claims on $collection_dir ..." | |
| verify-claims --collection "$collection_dir" --format json || EXIT=$? | |
| done | |
| for collection_dir in staged-collections/*/v*; do | |
| [ -d "$collection_dir" ] || continue | |
| echo "Running verify-claims on $collection_dir ..." | |
| verify-claims --collection "$collection_dir" --format json || EXIT=$? | |
| done | |
| exit $EXIT | |
| # -- Gate 1: LinkML schema validation ------------------------------------ | |
| - name: LinkML schema validation | |
| run: | | |
| python - <<'EOF' | |
| import sys, pathlib, subprocess, importlib.util | |
| # asb-schema is not yet on PyPI; the LinkML schema lives in the | |
| # AgenticScienceBuilder repo at scitask-schema/. CI here doesn't | |
| # have it checked out, and a wheel install isn't viable yet. | |
| # | |
| # Until asb-schema is published (v1.1+), this step performs a | |
| # structural sanity check on collection.yaml instead of full | |
| # LinkML conformance: required top-level keys + value types. | |
| # Real LinkML validation runs at release time once asb-schema | |
| # ships to PyPI. | |
| import yaml | |
| required_keys = {"slug", "title", "version", "n_skills", "skills"} | |
| collection_files = list(pathlib.Path("collections").rglob("collection.yaml")) | |
| collection_files += list( | |
| pathlib.Path("staged-collections").rglob("collection.yaml") | |
| ) | |
| if not collection_files: | |
| print("SKIP: no collection.yaml files found") | |
| sys.exit(0) | |
| failures = [] | |
| for cf in collection_files: | |
| try: | |
| doc = yaml.safe_load(cf.read_text()) or {} | |
| except Exception as exc: | |
| failures.append(f"{cf}: YAML parse error: {exc}") | |
| continue | |
| missing = required_keys - set(doc.keys()) | |
| if missing: | |
| failures.append(f"{cf}: missing keys {sorted(missing)}") | |
| if not isinstance(doc.get("skills"), list): | |
| failures.append(f"{cf}: 'skills' must be a list") | |
| if not isinstance(doc.get("n_skills"), int): | |
| failures.append(f"{cf}: 'n_skills' must be int") | |
| if failures: | |
| print("FAIL: structural validation failures:") | |
| for f in failures: | |
| print(f" - {f}") | |
| sys.exit(1) | |
| print(f"PASS: structural validation OK ({len(collection_files)} files)") | |
| print("INFO: Full LinkML conformance deferred until asb-schema ships to PyPI (v1.1).") | |
| EOF |