Merge pull request #57 from Sea-Haven-Industries/feature/ci-static-build-support

ci-static: add build mode for templated static sites
This commit is contained in:
Adam Moussa 2026-06-12 16:40:04 -04:00 • committed by GitHub
commit e4e4b42ce6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,31 +1,38 @@
name: CI — Static Site name: CI — Static Site
# Reusable CI for static HTML/CSS/JS sites (S3 + CloudFront repos with no build # Reusable CI for static HTML/CSS/JS sites (S3 + CloudFront repos). Emits the
# framework). Emits the `ci / ci` status context required by the org "main branch # `ci / ci` status context required by the org "main branch protection" ruleset.
# protection" ruleset, which language-specific CI reusables already satisfy but
# static sites previously could not.
# #
# All checks are dependency-light: htmlhint via npx, everything else via the # Supports two modes:
# python3 / xmllint preinstalled on ubuntu runners. No per-repo config needed. # - 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): # All checks are dependency-light: htmlhint via npx, the rest via python3.
# name: CI #
# on: # Caller example (build mode):
# pull_request:
# branches: [main]
# jobs: # jobs:
# ci: # ci:
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-static.yaml@main # uses: Sea-Haven-Industries/.github/.github/workflows/ci-static.yaml@main
# with:
# build-command: "npx @11ty/eleventy"
# check-dir: "_site"
on: on:
workflow_call: workflow_call:
inputs: inputs:
html-glob: check-dir:
description: "Glob of HTML files to lint/validate" description: "Directory the checks run against (repo root in source mode, build output dir in build mode)"
type: string 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: node-version:
description: "Node.js version for htmlhint" description: "Node.js version for build / htmlhint"
type: string type: string
default: "24" default: "24"
run-htmlhint: run-htmlhint:
@ -41,11 +48,11 @@ on:
type: boolean type: boolean
default: true default: true
run-link-check: 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 type: boolean
default: true default: true
run-conventions-check: 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 type: boolean
default: true default: true
@ -59,14 +66,28 @@ jobs:
concurrency: concurrency:
group: ci-static-${{ github.workflow }}-${{ github.ref }} group: ci-static-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
env:
CHECK_DIR: ${{ inputs.check-dir }}
BUILD_COMMAND: ${{ inputs.build-command }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
if: ${{ inputs.run-htmlhint }} if: ${{ inputs.run-htmlhint || inputs.build-command != '' }}
with: with:
node-version: ${{ inputs.node-version }} 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 - name: HTMLHint
if: ${{ inputs.run-htmlhint }} if: ${{ inputs.run-htmlhint }}
run: | run: |
@ -86,15 +107,16 @@ jobs:
"alt-require": true "alt-require": true
} }
EOF 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 - name: Validate JSON-LD blocks
if: ${{ inputs.run-jsonld-check }} if: ${{ inputs.run-jsonld-check }}
run: | run: |
python3 - <<'PY' python3 - <<'PY'
import glob, json, re, sys import glob, json, os, re, sys
base = os.environ.get("CHECK_DIR", ".")
errs = 0 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() html = open(path, encoding="utf-8").read()
for m in re.finditer( for m in re.finditer(
r'<script[^>]*type="application/ld\+json"[^>]*>(.*?)</script>', html, re.S r'<script[^>]*type="application/ld\+json"[^>]*>(.*?)</script>', html, re.S
@ -113,16 +135,18 @@ jobs:
run: | run: |
python3 - <<'PY' python3 - <<'PY'
import os, sys, xml.dom.minidom as M import os, sys, xml.dom.minidom as M
base = os.environ.get("CHECK_DIR", ".")
p = os.path.join(base, "sitemap.xml")
errs = 0 errs = 0
if os.path.exists("sitemap.xml"): if os.path.exists(p):
try: try:
M.parse("sitemap.xml") M.parse(p)
print("sitemap.xml is well-formed.") print("sitemap.xml is well-formed.")
except Exception as e: except Exception as e:
print(f"::error file=sitemap.xml::Malformed XML: {e}") print(f"::error file={p}::Malformed XML: {e}")
errs += 1 errs += 1
else: else:
print("::warning::No sitemap.xml found.") print(f"::warning::No sitemap.xml found in {base}")
sys.exit(1 if errs else 0) sys.exit(1 if errs else 0)
PY PY
@ -131,8 +155,9 @@ jobs:
run: | run: |
python3 - <<'PY' python3 - <<'PY'
import glob, os, re, sys import glob, os, re, sys
base = os.environ.get("CHECK_DIR", ".")
errs = 0 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() html = open(path, encoding="utf-8").read()
for attr in ("href", "src"): for attr in ("href", "src"):
for m in re.finditer(rf'{attr}="([^"]+)"', html): for m in re.finditer(rf'{attr}="([^"]+)"', html):
@ -141,9 +166,13 @@ jobs:
continue continue
target = url.split("?")[0].split("#")[0] target = url.split("?")[0].split("#")[0]
if not target.startswith("/"): if not target.startswith("/"):
continue # skip relative links; root-relative is the repo convention continue # root-relative is the repo convention
p = target.lstrip("/") rel = target.lstrip("/")
if not any(os.path.exists(c) for c in (p, os.path.join(p, "index.html"))): 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}") print(f"::error file={path}::Broken internal reference: {url}")
errs += 1 errs += 1
print("All internal references resolve." if not errs else f"{errs} broken internal reference(s).") print("All internal references resolve." if not errs else f"{errs} broken internal reference(s).")