Skip to content
Open
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
45 changes: 45 additions & 0 deletions .github/actions/validate-schedule/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: 'Validate ICI3D schedule data'
description: >
Runs the shared schedule validator (Tier 0 JSON Schema + Tier 1 referential:
instructor keys resolve, sessions non-overlapping within a track, end >= start)
on a clinic's schedule YAML. Designed to be called from a clinic repo that
vendors ICI3D.github.io as a submodule, so the validator, schema, role tokens,
and people data all come from one shared source. The clinic workflow just
checks out (with submodules) and calls this action.
inputs:
paths:
description: >
Glob of schedule data files to validate, relative to the calling repo's
root. Defaults to every cohort file under _data/schedule/<clinic>/.
required: false
default: '_data/schedule/*/*.yml'
runs:
using: 'composite'
steps:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Install validator dependencies
shell: bash
run: python -m pip install --quiet pyyaml jsonschema

- name: Validate schedule data
shell: bash
env:
PATHS: ${{ inputs.paths }}
# validate_schedule.py lives two levels up from this action dir, in the
# shared repo's tools/. Its schema / roles / people-dir defaults are
# resolved relative to its own location, so they point at the shared
# repo's schemas/, _data/schedule/roles.yml, and _data/team.
VALIDATOR: ${{ github.action_path }}/../../../tools/validate_schedule.py
run: |
shopt -s nullglob
files=($PATHS)
if [ ${#files[@]} -eq 0 ]; then
echo "No schedule files matching '$PATHS' — nothing to validate."
exit 0
fi
echo "Validating: ${files[*]}"
python3 "$VALIDATOR" "${files[@]}" --github
43 changes: 43 additions & 0 deletions .github/workflows/schedule-validate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Validate this repo's own schedule data (#54) via the shared pre-commit hook.
#
# The hook (.pre-commit-config.yaml -> tools/validate_schedule.py) is the SAME
# check contributors run locally with `pre-commit install`, so local and CI
# validation are one source of truth. Clinic repos run the equivalent check on
# their own schedule data via the distributed pre-commit hook (see ici3d_schedule/README.md).
name: Validate schedule data

on:
pull_request:
paths:
- '_data/schedule/**'
- '_data/team/**' # people data feeds Tier-1 resolution
- 'ici3d_schedule/**'
- 'tools/validate_schedule.py'
- 'pyproject.toml'
- '.pre-commit-config.yaml'
- '.github/workflows/schedule-validate.yml'
push:
branches: ['master']
paths:
- '_data/schedule/**'
- '_data/team/**'
- 'ici3d_schedule/**'
- 'tools/validate_schedule.py'
- 'pyproject.toml'
- '.pre-commit-config.yaml'
workflow_dispatch:

permissions:
contents: read

jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- uses: pre-commit/action@v3.0.1
with:
extra_args: validate-schedule --all-files
23 changes: 23 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Local validation for ICI3D clinic schedule data (#54 "flat data -> validate -> render").
#
# Contributors editing _data/schedule/<clinic>/<year>.yml get the SAME check CI
# runs, on every commit:
#
# pip install pre-commit && pre-commit install
#
# CI (.github/workflows/schedule-validate.yml) runs this same hook, so local and
# CI validation share one source of truth: tools/validate_schedule.py. roles.yml
# is validator config (allowed role tokens), not a schedule document, so it is
# excluded from the matched files.
repos:
- repo: local
hooks:
- id: validate-schedule
name: Validate ICI3D schedule data (Tier 0 schema + Tier 1 references)
entry: python tools/validate_schedule.py
language: python
additional_dependencies:
- pyyaml
- jsonschema
files: ^_data/schedule/.*\.ya?ml$
exclude: ^_data/schedule/roles\.ya?ml$
21 changes: 21 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Distributed pre-commit hook for ICI3D clinic repos (#54). A clinic validates its
# schedule data against the shared, versioned schema WITHOUT vendoring it, by adding
# to its own .pre-commit-config.yaml:
#
# repos:
# - repo: https://github.com/ICI3D/ICI3D.github.io
# rev: <tag or commit sha>
# hooks:
# - id: validate-schedule
#
# pre-commit installs this package (schema + role tokens bundled) into an isolated
# environment and runs it on the clinic's _data/schedule/*.yml files. Instructor keys
# resolve against the clinic's own _data/team (the same records the renderer reads via
# site.data.team); override with `args: [--people-dir, some/other/dir]` if needed.
- id: validate-schedule
name: Validate ICI3D schedule data (Tier 0 schema + Tier 1 references)
description: Schema + referential validation for ICI3D clinic schedule YAML.
entry: validate-schedule
language: python
files: ^_data/schedule/.*\.ya?ml$
exclude: ^_data/schedule/roles\.ya?ml$
97 changes: 97 additions & 0 deletions _data/schedule/example/sample.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Synthetic example cohort for the #54 schedule data loop.
#
# This is NOT a real clinic schedule. It documents the flat-data format and gives
# the validator (tools/validate_schedule.py) and renderer (_includes/schedule.html)
# something to exercise in CI. Real per-clinic schedules live in their own clinic
# repos; where a clinic's build reads that data from is the #56 topology decision.
#
# Instructor entries are either a role token (see _data/schedule/roles.yml) or a
# person key that resolves to _data/team/<key>.yml. This example uses both.
clinic: example
year: 2099
status: draft
title: Example Clinic 2099
timezone: Africa/Johannesburg
display_timezones:
- Africa/Johannesburg
- America/New_York
tracks:
- main
locations:
main-hall: Main Hall
comp-lab: Comp. Lab
faculty:
- faculty
- pulliam
weeks:
- title: "Week 1: Foundations"
open: true
days:
- date: "2099-01-05"
label: "Monday, 5 Jan"
sessions:
- start: "09:00"
end: "09:15"
kind: organizing
track: main
title: "Welcome and orientation"
instructors: [organizers]
location: main-hall
- start: "09:15"
end: "10:30"
kind: lecture
track: main
title: "Introduction to dynamical models"
instructors: [pulliam]
location: main-hall
links:
- {text: "slides", url: "https://example.org/slides/intro"}
- start: "10:30"
end: "11:00"
kind: tea
- start: "11:00"
end: "12:30"
kind: computer-session
track: main
title: "Tutorial 1: R and the SIR model"
instructors: [tutors]
location: comp-lab
- start: "12:30"
end: "13:30"
kind: meal
meal: lunch
- start: "13:30"
end: "15:00"
kind: discussion
track: main
title: "Model assumptions and their consequences"
instructors: [faculty]
location: main-hall
# a faculty-only (shadow) row: present in the source, dropped from the
# rendered participant view by the renderer.
- start: "15:00"
end: "15:30"
kind: organizing
track: main
title: "Faculty debrief"
shadow: true
instructors: [faculty]
notes:
- text: "Bring a laptop with R installed."
- date: "2099-01-06"
label: "Tuesday, 6 Jan"
sessions:
- start: "09:00"
end: "10:30"
kind: lecture
track: main
title: "Stochastic models"
instructors: [pulliam]
location: main-hall
- start: "10:30"
end: "12:00"
kind: group-work
track: main
title: "Project scoping"
instructors: [mentors]
location: main-hall
95 changes: 95 additions & 0 deletions _includes/schedule.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
{%- comment -%}
Shared, fixed schedule renderer for the #54 data loop.

Turns one flat cohort data file (_data/schedule/<clinic>/<year>.yml, validated by
tools/validate_schedule.py) into the rendered schedule. This include is NEVER edited
during a clinic; only the data file is. A bad data edit fails validation, it cannot
break the build, because no Liquid lives in the edited file.

Usage from a clinic schedule page (layout: clinic): assign the cohort to a variable,
then include schedule.html with data=<that variable> (the include tag cannot take a
bracket-subscripted value directly, so assign it first).
shadow:true sessions and notes are dropped here (participant view). A separate
faculty build would include them; see DESIGN.md / #56 for where that lives.
{%- endcomment -%}
{%- assign sched = include.data -%}
{%- for week in sched.weeks -%}
<details class="schedule-week"{% if week.open %} open{% endif %}>
<summary>{{ week.title }}</summary>
{%- for day in week.days -%}
<div class="schedule-day">
<h3>{{ day.label | default: day.date }}</h3>
<ul class="schedule-sessions">
{%- for s in day.sessions -%}
{%- unless s.shadow -%}
{%- case s.kind -%}
{%- when 'lecture' -%}{%- assign label = 'Lecture' -%}
{%- when 'discussion' -%}{%- assign label = 'Discussion' -%}
{%- when 'computer-session' -%}{%- assign label = 'Comp. Session' -%}
{%- when 'live-coding' -%}{%- assign label = 'Live coding' -%}
{%- when 'group-work' -%}{%- assign label = 'Group Work' -%}
{%- when 'organizing' -%}{%- assign label = 'Organizational' -%}
{%- when 'poster' -%}{%- assign label = 'Posters' -%}
{%- when 'reading' -%}{%- assign label = 'Reading' -%}
{%- when 'social' -%}{%- assign label = 'Social event' -%}
{%- when 'meal' -%}{%- assign label = s.meal | capitalize -%}
{%- when 'coffee' -%}{%- assign label = 'Coffee' -%}
{%- when 'tea' -%}{%- assign label = 'Tea' -%}
{%- when 'break' -%}{%- assign label = 'Free' -%}
{%- when 'todo' -%}{%- assign label = 'TODO' -%}
{%- else -%}{%- assign label = '' -%}
{%- endcase -%}
<li class="session session--{{ s.kind }}">
{%- if s.start -%}<span class="time">{{ s.start }}{% if s.end %}&ndash;{{ s.end }}{% endif %}</span>{%- endif -%}
{%- if label != '' %} <span class="kind">{{ label }}</span>{% endif -%}
{%- comment -%} the title's own link (display text == title) is rendered AS the linked title, not repeated below {%- endcomment -%}
{%- assign primary_url = '' -%}
{%- for l in s.links -%}{%- if l.text == s.title and primary_url == '' -%}{%- assign primary_url = l.url -%}{%- endif -%}{%- endfor -%}
{%- if s.title -%}
{%- assign title_html = s.title | markdownify | remove: '<p>' | remove: '</p>' | strip -%}
<span class="title">{% if primary_url != '' %}<a href="{{ primary_url }}">{{ title_html }}</a>{% else %}{{ title_html }}{% endif %}</span>
{%- endif -%}
{%- comment -%} who: single-track instructor list {%- endcomment -%}
{%- if s.instructors and s.instructors.size > 0 -%}
<span class="who">(
{%- for key in s.instructors -%}
{%- assign person = site.data.team[key] -%}
{%- if person -%}<a href="{{ site.baseurl }}/team/{{ key }}/">{{ person.name }}</a>{%- else -%}{{ key }}{%- endif -%}
{%- unless forloop.last %}, {% endunless -%}
{%- endfor -%}
{%- if s.location %}, {{ sched.locations[s.location] | default: s.location }}{% endif -%})</span>
{%- elsif s.instructors_by_track -%}
<span class="who">(
{%- for pair in s.instructors_by_track -%}
{{ pair[0] }}:
{%- for key in pair[1] -%} {% assign person = site.data.team[key] %}{% if person %}<a href="{{ site.baseurl }}/team/{{ key }}/">{{ person.name }}</a>{% else %}{{ key }}{% endif %}{%- unless forloop.last %},{% endunless -%}{%- endfor -%}
{%- unless forloop.last %}; {% endunless -%}
{%- endfor -%})</span>
{%- elsif s.location -%}
<span class="who">({{ sched.locations[s.location] | default: s.location }})</span>
{%- endif -%}
{%- comment -%} extra resource links (everything except the primary title link rendered above) {%- endcomment -%}
{%- assign n_extra = 0 -%}
{%- for l in s.links -%}{%- unless l.url == primary_url and l.text == s.title -%}{%- assign n_extra = n_extra | plus: 1 -%}{%- endunless -%}{%- endfor -%}
{%- if n_extra > 0 %} <span class="links">[{% for l in s.links %}{% unless l.url == primary_url and l.text == s.title %}<a href="{{ l.url }}">{{ l.text | default: 'link' }}</a>{% unless forloop.last %} {% endunless %}{% endunless %}{% endfor %}]</span>{% endif -%}
{%- assign has_notes = false -%}
{%- for n in s.notes -%}{%- unless n.shadow %}{%- assign has_notes = true -%}{%- endunless -%}{%- endfor -%}
{%- if has_notes -%}
<ul class="notes">{%- for n in s.notes -%}{%- unless n.shadow -%}<li>{{ n.text | default: n | markdownify | remove: '<p>' | remove: '</p>' | strip }}</li>{%- endunless -%}{%- endfor -%}</ul>
{%- endif -%}
</li>
{%- endunless -%}
{%- endfor -%}
</ul>
{%- assign has_day_notes = false -%}
{%- for n in day.notes -%}{%- unless n.shadow %}{%- assign has_day_notes = true -%}{%- endunless -%}{%- endfor -%}
{%- if day.links.size > 0 or has_day_notes -%}
<ul class="schedule-day-extras">
{%- for l in day.links -%}<li><a href="{{ l.url }}">{{ l.text | default: 'link' }}</a>{% if l.deadline %} (by {{ l.deadline }}){% endif %}</li>{%- endfor -%}
{%- for n in day.notes -%}{%- unless n.shadow -%}<li>{{ n.text | default: n | markdownify | remove: '<p>' | remove: '</p>' | strip }}</li>{%- endunless -%}{%- endfor -%}
</ul>
{%- endif -%}
</div>
{%- endfor -%}
</details>
{%- endfor -%}
48 changes: 48 additions & 0 deletions ici3d_schedule/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# ici3d_schedule — schedule validation for ICI3D clinics (#54)

The **flat-data schedule loop**: a clinic edits one YAML file per cohort
(`_data/schedule/<clinic>/<year>.yml`), a validator gates it (schema + referential
checks), and a fixed Liquid include renders it. Editing data can never break the
build, because no Liquid lives in the edited file — a bad edit fails validation instead.

This package ships the **validator**, the **JSON Schema**, and the allowed **role
vocabulary**, so any clinic repo validates against the shared, versioned schema
without vendoring it.

## Two independent channels (Path A)

Rendering and validation are distributed separately, both from this repo:

| Concern | Mechanism | What the clinic adds |
|---|---|---|
| Render `schedule.html` | Jekyll `remote_theme` | `remote_theme: ICI3D/ICI3D.github.io` + `jekyll-remote-theme` plugin |
| Validate schedule data | this pre-commit hook | a `.pre-commit-config.yaml` entry (below) |

A Jekyll theme distributes `_includes`/`_layouts`/`_sass`/`assets` only — not `_data`
and not this Python validator. So schedule **data** and **people records**
(`_data/team`, which the renderer reads as `site.data.team`) are **clinic-owned**, and
the validator resolves instructors against the clinic's own `_data/team`.

## Local validation (contributors)

pip install pre-commit
pre-commit install

Every commit that touches `_data/schedule/**` now runs the same check CI runs.

## Consuming from a clinic repo

`.pre-commit-config.yaml`:

repos:
- repo: https://github.com/ICI3D/ICI3D.github.io
rev: <tag or commit sha>
hooks:
- id: validate-schedule

pre-commit installs this package (schema + roles bundled) in an isolated environment
and runs it on the clinic's `_data/schedule/*.yml`. Instructor keys resolve against the
clinic's `_data/team`; point elsewhere with `args: [--people-dir, some/dir]`.

Run the identical check in CI with `pre-commit/action`, so local and CI validation are
one source of truth.
6 changes: 6 additions & 0 deletions ici3d_schedule/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""ICI3D clinic schedule tooling (#54 "flat data -> validate -> render" loop).

Ships the schedule JSON Schema and the allowed role-token vocabulary as package
data (ici3d_schedule/data/) and exposes the validator behind the `validate-schedule`
console script, which the distributed pre-commit hook (.pre-commit-hooks.yaml) runs.
"""
Loading