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
51 changes: 51 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: CI

on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt

- name: Lint (flake8)
run: |
flake8 .

- name: Run unit tests (pytest)
run: |
pytest -q --maxfail=1

- name: Execute all notebooks (nbconvert)
run: |
python - <<'PY'
import glob, subprocess, sys
notebooks = glob.glob('**/*.ipynb', recursive=True)
if not notebooks:
print("No notebooks found.")
sys.exit(0)
for n in notebooks:
print("Executing", n)
subprocess.check_call([
sys.executable, "-m", "jupyter", "nbconvert",
"--to", "notebook", "--execute", "--inplace",
"--ExecutePreprocessor.timeout=600", n
])
PY
91 changes: 91 additions & 0 deletions .github/workflows/deploy-notebooks-pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
on:
push:
branches:
- '**'

name: Build and deploy notebooks to GitHub Pages (per-branch)

jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
contents: write # needed to push gh-pages
steps:
- name: Checkout repository (current branch)
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Determine branch name
id: branch
run: |
echo "BRANCH_NAME=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV
echo "branch=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT

- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'

- name: Install build dependencies
run: |
python -m pip install --upgrade pip
if [ -f requirements.txt ]; then pip install -r requirements.txt; else pip install jupyter nbconvert nbformat; fi

- name: Build notebooks to HTML for this branch
run: |
python -m pip install --upgrade pip
mkdir -p site/${{ env.BRANCH_NAME }}
python scripts/build_notebooks.py site/${{ env.BRANCH_NAME }}

- name: Prepare gh-pages branch workspace
run: |
# If gh-pages exists, clone it, otherwise init an empty gh-pages branch
REPO_URL="https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git"
if git ls-remote --exit-code origin gh-pages; then
git clone --depth 1 --branch gh-pages "$REPO_URL" gh-pages || git clone "$REPO_URL" gh-pages
else
# create temporary folder, init gh-pages and push
mkdir gh-pages
cd gh-pages
git init
git remote add origin "$REPO_URL"
git checkout -b gh-pages || true
touch .nojekyll
git add .nojekyll
git commit -m "Initialize gh-pages"
git push origin gh-pages
cd ..
git clone --depth 1 --branch gh-pages "$REPO_URL" gh-pages
fi

- name: Copy built site into gh-pages under branch folder
run: |
set -e
rsync -a --delete site/${{ env.BRANCH_NAME }}/ gh-pages/${{ env.BRANCH_NAME }}/
# ensure a top-level index exists listing branches (optional)
python3 - <<'PY'
import os, json
root='gh-pages'
items = sorted([d for d in os.listdir(root) if os.path.isdir(os.path.join(root,d))])
index_path = os.path.join(root, 'index.html')
with open(index_path, 'w') as f:
f.write("<html><head><meta charset='utf-8'><title>Branches</title></head><body>")
f.write("<h1>Published branches</h1><ul>")
for d in items:
f.write(f'<li><a href="./{d}/">{d}</a></li>')
f.write("</ul></body></html>")
PY

- name: Commit and push changes to gh-pages
working-directory: gh-pages
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add --all
if git diff --quiet --cached; then
echo "No changes to deploy"
else
git commit -m "Deploy notebooks for branch '${{ env.BRANCH_NAME }}' [ci skip]"
git push origin gh-pages
fi
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.env
.venv
__pycache__/
site/
gh-pages/
*.pyc
.ipynb_checkpoints/
11 changes: 11 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
repos:
- repo: https://github.com/psf/black
rev: 24.1.0
hooks:
- id: black
language_version: python3.11

- repo: https://gitlab.com/pycqa/flake8
rev: 6.0.0
hooks:
- id: flake8
9 changes: 9 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
FROM python:3.11-slim

ENV DEBIAN_FRONTEND=noninteractive
WORKDIR /app
COPY requirements.txt /app/requirements.txt
RUN python -m pip install --upgrade pip && \
pip install --no-cache-dir -r /app/requirements.txt
COPY . /app
CMD ["bash"]
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,17 @@
# -
~
# Repository: Notebook pages (branch-per-branch)

