name: CI — Static Site # 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. # # 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. # # 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@ # v1.0.4 # with: # build-command: "npx @11ty/eleventy" # check-dir: "_site" on: workflow_call: inputs: check-dir: description: "Directory the checks run against (repo root in source mode, build output dir in build mode)" type: string 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 build / htmlhint" type: string default: "24" run-htmlhint: description: "Run htmlhint structural validation" type: boolean default: true run-jsonld-check: description: "Validate every application/ld+json block parses as JSON" type: boolean default: true run-sitemap-check: description: "Validate sitemap.xml is well-formed XML (if present)" type: boolean default: true run-link-check: 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 (always run against repo root)" type: boolean default: true permissions: contents: read jobs: ci: runs-on: ubuntu-latest timeout-minutes: 15 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@v7 - uses: actions/setup-node@v7 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: | cat > "${RUNNER_TEMP}/.htmlhintrc" <<'EOF' { "tagname-lowercase": true, "attr-lowercase": true, "attr-value-double-quotes": true, "doctype-first": true, "doctype-html5": true, "tag-pair": true, "spec-char-escape": false, "id-unique": true, "src-not-empty": true, "attr-no-duplication": true, "title-require": true, "alt-require": true } EOF 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, os, re, sys base = os.environ.get("CHECK_DIR", ".") errs = 0 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 ): try: json.loads(m.group(1).strip()) except Exception as e: print(f"::error file={path}::Invalid JSON-LD: {e}") errs += 1 print("All JSON-LD blocks valid." if not errs else f"{errs} invalid JSON-LD block(s).") sys.exit(1 if errs else 0) PY - name: Validate sitemap.xml if: ${{ inputs.run-sitemap-check }} 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(p): try: M.parse(p) print("sitemap.xml is well-formed.") except Exception as e: print(f"::error file={p}::Malformed XML: {e}") errs += 1 else: print(f"::warning::No sitemap.xml found in {base}") sys.exit(1 if errs else 0) PY - name: Check internal links and asset references if: ${{ inputs.run-link-check }} run: | python3 - <<'PY' import glob, os, re, sys base = os.environ.get("CHECK_DIR", ".") errs = 0 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): url = m.group(1) if re.match(r'^(https?:|mailto:|tel:|#|data:|//|javascript:)', url): continue target = url.split("?")[0].split("#")[0] if not target.startswith("/"): 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).") sys.exit(1 if errs else 0) PY - name: Conventions check if: ${{ inputs.run-conventions-check }} run: | errors=0 fail() { echo "::error::$1"; errors=$((errors + 1)); } [[ -f README.md ]] || fail "Missing README.md" if [[ -f .gitignore ]]; then grep -qE '^\.env$|^\.env\b' .gitignore || fail ".gitignore does not include .env" else fail "Missing .gitignore" fi if [[ $errors -gt 0 ]]; then echo "Conventions check failed with $errors error(s)." exit 1 fi echo "Conventions check passed."