From e43a9cb4477e61840664a9a3b87e1ecd7827ec18 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 12 Jun 2026 16:29:19 -0400 Subject: [PATCH] ci-static: add build mode for templated static sites Add check-dir + build-command inputs. When build-command is set, run npm ci + the build, then validate the built output in check-dir (e.g. _site) instead of repo source. Without this, a site that templates its HTML (Eleventy etc.) has no source HTML and the checks pass vacuously. Backward-compatible: defaults (check-dir='.', build-command='') preserve source-mode behavior for existing callers. build-command is passed via env to avoid expression injection into the run script. --- .github/workflows/ci-static.yaml | 87 +++++++++++++++++++++----------- 1 file changed, 58 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci-static.yaml b/.github/workflows/ci-static.yaml index 6d0f903..0b764a3 100644 --- a/.github/workflows/ci-static.yaml +++ b/.github/workflows/ci-static.yaml @@ -1,31 +1,38 @@ name: CI — Static Site -# Reusable CI for static HTML/CSS/JS sites (S3 + CloudFront repos with no build -# framework). Emits the `ci / ci` status context required by the org "main branch -# protection" ruleset, which language-specific CI reusables already satisfy but -# static sites previously could not. +# Reusable CI for static HTML/CSS/JS sites (S3 + CloudFront repos). Emits the +# `ci / ci` status context required by the org "main branch protection" ruleset. # -# All checks are dependency-light: htmlhint via npx, everything else via the -# python3 / xmllint preinstalled on ubuntu runners. No per-repo config needed. +# Supports two modes: +# - Source mode (default): validates HTML in place at the repo root. +# - Build mode: set `build-command` (e.g. an Eleventy build) + `check-dir` +# (e.g. "_site") so the checks validate the BUILT output that actually +# ships — not the source templates. Without this, a templated site's +# source has no plain HTML and the checks would pass vacuously. # -# Caller example (.github/workflows/ci.yaml): -# name: CI -# on: -# pull_request: -# branches: [main] +# All checks are dependency-light: htmlhint via npx, the rest via python3. +# +# Caller example (build mode): # jobs: # ci: # uses: Sea-Haven-Industries/.github/.github/workflows/ci-static.yaml@main +# with: +# build-command: "npx @11ty/eleventy" +# check-dir: "_site" on: workflow_call: inputs: - html-glob: - description: "Glob of HTML files to lint/validate" + check-dir: + description: "Directory the checks run against (repo root in source mode, build output dir in build mode)" type: string - default: "**/*.html" + default: "." + build-command: + description: "Optional build command to run before checks (implies `npm ci` first). Leave empty for source mode." + type: string + default: "" node-version: - description: "Node.js version for htmlhint" + description: "Node.js version for build / htmlhint" type: string default: "24" run-htmlhint: @@ -41,11 +48,11 @@ on: type: boolean default: true run-link-check: - description: "Verify root-relative internal links and asset references resolve to files in the repo" + description: "Verify root-relative internal links and asset references resolve to files" type: boolean default: true run-conventions-check: - description: "Require README.md and a .gitignore that covers .env" + description: "Require README.md and a .gitignore that covers .env (always run against repo root)" type: boolean default: true @@ -59,14 +66,28 @@ jobs: concurrency: group: ci-static-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true + env: + CHECK_DIR: ${{ inputs.check-dir }} + BUILD_COMMAND: ${{ inputs.build-command }} steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 - if: ${{ inputs.run-htmlhint }} + if: ${{ inputs.run-htmlhint || inputs.build-command != '' }} with: node-version: ${{ inputs.node-version }} + - name: Build site + if: ${{ inputs.build-command != '' }} + run: | + npm ci + # build-command passed via env to avoid expression injection into the script body + eval "$BUILD_COMMAND" + if [[ ! -d "$CHECK_DIR" ]]; then + echo "::error::build-command did not produce check-dir '$CHECK_DIR'" + exit 1 + fi + - name: HTMLHint if: ${{ inputs.run-htmlhint }} run: | @@ -86,15 +107,16 @@ jobs: "alt-require": true } EOF - npx --yes htmlhint --config "${RUNNER_TEMP}/.htmlhintrc" "${{ inputs.html-glob }}" + npx --yes htmlhint --config "${RUNNER_TEMP}/.htmlhintrc" "${CHECK_DIR%/}/**/*.html" - name: Validate JSON-LD blocks if: ${{ inputs.run-jsonld-check }} run: | python3 - <<'PY' - import glob, json, re, sys + import glob, json, os, re, sys + base = os.environ.get("CHECK_DIR", ".") errs = 0 - for path in sorted(glob.glob("**/*.html", recursive=True)): + for path in sorted(glob.glob(os.path.join(base, "**/*.html"), recursive=True)): html = open(path, encoding="utf-8").read() for m in re.finditer( r']*type="application/ld\+json"[^>]*>(.*?)', html, re.S @@ -113,16 +135,18 @@ jobs: run: | python3 - <<'PY' import os, sys, xml.dom.minidom as M + base = os.environ.get("CHECK_DIR", ".") + p = os.path.join(base, "sitemap.xml") errs = 0 - if os.path.exists("sitemap.xml"): + if os.path.exists(p): try: - M.parse("sitemap.xml") + M.parse(p) print("sitemap.xml is well-formed.") except Exception as e: - print(f"::error file=sitemap.xml::Malformed XML: {e}") + print(f"::error file={p}::Malformed XML: {e}") errs += 1 else: - print("::warning::No sitemap.xml found.") + print(f"::warning::No sitemap.xml found in {base}") sys.exit(1 if errs else 0) PY @@ -131,8 +155,9 @@ jobs: run: | python3 - <<'PY' import glob, os, re, sys + base = os.environ.get("CHECK_DIR", ".") errs = 0 - for path in sorted(glob.glob("**/*.html", recursive=True)): + for path in sorted(glob.glob(os.path.join(base, "**/*.html"), recursive=True)): html = open(path, encoding="utf-8").read() for attr in ("href", "src"): for m in re.finditer(rf'{attr}="([^"]+)"', html): @@ -141,9 +166,13 @@ jobs: continue target = url.split("?")[0].split("#")[0] if not target.startswith("/"): - continue # skip relative links; root-relative is the repo convention - p = target.lstrip("/") - if not any(os.path.exists(c) for c in (p, os.path.join(p, "index.html"))): + continue # root-relative is the repo convention + rel = target.lstrip("/") + cands = ( + os.path.join(base, rel), + os.path.join(base, rel, "index.html"), + ) + if not any(os.path.exists(c) for c in cands): print(f"::error file={path}::Broken internal reference: {url}") errs += 1 print("All internal references resolve." if not errs else f"{errs} broken internal reference(s).")