Skip to content

stage: metabolomics/v1 (initial dogfooding cut) #12

stage: metabolomics/v1 (initial dogfooding cut)

stage: metabolomics/v1 (initial dogfooding cut) #12

Workflow file for this run

# 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