[codex] Refresh GitHub beginners QA dependencies #132
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Book QA (Unicode + Links + Textlint + Layout Risk) | |
| on: | |
| pull_request: | |
| push: | |
| branches: [ main ] | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' | |
| jobs: | |
| qa: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pages: read | |
| steps: | |
| - name: Checkout book | |
| uses: actions/checkout@v6 | |
| - name: Bidi/hidden Unicode check (tracked files) | |
| run: python3 scripts/check_bidi_unicode.py | |
| - name: Checkout book-formatter (pinned) | |
| uses: actions/checkout@v6 | |
| with: | |
| repository: itdojp/book-formatter | |
| ref: da2a49e7d2dcd9e1fa885e910c458130fe8d73a4 | |
| path: book-formatter | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: '20' | |
| cache: npm | |
| cache-dependency-path: book-formatter/package-lock.json | |
| - name: Check metadata consistency | |
| run: node scripts/check-metadata-consistency.js | |
| - name: Install dependencies (book-formatter) | |
| working-directory: book-formatter | |
| run: npm ci | |
| - name: Determine scan directory | |
| id: scan | |
| shell: bash | |
| run: | | |
| if [ -d docs ]; then | |
| echo "dir=docs" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "dir=." >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Unicode check (fail on warnings) | |
| run: node book-formatter/scripts/check-unicode.js "${{ steps.scan.outputs.dir }}" --allowlist .book-formatter/unicode-allowlist.json --fail-on warn | |
| - name: Textlint (PRH dictionary; fail on errors) | |
| run: node book-formatter/scripts/check-textlint.js "${{ steps.scan.outputs.dir }}" --fail-on error | |
| - name: Link check (internal + anchors) | |
| run: node book-formatter/scripts/check-links.js "${{ steps.scan.outputs.dir }}" | |
| - name: Layout risk scan (long lines / tables / images) | |
| run: node book-formatter/scripts/check-layout-risk.js "${{ steps.scan.outputs.dir }}" --fail-on error --output "${{ runner.temp }}/layout-risk-report.json" | |
| - name: Markdown structure check (front matter / headings / fences) | |
| run: node book-formatter/scripts/check-markdown-structure.js "${{ steps.scan.outputs.dir }}" --fail-on error --output "${{ runner.temp }}/markdown-structure-report.json" | |
| - name: Upload markdown structure report | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: markdown-structure-report | |
| path: ${{ runner.temp }}/markdown-structure-report.json | |
| if-no-files-found: ignore | |
| - name: Upload layout risk report | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: layout-risk-report | |
| path: ${{ runner.temp }}/layout-risk-report.json | |
| if-no-files-found: ignore | |
| - name: Setup Pages | |
| uses: actions/configure-pages@v5 | |
| - name: Build (Jekyll; GitHub Pages compatible) | |
| uses: actions/jekyll-build-pages@v1 | |
| with: | |
| source: ./${{ steps.scan.outputs.dir }} | |
| destination: ./_site | |
| - name: Smoke check built site (top + navigation + assets) | |
| shell: bash | |
| run: | | |
| python3 -m pip install --user pyyaml | |
| python3 - << 'PY' | |
| import sys | |
| from pathlib import Path | |
| import yaml | |
| scan_dir = Path("${{ steps.scan.outputs.dir }}").resolve() | |
| site_dir = Path("_site").resolve() | |
| if not site_dir.exists(): | |
| print(f"::error::Built site directory not found: {site_dir}") | |
| sys.exit(1) | |
| def normalize_path(p): | |
| if not isinstance(p, str): | |
| return None | |
| p = p.strip() | |
| if not p: | |
| return None | |
| if p.startswith(("http://", "https://", "mailto:")): | |
| return None | |
| if not p.startswith("/"): | |
| p = "/" + p | |
| lower = p.lower() | |
| if lower.endswith((".md", ".html", ".htm", ".pdf", ".txt")): | |
| return p | |
| return p if p.endswith("/") else p + "/" | |
| def read_nav_paths(): | |
| nav = scan_dir / "_data" / "navigation.yml" | |
| if not nav.exists(): | |
| return [] | |
| try: | |
| raw = nav.read_text(encoding="utf-8") | |
| data = yaml.safe_load(raw) or {} | |
| except yaml.YAMLError as e: | |
| nav_display = nav | |
| try: | |
| nav_display = nav.relative_to(Path.cwd()) | |
| except ValueError: | |
| pass | |
| print(f"::error file={nav_display}::Failed to parse navigation YAML: {e}") | |
| sys.exit(1) | |
| paths = [] | |
| # Keep this segment list broad to support different book structures. | |
| for key in ["introduction", "chapters", "additional", "resources", "appendices", "afterword"]: | |
| for item in (data.get(key) or []): | |
| if isinstance(item, dict): | |
| nested = item.get("items") | |
| if not isinstance(nested, list): | |
| nested = item.get("children") | |
| if isinstance(nested, list): | |
| for sub in (nested or []): | |
| if not isinstance(sub, dict): | |
| continue | |
| p = normalize_path(sub.get("path")) | |
| if p: | |
| paths.append(p) | |
| continue | |
| if not isinstance(item, dict): | |
| continue | |
| p = normalize_path(item.get("path")) | |
| if p: | |
| paths.append(p) | |
| return paths | |
| def discover_paths(): | |
| paths = [] | |
| for seg in ["introduction", "chapters", "additional", "resources", "appendices", "afterword"]: | |
| d = scan_dir / seg | |
| if d.is_dir(): | |
| for child in sorted(d.iterdir()): | |
| if child.is_dir(): | |
| paths.append(f"/{seg}/{child.name}/") | |
| return paths | |
| paths = read_nav_paths() or discover_paths() or ["/"] | |
| if "/" not in paths: | |
| paths.insert(0, "/") | |
| # De-dup while keeping order | |
| seen = set() | |
| uniq = [] | |
| for p in paths: | |
| if p in seen: | |
| continue | |
| seen.add(p) | |
| uniq.append(p) | |
| paths = uniq | |
| def exists_any(candidates): | |
| for c in candidates: | |
| if c.exists() and c.is_file() and c.stat().st_size > 0: | |
| return True | |
| return False | |
| def candidates_for(path_str): | |
| if path_str == "/": | |
| return [site_dir / "index.html"] | |
| rel = path_str.lstrip("/") | |
| lower = path_str.lower() | |
| if lower.endswith((".html", ".htm", ".pdf", ".txt")): | |
| return [site_dir / rel] | |
| if lower.endswith(".md"): | |
| # Jekyll usually converts Markdown to HTML, but Markdown without front matter may be copied as-is. | |
| html_rel = rel[:-3] + ".html" | |
| return [site_dir / rel, site_dir / html_rel] | |
| # Directory-like (pretty permalink) | |
| rel_dir = rel | |
| if not rel_dir.endswith("/"): | |
| rel_dir += "/" | |
| # Both `/<dir>/index.html` and `/<dir>.html` are accepted. | |
| return [site_dir / rel_dir / "index.html", site_dir / (rel_dir[:-1] + ".html")] | |
| missing = [] | |
| for p in paths: | |
| if not exists_any(candidates_for(p)): | |
| missing.append(p) | |
| # Ensure core assets exist in the built output. | |
| required_assets = [ | |
| "assets/css/main.css", | |
| "assets/css/syntax-highlighting.css", | |
| "assets/js/theme.js", | |
| "assets/js/search.js", | |
| "assets/js/code-copy-lightweight.js", | |
| ] | |
| missing_assets = [ | |
| a | |
| for a in required_assets | |
| if not ( | |
| (site_dir / a).exists() | |
| and (site_dir / a).is_file() | |
| and (site_dir / a).stat().st_size > 0 | |
| ) | |
| ] | |
| if missing or missing_assets: | |
| if missing: | |
| print("::error::Missing pages in built site:") | |
| for p in missing: | |
| print(f" - {p}") | |
| if missing_assets: | |
| print("::error::Missing assets in built site:") | |
| for a in missing_assets: | |
| print(f" - {a}") | |
| sys.exit(1) | |
| print(f"OK: built site smoke check passed ({len(paths)} paths, {len(required_assets)} assets)") | |
| PY |