mirror of
https://github.com/Sea-Haven-Industries/.github.git
synced 2026-10-02 09:43:16 +00:00
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.
197 lines
7.3 KiB
YAML
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."
|