From 257e54b371c48d54901e1cc99bdecfa9f275106f Mon Sep 17 00:00:00 2001 From: Evan Meyer Date: Wed, 28 May 2025 20:40:49 -0500 Subject: [PATCH 01/20] feat: improved tutorial automation to work better with oso documentation --- ...load_colab.yml => tutorial-automation.yml} | 77 +++++++------------ scripts/add_mdx_header.py | 24 ++++++ scripts/embed_colab_link.py | 36 +++++++++ scripts/nb_to_mdx.py | 42 ++++++++++ scripts/sanitize_html.py | 13 ++++ 5 files changed, 142 insertions(+), 50 deletions(-) rename .github/workflows/{upload_colab.yml => tutorial-automation.yml} (62%) create mode 100644 scripts/add_mdx_header.py create mode 100644 scripts/embed_colab_link.py create mode 100644 scripts/nb_to_mdx.py create mode 100644 scripts/sanitize_html.py diff --git a/.github/workflows/upload_colab.yml b/.github/workflows/tutorial-automation.yml similarity index 62% rename from .github/workflows/upload_colab.yml rename to .github/workflows/tutorial-automation.yml index a36c04a9..8c9f2b88 100644 --- a/.github/workflows/upload_colab.yml +++ b/.github/workflows/tutorial-automation.yml @@ -1,4 +1,8 @@ -name: Notebook Ops - Upload or Push +name: Tutorial Automation +description: | + Automates the process of uploading Jupyter notebooks to Google Colab, + embedding Colab links, converting notebooks to MDX, and creating PRs + in the oso repository. on: workflow_dispatch: @@ -38,33 +42,9 @@ jobs: - name: Upload & embed Colab link run: | - export NB="${{ inputs.notebook }}" - echo "Uploading $NB to Colab Drive…" - export LINK=$(python scripts/upload_to_drive.py "$NB" --folder "$COLAB_FOLDER_ID") - - echo "Embedding link into notebook…" - python - <<'PY' - import json, pathlib, os, sys - nb_path = pathlib.Path(os.environ["NB"]) - link = os.environ["LINK"] - nb = json.loads(nb_path.read_text()) - link_cell = { - "cell_type": "markdown", - "metadata": {}, - "source": [f'[Open in Colab]({link})\n'] - } - # Only add if we haven't added it before - first_src = nb["cells"][0]["source"] if nb["cells"] else [] - if isinstance(first_src, list): - already = any("open in colab" in line.lower() for line in first_src) - else: - already = "open in colab" in first_src.lower() - if not already: - nb["cells"].insert(0, link_cell) - nb_path.write_text(json.dumps(nb, indent=1, ensure_ascii=False)) - else: - print("Link already present; skipping insert.") - PY + python scripts/embed_colab_link.py \ + --notebook "${{ inputs.notebook }}" \ + --folder "$COLAB_FOLDER_ID" - name: Commit updated notebook run: | @@ -73,7 +53,7 @@ jobs: git add "${{ inputs.notebook }}" git commit -m "chore: embed Colab link in ${{ inputs.notebook }}" || echo "No changes to commit" git push - + # Convert → MDX → PR to oso repo send-pull-requests: if: inputs.command == 'push' @@ -84,7 +64,7 @@ jobs: COLAB_FOLDER_ID: ${{ vars.COLAB_FOLDER_ID }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} TARGET_PAT: ${{ secrets.TARGET_PAT }} - NOTEBOOKS: ${{ inputs.notebook }} # single-file mode + NOTEBOOKS: ${{ inputs.notebook }} steps: - uses: actions/checkout@v4 with: { fetch-depth: 0 } @@ -92,35 +72,32 @@ jobs: - uses: actions/setup-python@v5 with: { python-version: '3.10' } + - uses: actions/setup-node@v4 + with: { node-version: '20.x' } + - name: Install deps run: | pip install nbconvert \ google-api-python-client \ google-auth google-auth-httplib2 google-auth-oauthlib \ google-genai + npm install --global prettier - name: Upload notebook & convert to MDX run: | - mkdir -p mdx_build - NB="$NOTEBOOKS" - BASENAME="$(basename "$NB" .ipynb)" - echo "Processing $NB" - - LINK=$(python scripts/upload_to_drive.py "$NB" --folder "$COLAB_FOLDER_ID") - - jupyter nbconvert "$NB" \ - --to markdown \ - --output "$BASENAME" \ - --output-dir mdx_build - - MARKDOWN_PATH="mdx_build/${BASENAME}.md" - MDX_OUT="mdx_build/${BASENAME}.mdx" - printf 'Open in Colab\n\n' "$LINK" > "$MDX_OUT" - cat "$MARKDOWN_PATH" >> "$MDX_OUT" - - mkdir -p mdx_build/apps/docs/docs/tutorials - mv mdx_build/*.mdx mdx_build/apps/docs/docs/tutorials/ - mv mdx_build/*_files mdx_build/apps/docs/docs/tutorials/ 2>/dev/null || true + python scripts/nb_to_mdx.py \ + --notebook "$NOTEBOOKS" \ + --folder "$COLAB_FOLDER_ID" \ + --outdir "mdx_build" + + - name: Strip HTML from MDX + run: python scripts/sanitize_mdx_html.py mdx_build + + - name: Add MDX front-matter / imports + run: python scripts/add_mdx_header.py mdx_build/apps/docs/docs/tutorials + + - name: Format with Prettier + run: npx prettier --write "mdx_build/apps/docs/docs/tutorials/**/*.mdx" - name: Update tutorials index with Gemini run: python scripts/update_tutorial_index.py diff --git a/scripts/add_mdx_header.py b/scripts/add_mdx_header.py new file mode 100644 index 00000000..72ad3bde --- /dev/null +++ b/scripts/add_mdx_header.py @@ -0,0 +1,24 @@ +import pathlib, re, sys + +tut_dir = pathlib.Path(sys.argv[1]).resolve() +index_md = tut_dir / "index.md" +if not index_md.exists(): + sys.exit("index.md not found") + +mapping = {} +for pos, line in enumerate(index_md.read_text().splitlines(), start=1): + m = re.match(r"- .*?\[(.+?)\]\(\./(.+?)\.mdx\)", line.strip()) + if m: + mapping[f"{m.group(2)}.mdx"] = (pos, m.group(1)) + +for mdx in tut_dir.glob("*.mdx"): + sidebar, title = mapping.get(mdx.name, (0, mdx.stem.title())) + header = f"""---\ntitle: {title}\nsidebar_position: {sidebar}\n---\n\nimport Tabs from '@theme/Tabs';\nimport TabItem from '@theme/TabItem';\n\n""" + body = mdx.read_text() + + if body.lstrip().startswith("---"): + body = re.sub(r"^---[\s\S]+?---\s+", header, body, count=1, flags=re.M) + else: + body = header + body + mdx.write_text(body) +print("✓ Front-matter injected") diff --git a/scripts/embed_colab_link.py b/scripts/embed_colab_link.py new file mode 100644 index 00000000..e9dcb4d2 --- /dev/null +++ b/scripts/embed_colab_link.py @@ -0,0 +1,36 @@ +import argparse, json, pathlib, subprocess, sys + +parser = argparse.ArgumentParser() +parser.add_argument("--notebook", required=True) +parser.add_argument("--folder", required=True) +args = parser.parse_args() + +nb_path = pathlib.Path(args.notebook).resolve() +if not nb_path.exists(): + sys.exit(f"{nb_path} not found") + +# upload (returns public link) +link = subprocess.check_output( + ["python", "scripts/upload_to_drive.py", str(nb_path), "--folder", args.folder], + text=True, +).strip() + +# insert markdown cell if not present +nb = json.loads(nb_path.read_text()) +first_src = nb["cells"][0]["source"] if nb["cells"] else [] +already = ( + any("open in colab" in line.lower() for line in first_src) + if isinstance(first_src, list) + else "open in colab" in first_src.lower() +) +if not already: + nb["cells"].insert( + 0, + { + "cell_type": "markdown", + "metadata": {}, + "source": [f'[Open in Colab]({link})\n'], + }, + ) + nb_path.write_text(json.dumps(nb, indent=1, ensure_ascii=False)) +print("✓ Colab link embedded") diff --git a/scripts/nb_to_mdx.py b/scripts/nb_to_mdx.py new file mode 100644 index 00000000..dd5f248c --- /dev/null +++ b/scripts/nb_to_mdx.py @@ -0,0 +1,42 @@ +import argparse, pathlib, subprocess + +parser = argparse.ArgumentParser() +parser.add_argument("--notebook", required=True) +parser.add_argument("--folder", required=True) +parser.add_argument("--outdir", default="mdx_build") +args = parser.parse_args() + +nb = pathlib.Path(args.notebook).resolve() +outdir = pathlib.Path(args.outdir) +outdir.mkdir(parents=True, exist_ok=True) +basename = nb.stem + +# get Colab link +link = subprocess.check_output( + ["python", "scripts/upload_to_drive.py", str(nb), "--folder", args.folder], + text=True, +).strip() + +# go from nbconvert to markdown +subprocess.run( + [ + "jupyter", + "nbconvert", + str(nb), + "--to", + "markdown", + "--output", + basename, + "--output-dir", + str(outdir), + ], + check=True, +) + +md_path = outdir / f"{basename}.md" +mdx_path = outdir / f"{basename}.mdx" + +header = f'Open in Colab\n\n' +mdx_path.write_text(header + md_path.read_text()) + +print(f"✓ Wrote {mdx_path}") diff --git a/scripts/sanitize_html.py b/scripts/sanitize_html.py new file mode 100644 index 00000000..1e346e9d --- /dev/null +++ b/scripts/sanitize_html.py @@ -0,0 +1,13 @@ +import pathlib, re, sys + +root = pathlib.Path(sys.argv[1]) +html_block = re.compile(r"(|)", re.I) + +for mdx in root.rglob("*.mdx"): + text = mdx.read_text() + text = re.sub(html_block, "", text) + text = "\n".join( + line for line in text.splitlines() if not line.lstrip().startswith("<") + ) + mdx.write_text(text) +print("✓ HTML stripped") From e98e2e8ed3fc31a4ef84d039c8c1f12fbdd81460 Mon Sep 17 00:00:00 2001 From: Evan Meyer Date: Wed, 28 May 2025 20:42:43 -0500 Subject: [PATCH 02/20] refactor: changed how commands are called to be more intuitive --- .github/workflows/tutorial-automation.yml | 4 ++-- tutorials/forecasting.ipynb | 9 +-------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/.github/workflows/tutorial-automation.yml b/.github/workflows/tutorial-automation.yml index 8c9f2b88..ac95e99c 100644 --- a/.github/workflows/tutorial-automation.yml +++ b/.github/workflows/tutorial-automation.yml @@ -23,7 +23,7 @@ permissions: jobs: # Upload notebook to Colab upload-to-colab: - if: inputs.command == 'upload' + if: inputs.command == 'colab' runs-on: ubuntu-latest environment: deploy env: @@ -56,7 +56,7 @@ jobs: # Convert → MDX → PR to oso repo send-pull-requests: - if: inputs.command == 'push' + if: inputs.command == 'docs' runs-on: ubuntu-latest environment: deploy env: diff --git a/tutorials/forecasting.ipynb b/tutorials/forecasting.ipynb index 5b6b2727..68be6bcc 100644 --- a/tutorials/forecasting.ipynb +++ b/tutorials/forecasting.ipynb @@ -1,12 +1,5 @@ { "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "[Open in Colab](https://colab.research.google.com/drive/11Gkfhq6-3mJpIsA2K12EBnk7uDNFT_6O)\n" - ] - }, { "cell_type": "markdown", "id": "ed162a68", @@ -1345,4 +1338,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} From a91a25a434f2cf4681fbf6b14495357a93ce85fd Mon Sep 17 00:00:00 2001 From: Evan Meyer Date: Wed, 28 May 2025 20:45:10 -0500 Subject: [PATCH 03/20] refactor: changed how commands are called to be more intuitive --- .github/workflows/tutorial-automation.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tutorial-automation.yml b/.github/workflows/tutorial-automation.yml index ac95e99c..57d8f5bf 100644 --- a/.github/workflows/tutorial-automation.yml +++ b/.github/workflows/tutorial-automation.yml @@ -8,10 +8,10 @@ on: workflow_dispatch: inputs: command: - description: "upload → add Colab link to .ipynb | push → convert to MDX & PR to oso repo" + description: "colab → add Colab link to .ipynb | docs → convert to MDX & PR to oso repo" required: true type: choice - options: [upload, push] + options: [colab, docs] notebook: description: "Path (relative to repo root) to the .ipynb you want to process" required: true From 0b918a9e4116ce815d8dcf925c382a8f617a4d84 Mon Sep 17 00:00:00 2001 From: Evan Meyer Date: Wed, 28 May 2025 22:35:34 -0500 Subject: [PATCH 04/20] fix: incorrect filename --- scripts/{sanitize_html.py => sanitize_mdx_html.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename scripts/{sanitize_html.py => sanitize_mdx_html.py} (100%) diff --git a/scripts/sanitize_html.py b/scripts/sanitize_mdx_html.py similarity index 100% rename from scripts/sanitize_html.py rename to scripts/sanitize_mdx_html.py From 0714d73a93b2ff082639293bb857f55685551f68 Mon Sep 17 00:00:00 2001 From: Evan Meyer Date: Wed, 28 May 2025 22:49:38 -0500 Subject: [PATCH 05/20] fix: fixed problem with mdx sidebar --- .github/workflows/tutorial-automation.yml | 12 ++++++-- scripts/add_mdx_header.py | 35 +++++++++++++---------- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/.github/workflows/tutorial-automation.yml b/.github/workflows/tutorial-automation.yml index 57d8f5bf..19a8efbd 100644 --- a/.github/workflows/tutorial-automation.yml +++ b/.github/workflows/tutorial-automation.yml @@ -15,6 +15,10 @@ on: notebook: description: "Path (relative to repo root) to the .ipynb you want to process" required: true + sidebar_position: + description: "Number shown in docs sidebar (1 = top)" + required: true + type: number permissions: contents: write # commit back to this repo @@ -65,6 +69,7 @@ jobs: GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} TARGET_PAT: ${{ secrets.TARGET_PAT }} NOTEBOOKS: ${{ inputs.notebook }} + SIDEBAR_POS: ${{ inputs.sidebar_position }} steps: - uses: actions/checkout@v4 with: { fetch-depth: 0 } @@ -93,8 +98,11 @@ jobs: - name: Strip HTML from MDX run: python scripts/sanitize_mdx_html.py mdx_build - - name: Add MDX front-matter / imports - run: python scripts/add_mdx_header.py mdx_build/apps/docs/docs/tutorials + - name: Add MDX front-matter / imports + run: | + python scripts/add_mdx_header.py \ + --dir mdx_build/apps/docs/docs/tutorials \ + --pos "$SIDEBAR_POS" - name: Format with Prettier run: npx prettier --write "mdx_build/apps/docs/docs/tutorials/**/*.mdx" diff --git a/scripts/add_mdx_header.py b/scripts/add_mdx_header.py index 72ad3bde..aa30f6fc 100644 --- a/scripts/add_mdx_header.py +++ b/scripts/add_mdx_header.py @@ -1,24 +1,29 @@ -import pathlib, re, sys +import argparse, pathlib, re -tut_dir = pathlib.Path(sys.argv[1]).resolve() -index_md = tut_dir / "index.md" -if not index_md.exists(): - sys.exit("index.md not found") +parser = argparse.ArgumentParser() +parser.add_argument("--dir", required=True, help="Directory that contains *.mdx") +parser.add_argument("--pos", required=True, type=int, help="Sidebar position") +args = parser.parse_args() -mapping = {} -for pos, line in enumerate(index_md.read_text().splitlines(), start=1): - m = re.match(r"- .*?\[(.+?)\]\(\./(.+?)\.mdx\)", line.strip()) - if m: - mapping[f"{m.group(2)}.mdx"] = (pos, m.group(1)) +tut_dir = pathlib.Path(args.dir).resolve() +sidebar_pos = args.pos for mdx in tut_dir.glob("*.mdx"): - sidebar, title = mapping.get(mdx.name, (0, mdx.stem.title())) - header = f"""---\ntitle: {title}\nsidebar_position: {sidebar}\n---\n\nimport Tabs from '@theme/Tabs';\nimport TabItem from '@theme/TabItem';\n\n""" - body = mdx.read_text() + title = mdx.stem.replace("-", " ").title() + header = ( + f"---\n" + f"title: {title}\n" + f"sidebar_position: {sidebar_pos}\n" + f"---\n\n" + "import Tabs from '@theme/Tabs';\n" + "import TabItem from '@theme/TabItem';\n\n" + ) - if body.lstrip().startswith("---"): + body = mdx.read_text() + if body.lstrip().startswith("---"): # replace, don't double-insert body = re.sub(r"^---[\s\S]+?---\s+", header, body, count=1, flags=re.M) else: body = header + body mdx.write_text(body) -print("✓ Front-matter injected") + +print("✓ Front-matter (pos =", sidebar_pos, ") injected into", tut_dir) From 428c0936d77f50cc5b5b5c0bab448296aaff8b21 Mon Sep 17 00:00:00 2001 From: Evan Meyer Date: Wed, 28 May 2025 23:02:30 -0500 Subject: [PATCH 06/20] fix: fixed problem with locating notebook in temp directory --- .github/workflows/tutorial-automation.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/tutorial-automation.yml b/.github/workflows/tutorial-automation.yml index 19a8efbd..e7dd2031 100644 --- a/.github/workflows/tutorial-automation.yml +++ b/.github/workflows/tutorial-automation.yml @@ -95,6 +95,12 @@ jobs: --folder "$COLAB_FOLDER_ID" \ --outdir "mdx_build" + - name: Place MDX under docs/tutorials tree + run: | + mkdir -p mdx_build/apps/docs/docs/tutorials + mv mdx_build/*.mdx mdx_build/apps/docs/docs/tutorials/ + mv mdx_build/*_files 2>/dev/null || true + - name: Strip HTML from MDX run: python scripts/sanitize_mdx_html.py mdx_build From 905fcf957d97fbf336e95b7a10afeacd3ff68776 Mon Sep 17 00:00:00 2001 From: Evan Meyer Date: Wed, 28 May 2025 23:22:57 -0500 Subject: [PATCH 07/20] fix: run prettier over index.md too and make sure images are copied over --- .github/workflows/tutorial-automation.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tutorial-automation.yml b/.github/workflows/tutorial-automation.yml index e7dd2031..3473d41b 100644 --- a/.github/workflows/tutorial-automation.yml +++ b/.github/workflows/tutorial-automation.yml @@ -95,11 +95,16 @@ jobs: --folder "$COLAB_FOLDER_ID" \ --outdir "mdx_build" - - name: Place MDX under docs/tutorials tree + - name: Stage MDX (and image folders) under docs/tutorials run: | mkdir -p mdx_build/apps/docs/docs/tutorials - mv mdx_build/*.mdx mdx_build/apps/docs/docs/tutorials/ - mv mdx_build/*_files 2>/dev/null || true + # move the MDX + mv mdx_build/*.mdx mdx_build/apps/docs/docs/tutorials/ + # move any nbconvert asset folders + shopt -s nullglob + for d in mdx_build/*_files ; do + mv "$d" mdx_build/apps/docs/docs/tutorials/ + done - name: Strip HTML from MDX run: python scripts/sanitize_mdx_html.py mdx_build @@ -111,7 +116,7 @@ jobs: --pos "$SIDEBAR_POS" - name: Format with Prettier - run: npx prettier --write "mdx_build/apps/docs/docs/tutorials/**/*.mdx" + run: npx prettier --write "mdx_build/apps/docs/docs/tutorials/**/*.{md,mdx}" - name: Update tutorials index with Gemini run: python scripts/update_tutorial_index.py From 50e552a8a4da1553cfa5cea37fc5385250a23f27 Mon Sep 17 00:00:00 2001 From: Evan Meyer Date: Wed, 28 May 2025 23:46:17 -0500 Subject: [PATCH 08/20] fix: run prettier write on the tutorials folder in oso --- .github/workflows/tutorial-automation.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tutorial-automation.yml b/.github/workflows/tutorial-automation.yml index 3473d41b..2b64b556 100644 --- a/.github/workflows/tutorial-automation.yml +++ b/.github/workflows/tutorial-automation.yml @@ -115,9 +115,6 @@ jobs: --dir mdx_build/apps/docs/docs/tutorials \ --pos "$SIDEBAR_POS" - - name: Format with Prettier - run: npx prettier --write "mdx_build/apps/docs/docs/tutorials/**/*.{md,mdx}" - - name: Update tutorials index with Gemini run: python scripts/update_tutorial_index.py @@ -137,6 +134,16 @@ jobs: rsync -a mdx_build/apps/docs/docs/tutorials/index.* \ oso/apps/docs/docs/tutorials/ + # run prettier write on the new files + - name: Format new tutorials with Prettier + run: | + cd mdx_build/apps/docs/docs/tutorials + NEW_FILES=$(find . -type f \( -name '*.md' -o -name '*.mdx' \) -print) + + for f in $NEW_FILES; do + npx prettier --write "oso/apps/docs/docs/tutorials/$f" + done + - name: Create or update PR uses: peter-evans/create-pull-request@v5 with: From 7b44341ae456fc64a50bcc6de272bcf0cd381416 Mon Sep 17 00:00:00 2001 From: Evan Meyer Date: Wed, 28 May 2025 23:51:47 -0500 Subject: [PATCH 09/20] fix: more prettier errors --- .github/workflows/tutorial-automation.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tutorial-automation.yml b/.github/workflows/tutorial-automation.yml index 2b64b556..05e7fee4 100644 --- a/.github/workflows/tutorial-automation.yml +++ b/.github/workflows/tutorial-automation.yml @@ -133,12 +133,12 @@ jobs: oso/apps/docs/docs/tutorials/ rsync -a mdx_build/apps/docs/docs/tutorials/index.* \ oso/apps/docs/docs/tutorials/ - - # run prettier write on the new files + - name: Format new tutorials with Prettier run: | - cd mdx_build/apps/docs/docs/tutorials - NEW_FILES=$(find . -type f \( -name '*.md' -o -name '*.mdx' \) -print) + NEW_FILES=$(find mdx_build/apps/docs/docs/tutorials \ + -type f \( -name '*.md' -o -name '*.mdx' \) \ + -printf '%P\n') for f in $NEW_FILES; do npx prettier --write "oso/apps/docs/docs/tutorials/$f" From 34c5a756191e4d94634cf591468b954e6af89b7e Mon Sep 17 00:00:00 2001 From: Evan Meyer Date: Thu, 29 May 2025 00:01:19 -0500 Subject: [PATCH 10/20] fix: more prettier errors --- .github/workflows/tutorial-automation.yml | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/.github/workflows/tutorial-automation.yml b/.github/workflows/tutorial-automation.yml index 5c34a676..f04906d4 100644 --- a/.github/workflows/tutorial-automation.yml +++ b/.github/workflows/tutorial-automation.yml @@ -118,6 +118,9 @@ jobs: - name: Update tutorials index with Gemini run: python scripts/update_tutorial_index.py + - name: Format with Prettier + run: npx prettier --write "mdx_build/apps/docs/docs/tutorials/**/*.{md,mdx}" + - name: Checkout oso repo uses: actions/checkout@v4 with: @@ -133,26 +136,6 @@ jobs: oso/apps/docs/docs/tutorials/ rsync -a mdx_build/apps/docs/docs/tutorials/index.* \ oso/apps/docs/docs/tutorials/ - - - name: Format new tutorials with Prettier - run: | - NEW_FILES=$(find mdx_build/apps/docs/docs/tutorials \ - -type f \( -name '*.md' -o -name '*.mdx' \) \ - -printf '%P\n') - - for f in $NEW_FILES; do - npx prettier --write "oso/apps/docs/docs/tutorials/$f" - done - - # run prettier write on the new files - - name: Format new tutorials with Prettier - run: | - cd mdx_build/apps/docs/docs/tutorials - NEW_FILES=$(find . -type f \( -name '*.md' -o -name '*.mdx' \) -print) - - for f in $NEW_FILES; do - npx prettier --write "oso/apps/docs/docs/tutorials/$f" - done - name: Create or update PR uses: peter-evans/create-pull-request@v5 From 091c1534f8d63162e3906e4bff194dd774d8f53b Mon Sep 17 00:00:00 2001 From: Evan Meyer Date: Thu, 29 May 2025 00:17:43 -0500 Subject: [PATCH 11/20] feat: CI checks --- .github/workflows/tutorial-automation.yml | 10 --- .github/workflows/tutorial-checks.yml | 84 +++++++++++++++++++++++ 2 files changed, 84 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/tutorial-checks.yml diff --git a/.github/workflows/tutorial-automation.yml b/.github/workflows/tutorial-automation.yml index 28cdd940..f04906d4 100644 --- a/.github/workflows/tutorial-automation.yml +++ b/.github/workflows/tutorial-automation.yml @@ -136,16 +136,6 @@ jobs: oso/apps/docs/docs/tutorials/ rsync -a mdx_build/apps/docs/docs/tutorials/index.* \ oso/apps/docs/docs/tutorials/ - - - name: Format new tutorials with Prettier - run: | - NEW_FILES=$(find mdx_build/apps/docs/docs/tutorials \ - -type f \( -name '*.md' -o -name '*.mdx' \) \ - -printf '%P\n') - - for f in $NEW_FILES; do - npx prettier --write "oso/apps/docs/docs/tutorials/$f" - done - name: Create or update PR uses: peter-evans/create-pull-request@v5 diff --git a/.github/workflows/tutorial-checks.yml b/.github/workflows/tutorial-checks.yml new file mode 100644 index 00000000..859b2148 --- /dev/null +++ b/.github/workflows/tutorial-checks.yml @@ -0,0 +1,84 @@ +name: Tutorial Checks +description: | + Runs checks on Jupyter notebooks in the tutorials/ directory: + - Ensures only .ipynb files are present + - Formats notebooks with Prettier + - Executes notebooks and fails on any errors + +on: + pull_request: + paths: + - "tutorials/**" + +jobs: + check-notebooks: + runs-on: ubuntu-latest + + # ─── List all files (in tutorials/) to ignore here ─── + env: + IGNORED_FILES: | + tutorials/instructions.md + + steps: + - name: Check out PR branch + uses: actions/checkout@v4 + + - name: Detect files added or modified in tutorials/ + id: filter + uses: dorny/paths-filter@v3 + with: + filters: | + notebooks: + - added|modified: "tutorials/**" + + - name: Enforce *.ipynb-only rule + if: steps.filter.outputs.notebooks == 'true' + run: | + # get everything under tutorials/ that changed + raw=$(git diff --name-only ${{github.event.pull_request.base.sha}} ${{github.sha}} -- tutorials) + # filter out .ipynb files + not_ipynb=$(printf "%s\n" "$raw" | grep -vE '\.ipynb$' || true) + # filter out any ignored files + bad=$(printf "%s\n" "$not_ipynb" | grep -vFf <(echo "$IGNORED_FILES") || true) + if [ -n "$bad" ]; then + echo "::error::The following files are not allowed in tutorials/:" + echo "$bad" + exit 1 + fi + + - name: Export notebook list + if: steps.filter.outputs.notebooks == 'true' + id: list + run: | + echo "files=$(git diff --name-only ${{github.event.pull_request.base.sha}} ${{github.sha}} -- '*.ipynb' -- tutorials)" >> "$GITHUB_OUTPUT" + + - name: Set up Node & Prettier + if: steps.filter.outputs.notebooks == 'true' + uses: actions/setup-node@v4 + with: + node-version: '20' + + - run: npm install --global prettier + if: steps.filter.outputs.notebooks == 'true' + + - name: Prettier --check + if: steps.filter.outputs.notebooks == 'true' + run: | + prettier --check ${{ steps.list.outputs.files }} + + - name: Set up Python and Jupyter + if: steps.filter.outputs.notebooks == 'true' + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - run: pip install nbconvert + if: steps.filter.outputs.notebooks == 'true' + + - name: Execute notebooks + if: steps.filter.outputs.notebooks == 'true' + run: | + for nb in ${{ steps.list.outputs.files }}; do + echo "Running $nb" + jupyter nbconvert --execute --to notebook "$nb" --output /tmp/out.ipynb + done From fbee5594735680762c5c5d8ae917ae6ef40e80e7 Mon Sep 17 00:00:00 2001 From: Evan Meyer Date: Thu, 29 May 2025 03:43:20 -0500 Subject: [PATCH 12/20] fix: fetch full history --- .github/workflows/tutorial-checks.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tutorial-checks.yml b/.github/workflows/tutorial-checks.yml index 859b2148..66988579 100644 --- a/.github/workflows/tutorial-checks.yml +++ b/.github/workflows/tutorial-checks.yml @@ -14,7 +14,7 @@ jobs: check-notebooks: runs-on: ubuntu-latest - # ─── List all files (in tutorials/) to ignore here ─── + # list all files (in tutorials/) to ignore here env: IGNORED_FILES: | tutorials/instructions.md @@ -22,6 +22,8 @@ jobs: steps: - name: Check out PR branch uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Detect files added or modified in tutorials/ id: filter From 44d762a22d4ac9aa58111b77ec60f85a53bddc67 Mon Sep 17 00:00:00 2001 From: Evan Meyer Date: Thu, 29 May 2025 03:49:53 -0500 Subject: [PATCH 13/20] test: testing CI checks --- tutorials/forecasting.ipynb | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/tutorials/forecasting.ipynb b/tutorials/forecasting.ipynb index 177cddf0..fb2c56c4 100644 --- a/tutorials/forecasting.ipynb +++ b/tutorials/forecasting.ipynb @@ -2,21 +2,11 @@ "cells": [ { "cell_type": "markdown", -<<<<<<< tutorials -======= - "metadata": {}, - "source": [ - "[Open in Colab](https://colab.research.google.com/drive/1zEDIOioIyvj7dGCPch9C9c5eGrKX5BDQ)\n" - ] - }, - { - "cell_type": "markdown", ->>>>>>> main "id": "ed162a68", "metadata": {}, "source": [ "## TVL Forecasting with PyOSO\n", - "*A quick-start notebook guide on working with pyoso*" + "*A quickstart notebook guide on working with pyoso*" ] }, { From 2fc2a94c97ba000603c92a0ba16d888ae46affef Mon Sep 17 00:00:00 2001 From: Evan Meyer Date: Thu, 29 May 2025 03:56:13 -0500 Subject: [PATCH 14/20] fix: requirements --- .github/workflows/tutorial-checks.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tutorial-checks.yml b/.github/workflows/tutorial-checks.yml index 66988579..0679ae44 100644 --- a/.github/workflows/tutorial-checks.yml +++ b/.github/workflows/tutorial-checks.yml @@ -74,13 +74,19 @@ jobs: with: python-version: '3.11' - - run: pip install nbconvert + - name: Install Jupyter tooling + kernel if: steps.filter.outputs.notebooks == 'true' + run: | + pip install --quiet nbconvert ipykernel + python -m ipykernel install --name python3 --display-name "Python 3 (CI)" --user - name: Execute notebooks if: steps.filter.outputs.notebooks == 'true' run: | for nb in ${{ steps.list.outputs.files }}; do - echo "Running $nb" - jupyter nbconvert --execute --to notebook "$nb" --output /tmp/out.ipynb + echo "Running $nb" + jupyter nbconvert --execute \ + --ExecutePreprocessor.kernel_name=python3 \ # <── explicit for clarity + --to notebook "$nb" --output /tmp/out.ipynb done + \ No newline at end of file From fbc8dd7b6de7dd8cfa020229efd906d71aba2fe1 Mon Sep 17 00:00:00 2001 From: Evan Meyer Date: Thu, 29 May 2025 14:41:49 -0500 Subject: [PATCH 15/20] fix: CI checks fix --- .github/workflows/tutorial-checks.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tutorial-checks.yml b/.github/workflows/tutorial-checks.yml index 0679ae44..0a42ec68 100644 --- a/.github/workflows/tutorial-checks.yml +++ b/.github/workflows/tutorial-checks.yml @@ -84,9 +84,10 @@ jobs: if: steps.filter.outputs.notebooks == 'true' run: | for nb in ${{ steps.list.outputs.files }}; do - echo "Running $nb" - jupyter nbconvert --execute \ - --ExecutePreprocessor.kernel_name=python3 \ # <── explicit for clarity - --to notebook "$nb" --output /tmp/out.ipynb + echo "Running $nb" + jupyter nbconvert --execute --to notebook \ + --ExecutePreprocessor.kernel_name=python3 \ + --output /tmp/out.ipynb "$nb" done + \ No newline at end of file From 128c786cb0d06bd7b3ae5a1e7e08e2dbc351ab84 Mon Sep 17 00:00:00 2001 From: Evan Meyer Date: Thu, 29 May 2025 14:55:53 -0500 Subject: [PATCH 16/20] fix: CI checks fix --- .github/workflows/tutorial-checks.yml | 30 +++++++++++++++++---------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/.github/workflows/tutorial-checks.yml b/.github/workflows/tutorial-checks.yml index 0a42ec68..2a39f39c 100644 --- a/.github/workflows/tutorial-checks.yml +++ b/.github/workflows/tutorial-checks.yml @@ -33,14 +33,14 @@ jobs: notebooks: - added|modified: "tutorials/**" + # enforce *.ipynb-only rule (except IGNORED_FILES) - name: Enforce *.ipynb-only rule if: steps.filter.outputs.notebooks == 'true' run: | - # get everything under tutorials/ that changed - raw=$(git diff --name-only ${{github.event.pull_request.base.sha}} ${{github.sha}} -- tutorials) - # filter out .ipynb files + raw=$(git diff --name-only \ + "${{ github.event.pull_request.base.sha }}" "${{ github.sha }}" \ + -- 'tutorials/**') not_ipynb=$(printf "%s\n" "$raw" | grep -vE '\.ipynb$' || true) - # filter out any ignored files bad=$(printf "%s\n" "$not_ipynb" | grep -vFf <(echo "$IGNORED_FILES") || true) if [ -n "$bad" ]; then echo "::error::The following files are not allowed in tutorials/:" @@ -48,36 +48,46 @@ jobs: exit 1 fi + # export newline-separated list of tutorial notebooks - name: Export notebook list if: steps.filter.outputs.notebooks == 'true' id: list run: | - echo "files=$(git diff --name-only ${{github.event.pull_request.base.sha}} ${{github.sha}} -- '*.ipynb' -- tutorials)" >> "$GITHUB_OUTPUT" + changed=$(git diff --name-only \ + "${{ github.event.pull_request.base.sha }}" "${{ github.sha }}" \ + -- ':(glob)tutorials/**/*.ipynb') + { + echo 'files<> "$GITHUB_OUTPUT" + # prettier formatting - name: Set up Node & Prettier if: steps.filter.outputs.notebooks == 'true' uses: actions/setup-node@v4 with: node-version: '20' - - run: npm install --global prettier + - run: npm install --global prettier prettier-plugin-ipynb if: steps.filter.outputs.notebooks == 'true' - name: Prettier --check if: steps.filter.outputs.notebooks == 'true' run: | - prettier --check ${{ steps.list.outputs.files }} + echo "${{ steps.list.outputs.files }}" | xargs prettier --check + # execute notebooks - name: Set up Python and Jupyter if: steps.filter.outputs.notebooks == 'true' uses: actions/setup-python@v5 with: python-version: '3.11' - - name: Install Jupyter tooling + kernel + - name: Install Jupyter tooling + kernel if: steps.filter.outputs.notebooks == 'true' run: | - pip install --quiet nbconvert ipykernel + pip install --quiet nbconvert ipykernel python -m ipykernel install --name python3 --display-name "Python 3 (CI)" --user - name: Execute notebooks @@ -89,5 +99,3 @@ jobs: --ExecutePreprocessor.kernel_name=python3 \ --output /tmp/out.ipynb "$nb" done - - \ No newline at end of file From 51d2992196845a248414ae91f44c8c1ab2b342ab Mon Sep 17 00:00:00 2001 From: Evan Meyer Date: Thu, 29 May 2025 15:05:42 -0500 Subject: [PATCH 17/20] fix: CI checks fix --- .github/workflows/tutorial-checks.yml | 40 +++++++++------------------ 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/.github/workflows/tutorial-checks.yml b/.github/workflows/tutorial-checks.yml index 2a39f39c..c05f8340 100644 --- a/.github/workflows/tutorial-checks.yml +++ b/.github/workflows/tutorial-checks.yml @@ -4,7 +4,6 @@ description: | - Ensures only .ipynb files are present - Formats notebooks with Prettier - Executes notebooks and fails on any errors - on: pull_request: paths: @@ -18,7 +17,6 @@ jobs: env: IGNORED_FILES: | tutorials/instructions.md - steps: - name: Check out PR branch uses: actions/checkout@v4 @@ -32,62 +30,48 @@ jobs: filters: | notebooks: - added|modified: "tutorials/**" - - # enforce *.ipynb-only rule (except IGNORED_FILES) - name: Enforce *.ipynb-only rule if: steps.filter.outputs.notebooks == 'true' run: | - raw=$(git diff --name-only \ - "${{ github.event.pull_request.base.sha }}" "${{ github.sha }}" \ - -- 'tutorials/**') + # get everything under tutorials/ that changed + raw=$(git diff --name-only ${{github.event.pull_request.base.sha}} ${{github.sha}} -- tutorials) + # filter out .ipynb files not_ipynb=$(printf "%s\n" "$raw" | grep -vE '\.ipynb$' || true) + # filter out any ignored files bad=$(printf "%s\n" "$not_ipynb" | grep -vFf <(echo "$IGNORED_FILES") || true) if [ -n "$bad" ]; then echo "::error::The following files are not allowed in tutorials/:" echo "$bad" exit 1 fi - - # export newline-separated list of tutorial notebooks - name: Export notebook list if: steps.filter.outputs.notebooks == 'true' id: list run: | - changed=$(git diff --name-only \ - "${{ github.event.pull_request.base.sha }}" "${{ github.sha }}" \ - -- ':(glob)tutorials/**/*.ipynb') - { - echo 'files<> "$GITHUB_OUTPUT" - - # prettier formatting + echo "files=$(git diff --name-only ${{github.event.pull_request.base.sha}} ${{github.sha}} -- '*.ipynb' -- tutorials)" >> "$GITHUB_OUTPUT" - name: Set up Node & Prettier if: steps.filter.outputs.notebooks == 'true' uses: actions/setup-node@v4 with: node-version: '20' - - run: npm install --global prettier prettier-plugin-ipynb + - run: npm install --global prettier if: steps.filter.outputs.notebooks == 'true' - name: Prettier --check if: steps.filter.outputs.notebooks == 'true' run: | - echo "${{ steps.list.outputs.files }}" | xargs prettier --check - - # execute notebooks + prettier --check ${{ steps.list.outputs.files }} - name: Set up Python and Jupyter if: steps.filter.outputs.notebooks == 'true' uses: actions/setup-python@v5 with: python-version: '3.11' - - name: Install Jupyter tooling + kernel + - name: Install Jupyter tooling + kernel if: steps.filter.outputs.notebooks == 'true' run: | - pip install --quiet nbconvert ipykernel + pip install --quiet nbconvert ipykernel python -m ipykernel install --name python3 --display-name "Python 3 (CI)" --user - name: Execute notebooks @@ -95,7 +79,9 @@ jobs: run: | for nb in ${{ steps.list.outputs.files }}; do echo "Running $nb" - jupyter nbconvert --execute --to notebook \ + jupyter nbconvert --execute \ --ExecutePreprocessor.kernel_name=python3 \ - --output /tmp/out.ipynb "$nb" + --to notebook "$nb" \ + --output /tmp/out.ipynb done + \ No newline at end of file From 41dee98fbd10cf176ec90572aad596350eae9c5f Mon Sep 17 00:00:00 2001 From: Evan Meyer Date: Thu, 29 May 2025 15:18:01 -0500 Subject: [PATCH 18/20] fix: CI checks fix --- .github/workflows/tutorial-checks.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tutorial-checks.yml b/.github/workflows/tutorial-checks.yml index c05f8340..c074f81e 100644 --- a/.github/workflows/tutorial-checks.yml +++ b/.github/workflows/tutorial-checks.yml @@ -30,13 +30,16 @@ jobs: filters: | notebooks: - added|modified: "tutorials/**" + - name: Enforce *.ipynb-only rule if: steps.filter.outputs.notebooks == 'true' run: | # get everything under tutorials/ that changed raw=$(git diff --name-only ${{github.event.pull_request.base.sha}} ${{github.sha}} -- tutorials) - # filter out .ipynb files + + # filter out non .ipynb files not_ipynb=$(printf "%s\n" "$raw" | grep -vE '\.ipynb$' || true) + # filter out any ignored files bad=$(printf "%s\n" "$not_ipynb" | grep -vFf <(echo "$IGNORED_FILES") || true) if [ -n "$bad" ]; then @@ -44,11 +47,15 @@ jobs: echo "$bad" exit 1 fi + - name: Export notebook list if: steps.filter.outputs.notebooks == 'true' id: list run: | - echo "files=$(git diff --name-only ${{github.event.pull_request.base.sha}} ${{github.sha}} -- '*.ipynb' -- tutorials)" >> "$GITHUB_OUTPUT" + files=$(git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.sha }} -- '*.ipynb' -- tutorials \ + | paste -sd ' ' -) + echo "files=$files" >> "$GITHUB_OUTPUT" + - name: Set up Node & Prettier if: steps.filter.outputs.notebooks == 'true' uses: actions/setup-node@v4 @@ -62,6 +69,7 @@ jobs: if: steps.filter.outputs.notebooks == 'true' run: | prettier --check ${{ steps.list.outputs.files }} + - name: Set up Python and Jupyter if: steps.filter.outputs.notebooks == 'true' uses: actions/setup-python@v5 From 7c73f5d0d1a506a6f1e621f465f007b14c234c95 Mon Sep 17 00:00:00 2001 From: Evan Meyer Date: Thu, 29 May 2025 15:23:32 -0500 Subject: [PATCH 19/20] fix: CI checks fix --- .github/workflows/tutorial-checks.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tutorial-checks.yml b/.github/workflows/tutorial-checks.yml index c074f81e..b68a7cd8 100644 --- a/.github/workflows/tutorial-checks.yml +++ b/.github/workflows/tutorial-checks.yml @@ -35,10 +35,7 @@ jobs: if: steps.filter.outputs.notebooks == 'true' run: | # get everything under tutorials/ that changed - raw=$(git diff --name-only ${{github.event.pull_request.base.sha}} ${{github.sha}} -- tutorials) - - # filter out non .ipynb files - not_ipynb=$(printf "%s\n" "$raw" | grep -vE '\.ipynb$' || true) + raw=$(git diff --name-only ${{github.event.pull_request.base.sha}} ${{github.sha}} -- tutorials/**/*.ipynb) # filter out any ignored files bad=$(printf "%s\n" "$not_ipynb" | grep -vFf <(echo "$IGNORED_FILES") || true) @@ -52,7 +49,7 @@ jobs: if: steps.filter.outputs.notebooks == 'true' id: list run: | - files=$(git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.sha }} -- '*.ipynb' -- tutorials \ + files=$(git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.sha }} -- tutorials/**/*.ipynb \ | paste -sd ' ' -) echo "files=$files" >> "$GITHUB_OUTPUT" From 6cb24fb28a67f7da7698ed38be5c52a7ba2dfb63 Mon Sep 17 00:00:00 2001 From: Evan Meyer Date: Thu, 29 May 2025 17:30:33 -0500 Subject: [PATCH 20/20] feat: created readme outlining tutorial automation steps --- .github/workflows/tutorial-checks.yml | 2 +- tutorials/readme.md | 105 +++++++++++++++++++++++++- 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tutorial-checks.yml b/.github/workflows/tutorial-checks.yml index b68a7cd8..84953ee9 100644 --- a/.github/workflows/tutorial-checks.yml +++ b/.github/workflows/tutorial-checks.yml @@ -16,7 +16,7 @@ jobs: # list all files (in tutorials/) to ignore here env: IGNORED_FILES: | - tutorials/instructions.md + tutorials/readme.md steps: - name: Check out PR branch uses: actions/checkout@v4 diff --git a/tutorials/readme.md b/tutorials/readme.md index ba32fa2e..bf196515 100644 --- a/tutorials/readme.md +++ b/tutorials/readme.md @@ -1,9 +1,108 @@ -# Tutorials +# OSO Tutorials -Examples of applications and data science built on OSO's data platform. Each example is a notebook with you can fork and run yourself. +This folder contains runnable examples and data science tutorials built on OSO's data platform. +Each tutorial is a standalone Jupyter notebook (`.ipynb`) that can be viewed on Colab and is published to the OSO documentation site once approved. +--- +## How to Add a New Tutorial -For complete documentation and to run the notebooks see: https://docs.opensource.observer/docs/tutorials/ +Follow these steps to contribute your own tutorial to the platform: +### 1. Create & Upload Your Notebook + +- Write your tutorial as a Jupyter notebook (`.ipynb`) +- Save it to the `tutorials/` folder in the **`insights`** repository (where this file lives) + +### 2. Open a Pull Request + +- Open a PR with your new tutorial notebook +- Use the title prefix: **`NEW TUTORIAL:`** so we can easily identify it +- Your PR will run automated checks defined in `.github/workflows/tutorial-checks.yml`. These checks **must pass**: + - The file is a valid `.ipynb` + - The notebook is formatted using Prettier + - **Every cell in the notebook runs successfully** + +> ⚠️ **Make sure your tutorial is fully executable! CI will fail if any cell errors out.** + +### 3. Add Reviewers + +- Request review from [@evanameyer1](https://github.com/evanameyer1) or [@ccerv1](https://github.com/ccerv1) + +Once your tutorial is approved and merged, continue to the next step. + +--- + +## Publish Your Tutorial + +If you are happy with your tutorial solely exist in our insights repo then you do not need to run either of these workflows. These simply offer ways to build more coverage of your tutorial. + +We support two automation flows via `.github/workflows/tutorial-automation.yml`: + +### 1. Generate a Colab Notebook + +This creates a Colab link and inserts it into the notebook. + +```bash +gh workflow run "Tutorial Automation" \ + --ref main \ + -f command=colab \ + -f notebook="tutorials/your-notebook.ipynb" \ + -f sidebar_position=0 +```` + +* The Colab notebook will be auto-uploaded [here](https://drive.google.com/drive/u/2/folders/1ld_KWqNDMJl4NmzEFquNybCBph96hYDu) +* A line like `**[Open in Colab](https://...link...)**` will be added to the top of the notebook +* The changes are automatically committed and pushed to the same PR branch + +### 2. Generate Docs Page (MDX) + +This converts your notebook to a `.mdx` file for OSO's docs: + +```bash +gh workflow run "Tutorial Automation" \ + --ref main \ + -f command=docs \ + -f notebook="tutorials/your-notebook.ipynb" \ + -f sidebar_position=10 # See below on how to pick this +``` + +* A new MDX page is created for your tutorial at [https://docs.opensource.observer/docs/tutorials/](https://docs.opensource.observer/docs/tutorials/) +* The tutorials index (`index.mdx`) is updated with an LLM-generated title and description that matches our existing docs style +* A PR will be created on the **`oso`** repo titled `"Add MDX tutorial(s)"`, which must pass OSO’s CI + +--- + +## Sidebar Position Guide + +The `sidebar_position` parameter controls where your tutorial appears in the docs sidebar: + +* `0` = Top of the list (reserved for "Find a Tutorial") +* To **add your tutorial to the bottom**, find the bottommost tutorial in the docs sidebar: + + * Click **“Edit this page”** on the tutorial + * Look for its `sidebar_position` in the frontmatter + * Use that number +1 for your new tutorial + +> ⚠️ You **must** pass a `sidebar_position` even when running the `colab` workflow, but you can just use `0` there. + +--- + +## Tip: Quoting Names in GitHub CLI + +If you're using the workflow **name** instead of the workflow **ID**, wrap the name in quotes: + +```bash +gh workflow run "Tutorial Automation" ... +``` + +Or, run this to find the workflow ID (recommended): + +```bash +gh workflow list +``` + +--- + +Thanks for contributing to the OSO community! 🚀