Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Generate loaf registry index
run: python3 scripts/generate_loaf_index.py
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Generate grain registry index
run: python3 scripts/generate_grain_index.py
env:
Expand Down
52 changes: 52 additions & 0 deletions _data/loaf-index.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"generated": "2026-07-13T04:39:09Z",
"total": 2,
"summary": {
"prototype": 2
},
"loaves": [
{
"repo": "Loaf_x004",
"id": "loaf-x004",
"name": "x004 Loaf",
"status": "prototype",
"role": "backplane",
"summary": "Four-Slice backplane distributing 12V and I2C over the Slice bus.",
"slice_slots": 4,
"bus_type": "i2c",
"hw_version": "0.3.0",
"hw_gen_current": 2,
"pcb_layers": 2,
"schema_version": "1.0",
"tags": [
"backplane",
"four-slice",
"i2c"
],
"updated": "2026-07-13",
"url": "https://feastorg.github.io/Loaf_x004/"
},
{
"repo": "Loaf_ESPT",
"id": "loaf-espt",
"name": "ESP32 Thing Plus Controller Loaf",
"status": "prototype",
"role": "controller",
"summary": "Controller Loaf carrying a SparkFun ESP32 Thing Plus, chaining onto a backplane.",
"slice_slots": 0,
"bus_type": "i2c",
"hw_version": "0.2.0",
"hw_gen_current": 2,
"pcb_layers": 2,
"schema_version": "1.0",
"tags": [
"controller",
"esp32",
"thing-plus",
"i2c"
],
"updated": "2026-07-13",
"url": "https://feastorg.github.io/Loaf_ESPT/"
}
]
}
2 changes: 1 addition & 1 deletion _pages/grains.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
layout: default
title: Grain Registry
parent: Projects
nav_order: 2
nav_order: 3
has_children: true
permalink: /grains/
---
Expand Down
60 changes: 60 additions & 0 deletions _pages/loaves.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
layout: default
title: Loaf Registry
parent: Projects
nav_order: 2
has_children: true
permalink: /loaves/
---

# Loaf Registry

All BREADS-compatible Loaves, auto-generated from each repo's `loaf.yaml` manifest.

A **Loaf** is the attachment and interconnect layer that lets Slices operate together as
one BREAD. A `backplane` provides Slice attachment; a `controller` provides the controller
interface and chains onto a backplane, with no Slice slots of its own.

{% assign loaves = site.data['loaf-index'].loaves %}
{% assign summary = site.data['loaf-index'].summary %}
{% assign generated = site.data['loaf-index'].generated %}
{% assign total = site.data['loaf-index'].total %}

{% if loaves %}

**{{ total }} loaves** · Generated {{ generated | slice: 0, 10 }}
{%- if summary.released and summary.released > 0 %} · <span class="slice-badge slice-badge--released">released {{ summary.released }}</span>{% endif %}
{%- if summary.validated and summary.validated > 0 %} · <span class="slice-badge slice-badge--validated">validated {{ summary.validated }}</span>{% endif %}
{%- if summary.prototype and summary.prototype > 0 %} · <span class="slice-badge slice-badge--prototype">prototype {{ summary.prototype }}</span>{% endif %}
{%- if summary.concept and summary.concept > 0 %} · <span class="slice-badge slice-badge--concept">concept {{ summary.concept }}</span>{% endif %}
{%- if summary.deprecated and summary.deprecated > 0 %} · <span class="slice-badge slice-badge--deprecated">deprecated {{ summary.deprecated }}</span>{% endif %}

---

{% assign roles = "backplane,hybrid,controller" | split: "," %}

{% for role in roles %}
{% assign role_loaves = loaves | where: "role", role %}
{% if role_loaves.size > 0 %}

## {{ role | capitalize }}

<div class="slice-registry" markdown="block">

| Loaf | Summary | Slots | Bus | Status | HW | Tags |
|---|---|---|---|---|---|---|
{% for l in role_loaves -%}
| [{{ l.name | default: l.repo }}]({{ l.url }}) | {{ l.summary | default: "—" }} | {{ l.slice_slots }} | {{ l.bus_type | default: "—" }} | <span class="slice-badge slice-badge--{{ l.status }}">{{ l.status }}</span> | {% if l.hw_version %}v{{ l.hw_version }} (gen {{ l.hw_gen_current }}){% else %}—{% endif %} | {% if l.tags and l.tags.size > 0 %}{{ l.tags | join: ", " }}{% else %}—{% endif %} |
{% endfor %}

</div>

{% endif %}
{% endfor %}

{% else %}

_No Loaf manifests found. The index is regenerated daily from `loaf.yaml` in each
`Loaf_*` repo._

{% endif %}
164 changes: 164 additions & 0 deletions scripts/generate_loaf_index.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""
Generate _data/loaf-index.json from loaf.yaml manifests across all Loaf_* repos
in the feastorg GitHub organization.

