Skip to content

Dogfood Gate

Dogfood Gate #437

Workflow file for this run

# This workflow is managed by gh actions-lock.
# SPDX-License-Identifier: MPL-2.0
# This workflow is managed by gh actions-lock.
# This workflow is managed by gh actions-lock.
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
#
# dogfood-gate.yml — Hyperpolymath Dogfooding Quality Gate
# Validates that the repo uses hyperpolymath's own formats and tools.
# Companion to static-analysis-gate.yml (security) — this is for format compliance.
name: Dogfood Gate
on:
pull_request:
branches: ['**']
push:
branches: [main, master]
permissions:
actions: read
contents: read
jobs:
# ---------------------------------------------------------------------------
# Job 1: A2ML manifest validation
# ---------------------------------------------------------------------------
a2ml-validate:
name: Validate DEED manifests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v7.0.1
- name: Check for A2ML files
id: detect
run: |
COUNT=$(find . -type f \( -name '*.a2ml' -o -name '*.deed' \) -not -path './.git/*' | wc -l)
echo "count=$COUNT" >> "$GITHUB_OUTPUT"
if [ "$COUNT" -eq 0 ]; then
echo "::warning::No .a2ml/.deed manifest files found. Every RSR repo should have a repo deed (<reponame>_chora.deed); legacy 0-AI-MANIFEST.a2ml accepted mid-migration — standards #837"
fi
- name: Validate A2ML manifests
if: steps.detect.outputs.count > 0
run: bash .github/hooks/validate-a2ml.sh
- name: Write summary
run: |
A2ML_COUNT="${{ steps.detect.outputs.count }}"
if [ "$A2ML_COUNT" -eq 0 ]; then
cat <<'EOF' >> "$GITHUB_STEP_SUMMARY"
## A2ML Validation
:warning: **No .a2ml/.deed manifest files found.** Every RSR-compliant repo should have a repo deed (`<reponame>_chora.deed`) at its root.
Copy one from [rsr-template-repo](https://github.com/hyperpolymath/rsr-template-repo).
EOF
else
echo "## A2ML Validation" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Scanned **${A2ML_COUNT}** manifest file(s) (.deed, or legacy .a2ml). See step output for details." >> "$GITHUB_STEP_SUMMARY"
fi
# ---------------------------------------------------------------------------
# Job 2: K9 contract validation
# ---------------------------------------------------------------------------
k9-validate:
name: Validate K9 contracts
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v7.0.1
- name: Check for K9 files
id: detect
run: |
COUNT=$(find . \( -name '*.k9' -o -name '*.k9.ncl' \) -not -path './.git/*' | wc -l)
CONFIG_COUNT=$(find . \( -name '*.toml' -o -name '*.yaml' -o -name '*.yml' -o -name '*.json' \) \
-not -path './.git/*' -not -path './node_modules/*' -not -path './.deno/*' \
-not -name 'package-lock.json' -not -name 'Cargo.lock' -not -name 'deno.lock' | wc -l)
echo "k9_count=$COUNT" >> "$GITHUB_OUTPUT"
echo "config_count=$CONFIG_COUNT" >> "$GITHUB_OUTPUT"
if [ "$COUNT" -eq 0 ] && [ "$CONFIG_COUNT" -gt 0 ]; then
echo "::warning::Found $CONFIG_COUNT config files but no K9 contracts. Run k9iser to generate contracts."
fi
- name: Validate K9 contracts
if: steps.detect.outputs.k9_count > 0
run: bash .github/hooks/validate-k9.sh
- name: Write summary
run: |
K9_COUNT="${{ steps.detect.outputs.k9_count }}"
CFG_COUNT="${{ steps.detect.outputs.config_count }}"
if [ "$K9_COUNT" -eq 0 ]; then
cat <<'EOF' >> "$GITHUB_STEP_SUMMARY"
## K9 Contract Validation
:warning: **No .a2ml/.deed manifest files found.** Every RSR-compliant repo should have a repo deed (`<reponame>_chora.deed`) at its root.
Generate contracts with: `k9iser generate .`
EOF
else
echo "## K9 Contract Validation" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Validated **${K9_COUNT}** K9 contract(s) against **${CFG_COUNT}** config file(s)." >> "$GITHUB_STEP_SUMMARY"
fi
# ---------------------------------------------------------------------------
# Job 3: Empty-linter — invisible character detection
# ---------------------------------------------------------------------------
empty-lint:
name: Empty-linter (invisible characters)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v7.0.1
- name: Scan for invisible characters
id: lint
run: |
# Inline invisible character detection (from empty-linter's core patterns).
# Checks for: zero-width spaces, zero-width joiners, BOM, soft hyphens,
# non-breaking spaces, null bytes, and other invisible Unicode in source files.
python3 - <<'PY'
import os
from pathlib import Path
root = Path(os.environ["GITHUB_WORKSPACE"])
skipped_dirs = {
".cache", ".deno", ".elixir_ls", ".git", ".lake", ".zig-cache",
"_build", "build", "coverage", "deps", "dist", "external_corpora",
"node_modules", "out", "target", "vendor", "zig-cache", "zig-out",
}
intentional_fixture_dirs = {
("tests", "fixtures", "bom-detection"),
("tests", "fixtures", "empty-linter"),
}
# Generated files are not linted for invisible characters: their
# content comes from tooling/history, not authors. CHANGELOG.md is
# regenerated by the changelog bot from squash-merge commit messages
# that contain GitHub's ZWSP-defused @-mentions (@\u200buser) — linting
# it flips main red on every bot regeneration (#192 did exactly
# that: 206 hits, zero of them authored). Estate-grade fix (strip
# at generation in canonical cliff.toml) is tracked upstream.
generated_files = {"CHANGELOG.md"}
source_suffixes = {
".adoc", ".adb", ".ads", ".agda", ".c", ".cc", ".clj", ".cljs",
".cpp", ".erl", ".ex", ".exs", ".fs", ".fsi", ".fsx", ".gleam",
".h", ".hh", ".hpp", ".hrl", ".hs", ".idr", ".java", ".jl",
".js", ".json", ".kt", ".kts", ".lean", ".lua", ".md", ".ml",
".php", ".r", ".rb", ".res", ".rs", ".scala", ".sh", ".swift",
".toml", ".ts", ".v", ".yaml", ".yml", ".zig",
}
invisible_codepoints = {
0x00A0, 0x00AD, 0x2060, 0xFEFF,
*range(0x200B, 0x2010),
*range(0x202A, 0x2030),
*range(0x2066, 0x206A),
}
def command_escape(value):
return str(value).replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
def property_escape(value):
return command_escape(value).replace(":", "%3A").replace(",", "%2C")
# Runtime regression for GitHub workflow-command property delimiters.
assert property_escape("docs/a,b::c.md") == "docs/a%2Cb%3A%3Ac.md"
def intentionally_invalid_fixture(relative):
return any(relative.parts[:len(prefix)] == prefix for prefix in intentional_fixture_dirs)
findings = []
errors = []
for directory, dirnames, filenames in os.walk(root, topdown=True):
dirnames[:] = [name for name in dirnames if name not in skipped_dirs]
directory_path = Path(directory)
for filename in filenames:
path = directory_path / filename
relative = path.relative_to(root)
if (
path.is_symlink()
or path.suffix.lower() not in source_suffixes
or intentionally_invalid_fixture(relative)
or relative.as_posix() in generated_files
):
continue
try:
data = path.read_bytes()
except OSError as error:
errors.append((relative, f"could not read file: {error}"))
continue
reasons = set()
if data.startswith(b"\xef\xbb\xbf"):
reasons.add("leading UTF-8 BOM")
if any(byte <= 0x08 or byte in (0x0B, 0x0C) or 0x0E <= byte <= 0x1F for byte in data):
reasons.add("C0 control character")
try:
text_content = data.decode("utf-8", errors="strict")
except UnicodeDecodeError as error:
errors.append((relative, f"invalid UTF-8 at byte {error.start}"))
continue
if any(ord(character) in invisible_codepoints for character in text_content):
reasons.add("invisible Unicode code point")
if reasons:
findings.append((relative, ", ".join(sorted(reasons))))
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
output.write(f"findings={len(findings)}\n")
output.write(f"exit_code={2 if errors else 0}\n")
output.write("ready=true\n")
for relative, reasons in findings:
print(f"::warning file={property_escape(relative)}::Invisible characters detected: {command_escape(reasons)}")
for relative, reason in errors:
print(f"::error file={property_escape(relative)}::Invisible-character scan failed: {command_escape(reason)}")
PY
- name: Write summary
run: |
if [ "${{ steps.lint.outputs.ready }}" = "true" ]; then
FINDINGS="${{ steps.lint.outputs.findings }}"
EXIT_CODE="${{ steps.lint.outputs.exit_code }}"
if [ "$EXIT_CODE" -ne 0 ] 2>/dev/null; then
echo "## Empty-Linter Results" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo ":x: Scanner execution failed; see error annotations above." >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
if [ "$FINDINGS" -gt 0 ] 2>/dev/null; then
echo "## Empty-Linter Results" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Found **${FINDINGS}** invisible character issue(s). See annotations above." >> "$GITHUB_STEP_SUMMARY"
exit 1
else
echo "## Empty-Linter Results" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo ":white_check_mark: No invisible character issues found." >> "$GITHUB_STEP_SUMMARY"
fi
else
echo "## Empty-Linter" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Skipped: empty-linter not available." >> "$GITHUB_STEP_SUMMARY"
fi
# ---------------------------------------------------------------------------
# Job 4: Groove manifest check (for repos that should expose services)
# ---------------------------------------------------------------------------
groove-check:
name: Groove manifest check
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v7.0.1
- name: Check for Groove manifest
id: groove
run: |
# Check for static or dynamic Groove endpoints
HAS_MANIFEST="false"
HAS_GROOVE_CODE="false"
if [ -f ".well-known/groove/manifest.json" ]; then
HAS_MANIFEST="true"
# Validate the manifest JSON
if ! jq empty .well-known/groove/manifest.json 2>/dev/null; then
echo "::error file=.well-known/groove/manifest.json::Invalid JSON in Groove manifest"
else
SVC_ID=$(jq -r '.service_id // "unknown"' .well-known/groove/manifest.json)
echo "service_id=$SVC_ID" >> "$GITHUB_OUTPUT"
fi
fi
# Check for Groove endpoint code (Rust, Elixir, Zig, V)
if grep -rl 'well-known/groove' --include='*.rs' --include='*.ex' --include='*.zig' --include='*.v' --include='*.res' . 2>/dev/null | head -1 | grep -q .; then
HAS_GROOVE_CODE="true"
fi
# Check if this repo likely serves HTTP (has server/listener code)
HAS_SERVER="false"
if grep -rl 'TcpListener\|Bandit\|Plug.Cowboy\|httpz\|vweb\|axum::serve\|actix_web' --include='*.rs' --include='*.ex' --include='*.zig' --include='*.v' . 2>/dev/null | head -1 | grep -q .; then
HAS_SERVER="true"
fi
echo "has_manifest=$HAS_MANIFEST" >> "$GITHUB_OUTPUT"
echo "has_groove_code=$HAS_GROOVE_CODE" >> "$GITHUB_OUTPUT"
echo "has_server=$HAS_SERVER" >> "$GITHUB_OUTPUT"
if [ "$HAS_SERVER" = "true" ] && [ "$HAS_MANIFEST" = "false" ] && [ "$HAS_GROOVE_CODE" = "false" ]; then
echo "::warning::This repo has server code but no Groove endpoint. Add .well-known/groove/manifest.json for service discovery."
fi
- name: Write summary
run: |
echo "## Groove Protocol Check" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Check | Status |" >> "$GITHUB_STEP_SUMMARY"
echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY"
echo "| Static manifest (.well-known/groove/manifest.json) | ${{ steps.groove.outputs.has_manifest }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Groove endpoint in code | ${{ steps.groove.outputs.has_groove_code }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Has HTTP server code | ${{ steps.groove.outputs.has_server }} |" >> "$GITHUB_STEP_SUMMARY"
# ---------------------------------------------------------------------------
# Job 5: Dogfooding summary
# ---------------------------------------------------------------------------
dogfood-summary:
name: Dogfooding compliance summary
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [a2ml-validate, k9-validate, empty-lint, groove-check]
if: always()
steps:
- name: Checkout repository
uses: actions/checkout@v7.0.1
- name: Generate dogfooding scorecard
run: |
SCORE=0
MAX=5
# A2ML manifest present?
if find . -type f \( -name '*.a2ml' -o -name '*.deed' \) -not -path './.git/*' | head -1 | grep -q .; then
SCORE=$((SCORE + 1))
A2ML_STATUS=":white_check_mark:"
else
A2ML_STATUS=":x:"
fi
# K9 contracts present?
if find . \( -name '*.k9' -o -name '*.k9.ncl' \) -not -path './.git/*' | head -1 | grep -q .; then
SCORE=$((SCORE + 1))
K9_STATUS=":white_check_mark:"
else
K9_STATUS=":x:"
fi
# .editorconfig present?
if [ -f ".editorconfig" ]; then
SCORE=$((SCORE + 1))
EC_STATUS=":white_check_mark:"
else
EC_STATUS=":x:"
fi
# Groove manifest or code?
if [ -f ".well-known/groove/manifest.json" ] || grep -rl 'well-known/groove' --include='*.rs' --include='*.ex' --include='*.zig' . 2>/dev/null | head -1 | grep -q .; then
SCORE=$((SCORE + 1))
GROOVE_STATUS=":white_check_mark:"
else
GROOVE_STATUS=":ballot_box_with_check:"
fi
# VeriSimDB integration?
if grep -rl 'verisimdb\|VeriSimDB' --include='*.toml' --include='*.yaml' --include='*.yml' --include='*.json' --include='*.rs' --include='*.ex' . 2>/dev/null | head -1 | grep -q .; then
SCORE=$((SCORE + 1))
VSDB_STATUS=":white_check_mark:"
else
VSDB_STATUS=":ballot_box_with_check:"
fi
cat <<EOF >> "$GITHUB_STEP_SUMMARY"
## Dogfooding Scorecard
**Score: ${SCORE}/${MAX}**
| Tool/Format | Status | Notes |
|-------------|--------|-------|
| AI manifest (0-AI-MANIFEST.deed, or legacy .a2ml) | ${A2ML_STATUS} | Required for all RSR repos |
| K9 contracts | ${K9_STATUS} | Required for repos with config files |
| .editorconfig | ${EC_STATUS} | Required for all repos |
| Groove endpoint | ${GROOVE_STATUS} | Required for service repos |
| VeriSimDB integration | ${VSDB_STATUS} | Required for stateful repos |
---
*Generated by the [Dogfood Gate](https://github.com/hyperpolymath/rsr-template-repo) workflow.*
*Dogfooding is guinea pig fooding — we test our tools on ourselves.*
EOF