Skip to content

Commit 1980a88

Browse files
committed
Add ci check
1 parent ae25649 commit 1980a88

3 files changed

Lines changed: 220 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: CI
2+
3+
# Runs the same checks you can run locally with `python3 scripts/check.py`,
4+
# plus an HTML5 validation pass over every page. No build, no deploy — Pages
5+
# still deploys from the branch on its own.
6+
on:
7+
push:
8+
branches: [main]
9+
pull_request:
10+
branches: [main]
11+
workflow_dispatch:
12+
13+
jobs:
14+
checks:
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- name: Set up Python
20+
uses: actions/setup-python@v5
21+
with:
22+
python-version: "3.x"
23+
24+
- name: Structural checks (manifest, links, naming)
25+
run: python3 scripts/check.py
26+
27+
- name: Validate HTML
28+
uses: Cyb3r-Jak3/html5validator-action@v7.2.0
29+
with:
30+
root: .
31+
# The series-nav bar is injected by JS at runtime, so static HTML
32+
# validation only sees the authored markup — which is what we want.

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,20 @@ static files on GitHub Pages. No build step, no framework — just files.
3636
4. Commit and push. The landing page shows the new box (newest first) and the
3737
prev/next navigation updates itself — no other file needs editing.
3838

39+
## Checks (CI)
40+
41+
Before pushing, you can run the same checks CI runs:
42+
43+
```bash
44+
python3 scripts/check.py
45+
```
46+
47+
It verifies `posts.json` is valid, every slug has a matching
48+
`posts/<slug>/index.html` that includes the nav script, there are no
49+
root-absolute links, and all filenames are lowercase. On every push and pull
50+
request, `.github/workflows/ci.yml` runs this script and then validates the
51+
HTML of every page. CI only checks — GitHub Pages still deploys on its own.
52+
3953
## Conventions that keep it working
4054

