Skip to content

Commit 5017f7f

Browse files
ivorbCopilot
andauthored
Add Tier 0 content checks and Tier 1 SDK contract checks (#229)
* Add Tier 0 content checks and Tier 1 SDK contract checks Neither tier needs Azure resources, credentials or spend, so both run without provisioning anything. Both use Python 3.13, matching what the labs tell learners to install. Tier 0 (.github/workflows/content-checks.yml, every pull request): - frontmatter parses as YAML and has a title. Catches the unescaped apostrophe in a single-quoted value that breaks the site build. - every python code block parses. Learners paste these straight into a file, so a bad indent breaks the lab. Two fixes already on main ("fix fence indentation", "fix code sample indentation") were this bug. - relative links and image references resolve. - tracked text files honour the .gitattributes LF policy, so tooling that writes with platform newlines can't silently undo normalization. Tier 1 (.github/workflows/sdk-contract.yml, nightly): - installs each lab's pinned requirements and verifies every module and symbol the lab imports still resolves, in both the lab's Python files and the code blocks the instructions tell learners to paste. - runs pinned and unpinned; unpinned is advisory early warning for the next version bump. - opens an issue on scheduled failure, since these labs are maintained asynchronously and nobody watches a badge. The import contract is derived from the content rather than hand-written, so it can't drift. Only keyword arguments are pinned by hand. Design notes worth knowing: - Code blocks are snippets, not programs. Parsing them as-is reports 134 false positives out of 148, so blocks are dedented, and a trailing block opener whose body the lab adds later is closed before parsing. Real syntax errors still fail. - Starter files are intentionally not valid Python: a placeholder sits where the learner adds a with block or function body, so the code beneath is already indented. Only Solution/ files must parse. - Tier 1 resolves imports statically instead of executing lab modules, which would attempt network calls from scripts that build clients at module level. - The link checker masks code regions first; Python such as local_functions[item.name](**kwargs) otherwise reads as a markdown link. This found the openai 3.x / httpx break fixed in #230. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc4690cc-57d6-48a7-abc8-bc8570113ab1 * Run Tier 0 on every pull request, not a filtered subset The path filter meant a pull request touching only _includes, index.md or readme.md ran no checks at all. That is the wrong blind spot to have: the lab landing pages are generated from an include, so a change there can break every one of them. The whole suite takes about ten seconds, so filtering saved very little. Pushes to main were already unfiltered and stay that way. Tier 1 keeps its filter - it installs dependencies across a matrix of every lab, so it stays on the nightly schedule plus pull requests that touch a requirements.txt, the check, or its workflow. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc4690cc-57d6-48a7-abc8-bc8570113ab1 --------- Co-authored-by: Ivor Berry <11559977+ivorb@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc4690cc-57d6-48a7-abc8-bc8570113ab1
1 parent e2ba115 commit 5017f7f

10 files changed

Lines changed: 892 additions & 0 deletions
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: Content checks
2+
3+
# Tier 0: fast, static checks that need no Azure resources and no credentials.
4+
# These catch the failure modes that actually reach learners - a code block that
5+
# won't parse, frontmatter that breaks the site build, a link to a page that was
6+
# renamed, or line endings drifting back to CRLF.
7+
8+
on:
9+
# No path filter: the whole suite takes about ten seconds, and filtering
10+
# only creates blind spots. A change to _includes or a root markdown file
11+
# can break every lab page, so it should be checked too.
12+
pull_request:
13+
push:
14+
branches: [main]
15+
workflow_dispatch:
16+
17+
permissions:
18+
contents: read
19+
20+
jobs:
21+
content-checks:
22+
runs-on: ubuntu-latest
23+
steps:
24+
- uses: actions/checkout@v4
25+
26+
- uses: actions/setup-python@v5
27+
with:
28+
# Matches what the labs tell learners to install ("Python 3.13 or
29+
# later") and what they are tested against. Testing on anything else
30+
# risks passing on a configuration no learner uses.
31+
python-version: "3.13"
32+
33+
- name: Install check dependencies
34+
run: pip install --disable-pip-version-check -r tools/checks/requirements.txt
35+
36+
# Each check runs as its own step so a failure is obvious in the UI, and
37+
# always() means one failing check doesn't hide the others.
38+
- name: Frontmatter is valid
39+
if: always()
40+
run: python tools/checks/check_frontmatter.py
41+
42+
- name: Python code blocks parse
43+
if: always()
44+
run: python tools/checks/check_code_blocks.py
45+
46+
- name: Links and media resolve
47+
if: always()
48+
run: python tools/checks/check_links.py
49+
50+
- name: Line endings match .gitattributes
51+
if: always()
52+
run: python tools/checks/check_line_endings.py

.github/workflows/sdk-contract.yml

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
name: SDK contract
2+
3+
# Tier 1: install each lab's pinned requirements, import the modules the lab
4+
# ships, and assert the SDK symbols and keyword arguments the instructions use
5+
# still exist.
6+
#
7+
# No Azure resources, no credentials, no spend - so this can run nightly and
8+
# give early warning before a learner hits a broken lab.
9+
#
10+
# It runs twice per lab:
11+
# pinned - the versions in requirements.txt. A failure means something was
12+
# yanked or a transitive dependency broke.
13+
# latest - unpinned. A failure is early warning for the next version bump,
14+
# so it does not fail the job.
15+
16+
on:
17+
schedule:
18+
- cron: "0 6 * * *"
19+
pull_request:
20+
paths:
21+
- "Labfiles/**/requirements.txt"
22+
- "tools/checks/check_sdk_contract.py"
23+
- ".github/workflows/sdk-contract.yml"
24+
workflow_dispatch:
25+
26+
permissions:
27+
contents: read
28+
issues: write
29+
30+
jobs:
31+
discover:
32+
runs-on: ubuntu-latest
33+
outputs:
34+
labs: ${{ steps.list.outputs.labs }}
35+
steps:
36+
- uses: actions/checkout@v4
37+
- uses: actions/setup-python@v5
38+
with:
39+
python-version: "3.13"
40+
- id: list
41+
run: |
42+
labs=$(python tools/checks/check_sdk_contract.py --list | jq -R -s -c 'split("\n") | map(select(length > 0))')
43+
echo "labs=$labs" >> "$GITHUB_OUTPUT"
44+
45+
contract:
46+
needs: discover
47+
runs-on: ubuntu-latest
48+
strategy:
49+
fail-fast: false
50+
matrix:
51+
lab: ${{ fromJSON(needs.discover.outputs.labs) }}
52+
steps:
53+
- uses: actions/checkout@v4
54+
55+
- uses: actions/setup-python@v5
56+
with:
57+
# Matches what the labs tell learners to install ("Python 3.13 or
58+
# later") and what they are tested against. Testing on anything else
59+
# risks passing on a configuration no learner uses.
60+
python-version: "3.13"
61+
62+
- name: Install pinned requirements
63+
run: |
64+
pip install --disable-pip-version-check -r tools/checks/requirements.txt
65+
pip install --disable-pip-version-check -r "Labfiles/${{ matrix.lab }}/Python/requirements.txt"
66+
67+
- name: Check SDK contract (pinned)
68+
run: python tools/checks/check_sdk_contract.py --lab "${{ matrix.lab }}"
69+
70+
- name: Check SDK contract (latest, advisory)
71+
continue-on-error: true
72+
run: |
73+
sed -E 's/[=<>~!]=.*//' "Labfiles/${{ matrix.lab }}/Python/requirements.txt" > /tmp/latest.txt
74+
pip install --disable-pip-version-check --upgrade -r /tmp/latest.txt
75+
python tools/checks/check_sdk_contract.py --lab "${{ matrix.lab }}"
76+
77+
report:
78+
needs: contract
79+
if: failure() && github.event_name == 'schedule'
80+
runs-on: ubuntu-latest
81+
steps:
82+
# These labs are maintained asynchronously, so a red badge nobody is
83+
# watching is worth nothing. Open an issue instead.
84+
- name: Open an issue for the failure
85+
uses: actions/github-script@v7
86+
with:
87+
script: |
88+
const title = 'SDK contract check failed';
89+
const url = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
90+
const existing = await github.rest.issues.listForRepo({
91+
owner: context.repo.owner,
92+
repo: context.repo.repo,
93+
state: 'open',
94+
labels: 'sdk-contract',
95+
});
96+
if (existing.data.length > 0) {
97+
await github.rest.issues.createComment({
98+
owner: context.repo.owner,
99+
repo: context.repo.repo,
100+
issue_number: existing.data[0].number,
101+
body: `Nightly SDK contract check failed again: ${url}`,
102+
});
103+
return;
104+
}
105+
await github.rest.issues.create({
106+
owner: context.repo.owner,
107+
repo: context.repo.repo,
108+
title,
109+
labels: ['sdk-contract'],
110+
body: [
111+
'The nightly SDK contract check failed, which usually means a',
112+
'package the labs depend on changed an import path or a keyword',
113+
'argument.',
114+
'',
115+
`Run: ${url}`,
116+
].join('\n'),
117+
});

tools/checks/README.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Lab content checks
2+
3+
Automated checks that catch problems in the lab content before a learner hits
4+
them. They are deliberately cheap: **no Azure resources, no credentials, and no
5+
spend**, so they can run on every pull request.
6+
7+
## Running them locally
8+
9+
```
10+
pip install -r tools/checks/requirements.txt
11+
python tools/checks/check_frontmatter.py
12+
python tools/checks/check_code_blocks.py
13+
python tools/checks/check_links.py
14+
python tools/checks/check_line_endings.py
15+
```
16+
17+
Each script exits non-zero when it finds a problem and prints the file and line.
18+
In GitHub Actions the same output becomes an inline annotation on the pull
19+
request diff.
20+
21+
## Tier 0 — content checks (every pull request, and every push to `main`)
22+
23+
| Check | What it catches |
24+
| --- | --- |
25+
| `check_frontmatter.py` | YAML that won't parse, which breaks the site build. The usual cause is an unescaped apostrophe inside a single-quoted value, such as `verify: 'you don't have access'`. |
26+
| `check_code_blocks.py` | A ```` ```python ```` block that isn't valid Python. Learners paste these straight into a file, so a bad indent breaks the lab. |
27+
| `check_links.py` | A link to a page that was renamed or moved, or a screenshot that no longer exists. |
28+
| `check_line_endings.py` | Text files drifting back to CRLF in the index, against the `.gitattributes` policy. |
29+
30+
### Why the code block check normalizes first
31+
32+
The blocks are snippets, not programs, so parsing them as-is is useless — 134 of
33+
148 blocks in this repo would report a false positive. The check therefore:
34+
35+
1. **Dedents** the block, because snippets are indented to sit inside a function
36+
body.
37+
2. **Closes a trailing open block** if parsing failed *only* because a `with` or
38+
`def` has no body — the lab adds that body in a later step.
39+
40+
A genuine syntax error — a typo, an unbalanced bracket, or a bad indent *within*
41+
the snippet — still fails. That is the class of bug behind the `fix fence
42+
indentation` and `fix code sample indentation` fixes on `main`.
43+
44+
## Tier 1 — SDK contract (nightly)
45+
46+
`check_sdk_contract.py` installs each lab's pinned requirements, then checks that
47+
every module and symbol the lab imports still resolves — both in the Python
48+
files the lab ships **and in the code blocks the instructions tell learners to
49+
paste**.
50+
51+
It runs nightly, and on any pull request that touches a `requirements.txt`, the
52+
check itself, or its workflow — the changes most likely to break it. It is
53+
path-filtered because it installs dependencies across a matrix of every lab,
54+
which is too slow to run on unrelated pull requests.
55+
56+
This targets the breakages this repo actually hits, which are import- and
57+
signature-level:
58+
59+
- *Fix Exercise 3: Update FastMCP import to standalone package*
60+
- *Update labs 02 and 03 to `azure-ai-projects==2.0.0b4`*
61+
- *Revert agent-framework bump on legacy labs 07/08*
62+
63+
Each would have been caught here, without any Azure resources, before reaching a
64+
learner.
65+
66+
It runs twice per lab. **Pinned** uses the versions in `requirements.txt`; a
67+
failure means something was yanked or a transitive dependency broke. **Latest**
68+
strips the pins; a failure there is early warning for the next version bump and
69+
does not fail the job.
70+
71+
Because these labs are maintained asynchronously, a scheduled failure opens a
72+
GitHub issue rather than relying on someone noticing a red badge.
73+
74+
### The import contract maintains itself
75+
76+
There is no hand-written list of expected symbols. The contract is derived from
77+
the content, so it cannot drift: add an import to a lab or an instruction code
78+
block and it is checked automatically.
79+
80+
Only **keyword arguments** are pinned by hand, in `KWARG_CONTRACTS`, because
81+
they can't be derived reliably. If you change a code block to pass a new
82+
argument, add it there so a future SDK rename fails loudly instead of silently
83+
breaking the lab.
84+
85+
Starter files are expected to be incomplete — a `def` whose body the learner
86+
fills in later is a syntax error by design — so a parse failure with that
87+
signature is ignored outside `Solution/`.
88+
89+
## Not yet covered
90+
91+
These need groundwork that doesn't exist yet:
92+
93+
| Check | Blocked on |
94+
| --- | --- |
95+
| Strict frontmatter schema (`type`, `section`, `difficulty`, `order`) | The consolidated labs carrying that metadata. |
96+
| Generated task tables match frontmatter | The same metadata, plus the table include. |
97+
| Instructions match the `Solution/` code | A convention for mapping a code block to its solution file, e.g. an HTML comment above each block. |
98+
| Shared infrastructure hasn't drifted between labs | A canonical `Labfiles/_shared/` with a sync script. |

tools/checks/check_code_blocks.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/usr/bin/env python3
2+
"""Check that every ```python block in the lab instructions is valid Python.
3+
4+
Learners paste these blocks straight into a file, so a syntax error - almost
5+
always a bad indent - breaks the lab. Two fixes on main ("fix fence
6+
indentation", "fix code sample indentation") were exactly this class of bug.
7+
8+
Normalization matters here. The blocks are snippets, not programs:
9+
10+
1. They are indented to sit inside a function body, so the whole block is
11+
dedented first. Without this, 134 of 148 blocks would report false
12+
positives.
13+
2. Some intentionally end on an open block - a `with` or `def` whose body the
14+
lab adds in a later step. If parsing fails *only* because a block opener
15+
has no body, a `pass` is appended and it is retried.
16+
17+
A genuine syntax error - typo, unbalanced bracket, or bad indent *within* the
18+
snippet - still fails, which is the point.
19+
"""
20+
21+
import ast
22+
import re
23+
import textwrap
24+
25+
from common import Reporter, lab_pages, main_guard
26+
27+
FENCE = re.compile(r"(?ms)^[ \t]*```python[ \t]*\r?\n(.*?)^[ \t]*```")
28+
INCOMPLETE_BLOCK = "expected an indented block"
29+
30+
31+
def close_open_block(code: str) -> str:
32+
"""Give a trailing block opener a body so a fragment can be parsed."""
33+
lines = [l for l in code.rstrip().split("\n") if l.strip()]
34+
if not lines:
35+
return code
36+
last = lines[-1]
37+
indent = len(last) - len(last.lstrip())
38+
if last.rstrip().endswith(":"):
39+
indent += 4
40+
return code.rstrip() + "\n" + " " * indent + "pass\n"
41+
42+
43+
def parse_snippet(code: str):
44+
"""Return None if the snippet is acceptable, else a SyntaxError to report."""
45+
dedented = textwrap.dedent(code)
46+
try:
47+
ast.parse(dedented)
48+
return None
49+
except SyntaxError as first:
50+
if INCOMPLETE_BLOCK not in str(first):
51+
return first
52+
53+
try:
54+
ast.parse(close_open_block(dedented))
55+
return None
56+
except SyntaxError as second:
57+
return second
58+
59+
60+
def check() -> int:
61+
r = Reporter("python code blocks")
62+
63+
for path in lab_pages():
64+
text = path.read_text(encoding="utf-8")
65+
for match in FENCE.finditer(text):
66+
r.checked += 1
67+
err = parse_snippet(match.group(1))
68+
if err is None:
69+
continue
70+
71+
# Line of the ``` fence, plus the offset reported inside the block.
72+
fence_line = text[: match.start()].count("\n") + 1
73+
line = fence_line + (err.lineno or 1)
74+
reason = str(err).split("(")[0].strip()
75+
r.add(path, f"invalid Python in code block: {reason}", line)
76+
77+
return r.finish("blocks")
78+
79+
80+
if __name__ == "__main__":
81+
main_guard(check)

0 commit comments

Comments
 (0)