From a7e4256ae7ab7a56800b161cd8ad67909ff46b6e Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 12 Jun 2026 15:59:29 -0400 Subject: [PATCH] Add ci-static reusable workflow and content label rule - ci-static.yaml: reusable CI for static HTML/CSS/JS sites (S3+CloudFront repos with no build framework). Job 'ci' emits the 'ci / ci' status context required by the org main-branch ruleset, which static sites previously could not satisfy (only ci-dotnet/python-sam/typescript-cdk existed). Checks: htmlhint, JSON-LD validity, sitemap well-formedness, internal-link/asset resolution, README/.gitignore conventions. - callable-labeler.yaml: add a 'content' rule (html/css/assets/sitemap/ robots) so static-site PRs get labeled instead of matching nothing. --- .github/workflows/callable-labeler.yaml | 8 ++ .github/workflows/ci-static.yaml | 168 ++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 .github/workflows/ci-static.yaml diff --git a/.github/workflows/callable-labeler.yaml b/.github/workflows/callable-labeler.yaml index c4c6a91..8232ae4 100644 --- a/.github/workflows/callable-labeler.yaml +++ b/.github/workflows/callable-labeler.yaml @@ -56,6 +56,14 @@ jobs: - 'lambdas/**' - 'api/**' - 'services/**' + content: + - changed-files: + - any-glob-to-any-file: + - '**/*.html' + - '**/*.css' + - 'assets/**' + - 'sitemap.xml' + - 'robots.txt' ci: - changed-files: - any-glob-to-any-file: diff --git a/.github/workflows/ci-static.yaml b/.github/workflows/ci-static.yaml new file mode 100644 index 0000000..6d0f903 --- /dev/null +++ b/.github/workflows/ci-static.yaml @@ -0,0 +1,168 @@ +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. +# +# All checks are dependency-light: htmlhint via npx, everything else via the +# python3 / xmllint preinstalled on ubuntu runners. No per-repo config needed. +# +# Caller example (.github/workflows/ci.yaml): +# name: CI +# on: +# pull_request: +# branches: [main] +# jobs: +# ci: +# uses: Sea-Haven-Industries/.github/.github/workflows/ci-static.yaml@main + +on: + workflow_call: + inputs: + html-glob: + description: "Glob of HTML files to lint/validate" + type: string + default: "**/*.html" + node-version: + description: "Node.js version for 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 in the repo" + type: boolean + default: true + run-conventions-check: + description: "Require README.md and a .gitignore that covers .env" + 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 + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + if: ${{ inputs.run-htmlhint }} + with: + node-version: ${{ inputs.node-version }} + + - 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" "${{ inputs.html-glob }}" + + - name: Validate JSON-LD blocks + if: ${{ inputs.run-jsonld-check }} + run: | + python3 - <<'PY' + import glob, json, re, sys + errs = 0 + for path in sorted(glob.glob("**/*.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 + errs = 0 + if os.path.exists("sitemap.xml"): + try: + M.parse("sitemap.xml") + print("sitemap.xml is well-formed.") + except Exception as e: + print(f"::error file=sitemap.xml::Malformed XML: {e}") + errs += 1 + else: + print("::warning::No sitemap.xml found.") + 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 + errs = 0 + for path in sorted(glob.glob("**/*.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 # 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"))): + 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."