.github/.github/workflows/ci-static.yaml
Adam Moussa 4a6cbfd362
Some checks failed
ci / ci / ci (push) Has been cancelled
Release on reusable change / version (push) Has been cancelled
Release on reusable change / release (push) Has been cancelled
ci(workflows): pin remaining GitHub Actions to SHA (#139)
Mutable tags and the dependency-review v5 branch executed inside every consumer, including OIDC deploy jobs. SHA pins with version comments match the policy scanner and let Renovate advance them.
2026-08-26 18:25:53 -04:00

197 lines
7.3 KiB
YAML

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@<full-commit-sha> # 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
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'<script[^>]*type="application/ld\+json"[^>]*>(.*?)</script>', 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."