This repository contains many Jupyter notebooks. This change adds CI and an automated build-and-deploy workflow that runs on pushes to any branch and publishes rendered HTML pages for that branch under gh-pages/<branch-name>.

Files added:
- .github/workflows/ci.yml — runs lint/tests and executes notebooks on pushes/PRs to main.
- .github/workflows/deploy-notebooks-pages.yml — builds and deploys per-branch HTML pages to gh-pages when any branch is pushed.
- scripts/build_notebooks.py — script that executes notebooks and exports them to HTML.
- requirements.txt, Dockerfile, .pre-commit-config.yaml, .gitignore — tooling and environment files.

How to run locally:
1. python -m venv .venv
2. source .venv/bin/activate
3. pip install -r requirements.txt
4. python scripts/build_notebooks.py site/local-branch

Enable GitHub Pages to serve the gh-pages branch in repository Settings -> Pages. The per-branch pages will be available at https://<owner>.github.io/<repo>/<branch>/ .
7 changes: 7 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
jupyter
nbconvert
nbformat
pytest
flake8
black
pre-commit
26 changes: 26 additions & 0 deletions scripts/build_notebooks.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#!/usr/bin/env python3
"""
Execute all notebooks in the repository (recursive) and export them to HTML into an output folder.
Execute all notebooks in the repository (recursive) and export them to
HTML into an output folder.

Usage:
python scripts/build_notebooks.py <output_dir> [--timeout SECONDS]
Expand All @@ -10,13 +12,26 @@
- export resulting notebook to HTML and place the HTML in <output_dir> preserving folder structure
"""
import sys
import os
import subprocess
from pathlib import Path

By default this will search for all .ipynb files (excluding
.ipynb_checkpoints) and:
- execute them with a timeout
- export resulting notebook to HTML and place the HTML in <output_dir> preserving folder structure
"""
import sys
import subprocess
from pathlib import Path

def find_notebooks(root="."):
nbs = []
for p in Path(root).rglob("*.ipynb"):
# skip checkpoints and files inside .git or site output
if ".ipynb_checkpoints" in p.parts or "site" in p.parts or "gh-pages" in p.parts:
if (".ipynb_checkpoints" in p.parts or "site" in p.parts or
"gh-pages" in p.parts):
if (".ipynb_checkpoints" in p.parts or "site" in p.parts or "gh-pages" in p.parts):
continue
nbs.append(p)
Expand All @@ -30,6 +45,7 @@ def main():
timeout = 600
if "--timeout" in sys.argv:
try:
timeout = int(sys.argv[sys.argv.index("--timeout")+1])
timeout = int(sys.argv[sys.argv.index("--timeout") + 1])
except Exception:
pass
Expand All @@ -42,6 +58,7 @@ def main():

print(f"Found {len(notebooks)} notebooks. Exporting to {outdir} ...")
for nb in notebooks:
rel = nb.relative_to(Path.cwd())
# Handle both absolute and relative paths
try:
rel = nb.relative_to(Path.cwd())
Expand All @@ -56,12 +73,21 @@ def main():
sys.executable, "-m", "jupyter", "nbconvert",
"--to", "html",
"--execute",
"--ExecutePreprocessor.timeout={}".format(timeout),
f"--ExecutePreprocessor.timeout={timeout}",
"--output-dir", str(target_dir),
str(nb)
])
except subprocess.CalledProcessError as e:
print(f"ERROR executing {nb}: {e}")
# Create a placeholder HTML with the failure message so CI pages report which notebooks failed
fail_html = target_dir.joinpath(nb.stem + ".html")
with open(fail_html, "w", encoding="utf-8") as fh:
fh.write(f"<html><body><h1>Execution failed for {nb}</h1><pre>{e}</pre></body></html>")
print("Done.")

# Create a placeholder HTML with the failure message so
# CI pages report which notebooks failed
fail_html = target_dir.joinpath(nb.stem + ".html")
with open(fail_html, "w", encoding="utf-8") as fh:
error_msg = (
Expand Down
Loading