Skip to content

Documentation Validation #59

Documentation Validation

Documentation Validation #59

# =============================================================================
# StrayMark - Documentation Validation Workflow
# =============================================================================
#
# This workflow validates documentation on each Pull Request and push to main.
# https://strangedays.tech
#
# Executes:
# 1. File naming convention validation
# 2. Metadata (front-matter) validation
# 3. Sensitive information detection
# 4. Markdown linting
# 5. Internal link verification
#
# =============================================================================
name: Documentation Validation
on:
push:
branches: [main, develop]
paths:
- '.straymark/**'
- '.github/workflows/docs-validation.yml'
pull_request:
branches: [main, develop]
paths:
- '.straymark/**'
# Single source of truth for the DocType prefixes the workflow recognizes.
# Adding a new DocType requires updating BOTH this list and
# `cli/src/document.rs::DocType::ALL_PREFIXES`. Keep them in sync.
# Files whose basename does not start with one of these prefixes followed by
# `-` are treated as framework / governance / template files and are silently
# skipped from the per-document checks (no manual exclude list needed).
env:
DOC_TYPE_PREFIXES: "AILOG|AIDEC|ADR|ETH|REQ|TES|INC|TDE|SEC|MCARD|SBOM|DPIA|PIPIA|CACFILE|TC260RA|AILABEL"
jobs:
validate-docs:
name: Validate Documentation
runs-on: ubuntu-latest
steps:
# =========================================================================
# Checkout
# =========================================================================
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0 # Required to compare with base branch
# =========================================================================
# Setup Node.js (for markdownlint)
# =========================================================================
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: '20'
- name: Install markdownlint-cli
run: npm install -g markdownlint-cli
# =========================================================================
# Get changed files
# =========================================================================
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v44
with:
files: |
.straymark/**/*.md
# =========================================================================
# Validate file naming convention
# =========================================================================
- name: Validate file naming convention
if: steps.changed-files.outputs.any_changed == 'true'
run: |
echo "📋 Validating file naming convention..."
ERRORS=0
# Whitelist approach: only files whose basename starts with one of
# the known DocType prefixes are validated. Framework / governance /
# template files (e.g., AGENT-RULES.md, AI-KPIS.md, NIST-AI-RMF-*)
# don't match any prefix and are silently skipped — no manual
# exclude list to maintain.
PREFIX_RE="^(${DOC_TYPE_PREFIXES})-"
# Optional single-letter suffix on the sequence number lets adopters
# resolve same-day same-sequence filename collisions without
# renumbering downstream entries (e.g. `-028b-` when `-028-` is
# already taken by an earlier-committed file on the same date).
VALID_PATTERN="^(${DOC_TYPE_PREFIXES})-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{3}[a-z]?-[a-z0-9-]+\.md$"
for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
filename=$(basename "$file")
# Skip files that are not adopter-created typed documents.
if ! echo "$filename" | grep -qE "$PREFIX_RE"; then
echo " ⊘ Skipped (framework / non-typed): $filename"
continue
fi
# File starts with a DocType prefix — validate the full pattern.
if ! echo "$filename" | grep -qE "$VALID_PATTERN"; then
echo " ✗ Invalid naming: $filename"
echo " Expected: [TYPE]-[YYYY-MM-DD]-[NNN]<a>-[description].md"
echo " Where <a> is an optional single lowercase letter for same-day"
echo " same-sequence collisions (e.g. -028b- when -028- is taken)."
echo " Valid TYPEs: ${DOC_TYPE_PREFIXES//|/, }"
ERRORS=$((ERRORS + 1))
else
echo " ✓ $filename"
fi
done
if [ $ERRORS -gt 0 ]; then
echo "::error::Found $ERRORS naming convention errors"
exit 1
fi
echo "✅ Naming convention valid"
# =========================================================================
# Validate front-matter
# =========================================================================
- name: Validate front-matter metadata
if: steps.changed-files.outputs.any_changed == 'true'
run: |
echo "📋 Validating metadata..."
ERRORS=0
PREFIX_RE="^(${DOC_TYPE_PREFIXES})-"
REQUIRED_FIELDS="id title status created"
for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
filename=$(basename "$file")
# Only validate adopter-created typed documents.
if ! echo "$filename" | grep -qE "$PREFIX_RE"; then
continue
fi
# Verify front-matter exists
if ! head -1 "$file" | grep -q "^---"; then
echo " ✗ Missing front-matter: $filename"
ERRORS=$((ERRORS + 1))
continue
fi
# Verify required fields
for field in $REQUIRED_FIELDS; do
if ! grep -q "^$field:" "$file"; then
echo " ✗ Missing field '$field' in: $filename"
ERRORS=$((ERRORS + 1))
fi
done
done
if [ $ERRORS -gt 0 ]; then
echo "::error::Found $ERRORS metadata errors"
exit 1
fi
echo "✅ Metadata valid"
# =========================================================================
# Validate risk_level / review_required cross-check
# =========================================================================
- name: Validate risk_level requires review_required
if: steps.changed-files.outputs.any_changed == 'true'
run: |
echo "📋 Validating risk_level and review_required..."
ERRORS=0
PREFIX_RE="^(${DOC_TYPE_PREFIXES})-"
for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
filename=$(basename "$file")
# Only validate adopter-created typed documents.
if ! echo "$filename" | grep -qE "$PREFIX_RE"; then
continue
fi
# Extract front-matter
FRONTMATTER=$(sed -n '/^---$/,/^---$/p' "$file" | sed '1d;$d')
if [ -z "$FRONTMATTER" ]; then
continue
fi
RISK_LEVEL=$(echo "$FRONTMATTER" | grep "^risk_level:" | head -1 | sed 's/risk_level: *//' | tr -d '\r' || true)
REVIEW_REQUIRED=$(echo "$FRONTMATTER" | grep "^review_required:" | head -1 | sed 's/review_required: *//' | tr -d '\r' || true)
if [ "$RISK_LEVEL" = "high" ] || [ "$RISK_LEVEL" = "critical" ]; then
if [ "$REVIEW_REQUIRED" != "true" ]; then
echo "::error file=$file::risk_level is '$RISK_LEVEL' but review_required is not true"
ERRORS=$((ERRORS + 1))
fi
fi
done
if [ $ERRORS -gt 0 ]; then
echo "::error::Found $ERRORS risk_level/review_required errors"
exit 1
fi
echo "✅ Risk level validation passed"
# =========================================================================
# Detect sensitive information
# =========================================================================
- name: Check for sensitive information
if: steps.changed-files.outputs.any_changed == 'true'
run: |
echo "🔒 Checking for sensitive information..."
WARNINGS=0
PATTERNS="password|api_key|apikey|secret|token|private_key|credentials"
for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
MATCHES=$(grep -inE "$PATTERNS" "$file" 2>/dev/null | head -5 || true)
if [ -n "$MATCHES" ]; then
echo "::warning file=$file::Possible sensitive information detected"
echo "$MATCHES"
WARNINGS=$((WARNINGS + 1))
fi
done
if [ $WARNINGS -gt 0 ]; then
echo "⚠️ Detected $WARNINGS files with possible sensitive information"
else
echo "✅ No sensitive information detected"
fi
# =========================================================================
# Markdown Lint
# =========================================================================
- name: Run markdownlint
if: steps.changed-files.outputs.any_changed == 'true'
run: |
echo "📝 Running markdownlint..."
# Create temporary configuration
cat > .markdownlint.json << 'EOF'
{
"default": true,
"MD013": false,
"MD033": false,
"MD041": false,
"MD024": { "siblings_only": true }
}
EOF
markdownlint ${{ steps.changed-files.outputs.all_changed_files }} || {
echo "::warning::markdownlint found formatting issues"
}
echo "✅ Linting completed"
# =========================================================================
# Verify internal links
# =========================================================================
- name: Check internal links
if: steps.changed-files.outputs.any_changed == 'true'
run: |
echo "🔗 Verifying internal links..."
ERRORS=0
for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
# Extract internal markdown links: [text](path)
LINKS=$(grep -oE '\[.+\]\([^http][^)]+\)' "$file" 2>/dev/null || true)
for link in $LINKS; do
# Extract only the path
path=$(echo "$link" | sed 's/.*](//' | sed 's/)//' | sed 's/#.*//')
if [ -n "$path" ]; then
# Resolve relative path
dir=$(dirname "$file")
fullpath="$dir/$path"
if [ ! -f "$fullpath" ] && [ ! -d "$fullpath" ]; then
echo " ✗ Broken link in $file: $path"
ERRORS=$((ERRORS + 1))
fi
fi
done
done
if [ $ERRORS -gt 0 ]; then
echo "::warning::Found $ERRORS broken links"
else
echo "✅ All internal links are valid"
fi
# =========================================================================
# Summary
# =========================================================================
- name: Summary
if: always()
run: |
echo "═══════════════════════════════════════════════════════════════"
echo "📊 Documentation validation completed"
echo "═══════════════════════════════════════════════════════════════"
echo ""
echo "Files validated: ${{ steps.changed-files.outputs.all_changed_files_count }}"
# ===========================================================================
# Job: Compliance check
# ===========================================================================
compliance-check:
name: Compliance Check
runs-on: ubuntu-latest
needs: validate-docs
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v44
with:
files: |
.straymark/**/*.md
- name: Verify high-risk documents have ETH reference
if: steps.changed-files.outputs.any_changed == 'true'
run: |
echo "📋 Checking compliance: high-risk documents must reference an ETH..."
# NOTE: Missing-ETH is intentionally a warning (not an error) — adopters
# may file the ETH later in a follow-up document. No ERRORS counter is
# kept because there is no exit-1 path; the warnings ride through the
# normal CI run and surface in the GitHub Actions UI.
PREFIX_RE="^(${DOC_TYPE_PREFIXES})-"
for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
filename=$(basename "$file")
if ! echo "$filename" | grep -qE "$PREFIX_RE"; then
continue
fi
FRONTMATTER=$(sed -n '/^---$/,/^---$/p' "$file" | sed '1d;$d')
if [ -z "$FRONTMATTER" ]; then
continue
fi
RISK_LEVEL=$(echo "$FRONTMATTER" | grep "^risk_level:" | head -1 | sed 's/risk_level: *//' | tr -d '\r' || true)
if [ "$RISK_LEVEL" = "high" ] || [ "$RISK_LEVEL" = "critical" ]; then
RELATED=$(echo "$FRONTMATTER" | grep -A20 "^related:" | grep "^ *-" || true)
HAS_ETH=$(echo "$RELATED" | grep -i "ETH-" || true)
if [ -z "$HAS_ETH" ]; then
echo "::warning file=$file::High-risk document ($RISK_LEVEL) has no ETH reference in 'related:'"
fi
fi
done
echo "✅ Compliance check completed"
- name: Verify EU AI Act high-risk documents have required section
if: steps.changed-files.outputs.any_changed == 'true'
run: |
echo "📋 Checking EU AI Act compliance..."
PREFIX_RE="^(${DOC_TYPE_PREFIXES})-"
for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
filename=$(basename "$file")
if ! echo "$filename" | grep -qE "$PREFIX_RE"; then
continue
fi
FRONTMATTER=$(sed -n '/^---$/,/^---$/p' "$file" | sed '1d;$d')
if [ -z "$FRONTMATTER" ]; then
continue
fi
EU_RISK=$(echo "$FRONTMATTER" | grep "^eu_ai_act_risk:" | head -1 | sed 's/eu_ai_act_risk: *//' | tr -d '\r' || true)
if [ "$EU_RISK" = "high" ]; then
BODY=$(sed -n '/^---$/,/^---$/!p' "$file" | tail -n +2)
if ! echo "$BODY" | grep -qi "EU AI Act"; then
echo "::warning file=$file::eu_ai_act_risk is 'high' but document lacks 'EU AI Act Considerations' section"
fi
fi
done
echo "✅ EU AI Act compliance check completed"
# ===========================================================================
# Job: Governance metrics (only on push to main)
# ===========================================================================
governance-metrics:
name: Governance Metrics
runs-on: ubuntu-latest
needs: validate-docs
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Generate governance metrics report
run: |
echo "📊 Generating governance metrics..."
STRAYMARK_DIR=".straymark"
# Derive the types array from the workflow-level env var so we never
# have to maintain it in two places.
IFS='|' read -ra TYPES <<< "$DOC_TYPE_PREFIXES"
# Header
{
echo "## StrayMark Governance Metrics"
echo ""
echo "Generated: $(date -u +"%Y-%m-%d %H:%M UTC")"
echo ""
echo "### Documents by Type"
echo ""
echo "| Type | Count |"
echo "|------|------:|"
} >> "$GITHUB_STEP_SUMMARY"
TOTAL=0
for TYPE in "${TYPES[@]}"; do
COUNT=$(find "$STRAYMARK_DIR" -name "${TYPE}-*.md" -not -path "*/templates/*" 2>/dev/null | wc -l)
TOTAL=$((TOTAL + COUNT))
echo "| $TYPE | $COUNT |" >> "$GITHUB_STEP_SUMMARY"
done
WEEK_AGO=$(date -u -d "7 days ago" +%Y-%m-%d 2>/dev/null || date -u -v-7d +%Y-%m-%d 2>/dev/null || echo "0000-00-00")
RECENT=$(find "$STRAYMARK_DIR" -name "*.md" -not -path "*/templates/*" -newer <(date -d "$WEEK_AGO" +%s 2>/dev/null || echo /dev/null) 2>/dev/null | wc -l || echo 0)
{
echo "| **TOTAL** | **$TOTAL** |"
echo ""
echo "### Recent Documents (last 7 days)"
echo ""
echo "Documents created/modified in the last 7 days: **$RECENT**"
echo ""
echo "### Risk Level Distribution"
echo ""
echo "| Risk Level | Count |"
echo "|------------|------:|"
} >> "$GITHUB_STEP_SUMMARY"
for RISK in low medium high critical; do
RISK_COUNT=$(grep -rl "^risk_level: *$RISK" "$STRAYMARK_DIR" --include="*.md" 2>/dev/null | grep -vc templates || echo 0)
echo "| $RISK | $RISK_COUNT |" >> "$GITHUB_STEP_SUMMARY"
done
{
echo ""
echo "### Review Compliance"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
HIGH_RISK_DOCS=$(grep -rl "^risk_level: *\(high\|critical\)" "$STRAYMARK_DIR" --include="*.md" 2>/dev/null | grep -v templates || true)
HIGH_COUNT=$(echo "$HIGH_RISK_DOCS" | grep -c . 2>/dev/null || echo 0)
if [ "$HIGH_COUNT" -gt 0 ]; then
REVIEWED=0
for doc in $HIGH_RISK_DOCS; do
if grep -q "^review_required: *true" "$doc" 2>/dev/null; then
REVIEWED=$((REVIEWED + 1))
fi
done
RATE=$((REVIEWED * 100 / HIGH_COUNT))
echo "High/critical risk documents with review_required: true: **$REVIEWED / $HIGH_COUNT ($RATE%)**" >> "$GITHUB_STEP_SUMMARY"
else
echo "No high/critical risk documents found." >> "$GITHUB_STEP_SUMMARY"
fi
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "✅ Governance metrics generated"
# ===========================================================================
# Job: Generate documentation index (only on main)
# ===========================================================================
generate-index:
name: Generate Documentation Index
runs-on: ubuntu-latest
needs: validate-docs
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Generate documentation index
run: |
echo "📚 Generating documentation index..."
cat > .straymark/INDEX.md << 'EOF'
# Documentation Index
*Automatically generated on $(date -u +"%Y-%m-%d %H:%M UTC")*
## Governance
EOF
# List documents by folder
for folder in .straymark/*/; do
folder_name=$(basename "$folder")
{
echo ""
echo "## ${folder_name}"
echo ""
} >> .straymark/INDEX.md
find "$folder" -name "*.md" -type f | sort | while IFS= read -r file; do
filename=$(basename "$file")
# Extract title from front-matter or use filename
title=$(grep "^title:" "$file" 2>/dev/null | sed 's/title: *//' | head -1 || echo "$filename")
echo "- [$title]($file)" >> .straymark/INDEX.md
done
done
echo "✅ Index generated: .straymark/INDEX.md"
- name: Commit index if changed
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
if git diff --quiet .straymark/INDEX.md; then
echo "No changes to index"
else
git add .straymark/INDEX.md
git commit -m "docs: update documentation index [skip ci]"
git push
fi