4155
- Use only RELATIVE links between pages — never `/posts/...` (root-absolute links

scripts/check.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
#!/usr/bin/env python3
2+
"""
3+
check.py — structural checks for the Research Notes static site.
4+
5+
Run locally before pushing: python3 scripts/check.py
6+
The CI workflow runs this same script, so green locally == green in CI.
7+
8+
It verifies the conventions that keep a no-build GitHub Pages site working:
9+
1. posts.json is valid JSON: a list of entries with the right fields/types.
10+
2. Slugs are unique, lowercase-hyphenated, and one date per YYYY-MM-DD.
11+
3. Every slug has a real chapter at posts/<slug>/index.html.
12+
4. Every chapter includes the series-nav.js script.
13+
5. No root-absolute links (href="/..." / src="/...") — they break project sites.
14+
6. All filenames under posts/ and assets/ are lowercase (Pages is case-sensitive).
15+
7. .nojekyll exists (so Pages serves files as-is).
16+
17+
Exit code is non-zero if any check fails.
18+
"""
19+
20+
import datetime
21+
import json
22+
import os
23+
import re
24+
import sys
25+
26+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
27+
28+
errors = []
29+
warnings = []
30+
31+
32+
def err(msg):
33+
errors.append(msg)
34+
35+
36+
def warn(msg):
37+
warnings.append(msg)
38+
39+
40+
def rel(path):
41+
return os.path.relpath(path, ROOT)
42+
43+
44+
SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
45+
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
46+
# Matches href="/..." or src="/..." but not "//host" (protocol-relative) and not
47+
# href="//" — i.e. exactly one leading slash, which is a root-absolute path.
48+
ABS_LINK_RE = re.compile(r"""(?:href|src)\s*=\s*["']/(?!/)""", re.IGNORECASE)
49+
50+
51+
def check_manifest():
52+
path = os.path.join(ROOT, "posts.json")
53+
if not os.path.isfile(path):
54+
err("posts.json is missing")
55+
return []
56+
try:
57+
with open(path, encoding="utf-8") as f:
58+
data = json.load(f)
59+
except json.JSONDecodeError as e:
60+
err(f"posts.json is not valid JSON: {e}")
61+
return []
62+
63+
if not isinstance(data, list):
64+
err("posts.json must be a JSON array of post objects")
65+
return []
66+
67+
seen_slugs = set()
68+
required = {"slug": str, "title": str, "date": str, "blurb": str, "tags": list}
69+
for i, entry in enumerate(data):
70+
where = f"posts.json[{i}]"
71+
if not isinstance(entry, dict):
72+
err(f"{where} is not an object")
73+
continue
74+
for field, ftype in required.items():
75+
if field not in entry:
76+
err(f"{where} is missing required field '{field}'")
77+
elif not isinstance(entry[field], ftype):
78+
err(f"{where} field '{field}' must be {ftype.__name__}")
79+
80+
slug = entry.get("slug")
81+
if isinstance(slug, str):
82+
if not SLUG_RE.match(slug):
83+
err(f"{where} slug '{slug}' must be lowercase letters/digits/hyphens")
84+
if slug in seen_slugs:
85+
err(f"{where} duplicate slug '{slug}'")
86+
seen_slugs.add(slug)
87+
88+
date = entry.get("date")
89+
if isinstance(date, str):
90+
if not DATE_RE.match(date):
91+
err(f"{where} date '{date}' must be YYYY-MM-DD")
92+
else:
93+
try:
94+
datetime.date.fromisoformat(date)
95+
except ValueError:
96+
err(f"{where} date '{date}' is not a real calendar date")
97+
98+
for j, tag in enumerate(entry.get("tags", []) if isinstance(entry.get("tags"), list) else []):
99+
if not isinstance(tag, str):
100+
err(f"{where} tags[{j}] must be a string")
101+
102+
return data
103+
104+
105+
def check_chapters(manifest):
106+
for entry in manifest:
107+
slug = entry.get("slug")
108+
if not isinstance(slug, str):
109+
continue
110+
chapter = os.path.join(ROOT, "posts", slug, "index.html")
111+
if not os.path.isfile(chapter):
112+
err(f"slug '{slug}' has no chapter at posts/{slug}/index.html")
113+
continue
114+
with open(chapter, encoding="utf-8") as f:
115+
html = f.read()
116+
if "assets/series-nav.js" not in html:
117+
err(f"posts/{slug}/index.html does not include the series-nav.js script")
118+
119+
120+
def check_html_links():
121+
posts_dir = os.path.join(ROOT, "posts")
122+
if not os.path.isdir(posts_dir):
123+
return
124+
for dirpath, _, filenames in os.walk(posts_dir):
125+
for name in filenames:
126+
if not name.endswith(".html"):
127+
continue
128+
path = os.path.join(dirpath, name)
129+
with open(path, encoding="utf-8") as f:
130+
html = f.read()
131+
for m in ABS_LINK_RE.finditer(html):
132+
line = html.count("\n", 0, m.start()) + 1
133+
err(f"{rel(path)}:{line} root-absolute link "
134+
f"({m.group(0).strip()}…) breaks on project sites; use a relative path")
135+
136+
137+
def check_lowercase():
138+
for sub in ("posts", "assets"):
139+
base = os.path.join(ROOT, sub)
140+
if not os.path.isdir(base):
141+
continue
142+
for dirpath, dirnames, filenames in os.walk(base):
143+
for name in list(dirnames) + filenames:
144+
if name != name.lower():
145+
err(f"{rel(os.path.join(dirpath, name))} is not lowercase "
146+
f"(GitHub Pages is case-sensitive)")
147+
148+
149+
def check_nojekyll():
150+
if not os.path.isfile(os.path.join(ROOT, ".nojekyll")):
151+
warn(".nojekyll is missing — Pages may run Jekyll and hide underscore files")
152+
153+
154+
def main():
155+
manifest = check_manifest()
156+
check_chapters(manifest)
157+
check_html_links()
158+
check_lowercase()
159+
check_nojekyll()
160+
161+
for w in warnings:
162+
print(f"warning: {w}")
163+
for e in errors:
164+
print(f"error: {e}")
165+
166+
if errors:
167+
print(f"\nFAILED: {len(errors)} error(s), {len(warnings)} warning(s)")
168+
return 1
169+
print(f"OK: all checks passed ({len(warnings)} warning(s))")
170+
return 0
171+
172+
173+
if __name__ == "__main__":
174+
sys.exit(main())

0 commit comments

Comments
 (0)