Usage:
python3 scripts/generate_loaf_index.py

Environment:
GITHUB_TOKEN — required; a token with read access to the feastorg org.
GITHUB_TOKEN is available automatically in Actions.

Output:
_data/loaf-index.json
"""

from __future__ import annotations

import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path

try:
import requests
except ImportError:
sys.exit("Error: 'requests' not installed. Run: pip install requests")

try:
import yaml
except ImportError:
sys.exit("Error: 'pyyaml' not installed. Run: pip install pyyaml")

ORG = "feastorg"
OUTPUT = Path("_data/loaf-index.json")
API = "https://api.github.com"

# Fields to extract from each loaf.yaml (all nullable)
EXTRACT = [
("id", lambda m: m.get("id")),
("name", lambda m: m.get("name")),
("status", lambda m: m.get("status")),
("role", lambda m: m.get("role")),
("summary", lambda m: m.get("summary")),
("slice_slots", lambda m: (m.get("interconnect") or {}).get("slice_slots")),
("bus_type", lambda m: ((m.get("interconnect") or {}).get("bus") or {}).get("type")),
("hw_version", lambda m: (m.get("version") or {}).get("hardware")),
("hw_gen_current", lambda m: (m.get("hardware") or {}).get("hw_gen_current")),
("pcb_layers", lambda m: ((m.get("hardware") or {}).get("pcb") or {}).get("layers")),
("schema_version", lambda m: m.get("schema_version")),
("tags", lambda m: (m.get("metadata") or {}).get("tags", [])),
("updated", lambda m: (m.get("metadata") or {}).get("updated")),
]

STATUS_ORDER = ["released", "validated", "prototype", "concept", "deprecated"]
ROLE_ORDER = ["backplane", "hybrid", "controller"]


def gh_session() -> requests.Session:
token = os.environ.get("GITHUB_TOKEN", "")
if not token:
sys.exit("Error: GITHUB_TOKEN environment variable not set.")
s = requests.Session()
s.headers.update(
{
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
)
return s


def list_loaf_repos(session: requests.Session) -> list[str]:
"""Return names of all public Loaf_* repos in the org."""
repos = []
page = 1
while True:
r = session.get(
f"{API}/orgs/{ORG}/repos",
params={"type": "public", "per_page": 100, "page": page},
)
r.raise_for_status()
batch = r.json()
if not batch:
break
repos.extend(repo["name"] for repo in batch if repo["name"].startswith("Loaf_"))
page += 1
return sorted(repos)


def fetch_manifest(session: requests.Session, repo: str) -> dict | None:
"""Fetch and parse loaf.yaml from the repo's default branch."""
url = f"https://raw.githubusercontent.com/{ORG}/{repo}/main/loaf.yaml"
r = session.get(url)
if r.status_code == 404:
print(f" SKIP {repo}: no loaf.yaml", flush=True)
return None
r.raise_for_status()
try:
return yaml.safe_load(r.text)
except yaml.YAMLError as e:
print(f" WARN {repo}: YAML parse error — {e}", flush=True)
return None


def extract(manifest: dict, repo: str) -> dict:
entry = {"repo": repo}
for key, fn in EXTRACT:
try:
entry[key] = fn(manifest)
except Exception:
entry[key] = None
entry["url"] = f"https://feastorg.github.io/{repo}/"
return entry


def sort_key(entry: dict) -> tuple:
status_rank = (
STATUS_ORDER.index(entry["status"]) if entry["status"] in STATUS_ORDER else 99
)
role_rank = ROLE_ORDER.index(entry["role"]) if entry["role"] in ROLE_ORDER else 99
return (status_rank, role_rank, entry.get("id") or "")


def main() -> None:
session = gh_session()

print(f"Listing Loaf_* repos in {ORG}...", flush=True)
repos = list_loaf_repos(session)
print(f"Found {len(repos)} repos.", flush=True)

loaves = []
for repo in repos:
print(f" Fetching {repo}...", flush=True)
manifest = fetch_manifest(session, repo)
if manifest is None:
continue
loaves.append(extract(manifest, repo))

loaves.sort(key=sort_key)

summary: dict[str, int] = {}
for entry in loaves:
key = entry.get("status") or "unknown"
summary[key] = summary.get(key, 0) + 1

output = {
"generated": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"total": len(loaves),
"summary": summary,
"loaves": loaves,
}

OUTPUT.parent.mkdir(parents=True, exist_ok=True)
OUTPUT.write_text(json.dumps(output, indent=2, ensure_ascii=False) + "\n")
print(f"\nWrote {len(loaves)} loaves to {OUTPUT}", flush=True)
for status, count in sorted(summary.items()):
print(f" {status}: {count}", flush=True)


if __name__ == "__main__":
main()